feat: give the chapters motion, diagrams, and retrieval practice
verify-and-publish / gate (push) Successful in 7m4s
verify-and-publish / publish (push) Has been skipped

Three strands of work on the chapter surface, all reading from the same
constraint: this site ships no runtime dependencies, so every effect below
is native CSS or an .astro component.

Motion (src/styles/motion.css, DrawRule.astro). A scroll-driven layer of
reveals, hero parallax, section depth, and a hairline that paints itself
along its path, scrubbed by `animation-timeline: view()` — 0KB against
lottie-web's ~60KB gzipped on the main thread. Every scrubbed rule sits
inside `prefers-reduced-motion: no-preference` and `@supports`, so a
Firefox reader or an opted-out one gets the complete static page rather
than one with holes in it. Ranges key to `cover 40%`-`cover 75%` where the
motion is meant to be watched: `view()` ranges key to first visibility,
which on a 2600px page is long before anyone is reading the section.
`.agents/skills/motion/references/scroll-driven.md` records the technique
and the two ways `pathLength` normalisation was broken while building it.

Diagrams (StepFlow.astro, WorktreeMap.astro). The `.steps` stack on
/skills/, /models/, and /agents/ becomes a numbered flow with connectors,
and /agents/ grows the worktree map it was describing in prose — reusing
the `trees` collection rather than a second set of strings. The map's
static variant is gated one class deeper than full-guide's page styles, so
the interactive copy renders byte-identical. Connectors are pseudo-elements,
not SVG: a stretched path desynchronises its own dash pattern, and a
straight line does not need one. Mermaid was considered and rejected at
~1MB of runtime.

Retrieval practice (/rules/, /skills/). A "check yourself" section of
native `<details>` question/answer pairs plus a citation row, both bilingual
through the existing `data-copy` toggle, and both JavaScript-free.

Two audit blind spots surfaced and are closed rather than worked around:
`build.inlineStylesheets: 'never'`, because Astro inlined sheets under ~4kB
and audit-ui.mjs reads its colour and size baseline from dist/_astro/*.css;
and `--columns` declared in the grid components, because an element-level
custom property is not a declaration the audit can resolve.

legacy/styles/skills.css is renamed and imported for its side effect. Inside
a *page*, `?url` resolves to that page's own CSS chunk whatever file it
names, so the link pointed at the wrong asset and the sheet was emitted but
never loaded — the package preview had been rendering unstyled and
overflowing since the Astro cutover.

verify.mjs gains 5 assertions for the recall sections and their Portuguese
copy: 89 now, against the 84 baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Marcos Paulo
2026-09-06 16:04:08 -03:00
parent ab2308441c
commit 79920c9e6c
38 changed files with 1712 additions and 133 deletions
+8 -2
View File
@@ -27,8 +27,14 @@ files — the pages listed above import them, and the build fails without them.
- **`legacy/styles/audit.css`** (was `full-guide/audit.css`) — responsive audit
overrides, imported by `full-guide.astro`.
- **`legacy/styles/chapters.css`** — imported by `ChapterLayout.astro`.
- **`legacy/styles/skills.css`**, **`skills-review.css`**, **`change-lens.css`**
— imported by their respective pages.
- **`legacy/styles/skills-chapter.css`**, **`skills-review.css`**,
**`change-lens.css`** — imported by their respective pages.
`skills-chapter.css` is imported for its side effect, not through the `?url` +
`<link slot="styles">` pattern the layout uses: inside a **page**, `?url` on a
stylesheet resolves to that page's own CSS chunk rather than to the imported
file, so the link points at the wrong asset and the sheet is emitted but never
loaded. It was named `skills.css` and silently unloaded that way until
2026-09-06.
- **`legacy/skills-review/`** — `app.js` and the module graph under it
(`catalog.js`, `submitted-catalog.js`, `files.js`, `submitted-files.js`,
`vote.js`). `catalog.js` + `submitted-catalog.js` are the review desk's real
+1 -1
View File
@@ -21,7 +21,7 @@ a bug.
```bash
python3 - <<'PY'
import re
files=['legacy/styles/guide.css','legacy/styles/chapters.css','legacy/styles/skills.css',
files=['legacy/styles/guide.css','legacy/styles/chapters.css','legacy/styles/skills-chapter.css',
'legacy/styles/skills-review.css','legacy/styles/change-lens.css',
'legacy/styles/audit.css','public/hands-on/starter/styles.css',
'public/hands-on/rules/styles.css']
+4
View File
@@ -41,6 +41,10 @@ If there is no answer, ship it static. That is a legitimate, common outcome.
- One thing moves at a time. No staggered card cascades.
- `will-change` only immediately before animating, removed after.
- No animation library. This site's thesis is having no runtime dependencies.
- Scroll-scrubbed motion (reveals, parallax, depth, line draw) has its own rules
— ranges, feature gates, the `linear` exception, SVG line drawing. Read
[`references/scroll-driven.md`](references/scroll-driven.md) before touching
`src/styles/motion.css`.
## Reduced motion is not optional
@@ -0,0 +1,188 @@
# Reference: scroll-driven motion
Everything scrubbed by scroll position on this site — reveals, parallax, section
depth, line draw. Read before adding a class to `src/styles/motion.css` or
reaching for a scroll library.
## Why this is the whole toolkit
`.agents/rules/animation.md` forbids animation libraries: the site's thesis is
having no runtime dependencies. That rules out Lottie, Rive, GSAP ScrollTrigger,
Motion One, and AOS. It is not a hardship — for the motion this site does, the
native features are also the better engineering:
| Approach | Runtime | Thread |
| ---------------------------- | --------- | ----------------------------- |
| `animation-timeline: view()` | 0 KB | compositor |
| lottie-web | ~60 KB gz | main, `requestAnimationFrame` |
| dotLottie | ~50 KB gz | main |
| Rive (WASM) | ~200 KB | main + WASM |
Lottie earns its place for cinematic, multi-layer, path-morphing artwork drawn
by a motion designer. Reveals, drifts, and a hairline drawing itself are not
that. If a future request genuinely needs frame-by-frame artwork, it is a rule
change to argue for in `.agents/rules/animation.md`, not a quiet `pnpm add`.
## Support and the two gates
Chromium 115+, Safari 26+. Firefox has it behind a flag. That is fine, because
every scrubbed rule sits inside two nested gates:
```css
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
/* scrubbed rules only */
}
}
```
The `@supports` gate is not decoration. Anything that hides an element at rest —
`opacity: 0`, `stroke-dashoffset: 100` — must live **inside** it. Declared
outside, a Firefox reader gets a permanently invisible element, because the
animation that would have revealed it never runs.
## Ranges
`animation-range` is the duration; there is no `animation-duration` on a
scrubbed animation. Defaults are not what you would guess: bare
`animation-range: normal` means `cover 0% cover 100%`, edge-to-edge for the
element's whole visible life, which is rarely the effect wanted. Always state
the range.
- `cover` — first pixel enters → last pixel leaves. The full journey.
- `contain` — only while fully visible. **Flips meaning** when the element is
taller than the viewport: it then means "the element fills the scrollport".
This is the usual source of confusion; prefer `entry`/`exit`/`cover`.
- `entry` — starts entering → fully inside. Use for one-shot arrivals.
- `exit` — starts leaving → fully outside. Use for departures.
Ranges may be mixed at the two ends (`entry 15% cover 55%`). Legal, and harder
to debug — do it only when a single named range cannot express the moment.
Always `both` for fill mode. Without it the animation snaps back to its `from`
state when scrolled past, and holds nothing before the range begins.
Always `linear` easing. The scroll position _is_ the timing function; a curve
here double-eases and reads as drag. This is the one sanctioned exception to
"never `linear`" in `.agents/rules/animation.md`; time-based motion still uses
`--ease-out`.
### Ranges key to first visibility, not to reading position
The trap that produced the first version of every animation on `/skills/`.
`entry` begins the moment one pixel crosses the bottom of the scrollport. On a
2600px page, a section's `entry` phase is over while the reader is still on the
hero — the motion is finished before anyone is looking at it. Measured on the
skills chapter: an `entry 15% cover 55%` line draw ran from scrollY 190 to 654,
and the section it belonged to did not reach the top of the viewport until 599.
For motion meant to be _watched_, target the band where the subject sits around
the middle of the screen: **`cover 40%` to `cover 75%`**. For motion meant to be
_over before reading starts_ — a reveal — `entry` is right, and that is why
`.reveal` keeps it.
Check this, do not eyeball it. Print
`getAnimations()[0].effect.getComputedTiming().progress` at several
`window.scrollTo` positions and confirm progress crosses 0→1 inside the range
where the section's own top is between roughly 0 and 400px.
### Amplitude has a floor
A drift spread across an element's whole visible life is mostly off-screen. A
22px/6px parallax pair measured out at 3-10px of separation across the window
where the section was actually read — arithmetically present, perceptually
absent. Motion that cannot be seen is not restraint, it is dead code. The pair
is 38px/10px for that reason, ~25px of separation in the reading window.
## What this site's classes mean
| Class | Range | For |
| ----------------------------- | --------------------- | ------------------------------------------ |
| `.reveal` | `entry 0% entry 45%` | a section rising into place, settled early |
| `.parallax-near` / `-far` | `exit 0% exit 100%` | hero pieces, which start already on screen |
| `.depth-slow` / `.depth-fast` | `entry 0% exit 100%` | the two columns of a mid-page section |
| `.draw-line path` | `cover 40% cover 75%` | a hairline painting itself, while watched |
The hero pair and the section pair differ in range for a reason: a hero is on
screen at load, so only its exit is watched; a mid-page section is watched from
first pixel to last.
Parallax rates go the **same** direction with different magnitudes. Opposite
directions read as the page coming apart, not as depth.
## Line draw
```css
/* inside both gates */
.draw-line path {
stroke-dasharray: 100;
stroke-dashoffset: 100;
animation: line-draw linear both;
animation-timeline: view();
animation-range: entry 15% cover 55%;
}
@keyframes line-draw {
to {
stroke-dashoffset: 0;
}
}
```
`pathLength="100"` normalises the path to 100 user units whatever its real
geometry, so the dash numbers above are constant with no JS measurement step.
`fill: none` on the path, or the shape fills in behind the stroke.
`stroke-dasharray` and `stroke-dashoffset` must be **equal** at rest. Offset
smaller than the array → the line starts part-drawn. Larger → a gap that never
closes.
### Never let user space and device space diverge
`pathLength` normalises in **user space**. Anything that makes the rendered
geometry a different shape from the user-space geometry desynchronises the dash
pattern, and the line renders as a painted run, a gap, and a floating fragment.
Two ways to cause it, both hit while building `DrawRule.astro`:
- `preserveAspectRatio="none"` on a stretched viewBox — each axis scales by a
different factor.
- `vector-effect="non-scaling-stroke"` — Chromium spaces dashes in device space
while `pathLength` normalises in user space. Uniform scaling does not save you
here.
The fix is to remove the scaling rather than compensate for it: build the
viewBox at the rendered pixel size and emit the path in those units. Scale
factor 1, one space, `stroke-width: 1` is 1px, and neither attribute is needed.
Verify by counting painted runs, not by looking — a correct draw has exactly one
at every progress value. Sample points along `path.getTotalLength()`, convert
the computed `stroke-dasharray`/`stroke-dashoffset` out of the normalised 100
units, and count transitions from gap to dash.
`stroke-dashoffset` is the site's one sanctioned exception to
transform/opacity-only. It forces a repaint, not a layout, and the subject is a
hairline — but do not extend the exception to filled artwork.
## Straight lines do not need SVG
A plain vertical or horizontal rule that grows is `transform: scaleY()` with
`transform-origin: top` on a 1px div. Fully composited, no repaint, no SVG.
Reach for the dash technique only when the path bends.
## Printing
Print freezes animations at their current time, so a section that never entered
prints invisible and a half-drawn line prints as a fragment. Every scrubbed
class needs an entry in the `@media print` block at the foot of `motion.css`
`animation: none` for transforms, plus `stroke-dasharray: none` for a drawn
line, since undoing the animation alone leaves the dash pattern in place.
## Checking it
- Chrome DevTools → **Rendering → Emulate `prefers-reduced-motion: reduce`**.
The static page must be correct and complete, not a page with holes in it.
- Firefox, or Chrome with `about:config`-style support disabled: same test for
the `@supports` fallback.
- `scroll-driven-animations.style` has a range visualiser; there is a
Scroll-Driven Animations debugger extension for DevTools.
- `bash .agents/scripts/gate.sh``audit-ui.mjs` fails the build on horizontal
overflow, which a mis-sized decorative SVG will cause.
+16 -1
View File
@@ -12,7 +12,22 @@ Split at
the
seam.
MAIN / ORCHESTRATOR
├── agent/ui → components + visual states · ├── agent/tests → acceptance + regressions · └── agent/docs → guide + examples · merge after each leaf returns a diff and evidence
├── agent/ui → components + visual states
├── agent/tests → acceptance + regressions
└── agent/docs → guide + examples
merge after each leaf returns a diff and evidence
Orchestrator
main
● clean
UI worker
agent/ui
● working
Test worker
agent/tests
● ready
Docs worker
agent/docs
● review
FRAME
Orchestrator
Owns scope, task graph, boundaries, and integration.
+3 -1
View File
@@ -19,7 +19,9 @@ Two knobs
Capability
× effort
ROUTING RULE
strong model + high effort → frame ambiguity · light model + low effort → bounded execution · raise one knob at a time → compare evidence
strong model + high effort → frame ambiguity
light model + low effort → bounded execution
raise one knob at a time → compare evidence
Sequence
Spend judgment
where it
+17
View File
@@ -4,6 +4,7 @@ field guide
Pipeline
Skills
Examples
Recall
EN
/
PT
@@ -94,6 +95,22 @@ A second reader checks intent.
├── languages
└── instructions
Read review policy →
Retrieval practice
answer before you open
Desirable difficulty
Close the
page.
Recall.
Retrieval practice — recalling an answer from memory before re-reading — is what builds long-term retention. Answer each question from memory first, then open it to check.
Where does a rule belong: context, skill, CLI check, or hook?
+
Guidance the agent must discover goes in AGENTS.md or a skill. Deterministic policy becomes a CLI command, cheap gates run at commit time, and judgment calls go to review.
Why does a skill need a trigger, not just a workflow?
+
The trigger says when to load it. Without one the skill either never fires or loads every time — and a skill that always loads is just a slower prompt.
What separates a repository rule from a prompt?
+
The prompt is advice for one run. The rule is reusable context plus an executable boundary — still present when the conversation is gone.
COPY / ADAPT
Ask your agent to map the enforcement stack.
Use this in the interview repository or adapt the path names to another project.
+16
View File
@@ -39,6 +39,22 @@ Use references for facts and scripts for deterministic mechanics.
04
Evaluate behavior
Test realistic prompts, edge cases, safety, and evidence.
Check yourself
Recall it before
you
ship it.
Answer from memory first — the reveal is the feedback.
Format specification ↗
Try it on the Tiny Tasks lab →
The skill never loads. What is the first suspect?
+
The trigger. A precise description says when to load the skill — and when to leave it out. A vague one never fires.
Where do the workflow, the facts, and the repeated mechanics each go?
+
The workflow stays in SKILL.md, conditional facts move to references/, and deterministic repeated mechanics become scripts/.
What proves a skill works?
+
Behavior, not headings: realistic prompts, edge cases, and safety checks with observable evidence — the same bar the review desk applies.
Agents & trees →
Rules case study →
Review submitted skills →
+8
View File
@@ -3,4 +3,12 @@ import { defineConfig } from 'astro/config';
export default defineConfig({
base: '/ai-for-dummies',
trailingSlash: 'always',
build: {
// Astro's default ('auto') inlines any stylesheet under ~4kB into a
// <style> in the page. scripts/audit-ui.mjs reads its colour, size, and
// breakpoint baseline from dist/_astro/*.css, so an inlined sheet drops
// out of that audit entirely. Emitting every stylesheet as a file keeps
// the whole cascade inside the audit's reach.
inlineStylesheets: 'never',
},
});
+1 -1
View File
@@ -1 +1 @@
:root{--ink:#122534;--paper:#f6f3ed;--line:#d0d5d2;--muted:#65717a;--blue:#215675;--gold:#ebbf58;--red:#a7483f;--font-sans:manrope,arial,sans-serif;--font-mono:'DM Mono',monospace;}*{box-sizing:border-box}body{margin:0;color:var(--ink);background:var(--paper);font:16px/1.6 var(--font-sans)}main{max-width:1400px;margin:auto;padding:0 5vw}.top{display:flex;justify-content:space-between;gap:20px;padding:24px 0;border-bottom:1px solid var(--line);font:700 11px var(--font-mono);letter-spacing:.08em;text-transform:uppercase}.top a{color:var(--ink);text-decoration:none}.hero{padding:100px 0 70px;max-width:950px}.eyebrow{color:var(--red);font:700 11px var(--font-mono);letter-spacing:.12em;text-transform:uppercase}.hero h1{margin:16px 0;font-size:clamp(52px,9vw,126px);line-height:.9;letter-spacing:-.07em}.hero h1 em,h2 em{font:400 .9em Georgia,serif;color:var(--red)}.hero p{max-width:680px;color:var(--muted);font-size:20px}.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--line);border:1px solid var(--line);margin-bottom:100px}.card{min-height:220px;padding:28px;background:var(--paper)}.card b{color:var(--red);font:24px var(--font-mono)}.card h2{margin:18px 0 8px;font-size:25px;letter-spacing:-.04em}.card p{margin:0 0 14px;color:var(--muted)}.card a{color:var(--blue);font-weight:700}.model,.pipeline,.practice{display:grid;grid-template-columns:1fr 2fr;gap:50px;padding:80px 0;border-top:1px solid var(--line)}.model h2,.pipeline h2,.practice h2{margin:0;font-size:clamp(34px,5vw,70px);line-height:.95;letter-spacing:-.06em}.panel{padding:28px;background:var(--ink);color:var(--paper)}.panel strong{display:block;color:var(--gold);font:700 12px var(--font-mono);letter-spacing:.1em}.panel code{display:block;margin-top:18px;color:#d6e1e4;font:14px/1.8 var(--font-mono);white-space:pre-wrap}.steps{display:grid;gap:1px;background:var(--line)}.steps article{display:grid;grid-template-columns:70px 1fr;gap:20px;padding:20px;background:var(--paper)}.steps b{color:var(--red);font:20px var(--font-mono)}.steps strong{display:block}.steps span{color:var(--muted)}.links{display:flex;flex-wrap:wrap;gap:10px;margin:28px 0 70px}.links a{padding:10px 13px;color:var(--ink);border:1px solid var(--ink);text-decoration:none;font:11px var(--font-mono);text-transform:uppercase}.links a:hover{color:var(--paper);background:var(--ink)}footer{padding:30px 0 70px;color:var(--muted);font-size:13px}@media(max-width:800px){.grid,.model,.pipeline,.practice{grid-template-columns:1fr}.hero{padding:65px 0 45px}.model,.pipeline,.practice{gap:25px;padding:55px 0}}@media(max-width:520px){main{padding:0 16px}.top span{display:none}.hero h1{font-size:56px}.hero p{font-size:17px}.card{min-height:0}.steps article{grid-template-columns:45px 1fr}}
:root{--ink:#122534;--paper:#f6f3ed;--line:#d0d5d2;--muted:#65717a;--blue:#215675;--gold:#ebbf58;--red:#a7483f;--font-sans:manrope,arial,sans-serif;--font-mono:'DM Mono',monospace;}*{box-sizing:border-box}body{margin:0;color:var(--ink);background:var(--paper);font:16px/1.6 var(--font-sans)}main{max-width:1400px;margin:auto;padding:0 5vw}.top{display:flex;justify-content:space-between;gap:20px;max-width:1400px;margin-inline:auto;padding:24px 5vw;border-bottom:1px solid var(--line);font:700 11px var(--font-mono);letter-spacing:.08em;text-transform:uppercase}.top a{color:var(--ink);text-decoration:none}.hero{padding:100px 0 70px;max-width:950px}.eyebrow{color:var(--red);font:700 11px var(--font-mono);letter-spacing:.12em;text-transform:uppercase}.hero h1{margin:16px 0;font-size:clamp(52px,9vw,126px);line-height:.9;letter-spacing:-.07em}.hero h1 em,h2 em{font:400 .9em Georgia,serif;color:var(--red)}.hero p{max-width:680px;color:var(--muted);font-size:20px}.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--line);border:1px solid var(--line);margin-bottom:100px}.card{min-height:220px;padding:28px;background:var(--paper)}.card b{color:var(--red);font:24px var(--font-mono)}.card h2{margin:18px 0 8px;font-size:25px;letter-spacing:-.04em}.card p{margin:0 0 14px;color:var(--muted)}.card a{color:var(--blue);font-weight:700}.model,.pipeline,.practice{display:grid;grid-template-columns:1fr 2fr;gap:50px;padding:80px 0;border-top:1px solid var(--line)}.model h2,.pipeline h2,.practice h2{margin:0;font-size:clamp(34px,5vw,70px);line-height:.95;letter-spacing:-.06em}.panel{padding:28px;background:var(--ink);color:var(--paper)}.panel strong{display:block;color:var(--gold);font:700 12px var(--font-mono);letter-spacing:.1em}.panel code{display:block;margin-top:18px;color:#d6e1e4;font:14px/1.8 var(--font-mono);white-space:pre-wrap}.steps{display:grid;gap:1px;background:var(--line)}.steps article{display:grid;grid-template-columns:70px 1fr;gap:20px;padding:20px;background:var(--paper)}.steps b{color:var(--red);font:20px var(--font-mono)}.steps strong{display:block}.steps span{color:var(--muted)}.links{display:flex;flex-wrap:wrap;gap:10px;margin:28px 0 70px}.links a{padding:10px 13px;color:var(--ink);border:1px solid var(--ink);text-decoration:none;font:11px var(--font-mono);text-transform:uppercase}.links a:hover{color:var(--paper);background:var(--ink)}footer{padding:30px 0 70px;color:var(--muted);font-size:13px}@media(max-width:800px){.grid,.model,.pipeline,.practice{grid-template-columns:1fr}.hero{padding:65px 0 45px}.model,.pipeline,.practice{gap:25px;padding:55px 0}}@media(max-width:520px){main{padding:0 16px}.top{padding-inline:16px}.top span{display:none}.hero h1{font-size:56px}.hero p{font-size:17px}.card{min-height:0}.steps article{grid-template-columns:45px 1fr}}
+30
View File
@@ -170,6 +170,17 @@ if (
throw new Error('built rules page lost its accessible controls');
if (!html.rules.includes('data-rules-copy') || !html.rules.includes('"pt"'))
throw new Error('built rules page lost its Portuguese interaction data');
if (
!has(html.rules, [
'id="recall"',
'data-copy="recallQ1"',
'data-copy="recallQ2"',
'data-copy="recallQ3"',
])
)
throw new Error('built rules page lost its retrieval-practice section');
if (!html.rules.includes('Dificuldade desejável') || !html.rules.includes('"recallA2"'))
throw new Error('built rules page lost its Portuguese recall copy');
if (
!has(html.review, [
@@ -233,6 +244,25 @@ if (
])
)
throw new Error('built skills page lost its package explorer');
if (
!has(html.skills, [
'id="recall"',
'<summary',
'The skill never loads. What is the first suspect?',
'agentskills.io/specification',
'hands-on/starter/',
])
)
throw new Error('built skills page lost its recall section or citations');
if (
!has(read('src/content/chapters/skills.json'), [
'"recall"',
'"links"',
'Teste-se',
'Especificação do formato',
])
)
throw new Error('skills chapter lost its Portuguese recall copy');
if (!has(html.starter, ['id="task-list"', 'id="task-count"']))
throw new Error('starter lab lost its task-list contract');
+8 -3
View File
@@ -6,6 +6,11 @@
// The eyebrow reuses the existing `Eyebrow` primitive but defaults to the
// `red` tone, which matches the chapter surfaces (not the guide surface).
// The h1 em treatment (Georgia italic, red) is the chapter-page signature.
//
// The `rise-in` / `parallax-*` classes come from src/styles/motion.css,
// loaded by ChapterLayout: the hero rises once on load, and the headline
// and intro lag the scroll at two depths as the hero exits. Both are
// inert without the motion layer or when the user opted out of motion.
import Eyebrow from '../primitives/Eyebrow.astro';
@@ -20,10 +25,10 @@ interface Props {
const { eyebrow, tone = 'red' } = Astro.props;
---
<section class="hero">
<section class="hero rise-in">
<Eyebrow label={eyebrow} tone={tone} />
<h1><slot name="title" /></h1>
<div class="intro"><slot /></div>
<h1 class="parallax-near"><slot name="title" /></h1>
<div class="intro parallax-far"><slot /></div>
<slot name="foot" />
</section>
+100
View File
@@ -0,0 +1,100 @@
---
// DrawRule — a hairline that paints itself along its own path as the section
// containing it scrolls into view. The scrubbing lives in the shared motion
// layer (src/styles/motion.css, `.draw-line`); this component only supplies
// the geometry.
//
// Purely decorative: it marks reading position and ties a heading column to
// the panel beside it. `aria-hidden` and no accessible name, because nothing
// here carries information a reader could miss (.agents/rules/accessibility.md).
//
// Two shapes:
// rule — a plain vertical hairline
// bracket — the `└─` the skill-package tree already speaks in: down the
// heading column, then a turn toward the panel
//
// `pathLength="100"` normalises the path to 100 units so motion.css can use
// constant dash numbers with no JS measurement step. Two earlier versions of
// this component broke that normalisation, both showing up the same way — the
// hairline rendered as a drawn run, a gap, and a floating fragment:
//
// 1. `preserveAspectRatio="none"` stretching a square viewBox. The dash
// pattern is laid out in user space, then stretched by a different
// factor on each axis.
// 2. `vector-effect="non-scaling-stroke"`. Chromium spaces dashes in device
// space while `pathLength` normalises in user space.
//
// So the viewBox is built at the rendered pixel size and the path is emitted
// in those units. User space and device space are then the same space, the
// scale factor is 1, and the whole class of mismatch is gone — no
// `preserveAspectRatio`, no `vector-effect`, `stroke-width: 1` is 1px.
interface Props {
shape?: 'rule' | 'bracket';
/** Rendered size of the SVG box, in CSS pixels. The bracket is square. */
height?: number;
/** Extra classes for the wrapping svg. */
class?: string;
}
const { shape = 'rule', height = 120, class: className } = Astro.props;
// The bracket turns at 78% of the descent, so the corner sits clear of the
// arm. The rule is the bare descent in a 4px-wide box, so it claims no width
// it does not use. `+ 0.5` puts the 1px stroke on the pixel grid.
const turn = Math.round(height * 0.78) + 0.5;
const foot = Math.round(height * 0.99) + 0.5;
const path =
shape === 'bracket'
? `M 0.5 0 V ${turn} Q 0.5 ${foot} ${Math.round(height * 0.22)} ${foot} H ${height}`
: `M 0.5 0 V ${height}`;
const viewBox = shape === 'bracket' ? `0 0 ${height} ${height}` : `0 0 4 ${height}`;
---
<svg
class:list={['draw-line', className]}
viewBox={viewBox}
aria-hidden="true"
focusable="false"
style={`--draw-height: ${height}px`}
>
<path d={path} pathLength="100"></path>
</svg>
<style>
.draw-line {
/* Declared here rather than left to a `var()` fallback: audit-ui.mjs
resolves every variable a page uses against the stylesheets it links,
and a fallback does not count as a definition. The `height` prop sets
this inline, which wins over this rule. */
--draw-height: 120px;
display: block;
/* Height drives the box; width follows from the viewBox aspect ratio.
`max-width` is the guard for the one case that would break it — a
column narrower than the glyph is tall. */
width: auto;
max-width: 100%;
height: var(--draw-height);
margin-top: 28px;
overflow: visible;
}
.draw-line path {
fill: none;
stroke: var(--line);
stroke-width: 1;
}
/* Below the chapter grid's single-column breakpoint the heading and the
panel stack, so the bracket's arm would point at nothing. Shorten the
box and let it read as a plain descent into the panel. */
@media (max-width: 800px) {
.draw-line {
/* Overrides the inline `--draw-height` by winning on cascade order,
not by `!important` — the inline style only sets the variable. */
height: 48px;
margin-top: 18px;
}
}
</style>
+10 -2
View File
@@ -16,12 +16,14 @@ interface Props {
label: string;
/** Number of columns at the widest breakpoint. */
columns?: number;
/** Extra classes for the section (e.g. `reveal` from the motion layer). */
class?: string;
}
const { label, columns = 3 } = Astro.props;
const { label, columns = 3, class: className } = Astro.props;
---
<section class="group" aria-label={label}>
<section class:list={['group', className]} aria-label={label}>
<div class="grid" style={`--columns: ${columns}`}>
<slot />
</div>
@@ -29,6 +31,12 @@ const { label, columns = 3 } = Astro.props;
<style>
.grid {
/* Declared here rather than left to the inline `style` alone:
audit-ui.mjs resolves every variable a page uses against the
stylesheets it links, and an element-level custom property is not a
declaration it can see. The `columns` prop overrides this inline. */
--columns: 3;
display: grid;
grid-template-columns: repeat(var(--columns), 1fr);
gap: 1px; /* hairline separators, drawn by the parent background */
+10 -2
View File
@@ -14,17 +14,25 @@
interface Props {
/** Number of columns at the widest breakpoint. */
columns?: number;
/** Extra classes for the section (e.g. `reveal` from the motion layer). */
class?: string;
}
const { columns = 3 } = Astro.props;
const { columns = 3, class: className } = Astro.props;
---
<section class="grid" style={`--columns: ${columns}`}>
<section class:list={['grid', className]} style={`--columns: ${columns}`}>
<slot />
</section>
<style>
.grid {
/* Declared here rather than left to the inline `style` alone:
audit-ui.mjs resolves every variable a page uses against the
stylesheets it links, and an element-level custom property is not a
declaration it can see. The `columns` prop overrides this inline. */
--columns: 3;
display: grid;
grid-template-columns: repeat(var(--columns), 1fr);
gap: 1px; /* hairline separators, drawn by the parent background */
+36 -1
View File
@@ -28,7 +28,12 @@ const { navLabel = 'Chapter navigation' } = Astro.props;
<style>
.footer {
padding: 30px 0 70px;
/* Same column geometry as `main` in chapters.css — see TopBar.astro.
The links block keeps its own rhythm inside the padded column. */
max-width: 1400px;
margin-inline: auto;
padding-block: 30px 70px;
padding-inline: 5vw;
color: var(--muted);
/* UNRESOLVED: legacy 13px fixed from chapters.css. No token matches.
Reported in task 09 report. */
@@ -49,6 +54,13 @@ const { navLabel = 'Chapter navigation' } = Astro.props;
text-decoration: none;
font: var(--step-0) var(--font-mono);
text-transform: uppercase;
/* Hover colour changes and press feedback share one transition list —
appending transform keeps the base link transition's spirit without
clobbering it (base.css styles bare `a`, this is more specific). */
transition:
color var(--dur-press) var(--ease-out),
background-color var(--dur-press) var(--ease-out),
transform var(--dur-press) var(--ease-out);
}
.links :global(a:hover) {
@@ -60,4 +72,27 @@ const { navLabel = 'Chapter navigation' } = Astro.props;
outline: 3px solid var(--red);
outline-offset: 2px;
}
/* Press feedback: the pill settles 3% under the finger. Movement only
when the user has not opted out; colour transitions stay for everyone. */
@media (prefers-reduced-motion: no-preference) {
.links :global(a:active) {
transform: scale(0.97);
}
}
@media (prefers-reduced-motion: reduce) {
.links :global(a) {
transition: none;
}
}
/* chapters.css switches main to a 16px gutter at this legacy threshold;
the footer switches with it so the column never splits. */
/* token-gap: matches the legacy chapters.css main gutter switch at 520px; not a named breakpoint */
@media (max-width: 520px) {
.footer {
padding-inline: 16px;
}
}
</style>
+125
View File
@@ -0,0 +1,125 @@
---
// StepFlow — the numbered practice steps drawn as a flow diagram: boxed nodes
// joined by a connector and an arrowhead, so the order reads as a sequence
// rather than as three items that happen to be numbered.
//
// Replaces the `.steps` stack from legacy/styles/chapters.css on the chapter
// pages. The rendered text is unchanged — same number, label, and copy in the
// same order — because .agents/snapshots/*.txt pin it.
//
// The connector is a 1px pseudo-element and a border triangle, not an SVG.
// A straight line does not need a path (see
// .agents/skills/motion/references/scroll-driven.md), and the chain's height
// depends on how the copy wraps, so anything scaled to fit would desynchronise
// its own dash pattern. Motion for this section is the shared `.depth-*` drift
// the page applies from outside.
interface Step {
label: string;
copy: string;
}
interface Props {
steps: Step[];
/** Extra classes for the list, e.g. a `.depth-fast` drift from the page. */
class?: string;
}
const { steps, class: className } = Astro.props;
---
<ol class:list={['flow', className]}>
{
steps.map((step, index) => (
<li>
<article>
<b>{String(index + 1).padStart(2, '0')}</b>
<div>
<strong>{step.label}</strong>
<span>{step.copy}</span>
</div>
</article>
</li>
))
}
</ol>
<style>
.flow {
/* Declared here rather than left to a `var()` fallback: audit-ui.mjs
resolves every variable a page uses against the stylesheets it links,
and a fallback does not count as a definition. */
--flow-gutter: 64px;
--flow-pad: 20px;
margin: 0;
padding: 0;
list-style: none;
}
.flow li {
position: relative;
}
/* The join lives on the second and later nodes, so the chain has no
dangling stub at either end. */
.flow li + li {
padding-top: 34px;
}
.flow li + li::before {
content: '';
position: absolute;
top: 0;
/* Centre of the number column: the node's padding plus half the gutter. */
left: calc(var(--flow-pad) + var(--flow-gutter) / 2);
width: 1px;
height: 24px;
background: var(--muted);
}
.flow li + li::after {
content: '';
position: absolute;
top: 22px;
left: calc(var(--flow-pad) + var(--flow-gutter) / 2 - 5px);
border-right: 5px solid transparent;
border-left: 5px solid transparent;
border-top: 8px solid var(--gold);
}
.flow article {
display: grid;
grid-template-columns: var(--flow-gutter) 1fr;
gap: 20px;
padding: var(--flow-pad);
background: var(--paper);
border: 1px solid var(--line);
}
.flow b {
/* Centred in its column so the number sits directly over the connector
that leaves the node below it. */
text-align: center;
color: var(--red);
font: var(--step-20) / 1 var(--font-mono);
}
.flow strong {
display: block;
}
.flow span {
color: var(--muted);
}
@media (max-width: 560px) {
.flow {
--flow-gutter: 45px;
--flow-pad: 16px;
}
.flow article {
gap: 12px;
}
}
</style>
+17 -1
View File
@@ -32,7 +32,14 @@ const { id } = Astro.props;
align-items: baseline;
justify-content: space-between;
gap: 20px;
padding: 24px 0;
/* Mirror the `main` column in chapters.css (max-width 1400px,
padding 0 5vw) so the bar is the same column as the content: at wide
viewports the header aligns with main instead of hugging the viewport
edge, and the hairline spans the column. */
max-width: 1400px;
margin-inline: auto;
padding-block: 24px;
padding-inline: 5vw;
border-bottom: 1px solid var(--line);
font: 700 var(--step-0) var(--font-mono);
letter-spacing: 0.08em;
@@ -61,4 +68,13 @@ const { id } = Astro.props;
display: none;
}
}
/* chapters.css switches main to a 16px gutter at this legacy threshold;
the bar switches with it so the column never splits. */
/* token-gap: matches the legacy chapters.css main gutter switch at 520px; not a named breakpoint */
@media (max-width: 520px) {
.top {
padding-inline: 16px;
}
}
</style>
+135 -12
View File
@@ -5,6 +5,11 @@
// Static shell: the SVG paths render server-side; the nodes are buttons with
// the `data-tree` hook asserted by `scripts/verify.mjs`. The `root` node and
// the `initial` branch are marked selected.
//
// `interactive={false}` renders the same diagram as plain divs with no tree
// semantics, for a page that ships no JS. A button that cannot be pressed and
// a `role="treeitem"` that nothing can select are both worse than static text
// (.agents/rules/accessibility.md); the node copy stays readable either way.
type Localized = { en: string; pt: string };
@@ -26,23 +31,37 @@ interface Props {
initial?: string;
rootLabel?: string | Localized;
rootSmall?: string | Localized;
/** False on pages with no selector island driving the nodes. */
interactive?: boolean;
}
const { branches, initial = 'main', rootLabel = 'ROOT', rootSmall = '● clean' } = Astro.props;
const {
branches,
initial = 'main',
rootLabel = 'ROOT',
rootSmall = '● clean',
interactive = true,
} = Astro.props;
const Node = interactive ? 'button' : 'div';
---
<div class="tree-stage" role="tree" aria-label="Repository worktree topology">
<div
class:list={['tree-stage', { 'is-static': !interactive }]}
role={interactive ? 'tree' : undefined}
aria-label={interactive ? 'Repository worktree topology' : undefined}
>
<svg viewBox="0 0 760 330" preserveAspectRatio="none" aria-hidden="true">
<path class="tree-edge trunk" d="M380 48 V118"></path>
<path class="tree-edge" d="M380 118 C380 170 110 150 110 224"></path>
<path class="tree-edge" d="M380 118 V224"></path>
<path class="tree-edge" d="M380 118 C380 170 650 150 650 224"></path>
</svg>
<button
<Node
class:list={['tree-node', 'root', { active: initial === 'main' }]}
data-tree="main"
role="treeitem"
aria-selected={initial === 'main'}
data-tree={interactive ? 'main' : undefined}
role={interactive ? 'treeitem' : undefined}
aria-selected={interactive ? initial === 'main' : undefined}
>
<span>
{
@@ -73,14 +92,14 @@ const { branches, initial = 'main', rootLabel = 'ROOT', rootSmall = '● clean'
)
}
</small>
</button>
</Node>
{
branches.map((branch) => (
<button
<Node
class:list={['tree-node', 'branch', branch.tone, { active: branch.id === initial }]}
data-tree={branch.id}
role="treeitem"
aria-selected={branch.id === initial}
data-tree={interactive ? branch.id : undefined}
role={interactive ? 'treeitem' : undefined}
aria-selected={interactive ? branch.id === initial : undefined}
>
<>
<span>
@@ -109,7 +128,7 @@ const { branches, initial = 'main', rootLabel = 'ROOT', rootSmall = '● clean'
)}
</small>
</>
</button>
</Node>
))
}
</div>
@@ -135,6 +154,10 @@ const { branches, initial = 'main', rootLabel = 'ROOT', rootSmall = '● clean'
fill: none;
stroke: var(--gold);
stroke-width: 2;
/* The viewBox is stretched by `preserveAspectRatio="none"`, so without
this a vertical edge is drawn thicker than a horizontal one by the
width ratio. Safe here because nothing dashes these paths. */
vector-effect: non-scaling-stroke;
}
/* Root sits at the top centre, spanning both columns. */
@@ -190,6 +213,106 @@ const { branches, initial = 'main', rootLabel = 'ROOT', rootSmall = '● clean'
outline-offset: -3px;
}
/* Static variant.
The interactive copy takes its geometry from the page styles in
src/pages/full-guide.astro, which position the nodes absolutely over the
SVG. The grid above is what is left when those styles are not present,
and it does not line up with the edges. So the static variant carries
its own stage: same absolute placement, tuned for a chapter page rather
than the guide's dark control surface.
Every selector here is `.tree-stage.is-static`, one class more specific
than anything full-guide.astro declares, so the interactive diagram is
untouched no matter which stylesheet lands first. */
.tree-stage.is-static {
display: block;
height: 330px;
padding: 0;
background: var(--deep);
border: 1px solid var(--ink);
overflow: hidden;
}
/* Nothing to press when the nodes are divs. */
.tree-stage.is-static .tree-node {
cursor: default;
}
/* Placement is gated on width rather than overridden below it: the node
offsets are percentages of a stage wide enough to hold three across,
and unwinding them one by one is a specificity fight. Under 800px the
topology becomes a stack instead, and the edges — geometry for a layout
that is gone — go with it. The `↓` markers in the narrow rules further
down carry the direction. */
@media (min-width: 800px) {
.tree-stage.is-static .tree-node {
position: absolute;
z-index: 2;
gap: 6px;
width: 164px;
min-height: 0;
padding: 13px 15px;
}
.tree-stage.is-static .tree-node.root {
top: 25px;
left: 50%;
width: 164px;
transform: translateX(-50%);
}
.tree-stage.is-static .tree-node.branch {
top: 220px;
}
.tree-stage.is-static .tree-node.branch.ui {
left: 3%;
}
.tree-stage.is-static .tree-node.branch.tests {
left: 50%;
transform: translateX(-50%);
}
.tree-stage.is-static .tree-node.branch.docs {
right: 3%;
}
}
@media (max-width: 800px) {
.tree-stage.is-static {
display: grid;
grid-template-columns: 1fr;
gap: 30px;
height: auto;
min-height: 0;
padding: 20px;
}
.tree-stage.is-static svg {
display: none;
}
/* The tone classes place branches across the two-column grid, which
does not exist here. */
.tree-stage.is-static .tree-node,
.tree-stage.is-static .tree-node.root,
.tree-stage.is-static .tree-node.branch.ui,
.tree-stage.is-static .tree-node.branch.tests,
.tree-stage.is-static .tree-node.branch.docs {
position: relative;
grid-column: 1;
justify-self: stretch;
width: 100%;
}
.tree-stage.is-static .tree-node:not(:last-child)::after {
content: '↓';
position: absolute;
bottom: -26px;
left: 50%;
color: var(--gold);
}
}
/* Branch tone variants — left/right placement stays on the grid columns. */
.tree-node.branch.ui {
grid-column: 1;
+134 -11
View File
@@ -42,6 +42,7 @@ import prompts from '../../content/rules/prompts.json';
<a href="#pipeline" data-copy="navPipeline">{copy.en.navPipeline}</a>
<a href="#skills" data-copy="navSkills">{copy.en.navSkills}</a>
<a href="#examples" data-copy="navExamples">{copy.en.navExamples}</a>
<a href="#recall" data-copy="navRecall">{copy.en.navRecall}</a>
</nav>
<div class="languages" aria-label="Language">
<button class="active" data-lang="en" aria-pressed="true">EN</button>
@@ -51,10 +52,10 @@ import prompts from '../../content/rules/prompts.json';
</header>
<main>
<section class="hero">
<section class="hero rise-in">
<p class="eyebrow" data-copy="heroEyebrow">{copy.en.heroEyebrow}</p>
<h1 data-copy="heroTitle" set:html={copy.en.heroTitle} />
<div class="hero-foot">
<h1 class="parallax-near" data-copy="heroTitle" set:html={copy.en.heroTitle} />
<div class="hero-foot parallax-far">
<p data-copy="heroText">{copy.en.heroText}</p>
<aside>
<span>CASE / NETCRACKER</span>
@@ -64,12 +65,12 @@ import prompts from '../../content/rules/prompts.json';
</div>
</section>
<section class="thesis">
<section class="thesis reveal">
<span data-copy="thesisLabel">{copy.en.thesisLabel}</span>
<strong data-copy="thesis">{copy.en.thesis}</strong>
</section>
<section class="pipeline-section" id="pipeline">
<section class="pipeline-section reveal" id="pipeline">
<div class="section-label">
<span data-copy="pipelineLabel">{copy.en.pipelineLabel}</span>
<span data-copy="pipelineMeta">{copy.en.pipelineMeta}</span>
@@ -115,7 +116,7 @@ import prompts from '../../content/rules/prompts.json';
<article class="stage-detail" id="stage-detail" aria-live="polite"></article>
</section>
<section class="skill-section" id="skills">
<section class="skill-section reveal" id="skills">
<div class="section-label">
<span data-copy="skillsLabel">{copy.en.skillsLabel}</span>
<span data-copy="skillsMeta">{copy.en.skillsMeta}</span>
@@ -158,7 +159,7 @@ import prompts from '../../content/rules/prompts.json';
</div>
</section>
<section class="examples" id="examples">
<section class="examples reveal" id="examples">
<div class="section-label">
<span data-copy="examplesLabel">{copy.en.examplesLabel}</span>
<span data-copy="examplesMeta">{copy.en.examplesMeta}</span>
@@ -223,7 +224,47 @@ fix(api): scope session query</code></pre>
</div>
</section>
<section class="copy-lab">
<!--
Retrieval practice (teach-skill structure): recall from memory, then
open for feedback. Native <details> — no JavaScript, keyboard-ready.
The language toggle rewrites these via data-copy like every other
string; recallTitle carries markup, so it is in HTML_KEYS below.
-->
<section class="recall reveal" id="recall">
<div class="section-label">
<span data-copy="recallLabel">{copy.en.recallLabel}</span>
<span data-copy="recallMeta">{copy.en.recallMeta}</span>
</div>
<div class="intro">
<div>
<p class="eyebrow" data-copy="recallEyebrow">{copy.en.recallEyebrow}</p>
<h2 data-copy="recallTitle" set:html={copy.en.recallTitle} />
</div>
<p data-copy="recallText">{copy.en.recallText}</p>
</div>
<div class="recall-grid">
<details>
<summary>
<span data-copy="recallQ1">{copy.en.recallQ1}</span><b aria-hidden="true">+</b>
</summary>
<p data-copy="recallA1">{copy.en.recallA1}</p>
</details>
<details>
<summary>
<span data-copy="recallQ2">{copy.en.recallQ2}</span><b aria-hidden="true">+</b>
</summary>
<p data-copy="recallA2">{copy.en.recallA2}</p>
</details>
<details>
<summary>
<span data-copy="recallQ3">{copy.en.recallQ3}</span><b aria-hidden="true">+</b>
</summary>
<p data-copy="recallA3">{copy.en.recallA3}</p>
</details>
</div>
</section>
<section class="copy-lab reveal">
<div>
<span data-copy="copyLabel">{copy.en.copyLabel}</span>
<h2 data-copy="copyTitle">{copy.en.copyTitle}</h2>
@@ -239,7 +280,7 @@ fix(api): scope session query</code></pre>
</article>
</section>
<section class="deeper">
<section class="deeper reveal">
<div>
<span data-copy="deeperLabel">{copy.en.deeperLabel}</span>
<strong data-copy="deeperTitle">{copy.en.deeperTitle}</strong>
@@ -413,6 +454,7 @@ fix(api): scope session query</code></pre>
pipelineTitle: 1,
skillsTitle: 1,
ratchetTitle: 1,
recallTitle: 1,
navPipeline: 1,
navSkills: 1,
navExamples: 1,
@@ -683,6 +725,7 @@ fix(api): scope session query</code></pre>
.pipeline-section,
.skill-section,
.examples,
.recall,
.copy-lab {
margin-bottom: 140px;
padding-top: 30px;
@@ -952,6 +995,73 @@ fix(api): scope session query</code></pre>
color: var(--blue);
}
/* Retrieval-practice cards. Same hairline grid as the examples above;
the question is the card face, the answer is one disclosure away. */
.recall-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1px;
margin-top: 45px;
background: var(--line);
}
.recall-grid details {
display: grid;
align-content: start;
min-height: 210px;
padding: 30px;
background: var(--paper);
}
.recall-grid summary {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
cursor: pointer;
/* Hide the UA triangle on both engines; the `+` marker carries the
state instead and is decorative (aria-hidden). */
list-style: none;
}
.recall-grid summary::-webkit-details-marker {
display: none;
}
.recall-grid summary span {
color: var(--ink);
font-size: var(--step-16);
font-weight: 700;
line-height: 1.35;
letter-spacing: -0.01em;
}
.recall-grid summary b {
color: var(--blue);
font: 700 var(--step-16) var(--font-mono);
transition: transform var(--dur-press) var(--ease-out);
}
/* `+` becomes `×`: the only state change the marker needs. */
.recall-grid details[open] summary b {
color: var(--gold);
transform: rotate(45deg);
}
.recall-grid details p {
margin: 14px 0 0;
color: var(--muted);
font-size: var(--step-14);
line-height: 1.7;
}
@media (prefers-reduced-motion: no-preference) {
.recall-grid details[open] p {
/* purpose: make the revealed answer legible as a state change */
animation: rules-detail-in var(--dur-rise) var(--ease-out) both;
}
/* Press feedback: the console's click targets settle 3% under the
finger (.agents/skills/improve-animations/AUDIT.md). Declared after
the :hover lifts so an active press reads as pressed, not lifted. */
.pipeline button:active,
.skill-list button:active {
transform: scale(0.97);
}
}
.copy-lab {
display: grid;
grid-template-columns: 0.75fr 1.25fr;
@@ -998,6 +1108,15 @@ fix(api): scope session query</code></pre>
600 9px 'DM Mono',
monospace;
cursor: pointer;
/* Press feedback only — nothing else changes on interaction, so the
list stays transform. */
transition: transform var(--dur-press) var(--ease-out);
}
@media (prefers-reduced-motion: no-preference) {
.copy-lab button:active {
transform: scale(0.97);
}
}
.copy-lab pre {
padding: 26px;
@@ -1070,6 +1189,7 @@ fix(api): scope session query</code></pre>
.pipeline button:focus-visible,
.skill-list button:focus-visible,
.copy-lab button:focus-visible,
.recall-grid summary:focus-visible,
.deeper a:focus-visible {
outline: 3px solid var(--gold);
outline-offset: -3px;
@@ -1147,7 +1267,8 @@ fix(api): scope session query</code></pre>
grid-template-columns: 1fr 1fr;
grid-template-rows: none;
}
.example-grid {
.example-grid,
.recall-grid {
grid-template-columns: 1fr;
}
.deeper nav {
@@ -1240,7 +1361,9 @@ fix(api): scope session query</code></pre>
transition: none;
}
.pipeline button,
.skill-list button {
.skill-list button,
.copy-lab button,
.recall-grid summary b {
transition: none;
}
.stage-detail > *,
@@ -89,9 +89,24 @@ const packageFiles: PackageFile[] = [
const preview = document.querySelector('#package-preview');
const buttons = document.querySelectorAll('[data-skill-file]');
const dataNode = document.querySelector('[data-skill-files]');
const tree = document.querySelector('[data-package-workbench] .package-tree');
if (!preview || !dataNode) return;
const files = JSON.parse(dataNode.textContent || '[]');
// Position the gold selection bar over the active button. The bar is a
// 1px-tall pseudo-element on the tree, moved and stretched by transform
// alone, so the slide composites — animating its `top`/`height` would
// force layout on every frame. Measured rather than computed from the
// index: the buttons are not all the same height once a long filename
// wraps.
function placeIndicator() {
const active = tree && tree.querySelector('.active');
if (!active) return;
tree.style.setProperty('--tab-x', active.offsetLeft + 'px');
tree.style.setProperty('--tab-y', active.offsetTop + 'px');
tree.style.setProperty('--tab-h', String(active.offsetHeight));
}
function renderPackage(id) {
const item = files.find((entry) => entry.id === id);
if (!item) return;
@@ -116,6 +131,7 @@ const packageFiles: PackageFile[] = [
button.classList.toggle('active', active);
button.setAttribute('aria-selected', String(active));
});
placeIndicator();
}
buttons.forEach(function (button) {
@@ -124,6 +140,10 @@ const packageFiles: PackageFile[] = [
});
});
// Button heights change at the two breakpoints below (the caption is
// dropped, the padding shrinks), which moves every offset under the bar.
window.addEventListener('resize', placeIndicator);
renderPackage('skill');
})();
</script>
@@ -139,11 +159,51 @@ const packageFiles: PackageFile[] = [
}
.package-tree {
/* Declared, not left to a `var()` fallback: audit-ui.mjs resolves every
variable a page uses against the stylesheets it links, and a fallback
does not count as a definition. The script below overrides these
inline once it has measured the active button. */
--tab-x: 0;
--tab-y: 0;
--tab-h: 0;
position: relative;
padding: 22px 16px;
border-right: 1px solid var(--muted);
min-width: 0;
}
/* Selection bar. One mark that slides between files, rather than a border
that blinks off one button and on to another — the movement is what
says "this is the same selection, now here". A 1px source box scaled by
`--tab-h`, so both the travel and the resize are `transform`.
`--tab-y` / `--tab-h` are written by the script above. Until it runs the
bar has zero height and is invisible, which is also what a JS-disabled
reader sees; `aria-selected` on the buttons is what actually conveys
the selection (.agents/rules/accessibility.md — motion is redundant
reinforcement, never the only signal). */
.package-tree::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 2px;
height: 1px;
background: var(--gold);
/* `--tab-x` because an absolutely positioned child resolves `left: 0`
against the tree's padding box, which sits 16px (10px at the narrow
breakpoint) left of where the buttons actually start. */
transform: translate(var(--tab-x), var(--tab-y)) scaleY(var(--tab-h));
transform-origin: top left;
}
@media (prefers-reduced-motion: no-preference) {
.package-tree::after {
transition: transform var(--dur-rise) var(--ease-out);
}
}
.package-tree > p,
.package-preview > span {
margin: 0 0 14px;
@@ -167,16 +227,24 @@ const packageFiles: PackageFile[] = [
cursor: pointer;
transition:
background 0.2s ease,
border-color 0.2s ease,
transform 0.2s ease;
}
/* The 2px transparent border-left stays: it reserves the gutter the
selection bar slides through, so the label never shifts when a file is
selected. */
.package-tree button:hover,
.package-tree button:focus-visible,
.package-tree button.active {
border-left-color: var(--gold);
background: var(--blue);
outline: 0;
}
/* Gold belongs to the selection bar now, so focus gets its own mark
instead of borrowing the border. Inset, so the tree's edge does not
clip it. */
.package-tree button:focus-visible {
outline: 2px solid var(--gold);
outline-offset: -2px;
}
.package-tree button:hover {
+62 -17
View File
@@ -1,31 +1,55 @@
{
"id": "agents",
"eyebrow": { "en": "Subagent workflow", "pt": "Subagent workflow" },
"title": { "en": "One branch<br>per <em>hand.</em>", "pt": "One branch<br>per <em>hand.</em>" },
"eyebrow": {
"en": "Subagent workflow",
"pt": "Subagent workflow"
},
"title": {
"en": "One branch<br>per <em>hand.</em>",
"pt": "One branch<br>per <em>hand.</em>"
},
"lede": {
"en": "Agents work when roles, files, and evidence are bounded. A worktree gives each worker its own checkout while the orchestrator protects intent.",
"pt": "Agents work when roles, files, and evidence are bounded. A worktree gives each worker its own checkout while the orchestrator protects intent."
},
"cards": [
{
"label": { "en": "FRAME", "pt": "FRAME" },
"title": { "en": "Orchestrator", "pt": "Orchestrator" },
"label": {
"en": "FRAME",
"pt": "FRAME"
},
"title": {
"en": "Orchestrator",
"pt": "Orchestrator"
},
"copy": {
"en": "Owns scope, task graph, boundaries, and integration.",
"pt": "Owns scope, task graph, boundaries, and integration."
}
},
{
"label": { "en": "HAND OFF", "pt": "HAND OFF" },
"title": { "en": "Worker", "pt": "Worker" },
"label": {
"en": "HAND OFF",
"pt": "HAND OFF"
},
"title": {
"en": "Worker",
"pt": "Worker"
},
"copy": {
"en": "Owns one coherent slice and one worktree.",
"pt": "Owns one coherent slice and one worktree."
}
},
{
"label": { "en": "PROVE", "pt": "PROVE" },
"title": { "en": "Verifier", "pt": "Verifier" },
"label": {
"en": "PROVE",
"pt": "PROVE"
},
"title": {
"en": "Verifier",
"pt": "Verifier"
},
"copy": {
"en": "Re-runs gates and reports remaining gaps.",
"pt": "Re-runs gates and reports remaining gaps."
@@ -34,37 +58,58 @@
],
"sections": [
{
"eyebrow": { "en": "The tree", "pt": "The tree" },
"title": { "en": "Split at<br>the <em>seam.</em>", "pt": "Split at<br>the <em>seam.</em>" },
"panelLabel": { "en": "MAIN / ORCHESTRATOR", "pt": "MAIN / ORCHESTRATOR" },
"eyebrow": {
"en": "The tree",
"pt": "The tree"
},
"title": {
"en": "Split at<br>the <em>seam.</em>",
"pt": "Split at<br>the <em>seam.</em>"
},
"panelLabel": {
"en": "MAIN / ORCHESTRATOR",
"pt": "MAIN / ORCHESTRATOR"
},
"panelCode": {
"en": "├── agent/ui → components + visual states · ├── agent/tests → acceptance + regressions · └── agent/docs → guide + examples · merge after each leaf returns a diff and evidence",
"pt": "├── agent/ui → components + visual states · ├── agent/tests → acceptance + regressions · └── agent/docs → guide + examples · merge after each leaf returns a diff and evidence"
"en": "├── agent/ui → components + visual states\n├── agent/tests → acceptance + regressions\n└── agent/docs → guide + examples\n\nmerge after each leaf returns a diff and evidence",
"pt": "├── agent/ui → components + visual states\n├── agent/tests → acceptance + regressions\n└── agent/docs → guide + examples\n\nmerge after each leaf returns a diff and evidence"
}
},
{
"eyebrow": { "en": "Handoff", "pt": "Handoff" },
"eyebrow": {
"en": "Handoff",
"pt": "Handoff"
},
"title": {
"en": "Context that<br>can <em>travel.</em>",
"pt": "Context that<br>can <em>travel.</em>"
},
"steps": [
{
"label": { "en": "Brief", "pt": "Brief" },
"label": {
"en": "Brief",
"pt": "Brief"
},
"copy": {
"en": "Goal, owned files, dependencies, non-goals, acceptance.",
"pt": "Goal, owned files, dependencies, non-goals, acceptance."
}
},
{
"label": { "en": "Isolation", "pt": "Isolation" },
"label": {
"en": "Isolation",
"pt": "Isolation"
},
"copy": {
"en": "One branch and worktree per independent change.",
"pt": "One branch and worktree per independent change."
}
},
{
"label": { "en": "Evidence", "pt": "Evidence" },
"label": {
"en": "Evidence",
"pt": "Evidence"
},
"copy": {
"en": "Commands, result, changed files, screenshots, gaps.",
"pt": "Commands, result, changed files, screenshots, gaps."
+62 -17
View File
@@ -1,31 +1,55 @@
{
"id": "models",
"eyebrow": { "en": "Model routing", "pt": "Model routing" },
"title": { "en": "Choose the<br><em>engine.</em>", "pt": "Choose the<br><em>engine.</em>" },
"eyebrow": {
"en": "Model routing",
"pt": "Model routing"
},
"title": {
"en": "Choose the<br><em>engine.</em>",
"pt": "Choose the<br><em>engine.</em>"
},
"lede": {
"en": "A model has a capability ceiling. Effort controls how much room it gets to reason. Route by uncertainty and verification cost.",
"pt": "A model has a capability ceiling. Effort controls how much room it gets to reason. Route by uncertainty and verification cost."
},
"cards": [
{
"label": { "en": "LOW", "pt": "LOW" },
"title": { "en": "Bounded rhythm", "pt": "Bounded rhythm" },
"label": {
"en": "LOW",
"pt": "LOW"
},
"title": {
"en": "Bounded rhythm",
"pt": "Bounded rhythm"
},
"copy": {
"en": "Lookup, small edits, formatting, and transformations with clear checks.",
"pt": "Lookup, small edits, formatting, and transformations with clear checks."
}
},
{
"label": { "en": "MEDIUM", "pt": "MEDIUM" },
"title": { "en": "Default work", "pt": "Default work" },
"label": {
"en": "MEDIUM",
"pt": "MEDIUM"
},
"title": {
"en": "Default work",
"pt": "Default work"
},
"copy": {
"en": "Normal implementation where the contract is clear but context matters.",
"pt": "Normal implementation where the contract is clear but context matters."
}
},
{
"label": { "en": "HIGH", "pt": "HIGH" },
"title": { "en": "Ambiguity", "pt": "Ambiguity" },
"label": {
"en": "HIGH",
"pt": "HIGH"
},
"title": {
"en": "Ambiguity",
"pt": "Ambiguity"
},
"copy": {
"en": "Planning, architecture, security judgment, and hard failures.",
"pt": "Planning, architecture, security judgment, and hard failures."
@@ -34,37 +58,58 @@
],
"sections": [
{
"eyebrow": { "en": "Two knobs", "pt": "Two knobs" },
"title": { "en": "Capability<br>× effort", "pt": "Capability<br>× effort" },
"panelLabel": { "en": "ROUTING RULE", "pt": "ROUTING RULE" },
"eyebrow": {
"en": "Two knobs",
"pt": "Two knobs"
},
"title": {
"en": "Capability<br>× effort",
"pt": "Capability<br>× effort"
},
"panelLabel": {
"en": "ROUTING RULE",
"pt": "ROUTING RULE"
},
"panelCode": {
"en": "strong model + high effort → frame ambiguity · light model + low effort → bounded execution · raise one knob at a time → compare evidence",
"pt": "strong model + high effort → frame ambiguity · light model + low effort → bounded execution · raise one knob at a time → compare evidence"
"en": "strong model + high effort → frame ambiguity\nlight model + low effort → bounded execution\nraise one knob at a time → compare evidence",
"pt": "strong model + high effort → frame ambiguity\nlight model + low effort → bounded execution\nraise one knob at a time → compare evidence"
}
},
{
"eyebrow": { "en": "Sequence", "pt": "Sequence" },
"eyebrow": {
"en": "Sequence",
"pt": "Sequence"
},
"title": {
"en": "Spend judgment<br>where it <em>compounds.</em>",
"pt": "Spend judgment<br>where it <em>compounds.</em>"
},
"steps": [
{
"label": { "en": "Plan", "pt": "Plan" },
"label": {
"en": "Plan",
"pt": "Plan"
},
"copy": {
"en": "Strong model: scope, risks, acceptance, and worktree split.",
"pt": "Strong model: scope, risks, acceptance, and worktree split."
}
},
{
"label": { "en": "Build", "pt": "Build" },
"label": {
"en": "Build",
"pt": "Build"
},
"copy": {
"en": "Focused worker: smallest context and lightest model that can pass.",
"pt": "Focused worker: smallest context and lightest model that can pass."
}
},
{
"label": { "en": "Review", "pt": "Review" },
"label": {
"en": "Review",
"pt": "Review"
},
"copy": {
"en": "Independent pass when missed issues cost more than the call.",
"pt": "Independent pass when missed issues cost more than the call."
+56
View File
@@ -54,6 +54,62 @@
}
}
]
},
{
"eyebrow": { "en": "Check yourself", "pt": "Teste-se" },
"title": {
"en": "Recall it before<br>you <em>ship it.</em>",
"pt": "Recupere antes<br>de <em>enviar.</em>"
},
"copy": {
"en": "Answer from memory first — the reveal is the feedback.",
"pt": "Responda de memória primeiro — a revelação é o feedback."
},
"recall": [
{
"question": {
"en": "The skill never loads. What is the first suspect?",
"pt": "A skill nunca carrega. Qual é o primeiro suspeito?"
},
"answer": {
"en": "The trigger. A precise description says when to load the skill — and when to leave it out. A vague one never fires.",
"pt": "O gatilho. Uma descrição precisa diz quando carregar a skill — e quando deixá-la de fora. Uma vaga nunca dispara."
}
},
{
"question": {
"en": "Where do the workflow, the facts, and the repeated mechanics each go?",
"pt": "Onde vão o workflow, os fatos e as mecânicas repetidas?"
},
"answer": {
"en": "The workflow stays in SKILL.md, conditional facts move to references/, and deterministic repeated mechanics become scripts/.",
"pt": "O workflow fica no SKILL.md, fatos condicionais vão para references/, e mecânicas determinísticas repetidas viram scripts/."
}
},
{
"question": {
"en": "What proves a skill works?",
"pt": "O que prova que uma skill funciona?"
},
"answer": {
"en": "Behavior, not headings: realistic prompts, edge cases, and safety checks with observable evidence — the same bar the review desk applies.",
"pt": "Comportamento, não títulos: prompts realistas, casos extremos e checagens de segurança com evidência observável — a mesma régua da review desk."
}
}
],
"links": [
{
"label": { "en": "Format specification ↗", "pt": "Especificação do formato ↗" },
"href": "https://agentskills.io/specification"
},
{
"label": {
"en": "Try it on the Tiny Tasks lab →",
"pt": "Experimente no lab Tiny Tasks →"
},
"href": "hands-on/starter/"
}
]
}
]
}
+22
View File
@@ -107,6 +107,12 @@ const skillInstallPrompts = defineCollection({
// rule panels on /models/, /agents/, and /skills/ (the routing rule, the
// MAIN / ORCHESTRATOR tree, and the package-anatomy hint).
//
// `sections[].recall` / `links` carry the retrieval-practice section on
// /skills/ ("check yourself"): `recall` holds question/answer pairs that
// render as native `<details>` (recall needs no JavaScript), and `links`
// holds the citation + practice-loop row — `href` is either an absolute
// external URL or a base-relative site path without the leading slash.
//
// `threadLabel` / `threadText` and `footer` carry the landing-page thread
// strip and footer line.
const chapters = defineCollection({
@@ -147,6 +153,22 @@ const chapters = defineCollection({
}),
)
.optional(),
recall: z
.array(
z.object({
question: localized,
answer: localized,
}),
)
.optional(),
links: z
.array(
z.object({
label: localized,
href: z.string(),
}),
)
.optional(),
}),
)
.optional(),
+24
View File
@@ -4,6 +4,7 @@
"navPipeline": "Pipeline",
"navSkills": "Skills",
"navExamples": "Examples",
"navRecall": "Recall",
"stageContext": "CONTEXT",
"stageReview": "REVIEW",
"heroEyebrow": "A real repository case study",
@@ -45,6 +46,17 @@
"copyTitle": "Ask your agent to map the enforcement stack.",
"copyText": "Use this in the interview repository or adapt the path names to another project.",
"copyButton": "COPY PROMPT",
"recallLabel": "Retrieval practice",
"recallMeta": "answer before you open",
"recallEyebrow": "Desirable difficulty",
"recallTitle": "Close the <br />page. <em>Recall.</em>",
"recallText": "Retrieval practice — recalling an answer from memory before re-reading — is what builds long-term retention. Answer each question from memory first, then open it to check.",
"recallQ1": "Where does a rule belong: context, skill, CLI check, or hook?",
"recallA1": "Guidance the agent must discover goes in AGENTS.md or a skill. Deterministic policy becomes a CLI command, cheap gates run at commit time, and judgment calls go to review.",
"recallQ2": "Why does a skill need a trigger, not just a workflow?",
"recallA2": "The trigger says when to load it. Without one the skill either never fires or loads every time — and a skill that always loads is just a slower prompt.",
"recallQ3": "What separates a repository rule from a prompt?",
"recallA3": "The prompt is advice for one run. The rule is reusable context plus an executable boundary — still present when the conversation is gone.",
"deeperLabel": "GO DEEPER",
"deeperTitle": "Read the implementation, not just this summary.",
"deepContext": "Repository context",
@@ -66,6 +78,7 @@
"navPipeline": "Pipeline",
"navSkills": "Skills",
"navExamples": "Exemplos",
"navRecall": "Recall",
"stageContext": "CONTEXTO",
"stageReview": "REVISÃO",
"heroEyebrow": "Um estudo de caso de repositório real",
@@ -107,6 +120,17 @@
"copyTitle": "Peça ao agente para mapear o enforcement.",
"copyText": "Use isto no repositório interview ou adapte os caminhos para outro projeto.",
"copyButton": "COPIAR PROMPT",
"recallLabel": "Prática de recuperação",
"recallMeta": "responda antes de abrir",
"recallEyebrow": "Dificuldade desejável",
"recallTitle": "Feche o <br />guia. <em>Recupere.</em>",
"recallText": "Prática de recuperação — lembrar a resposta da memória antes de reler — é o que constrói retenção de longo prazo. Responda cada pergunta de memória primeiro, depois abra para conferir.",
"recallQ1": "Onde uma regra mora: contexto, skill, check de CLI ou hook?",
"recallA1": "Orientação que o agente precisa descobrir vai no AGENTS.md ou numa skill. Política determinística vira comando de CLI, checks baratos rodam no commit, e julgamento vai para a revisão.",
"recallQ2": "Por que uma skill precisa de um gatilho, não só de um workflow?",
"recallA2": "O gatilho diz quando carregá-la. Sem ele a skill nunca dispara — ou carrega sempre — e uma skill que sempre carrega é só um prompt mais lento.",
"recallQ3": "O que separa uma regra de repositório de um prompt?",
"recallA3": "O prompt orienta uma execução. A regra é contexto reutilizável mais uma fronteira executável — ainda presente quando a conversa acaba.",
"deeperLabel": "APROFUNDE",
"deeperTitle": "Leia a implementação, não apenas este resumo.",
"deepContext": "Contexto do repositório",
+4
View File
@@ -14,6 +14,9 @@ import BaseLayout from './BaseLayout.astro';
import TopBar from '../components/blocks/TopBar.astro';
import SiteFooter from '../components/blocks/SiteFooter.astro';
import chaptersStylesheet from '../../legacy/styles/chapters.css?url';
// Shared motion layer (scroll reveals, hero parallax, load entrances).
// Opt-in classes; see src/styles/motion.css for the gating contract.
import motionStylesheet from '../styles/motion.css?url';
interface Props {
title: string;
@@ -26,6 +29,7 @@ const { title, description, lang = 'en' } = Astro.props;
<BaseLayout title={title} description={description} lang={lang}>
<link slot="styles" rel="stylesheet" href={chaptersStylesheet} />
<link slot="styles" rel="stylesheet" href={motionStylesheet} />
<TopBar id="top">
<slot name="top-previous" slot="previous" />
<slot name="top-center" slot="center" />
+59 -20
View File
@@ -3,9 +3,12 @@
// Identical URL (/agents/), zero client JS, copy lives in
// src/content/chapters/agents.json.
import { getEntry } from 'astro:content';
import { getCollection, getEntry } from 'astro:content';
import ChapterLayout from '../layouts/ChapterLayout.astro';
import ChapterHero from '../components/blocks/ChapterHero.astro';
import DrawRule from '../components/blocks/DrawRule.astro';
import StepFlow from '../components/blocks/StepFlow.astro';
import WorktreeMap from '../components/blocks/WorktreeMap.astro';
// Required-field guard. The chapters schema marks section eyebrow /
// panelLabel / panelCode / steps / copy as optional because the schema
@@ -43,6 +46,21 @@ const treePanelLabel = requireField(treeSection.panelLabel, 'sections[0].panelLa
const treePanelCode = requireField(treeSection.panelCode, 'sections[0].panelCode');
const handoffEyebrow = requireField(handoffSection.eyebrow, 'sections[1].eyebrow');
const handoffSteps = requireField(handoffSection.steps, 'sections[1].steps');
// The worktree map draws the same three leaves the panel above it lists, so
// it takes its copy from the `trees` collection the full guide already uses
// rather than introducing a second set of strings for the same checkouts
// (.agents/rules/content-i18n.md). English only: this page has no language
// toggle, and emitting the hidden `pt` spans would put untoggleable
// Portuguese into the rendered-text snapshot.
const trees = Object.fromEntries((await getCollection('trees')).map(({ data }) => [data.id, data]));
const treeBranches = ['ui', 'tests', 'docs'].map((id) => ({
id,
label: trees[id].owner.en,
strong: `agent/${id}`,
small: `${trees[id].status}`,
tone: id,
}));
---
<ChapterLayout title="AI For Dummies — Agents and trees" description={lede.replace(/<[^>]+>/g, '')}>
@@ -55,18 +73,32 @@ const handoffSteps = requireField(handoffSection.steps, 'sections[1].steps');
<p>{lede}</p>
</ChapterHero>
<section class="pipeline">
<div>
<section class="pipeline reveal">
<div class="depth-slow">
<p class="eyebrow">{treeEyebrow.en}</p>
<h2 set:html={treeSection.title.en} />
<DrawRule shape="bracket" height={110} />
</div>
<div class="panel">
<div class="panel depth-fast">
<strong>{treePanelLabel.en}</strong>
<code>{treePanelCode.en}</code>
</div>
{
/* Same three leaves as the panel above, as topology rather than as a
listing. Static: this page ships no JS, so the nodes are not
selectable and do not claim to be. */
}
<div class="tree-figure">
<WorktreeMap
branches={treeBranches}
interactive={false}
rootLabel={trees.main.owner.en}
rootSmall={`${trees.main.status}`}
/>
</div>
</section>
<section class="grid">
<section class="grid reveal">
{
cards.map((card) => (
<article class="card">
@@ -78,24 +110,16 @@ const handoffSteps = requireField(handoffSection.steps, 'sections[1].steps');
}
</section>
<section class="practice">
<div>
<section class="practice reveal">
<div class="depth-slow">
<p class="eyebrow">{handoffEyebrow.en}</p>
<h2 set:html={handoffSection.title.en} />
<DrawRule height={110} />
</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>
<StepFlow
class="depth-fast"
steps={handoffSteps.map((step) => ({ label: step.label.en, copy: step.copy.en }))}
/>
</section>
<nav slot="footer-links" class="links" aria-label="Chapter navigation">
@@ -104,3 +128,18 @@ const handoffSteps = requireField(handoffSection.steps, 'sections[1].steps');
<a href={`${base}hands-on/rules/`}>Try the rules lab </a>
</nav>
</ChapterLayout>
<style>
/* The map illustrates both columns, so it spans them. `min-width: 0` on
the panel because a grid item defaults to `min-content` width and the
panel now holds a preformatted tree that must be allowed to scroll
rather than widen the page. */
.pipeline > .panel {
min-width: 0;
}
.pipeline > .tree-figure {
grid-column: 1 / -1;
margin-top: 20px;
}
</style>
+2 -2
View File
@@ -48,7 +48,7 @@ const cards = data.cards ?? [];
>
</ChapterHero>
<GridGroup label="Guide chapters" columns={3}>
<GridGroup label="Guide chapters" columns={3} class="reveal">
{
cards.map((card) => (
<RouteCard
@@ -62,7 +62,7 @@ const cards = data.cards ?? [];
}
</GridGroup>
<section class="landing-note">
<section class="landing-note reveal">
<span>{data.threadLabel?.[lang]}</span>
<strong>{data.threadText?.[lang]}</strong>
</section>
+23 -19
View File
@@ -7,6 +7,8 @@
import { getEntry } from 'astro:content';
import ChapterLayout from '../layouts/ChapterLayout.astro';
import ChapterHero from '../components/blocks/ChapterHero.astro';
import DrawRule from '../components/blocks/DrawRule.astro';
import StepFlow from '../components/blocks/StepFlow.astro';
// Required-field guard. The chapters schema marks section eyebrow /
// panelLabel / panelCode / steps / copy as optional because the schema
@@ -55,7 +57,7 @@ const sequenceSteps = requireField(sequenceSection.steps, 'sections[1].steps');
<p>{lede}</p>
</ChapterHero>
<section class="grid">
<section class="grid reveal">
{
cards.map((card) => (
<article class="card">
@@ -67,35 +69,28 @@ const sequenceSteps = requireField(sequenceSection.steps, 'sections[1].steps');
}
</section>
<section class="model">
<div>
<section class="model reveal">
<div class="depth-slow">
<p class="eyebrow">{ruleEyebrow.en}</p>
<h2 set:html={ruleSection.title.en} />
<DrawRule shape="bracket" height={110} />
</div>
<div class="panel">
<div class="panel depth-fast">
<strong>{rulePanelLabel.en}</strong>
<code>{rulePanelCode.en}</code>
</div>
</section>
<section class="practice">
<div>
<section class="practice reveal">
<div class="depth-slow">
<p class="eyebrow">{sequenceEyebrow.en}</p>
<h2 set:html={sequenceSection.title.en} />
<DrawRule height={110} />
</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>
<StepFlow
class="depth-fast"
steps={sequenceSteps.map((step) => ({ label: step.label.en, copy: step.copy.en }))}
/>
</section>
<nav slot="footer-links" class="links" aria-label="Chapter navigation">
@@ -103,3 +98,12 @@ const sequenceSteps = requireField(sequenceSection.steps, 'sections[1].steps');
<a href={`${base}rules/`}>Rules case study </a>
</nav>
</ChapterLayout>
<style>
/* A grid item defaults to `min-content` width, and the panel now holds a
preformatted routing table; without this it widens the page instead of
scrolling. */
.model > .panel {
min-width: 0;
}
</style>
+4
View File
@@ -13,11 +13,15 @@
import BaseLayout from '../layouts/BaseLayout.astro';
import RulesInteractive from '../components/islands/RulesInteractive.astro';
// Shared motion layer — scroll reveals, hero parallax, load entrances.
// Opt-in classes; see src/styles/motion.css for the gating contract.
import motionStylesheet from '../styles/motion.css?url';
---
<BaseLayout
title="Rules That Survive the Prompt — AI For Dummies"
description="A concise case study of agent skills, CLI ratchets, Husky hooks, and PR review in netcracker/interview."
>
<link slot="styles" rel="stylesheet" href={motionStylesheet} />
<RulesInteractive />
</BaseLayout>
+7
View File
@@ -164,6 +164,7 @@ const serializedCatalog = JSON.stringify(catalog).replace(/</g, '\\u003c');
position: sticky;
top: 24px;
display: flex;
flex-direction: column;
align-self: start;
max-height: calc(100dvh - 48px);
overflow: hidden;
@@ -171,6 +172,12 @@ const serializedCatalog = JSON.stringify(catalog).replace(/</g, '\\u003c');
#skill-list {
min-height: 0;
/* Rows nudge 3px right on hover/focus (see below); on a scroll container
that widens the scrollable area, surfacing a horizontal scrollbar that a
focus scroll-into-view can leave stuck scrolled. The list only ever
scrolls vertically, so clip the axis outright — `clip`, unlike `hidden`,
cannot be scrolled programmatically. */
overflow-x: clip;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-color: var(--blue) var(--paper);
+214 -16
View File
@@ -8,7 +8,17 @@ 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 '../../legacy/styles/skills.css?url';
import DrawRule from '../components/blocks/DrawRule.astro';
import StepFlow from '../components/blocks/StepFlow.astro';
// A plain side-effect import, not the `?url` + `<link slot="styles">` pattern
// the layout uses for its legacy sheets. `?url` inside a *page* resolves to
// that page's own CSS chunk whatever file it names, so the link pointed at
// the scoped bundle while these rules were emitted to a second asset nothing
// loaded. The visible symptom was the JS-injected package preview rendering
// unstyled, its <pre> overflowing the page instead of scrolling. Renamed off
// `skills.css` at the same time so the two assets are told apart in a build
// listing.
import '../../legacy/styles/skills-chapter.css';
// Required-field guard. The chapters schema marks section eyebrow /
// panelLabel / panelCode / steps / copy as optional because the schema
@@ -42,10 +52,23 @@ 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');
// Retrieval-practice section ("check yourself") — teach-skill structure:
// recall from memory, then open the answer for feedback. The citation and
// practice-loop links ground the section in the spec and the hands-on lab.
const recallSection = sections[2];
if (!recallSection) {
throw new Error('skills chapter: missing sections[2]');
}
const recallEyebrow = requireField(recallSection.eyebrow, 'sections[2].eyebrow');
const recallCopy = requireField(recallSection.copy, 'sections[2].copy');
const recallItems = requireField(recallSection.recall, 'sections[2].recall');
const recallLinks = requireField(recallSection.links, 'sections[2].links');
// `href` is either an absolute external URL or a base-relative site path.
const linkHref = (href: string) =>
/^https?:/i.test(href) ? href : `${import.meta.env.BASE_URL}${href}`;
---
<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>
@@ -55,30 +78,72 @@ const createSteps = requireField(createSection.steps, 'sections[1].steps');
<p set:html={lede} />
</ChapterHero>
<section class="pipeline package-anatomy">
<div>
{
/* `depth-slow` / `depth-fast` are the shared motion layer's two parallax
rates (src/styles/motion.css): the heading column drifts further than
the panel beside it, so the two read at different distances. The
`DrawRule` bracket ties the heading to the package explorer — it draws
itself as the section enters. Both are opt-in classes on a section
that already carries `reveal`; nothing here is required for the page
to make sense, and neither runs without scroll-driven-animation
support or with reduced motion. */
}
<section class="pipeline package-anatomy reveal">
<div class="depth-slow">
<p class="eyebrow">{anatomyEyebrow.en}</p>
<h2 set:html={anatomySection.title.en} />
<p class="package-hint">{anatomyCopy.en}</p>
<DrawRule shape="bracket" height={150} />
</div>
<div class="depth-fast">
<SkillPackageExplorer />
</div>
<SkillPackageExplorer />
</section>
<section class="practice">
<div>
<section class="practice reveal">
<div class="depth-slow">
<p class="eyebrow">{createEyebrow.en}</p>
<h2 set:html={createSection.title.en} />
<DrawRule height={110} />
</div>
<div class="steps">
<StepFlow
class="depth-fast"
steps={createSteps.map((step) => ({ label: step.label.en, copy: step.copy.en }))}
/>
</section>
<section class="practice recall reveal" id="recall">
<div class="depth-slow">
<p class="eyebrow">{recallEyebrow.en}</p>
<h2 set:html={recallSection.title.en} />
<p class="recall-note">{recallCopy.en}</p>
<div class="recall-links">
{
recallLinks.map((link) => {
const external = /^https?:/i.test(link.href);
return (
<a
href={linkHref(link.href)}
target={external ? '_blank' : undefined}
rel={external ? 'noreferrer' : undefined}
>
{link.label.en}
</a>
);
})
}
</div>
</div>
<div class="recall-list depth-fast">
{
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>
recallItems.map((item) => (
<details>
<summary>
<span>{item.question.en}</span>
<b aria-hidden="true">+</b>
</summary>
<p>{item.answer.en}</p>
</details>
))
}
</div>
@@ -91,3 +156,136 @@ const createSteps = requireField(createSection.steps, 'sections[1].steps');
<a href={`${base}full-guide/#create-skill`}>Full guide: skill forge </a>
</nav>
</ChapterLayout>
<style>
/* Page-level styles only; reusable pieces belong in a component
(.agents/rules/componentization.md). The recall section reuses the
chapters.css `.practice` grid; these rules style the native <details>
recall cards and the citation/practice link row it carries. */
/* The depth wrapper stands in as the grid item the workbench used to be.
A grid item defaults to `min-width: auto`, which would let the code
samples inside the workbench force the section wider than the viewport
— the horizontal-scroll condition audit-ui.mjs fails the build on. */
.package-anatomy > .depth-fast {
min-width: 0;
}
.recall-note {
max-width: 680px;
margin: 18px 0 0;
color: var(--muted);
}
.recall-links {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 28px;
}
/* Same pill treatment as the chapter footer links (chapters.css
`.links a`) so the citation row reads as part of the page's nav
vocabulary, not a new component. */
.recall-links a {
padding: 10px 13px;
color: var(--ink);
border: 1px solid var(--ink);
text-decoration: none;
font: 700 var(--step-00) var(--font-mono);
text-transform: uppercase;
transition:
color var(--dur-press) var(--ease-out),
background-color var(--dur-press) var(--ease-out),
transform var(--dur-press) var(--ease-out);
}
.recall-links a:hover {
color: var(--paper);
background: var(--ink);
}
.recall-links a:focus-visible {
outline: 3px solid var(--red);
outline-offset: 2px;
}
@media (prefers-reduced-motion: no-preference) {
.recall-links a:active {
transform: scale(0.97);
}
}
@media (prefers-reduced-motion: reduce) {
.recall-links a {
transition: none;
}
}
.recall-list {
display: grid;
gap: 1px; /* hairline separators, drawn by the parent background */
background: var(--line);
border: 1px solid var(--line);
}
.recall-list details {
padding: 18px 20px;
background: var(--paper);
}
.recall-list summary {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 18px;
cursor: pointer;
/* Hide the UA triangle on both engines; the `+`/`×` marker below is
the indicator, and it is decorative (`aria-hidden`). */
list-style: none;
}
.recall-list summary::-webkit-details-marker {
display: none;
}
.recall-list summary span {
font-size: var(--step-1);
font-weight: 700;
}
.recall-list summary b {
color: var(--red);
font: 700 var(--step-16) var(--font-mono);
transition: transform var(--dur-press) var(--ease-out);
}
/* `+` becomes `×` — the only state change the marker needs. */
.recall-list details[open] summary b {
transform: rotate(45deg);
}
.recall-list details p {
max-width: 680px;
margin: 12px 0 0;
color: var(--muted);
}
/* purpose: make the revealed answer legible as a state change */
@media (prefers-reduced-motion: no-preference) {
.recall-list details[open] p {
animation: recall-answer-in var(--dur-rise) var(--ease-out) both;
}
}
@keyframes recall-answer-in {
from {
opacity: 0.01;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
+1 -1
View File
@@ -18,7 +18,7 @@ const introduction =
<p>{introduction}</p>
</ChapterHero>
<SectionGrid>
<SectionGrid class="reveal">
<article class="card">
<b>01</b><h2>Models</h2><p>Capability and effort are separate knobs.</p><a
href={`${base}models/`}>Open chapter </a
+12
View File
@@ -37,3 +37,15 @@ a:hover {
transition: none;
}
}
@media (width >= 2560px) {
body {
zoom: 1.5;
}
}
@media (width >= 3840px) {
body {
zoom: 1.75;
}
}
+213
View File
@@ -0,0 +1,213 @@
/* Motion layer — scroll reveals, hero parallax, section depth, line draw,
load entrances. Loaded by ChapterLayout (every chapter page) and /rules/
via a `?url` import; the classes are opt-in per element.
Everything here is progressive enhancement with two gates:
- Scrubbed motion (scroll reveal, parallax, depth, line draw) is nested in
`@supports (animation-timeline: view())`, so browsers without scroll-
driven animations render the fully static page — no pre-animated
sections, no half-run states.
- All motion is nested in `prefers-reduced-motion: no-preference`, so
opted-out users get the static page by construction rather than by
subtracting motion from them.
Scrubbed animations use `linear` easing: the scroll position is the
timing function, and any curve here would double-ease. Time-based
entrances use the house curve from tokens.css. `transform` and
`opacity` only — both composite on the GPU and keep the 200ms INP
budget intact (.agents/rules/animation.md).
Keyframes and reveals deliberately have no stagger: sections move as one
unit, which is this site's stated point of view on cascades. */
@media (prefers-reduced-motion: no-preference) {
/* Scroll reveal: a section rises into place as it enters the viewport.
`both` holds the hidden start state until entry begins, and the range
ends at 45% of entry, so a section is fully settled before its top
reaches mid-screen — reading pace is never gated on the animation.
Anchor jumps land past the range and render the final state. */
@supports (animation-timeline: view()) {
.reveal {
animation: reveal-rise linear both;
animation-timeline: view();
animation-range: entry 0% entry 45%;
}
/* Parallax: hero pieces lag the scroll at two depths while the hero
exits through the top of the viewport. The distances are small on
purpose — this is depth, not spectacle — and each drift completes
only once its element has left the screen. */
.parallax-near {
animation: parallax-near linear both;
animation-timeline: view();
animation-range: exit 0% exit 100%;
}
.parallax-far {
animation: parallax-far linear both;
animation-timeline: view();
animation-range: exit 0% exit 100%;
}
/* Section depth: the two columns of a `.pipeline` / `.practice` grid
drift at different rates for the whole time the section is on screen,
so the heading reads at a different distance from the panel beside it.
This is the hero's parallax extended to mid-page sections, and the
range differs for a reason — the hero animates on `exit` because it
starts already on screen, while a mid-page section is watched from
first pixel to last, so the range is `entry 0% exit 100%`.
Not a stagger. Nothing waits its turn: both columns move continuously
and simultaneously, only at different rates. The no-cascade point of
view in the header still holds. */
.depth-slow {
animation: depth-slow linear both;
animation-timeline: view();
animation-range: entry 0% exit 100%;
}
.depth-fast {
animation: depth-fast linear both;
animation-timeline: view();
animation-range: entry 0% exit 100%;
}
/* Line draw: a hairline paints itself along its own path as the section
scrolls in. This is the native stand-in for the frame-by-frame runtime
this site refuses to ship — a Lottie player is ~60KB gzipped driving
`requestAnimationFrame` on the main thread, against a 200ms INP
budget; this is 0KB, and the scroll position is the clock.
`pathLength="100"` on the path normalises its geometry to 100 units,
so these numbers hold for any rendered size with no JS measurement
step. `stroke-dasharray` and `stroke-dashoffset` must both equal that
length, or the line starts part-drawn (offset too small) or never
closes (offset too large).
`stroke-dashoffset` is the one property here that is not `transform`
or `opacity`. It repaints the path; it does not force layout, and the
subject is a 1px hairline, so the cost is a repaint of a few hundred
pixels per frame. That is the deliberate exception to the
transform/opacity rule in .agents/rules/animation.md — the only way
to draw a line along a path is to change how it is stroked.
The range is `cover`, not `entry`. Every `view()` range keys to first
visibility, and for a section on a tall page that is long before the
section is read — an `entry`-based draw measured out at scrollY
190-654 on /skills/, finishing while the reader was still on the hero.
`cover 40%` to `cover 75%` is the band where the subject sits around
the middle of the screen, which is where a reader actually is. */
.draw-line path {
stroke-dasharray: 100;
stroke-dashoffset: 100;
animation: line-draw linear both;
animation-timeline: view();
animation-range: cover 40% cover 75%;
}
}
/* Load entrance for above-the-fold heroes. Time-based, not scrubbed: one
coordinated rise of the whole hero block in the house curve. */
.rise-in {
animation: load-rise var(--dur-rise) var(--ease-out) both;
}
}
@keyframes reveal-rise {
from {
opacity: 0.01;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes parallax-near {
from {
transform: translateY(0);
}
to {
transform: translateY(14px);
}
}
@keyframes parallax-far {
from {
transform: translateY(0);
}
to {
transform: translateY(30px);
}
}
/* The two depth rates. Same direction, different magnitude — that is what
reads as distance; opposite directions read as the page coming apart.
The differential is the whole effect, so it has to clear the perception
threshold: an earlier 22/6 pair measured out at 3-10px of separation
across the window where a section is actually read, which is to say no
effect at all. 38/10 puts ~25px of separation in that window and still
reads as drift rather than spectacle. */
@keyframes depth-slow {
from {
transform: translateY(38px);
}
to {
transform: translateY(-38px);
}
}
@keyframes depth-fast {
from {
transform: translateY(10px);
}
to {
transform: translateY(-10px);
}
}
@keyframes line-draw {
to {
stroke-dashoffset: 0;
}
}
@keyframes load-rise {
from {
opacity: 0.01;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Printing freezes animations at their current time; a not-yet-entered
section would print invisible. Static for paper. */
@media print {
.reveal,
.parallax-near,
.parallax-far,
.depth-slow,
.depth-fast,
.rise-in {
animation: none;
}
/* A drawn line frozen mid-animation prints as a fragment. Undo the dash
pattern rather than the animation, so the full rule prints. */
.draw-line path {
animation: none;
stroke-dasharray: none;
stroke-dashoffset: 0;
}
}
+9
View File
@@ -121,6 +121,15 @@
--guide-prompt-rule: #ffffff32;
--guide-prompt-enhanced-copy: #e5e3ef;
/* Motion. The house curve is the strong ease-out already shipped inline
across pages (base.css link transitions, the review desk, the rules
island); naming it here lets new motion extend the same curve instead
of re-typing near-misses. Durations follow the 150-250ms UI budget in
.agents/rules/animation.md. */
--ease-out: cubic-bezier(0.2, 0, 0, 1);
--dur-press: 160ms;
--dur-rise: 200ms;
/* Breakpoints */
--bp-sm: 560px;
--bp-md: 800px;