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
@@ -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 {