Files
ai-for-dummies/src/components/islands/SkillPackageExplorer.astro
T
Marcos Paulo 79920c9e6c
verify-and-publish / gate (push) Successful in 7m4s
verify-and-publish / publish (push) Has been skipped
feat: give the chapters motion, diagrams, and retrieval practice
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>
2026-09-06 16:04:08 -03:00

356 lines
11 KiB
TypeScript

---
// SkillPackageExplorer — the four-file skill-package picker. The interactive
// version of the `data-package-file` buttons that the vanilla skills page
// shipped, rewritten for the Astro chapter surface. The four package entries
// mirror skills/app.js so the migration preserves content 1:1; the buttons
// carry `data-skill-file` and the click handler swaps the preview panel
// contents client-side.
//
// Page is `client:visible` rather than `client:load`: the picker sits below
// the hero and grid; deferring until it scrolls into view keeps initial JS
// to zero for the above-the-fold content. The page ships zero JS for the
// hero/grid/footer parts — only the picker island hydrates.
interface PackageFile {
id: 'skill' | 'references' | 'scripts' | 'assets';
prefix: '├── ' | '└── ';
label: string;
/** Small caption under the file label, mirrors the vanilla source. */
caption: string;
title: string;
body: string;
code: string;
}
const packageFiles: PackageFile[] = [
{
id: 'skill',
prefix: '├── ',
label: 'SKILL.md',
caption: 'trigger + workflow',
title: 'The operating contract',
body: 'The one file that should always be loaded. Define the exact trigger, the ordered workflow, safety limits, and the evidence the agent returns.',
code: '---\nname: review-ui\ndescription: Review a changed UI for focus, reflow, and motion.\n---\n\n1. Inspect the changed interaction.\n2. Run the UI checks.\n3. Return findings with evidence.',
},
{
id: 'references',
prefix: '├── ',
label: 'references/',
caption: 'conditional facts',
title: 'Facts, only when needed',
body: 'Keep conditional detail out of the main instruction. A dialog pattern, framework caveat, or accessibility checklist belongs here when it is not needed for every review.',
code: 'references/\n└── accessibility.md\n ├── keyboard interaction patterns\n └── focus and reflow checklist',
},
{
id: 'scripts',
prefix: '├── ',
label: 'scripts/',
caption: 'deterministic checks',
title: 'Mechanics that should not depend on memory',
body: 'Turn deterministic checks into runnable tools. The agent still judges the result, but it should not have to recreate a viewport test or filename rule by hand.',
code: 'scripts/\n└── check-reflow.mjs\n └── checks 320px, 1280px, and 4K widths',
},
{
id: 'assets',
prefix: '└── ',
label: 'assets/',
caption: 'templates + examples',
title: 'Starting material, not hidden instructions',
body: 'Use assets for templates and examples a person or agent can copy. Keep them clearly named so package readers can choose the right starting point.',
code: 'assets/\n├── review-report.md\n└── focus-test-fixture.html',
},
];
---
<div class="package-workbench" data-package-workbench>
<div class="package-tree" role="tablist" aria-label="Files in the review-ui skill package">
<p>REVIEW-UI / SKILL PACKAGE</p>
{
packageFiles.map((file, index) => (
<button
class:list={[{ active: index === 0 }]}
data-skill-file={file.id}
role="tab"
aria-selected={index === 0 ? 'true' : 'false'}
>
<code>{`${file.prefix}${file.label}`}</code>
<small>{file.caption}</small>
</button>
))
}
</div>
<article class="package-preview" id="package-preview" aria-live="polite"></article>
</div>
<script type="application/json" data-skill-files set:html={JSON.stringify(packageFiles)} />
<script is:inline>
(function () {
const preview = document.querySelector('#package-preview');
const buttons = document.querySelectorAll('[data-skill-file]');
const dataNode = document.querySelector('[data-skill-files]');
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;
preview.classList.remove('is-swapping');
void preview.offsetWidth;
preview.classList.add('is-swapping');
preview.innerHTML =
'<span>SELECTED / ' +
item.label +
'</span>' +
'<h3>' +
item.title +
'</h3>' +
'<p>' +
item.body +
'</p>' +
'<pre><code>' +
item.code +
'</code></pre>';
buttons.forEach(function (button) {
const active = button.dataset.skillFile === id;
button.classList.toggle('active', active);
button.setAttribute('aria-selected', String(active));
});
placeIndicator();
}
buttons.forEach(function (button) {
button.addEventListener('click', function () {
renderPackage(button.dataset.skillFile);
});
});
// 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>
<style>
.package-workbench {
display: grid;
grid-template-columns: minmax(190px, 0.85fr) minmax(0, 1.3fr);
min-width: 0;
background: var(--ink);
border: 1px solid var(--ink);
box-shadow: 10px 10px 0 color-mix(in srgb, var(--gold) 55%, transparent);
}
.package-tree {
/* 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;
color: var(--gold);
font: 700 var(--step-0) / 1.35 var(--font-mono);
letter-spacing: 0.1em;
}
.package-tree button {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
width: 100%;
padding: 12px 8px;
border: 0;
border-left: 2px solid transparent;
background: transparent;
color: var(--line);
text-align: left;
cursor: pointer;
transition:
background 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 {
background: var(--blue);
}
/* 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 {
transform: translateX(3px);
}
.package-tree code {
min-width: 0;
overflow-wrap: anywhere;
font: 700 var(--step-13) / 1.4 var(--font-mono);
}
.package-tree small {
color: var(--muted);
font: var(--step-0) / 1.25 var(--font-sans);
text-align: right;
}
.package-preview {
min-width: 0;
padding: 26px;
background: var(--ink);
color: var(--paper);
}
.package-preview h3 {
margin: 0 0 8px;
font-size: clamp(24px, 3vw, 38px);
line-height: 1.02;
letter-spacing: -0.045em;
}
.package-preview p {
max-width: 52ch;
margin: 0;
color: var(--line);
}
.package-preview pre {
max-width: 100%;
margin: 20px 0 0;
padding: 15px;
overflow: auto;
border: 1px solid var(--muted);
background: var(--deep);
color: var(--line);
font: var(--step-12) / 1.55 var(--font-mono);
}
.package-preview.is-swapping {
/* purpose: review-desk detail swap on selection (state change) */
animation: package-preview-in 200ms cubic-bezier(0.2, 0, 0, 1) both;
}
@keyframes package-preview-in {
from {
opacity: 0.25;
transform: translateY(7px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 800px) {
.package-workbench {
grid-template-columns: 1fr;
}
.package-tree {
border-right: 0;
/* token-gap: legacy var(--muted) mobile layout rule over --ink; no token matches; design-system-keeper */
border-bottom: 1px solid var(--muted);
}
.package-preview {
padding: 22px;
}
}
@media (max-width: 560px) {
.package-tree {
padding: 18px 10px;
}
.package-tree button {
padding: 12px 6px;
}
.package-tree small {
display: none;
}
.package-preview {
padding: 18px;
}
.package-preview pre {
font-size: var(--step-0);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
</style>