From 79920c9e6c52ec58910ec5f21b447e60dc9e7036 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Sun, 6 Sep 2026 16:04:08 -0300 Subject: [PATCH] feat: give the chapters motion, diagrams, and retrieval practice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `
` 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 --- .agents/context/architecture.md | 10 +- .agents/skills/design-tokens/SKILL.md | 2 +- .agents/skills/motion/SKILL.md | 4 + .../skills/motion/references/scroll-driven.md | 188 ++++++++++++++ .agents/snapshots/agents.txt | 17 +- .agents/snapshots/models.txt | 4 +- .agents/snapshots/rules.txt | 17 ++ .agents/snapshots/skills.txt | 16 ++ astro.config.mjs | 8 + legacy/styles/chapters.css | 2 +- .../styles/{skills.css => skills-chapter.css} | 0 scripts/verify.mjs | 30 +++ src/components/blocks/ChapterHero.astro | 11 +- src/components/blocks/DrawRule.astro | 100 ++++++++ src/components/blocks/GridGroup.astro | 12 +- src/components/blocks/SectionGrid.astro | 12 +- src/components/blocks/SiteFooter.astro | 37 ++- src/components/blocks/StepFlow.astro | 125 ++++++++++ src/components/blocks/TopBar.astro | 18 +- src/components/blocks/WorktreeMap.astro | 147 ++++++++++- src/components/islands/RulesInteractive.astro | 145 ++++++++++- .../islands/SkillPackageExplorer.astro | 74 +++++- src/content/chapters/agents.json | 79 ++++-- src/content/chapters/models.json | 79 ++++-- src/content/chapters/skills.json | 56 +++++ src/content/config.ts | 22 ++ src/content/rules/copy.json | 24 ++ src/layouts/ChapterLayout.astro | 4 + src/pages/agents.astro | 79 ++++-- src/pages/index.astro | 4 +- src/pages/models.astro | 42 ++-- src/pages/rules.astro | 4 + src/pages/skills-review.astro | 7 + src/pages/skills.astro | 230 ++++++++++++++++-- src/pages/summary.astro | 2 +- src/styles/base.css | 12 + src/styles/motion.css | 213 ++++++++++++++++ src/styles/tokens.css | 9 + 38 files changed, 1712 insertions(+), 133 deletions(-) create mode 100644 .agents/skills/motion/references/scroll-driven.md rename legacy/styles/{skills.css => skills-chapter.css} (100%) create mode 100644 src/components/blocks/DrawRule.astro create mode 100644 src/components/blocks/StepFlow.astro create mode 100644 src/styles/motion.css diff --git a/.agents/context/architecture.md b/.agents/context/architecture.md index 87810b8..2c90da9 100644 --- a/.agents/context/architecture.md +++ b/.agents/context/architecture.md @@ -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` + + `` 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 diff --git a/.agents/skills/design-tokens/SKILL.md b/.agents/skills/design-tokens/SKILL.md index 44a88f0..d943c45 100644 --- a/.agents/skills/design-tokens/SKILL.md +++ b/.agents/skills/design-tokens/SKILL.md @@ -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'] diff --git a/.agents/skills/motion/SKILL.md b/.agents/skills/motion/SKILL.md index 93fe7b5..edf85e9 100644 --- a/.agents/skills/motion/SKILL.md +++ b/.agents/skills/motion/SKILL.md @@ -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 diff --git a/.agents/skills/motion/references/scroll-driven.md b/.agents/skills/motion/references/scroll-driven.md new file mode 100644 index 0000000..9ad7656 --- /dev/null +++ b/.agents/skills/motion/references/scroll-driven.md @@ -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. diff --git a/.agents/snapshots/agents.txt b/.agents/snapshots/agents.txt index 5ba0291..442ec0d 100644 --- a/.agents/snapshots/agents.txt +++ b/.agents/snapshots/agents.txt @@ -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. diff --git a/.agents/snapshots/models.txt b/.agents/snapshots/models.txt index ddd49f0..9564cb0 100644 --- a/.agents/snapshots/models.txt +++ b/.agents/snapshots/models.txt @@ -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 diff --git a/.agents/snapshots/rules.txt b/.agents/snapshots/rules.txt index 53336b2..d2cef26 100644 --- a/.agents/snapshots/rules.txt +++ b/.agents/snapshots/rules.txt @@ -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. diff --git a/.agents/snapshots/skills.txt b/.agents/snapshots/skills.txt index 41f789c..1241c4c 100644 --- a/.agents/snapshots/skills.txt +++ b/.agents/snapshots/skills.txt @@ -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 → diff --git a/astro.config.mjs b/astro.config.mjs index da12024..9892a36 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -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 + // diff --git a/src/components/blocks/GridGroup.astro b/src/components/blocks/GridGroup.astro index 941289f..36dbc49 100644 --- a/src/components/blocks/GridGroup.astro +++ b/src/components/blocks/GridGroup.astro @@ -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; --- -
+
@@ -29,6 +31,12 @@ const { label, columns = 3 } = Astro.props; diff --git a/src/components/blocks/StepFlow.astro b/src/components/blocks/StepFlow.astro new file mode 100644 index 0000000..b16effd --- /dev/null +++ b/src/components/blocks/StepFlow.astro @@ -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; +--- + +
    + { + steps.map((step, index) => ( +
  1. +
    + {String(index + 1).padStart(2, '0')} +
    + {step.label} + {step.copy} +
    +
    +
  2. + )) + } +
+ + diff --git a/src/components/blocks/TopBar.astro b/src/components/blocks/TopBar.astro index 13e3c00..3d66427 100644 --- a/src/components/blocks/TopBar.astro +++ b/src/components/blocks/TopBar.astro @@ -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; + } + } diff --git a/src/components/blocks/WorktreeMap.astro b/src/components/blocks/WorktreeMap.astro index f790941..282f5d8 100644 --- a/src/components/blocks/WorktreeMap.astro +++ b/src/components/blocks/WorktreeMap.astro @@ -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'; --- -
+
- + { branches.map((branch) => ( - + )) }
@@ -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; diff --git a/src/components/islands/RulesInteractive.astro b/src/components/islands/RulesInteractive.astro index a654011..d06e93c 100644 --- a/src/components/islands/RulesInteractive.astro +++ b/src/components/islands/RulesInteractive.astro @@ -42,6 +42,7 @@ import prompts from '../../content/rules/prompts.json'; {copy.en.navPipeline} {copy.en.navSkills} {copy.en.navExamples} + {copy.en.navRecall}
@@ -51,10 +52,10 @@ import prompts from '../../content/rules/prompts.json';
-
+

{copy.en.heroEyebrow}

-

-
+

+

{copy.en.heroText}

-
+
{copy.en.thesisLabel} {copy.en.thesis}
-
+
-
+
-
+
-
+ +
+ +
+
+

{copy.en.recallEyebrow}

+

+

+

{copy.en.recallText}

+
+
+
+ + {copy.en.recallQ1} + +

{copy.en.recallA1}

+
+
+ + {copy.en.recallQ2} + +

{copy.en.recallA2}

+
+
+ + {copy.en.recallQ3} + +

{copy.en.recallA3}

+
+
+
+ +
{copy.en.copyLabel}

{copy.en.copyTitle}

@@ -239,7 +280,7 @@ fix(api): scope session query
-
+
{copy.en.deeperLabel} {copy.en.deeperTitle} @@ -413,6 +454,7 @@ fix(api): scope session query pipelineTitle: 1, skillsTitle: 1, ratchetTitle: 1, + recallTitle: 1, navPipeline: 1, navSkills: 1, navExamples: 1, @@ -683,6 +725,7 @@ fix(api): scope session query .pipeline-section, .skill-section, .examples, + .recall, .copy-lab { margin-bottom: 140px; padding-top: 30px; @@ -952,6 +995,73 @@ fix(api): scope session query 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 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 .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 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 transition: none; } .pipeline button, - .skill-list button { + .skill-list button, + .copy-lab button, + .recall-grid summary b { transition: none; } .stage-detail > *, diff --git a/src/components/islands/SkillPackageExplorer.astro b/src/components/islands/SkillPackageExplorer.astro index 6cd4778..463dc05 100644 --- a/src/components/islands/SkillPackageExplorer.astro +++ b/src/components/islands/SkillPackageExplorer.astro @@ -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'); })(); @@ -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 { diff --git a/src/content/chapters/agents.json b/src/content/chapters/agents.json index 45e00db..f5093a0 100644 --- a/src/content/chapters/agents.json +++ b/src/content/chapters/agents.json @@ -1,31 +1,55 @@ { "id": "agents", - "eyebrow": { "en": "Subagent workflow", "pt": "Subagent workflow" }, - "title": { "en": "One branch
per hand.", "pt": "One branch
per hand." }, + "eyebrow": { + "en": "Subagent workflow", + "pt": "Subagent workflow" + }, + "title": { + "en": "One branch
per hand.", + "pt": "One branch
per hand." + }, "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
the seam.", "pt": "Split at
the seam." }, - "panelLabel": { "en": "MAIN / ORCHESTRATOR", "pt": "MAIN / ORCHESTRATOR" }, + "eyebrow": { + "en": "The tree", + "pt": "The tree" + }, + "title": { + "en": "Split at
the seam.", + "pt": "Split at
the seam." + }, + "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
can travel.", "pt": "Context that
can travel." }, "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." diff --git a/src/content/chapters/models.json b/src/content/chapters/models.json index bc690af..48842df 100644 --- a/src/content/chapters/models.json +++ b/src/content/chapters/models.json @@ -1,31 +1,55 @@ { "id": "models", - "eyebrow": { "en": "Model routing", "pt": "Model routing" }, - "title": { "en": "Choose the
engine.", "pt": "Choose the
engine." }, + "eyebrow": { + "en": "Model routing", + "pt": "Model routing" + }, + "title": { + "en": "Choose the
engine.", + "pt": "Choose the
engine." + }, "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
× effort", "pt": "Capability
× effort" }, - "panelLabel": { "en": "ROUTING RULE", "pt": "ROUTING RULE" }, + "eyebrow": { + "en": "Two knobs", + "pt": "Two knobs" + }, + "title": { + "en": "Capability
× effort", + "pt": "Capability
× 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
where it compounds.", "pt": "Spend judgment
where it compounds." }, "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." diff --git a/src/content/chapters/skills.json b/src/content/chapters/skills.json index 7847d2a..06e11f3 100644 --- a/src/content/chapters/skills.json +++ b/src/content/chapters/skills.json @@ -54,6 +54,62 @@ } } ] + }, + { + "eyebrow": { "en": "Check yourself", "pt": "Teste-se" }, + "title": { + "en": "Recall it before
you ship it.", + "pt": "Recupere antes
de enviar." + }, + "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/" + } + ] } ] } diff --git a/src/content/config.ts b/src/content/config.ts index 81d6aba..b2666b1 100644 --- a/src/content/config.ts +++ b/src/content/config.ts @@ -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 `
` (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(), diff --git a/src/content/rules/copy.json b/src/content/rules/copy.json index 6c01e92..bd1e424 100644 --- a/src/content/rules/copy.json +++ b/src/content/rules/copy.json @@ -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
page. Recall.", + "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
guia. Recupere.", + "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", diff --git a/src/layouts/ChapterLayout.astro b/src/layouts/ChapterLayout.astro index b2347c3..860ff00 100644 --- a/src/layouts/ChapterLayout.astro +++ b/src/layouts/ChapterLayout.astro @@ -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; + diff --git a/src/pages/agents.astro b/src/pages/agents.astro index 00d207a..167d6df 100644 --- a/src/pages/agents.astro +++ b/src/pages/agents.astro @@ -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, +})); --- ]+>/g, '')}> @@ -55,18 +73,32 @@ const handoffSteps = requireField(handoffSection.steps, 'sections[1].steps');

{lede}

-
-
+
+

{treeEyebrow.en}

+

-
+
{treePanelLabel.en} {treePanelCode.en}
+ { + /* 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. */ + } +
+ +
-
+
{ cards.map((card) => (
@@ -78,24 +110,16 @@ const handoffSteps = requireField(handoffSection.steps, 'sections[1].steps'); }
-
-
+
+

{handoffEyebrow.en}

+

-
- { - handoffSteps.map((step, index) => ( -
- {String(index + 1).padStart(2, '0')} -
- {step.label.en} - {step.copy.en} -
-
- )) - } -
+ ({ label: step.label.en, copy: step.copy.en }))} + />
+ + diff --git a/src/pages/index.astro b/src/pages/index.astro index 85ffb91..5b8dbeb 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -48,7 +48,7 @@ const cards = data.cards ?? []; > - + { cards.map((card) => ( -
+
{data.threadLabel?.[lang]} {data.threadText?.[lang]}
diff --git a/src/pages/models.astro b/src/pages/models.astro index 041166f..93edea3 100644 --- a/src/pages/models.astro +++ b/src/pages/models.astro @@ -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');

{lede}

-
+
{ cards.map((card) => (
@@ -67,35 +69,28 @@ const sequenceSteps = requireField(sequenceSection.steps, 'sections[1].steps'); }
-
-
+
+

{ruleEyebrow.en}

+

-
+
{rulePanelLabel.en} {rulePanelCode.en}
-
-
+
+

{sequenceEyebrow.en}

+

-
- { - sequenceSteps.map((step, index) => ( -
- {String(index + 1).padStart(2, '0')} -
- {step.label.en} - {step.copy.en} -
-
- )) - } -
+ ({ label: step.label.en, copy: step.copy.en }))} + />
+ + diff --git a/src/pages/rules.astro b/src/pages/rules.astro index 536b629..bcf1d6d 100644 --- a/src/pages/rules.astro +++ b/src/pages/rules.astro @@ -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'; --- + diff --git a/src/pages/skills-review.astro b/src/pages/skills-review.astro index 458f67e..ae5620f 100644 --- a/src/pages/skills-review.astro +++ b/src/pages/skills-review.astro @@ -164,6 +164,7 @@ const serializedCatalog = JSON.stringify(catalog).replace(/` 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
 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}`;
 ---
 
 ]+>/g, '')}>
-  
   ← ROUTE MAP
   03 / SKILLS
   review desk ↗
@@ -55,30 +78,72 @@ const createSteps = requireField(createSection.steps, 'sections[1].steps');
     

-

-
+ { + /* `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. */ + } +
+

{anatomyEyebrow.en}

{anatomyCopy.en}

+ +

+
+
-
-
-
+
+

{createEyebrow.en}

+

-
+ ({ label: step.label.en, copy: step.copy.en }))} + /> +
+ +
+
+

{recallEyebrow.en}

+

+

{recallCopy.en}

+ +

+
{ - createSteps.map((step, index) => ( -
- {String(index + 1).padStart(2, '0')} -
- {step.label.en} - {step.copy.en} -
-
+ recallItems.map((item) => ( +
+ + {item.question.en} + + +

{item.answer.en}

+
)) }
@@ -91,3 +156,136 @@ const createSteps = requireField(createSection.steps, 'sections[1].steps'); Full guide: skill forge → + + diff --git a/src/pages/summary.astro b/src/pages/summary.astro index 02615c6..91fb63d 100644 --- a/src/pages/summary.astro +++ b/src/pages/summary.astro @@ -18,7 +18,7 @@ const introduction =

{introduction}

- +
01

Models

Capability and effort are separate knobs.

Open chapter →= 2560px) { + body { + zoom: 1.5; + } +} + +@media (width >= 3840px) { + body { + zoom: 1.75; + } +} diff --git a/src/styles/motion.css b/src/styles/motion.css new file mode 100644 index 0000000..c056c1b --- /dev/null +++ b/src/styles/motion.css @@ -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; + } +} diff --git a/src/styles/tokens.css b/src/styles/tokens.css index f436f44..f3bfda8 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -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;