Merge branch 'main' into refactor/task-13-page-chapters

This commit is contained in:
Marcos Paulo
2026-09-05 18:40:03 +00:00
14 changed files with 1171 additions and 113 deletions
+40
View File
@@ -0,0 +1,40 @@
# Context: full-guide language switching
## Decision
The Astro full guide keeps the current client-side language switch on its
existing `/full-guide/` URL. It server-renders both locale variants and the
language-toggle island shows the selected variant after `client:idle` hydration.
This deliberately preserves the current no-URL-change contract, including links
shared without a locale segment, and avoids a route/redirect and publishing
change. The cost is duplicated localized HTML and both locales in the response.
That is acceptable for this small guide and avoids sending duplicated string
data through every interactive island.
## Markup and island contract for task 15d
- Render each static localized fragment twice. Put `data-language-content="en"`
or `data-language-content="pt"` on its outer element. English is visible in
server HTML; the toggle uses the native `hidden` attribute for the inactive
locale.
- Add `<LanguageToggle />` to the guide top bar. Astro's `client:idle` directive
is only valid for framework components; this `.astro` island defers its
browser setup with `requestIdleCallback` (and a timeout fallback) instead. Do
not hydrate the page or use `client:load`; the control is deliberately
idle-priority.
- The island owns the `ai-for-dummies-language` localStorage key. Every read and
write remains inside `try`/`catch`, because previews may disable storage.
- On each selection the island sets `<html lang>` to `en` or `pt-BR`, updates
its `[data-lang]` buttons' `.active` class and `aria-pressed` state, updates
`[data-language-content]`, then dispatches `ai-for-dummies:languagechange` on
`window`. The event detail is `{ language: 'en' | 'pt' }`.
- The guide selector island (15a) must read `document.documentElement.lang` when
it hydrates and listen for that event. On receipt it must re-render the
currently active phase and all active selector panels from their collection
data. This preserves todays `applyLanguage` behaviour without coupling the
toggle to page selectors.
This is a page-local contract: the existing `/rules/` toggle continues using its
own `rules-language` key and must not be changed as part of full-guide
migration.
+35 -30
View File
@@ -5,9 +5,9 @@ interchangeable here, and the split is not about which is "smartest" — it is
about which failure mode each task punishes.
**Honest caveat up front:** I have not benchmarked these three on this
repository. The routing below is reasoned from task shape and each model's
known strengths. Validate it cheaply on **task 07** (small, self-contained,
easy to judge) before fanning out across ten worktrees.
repository. The routing below is reasoned from task shape and each model's known
strengths. Validate it cheaply on **task 07** (small, self-contained, easy to
judge) before fanning out across ten worktrees.
## Short answer
@@ -18,22 +18,27 @@ points where M3 is the wrong tool.
## Routing table
| Task | Model | Why this one |
| --- | --- | --- |
| 01 scaffold + gates | **Codex** | Config-heavy, many interacting tools (Astro + husky + lint-staged + CI), and success is binary — it builds and hooks fire, or not. Codex's long autonomous run-until-green loop suits it, and getting the foundation wrong is expensive later. |
| 02 design tokens | **Gemini** | Needs the whole CSS corpus in one context (8 stylesheets, ~90 KB) plus **visual judgement on screenshots**. Gemini's long context and multimodal comparison are the differentiator; the others would work file-by-file and miss cross-file drift. |
| 03 verification net | **Codex** | Writing test tooling with a tight feedback loop. Precision about what an assertion pins matters more than speed. |
| 04 content schema | **MiniMax-M3** | Small, well-specified, one file. |
| 0506 content migration | **MiniMax-M3** | High-volume mechanical string moves with a `diff` as the oracle. Cheap, parallel, verifiable. Exactly M3's sweet spot. |
| 07 primitives | **MiniMax-M3** | Small components from templates. Use this task to calibrate the whole routing decision. |
| 0811 component blocks | **MiniMax-M3 ×4 parallel** | Four bounded tasks, one template each, checklist-gated. Cost per task matters because there are many. |
| 1214, 17 pages | **MiniMax-M3** | Bounded, snapshot-diff verified. |
| 15 full guide | **Codex** | The hard one: 50 KB `app.js`, 12 render functions, tab state, bilingual swap. Long sustained reasoning over interacting pieces; the task most likely to need many iterations against a failing check. |
| 16 review desk | **Codex** | Same shape and worse — search, filtering, file fetching, six query params, markdown rendering, client-side diff. Highest defect risk in the plan. |
| 18 motion | **Gemini** | Judging whether motion looks right is perceptual. Feed it before/after captures. |
| 19 contract re-point | **Codex** | 42 assertions to translate without losing coverage. Meticulous, mechanical, verifiable. |
| 20 cutover | **you, with M3 assisting** | Touches production publishing. A human should be watching. |
| review of every task | **Gemini** | Fresh eyes, whole-diff-in-context, and it did not write the code. Never review with the model that wrote it. |
| Task | Model | Why this one |
| ------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 01 scaffold + gates | **Codex** | Config-heavy, many interacting tools (Astro + husky + lint-staged + CI), and success is binary — it builds and hooks fire, or not. Codex's long autonomous run-until-green loop suits it, and getting the foundation wrong is expensive later. |
| 02 design tokens | **Gemini** | Needs the whole CSS corpus in one context (8 stylesheets, ~90 KB) plus **visual judgement on screenshots**. Gemini's long context and multimodal comparison are the differentiator; the others would work file-by-file and miss cross-file drift. |
| 03 verification net | **Codex** | Writing test tooling with a tight feedback loop. Precision about what an assertion pins matters more than speed. |
| 04 content schema | **MiniMax-M3** | Small, well-specified, one file. |
| 0506 content migration | **MiniMax-M3** | High-volume mechanical string moves with a `diff` as the oracle. Cheap, parallel, verifiable. Exactly M3's sweet spot. |
| 07 primitives | **MiniMax-M3** | Small components from templates. Use this task to calibrate the whole routing decision. |
| 0811 component blocks | **MiniMax-M3 ×4 parallel** | Four bounded tasks, one template each, checklist-gated. Cost per task matters because there are many. |
| 1214, 17 pages | **MiniMax-M3** | Bounded, snapshot-diff verified. |
| 15 full guide | **split** | Ran twice on Codex, zero usable commits both times. This table's own swap rule applied: too big, so it became 15a15e. |
| 15a guide selector | **Codex** | Collapsing nine near-identical render functions into one island is the reasoning-heavy part that remains. |
| 15b copy prompt | MiniMax-M3 | Small, mechanical, and has an exact oracle: the clipboard payload must be byte-identical. |
| 15c language toggle | **Codex** | Needs a design decision written down, not a port — the selector-map approach cannot survive. |
| 15d assemble full guide | **Codex** | Assembly, but wide: 22 KB of bilingual markup against three islands and task 10's blocks. |
| 15e retire responsive.css | `agy` | Screenshot-diff driven — needs vision. |
| 16 review desk | **Codex** | Same shape and worse — search, filtering, file fetching, six query params, markdown rendering, client-side diff. Highest defect risk in the plan. |
| 18 motion | **Gemini** | Judging whether motion looks right is perceptual. Feed it before/after captures. |
| 19 contract re-point | **Codex** | 42 assertions to translate without losing coverage. Meticulous, mechanical, verifiable. |
| 20 cutover | **you, with M3 assisting** | Touches production publishing. A human should be watching. |
| review of every task | **Gemini** | Fresh eyes, whole-diff-in-context, and it did not write the code. Never review with the model that wrote it. |
## The reasoning in one line each
@@ -41,11 +46,11 @@ points where M3 is the wrong tool.
Use it for volume: 13 of the 20 tasks. Its weakness is long multi-file
reasoning where the spec is vague; every task above that it owns has a
template and a mechanical oracle.
- **Codex** — best at "keep iterating until the check passes" over a
complicated existing codebase. Use it where the loop is long and the answer is
not obvious: scaffold, the two hard pages, verification.
- **Codex** — best at "keep iterating until the check passes" over a complicated
existing codebase. Use it where the loop is long and the answer is not
obvious: scaffold, the two hard pages, verification.
- **Gemini** — biggest context and genuinely useful multimodal comparison. Use
it where the input is *everything at once* or where the judgement is
it where the input is _everything at once_ or where the judgement is
**visual**: token consolidation, motion, screenshot diffing, and code review.
## Cross-checking rule
@@ -56,13 +61,13 @@ reviews; Codex writes → Gemini reviews; Gemini writes → Codex reviews. The
## Swap the routing if you see this
| Symptom | Move the task to |
| --- | --- |
| M3 spends more than ~3 iterations failing the same gate | Codex |
| M3 edits files outside its task scope | Codex, and tighten the brief |
| Codex "fixes" a red suite by deleting assertions | anything — but re-read `context/verification.md` to it first; `gate.sh` blocks the merge either way |
| Gemini gives confident visual sign-off with no screenshots attached | require the artifacts; do not accept prose |
| A task needs more than two models' worth of hand-holding | the task is too big — split it |
| Symptom | Move the task to |
| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| M3 spends more than ~3 iterations failing the same gate | Codex |
| M3 edits files outside its task scope | Codex, and tighten the brief |
| Codex "fixes" a red suite by deleting assertions | anything — but re-read `context/verification.md` to it first; `gate.sh` blocks the merge either way |
| Gemini gives confident visual sign-off with no screenshots attached | require the artifacts; do not accept prose |
| A task needs more than two models' worth of hand-holding | the task is too big — split it |
## Cost shape
+29 -23
View File
@@ -37,32 +37,38 @@ on four branches; nothing is merged or pushed.
Phase 0 foundation 01 → (02 ∥ 03 ∥ 04)
Phase 1 content 05 ∥ 06 after 04
Phase 2 components 07 → (08 ∥ 09 ∥ 10 ∥ 11) after 02
Phase 3 pages 12 ∥ 13 ∥ 14 ∥ 17, then 15 ∥ 16
Phase 3 pages 12 ∥ 13 ∥ 14 ∥ 17, then 15a ∥ 15b ∥ 15c ∥ 16, then 15d, 15e
Phase 4 polish 18 ∥ 19, then 20
```
| # | Task | Agent | Depends on | Parallel with |
| --- | ------------------------------------------------ | --------------------- | ---------- | ------------- |
| 01 | [scaffold + gates](task-01-scaffold.md) | astro-architect | — | — |
| 02 | [design tokens](task-02-tokens.md) | design-system-keeper | 01 | 03, 04 |
| 03 | [verification net](task-03-verification-net.md) | verification-engineer | 01 | 02, 04 |
| 04 | [content schema](task-04-content-schema.md) | content-i18n-migrator | 01 | 02, 03 |
| 05 | [guide content](task-05-content-guide.md) | content-i18n-migrator | 04 | 06 |
| 06 | [review-desk content](task-06-content-review.md) | content-i18n-migrator | 04 | 05 |
| 07 | [primitives](task-07-primitives.md) | component-builder | 02 | — |
| 08 | [route cards](task-08-route-cards.md) | component-builder | 07 | 09, 10, 11 |
| 09 | [chapter blocks](task-09-chapter-blocks.md) | component-builder | 07 | 08, 10, 11 |
| 10 | [guide blocks](task-10-guide-blocks.md) | component-builder | 07 | 08, 09, 11 |
| 11 | [review-desk blocks](task-11-review-blocks.md) | component-builder | 07 | 08, 09, 10 |
| 12 | [landing page](task-12-page-landing.md) | page-migrator | 03, 08 | 13, 14, 17 |
| 13 | [chapter pages ×4](task-13-page-chapters.md) | page-migrator | 03, 09 | 12, 14, 17 |
| 14 | [rules page](task-14-page-rules.md) | page-migrator | 03, 09 | 12, 13, 17 |
| 15 | [full guide](task-15-page-full-guide.md) | page-migrator | 05, 10, 13 | 16 |
| 16 | [review desk](task-16-page-review-desk.md) | page-migrator | 06, 11, 13 | 15 |
| 17 | [hands-on passthrough](task-17-hands-on.md) | astro-architect | 01 | 12, 13, 14 |
| 18 | [motion pass](task-18-motion.md) | motion-designer | 15, 16 | 19 |
| 19 | [contract re-point](task-19-verify-repoint.md) | verification-engineer | 15, 16 | 18 |
| 20 | [cutover + cleanup](task-20-cutover.md) | astro-architect | all | |
| # | Task | Agent | Depends on | Parallel with |
| --- | ----------------------------------------------------------------- | --------------------- | ------------------ | ------------- |
| 01 | [scaffold + gates](task-01-scaffold.md) | astro-architect | — | — |
| 02 | [design tokens](task-02-tokens.md) | design-system-keeper | 01 | 03, 04 |
| 03 | [verification net](task-03-verification-net.md) | verification-engineer | 01 | 02, 04 |
| 04 | [content schema](task-04-content-schema.md) | content-i18n-migrator | 01 | 02, 03 |
| 05 | [guide content](task-05-content-guide.md) | content-i18n-migrator | 04 | 06 |
| 06 | [review-desk content](task-06-content-review.md) | content-i18n-migrator | 04 | 05 |
| 07 | [primitives](task-07-primitives.md) | component-builder | 02 | — |
| 08 | [route cards](task-08-route-cards.md) | component-builder | 07 | 09, 10, 11 |
| 09 | [chapter blocks](task-09-chapter-blocks.md) | component-builder | 07 | 08, 10, 11 |
| 10 | [guide blocks](task-10-guide-blocks.md) | component-builder | 07 | 08, 09, 11 |
| 11 | [review-desk blocks](task-11-review-blocks.md) | component-builder | 07 | 08, 09, 10 |
| 12 | [landing page](task-12-page-landing.md) | page-migrator | 03, 08 | 13, 14, 17 |
| 13 | [chapter pages ×4](task-13-page-chapters.md) | page-migrator | 03, 09 | 12, 14, 17 |
| 14 | [rules page](task-14-page-rules.md) | page-migrator | 03, 09 | 12, 13, 17 |
| 15 | [full guide](task-15-page-full-guide.md) **split into 15a15e** | — | — | |
| 05b | [interactiveCopy data](task-05b-guide-interactive-data.md) | content-i18n-migrator | 05 | |
| 15a | [guide selector](task-15a-guide-selector.md) | component-builder | 05, 10 | 15b, 15c |
| 15b | [copy prompt](task-15b-copy-prompt.md) | component-builder | 05 | 15a, 15c |
| 15c | [language toggle](task-15c-language-toggle.md) | content-i18n-migrator | 05 | 15a, 15b |
| 15d | [assemble full guide](task-15d-page-full-guide.md) | page-migrator | 05b, 10, 13, 15ac | 16 |
| 15e | [retire responsive.css](task-15e-responsive-css.md) | design-system-keeper | 15d, 16 | — |
| 16 | [review desk](task-16-page-review-desk.md) | page-migrator | 06, 11, 13 | 15d |
| 17 | [hands-on passthrough](task-17-hands-on.md) | astro-architect | 01 | 12, 13, 14 |
| 18 | [motion pass](task-18-motion.md) | motion-designer | 15d, 16 | 19 |
| 19 | [contract re-point](task-19-verify-repoint.md) | verification-engineer | 15d, 16 | 18 |
| 20 | [cutover + cleanup](task-20-cutover.md) | astro-architect | all | — |
Widest parallelism: **four agents** (tasks 0811, then 12/13/14/17). More than
that and they start contending on review capacity, not on files.
@@ -0,0 +1,78 @@
# Task 05b — The `interactiveCopy` data
**Agent**: `content-i18n-migrator` · **Model**: MiniMax-M3 **Depends on**: 05 ·
**Blocks**: 15d **Worktree**:
`.agents/scripts/worktree.sh start 05b guide-interactive-data`
## Why this task exists
It is not in the original plan, and it is the second hole of exactly this kind
(see [task 04b](task-04b-chapters-data.md), which filled the empty `chapters`
collection).
Task 05 moved `phases`, `providers`, `efforts`, `skillSources`, `handsOnPrompts`
and `skillInstallPrompts` out of `app.js`. It did not move
`const interactiveCopy` at `app.js:160`, which holds six more datasets:
| Key | Feeds | Rendered by |
| --------------- | ---------------------- | --------------------- |
| `workers` | `#worker-detail` | `renderWorker` |
| `trees` | `#tree-detail` | `renderTree` |
| `routes` | `#route-detail` | `renderRoute` |
| `skillFiles` | `#skill-detail` | `renderSkillFile` |
| `skillWorkflow` | `#builder-detail` | `renderSkillWorkflow` |
| `commonSkills` | `#common-skill-detail` | `renderCommonSkill` |
Task 15d stopped before writing a line because of this — six of the nine groups
`GuideSelector` drives have no data to read. It was right to stop.
## Scope
`src/content/config.ts` (new collections only) and the new `src/content/*/`
JSON. **You own `config.ts`** — you are the only agent who may edit it. Do not
change an existing collection's schema.
## The shapes are already written down
`src/components/islands/GuideSelector.astro` declares a `GuideSelectorData`
interface at the top of the file. It is the contract 15d will feed. Match it —
if you think a field there is wrong, say so in your report rather than diverging
silently.
Note the non-obvious ones, verified against `app.js`:
- `trees[id]` has `owner` and `note` localized, but `path` and `command` are
**single strings, not localized** — they are shell paths.
- `routes[id].score` is a **number**, rendered into `style="--score:N%"`.
- `workers[id]` is `{ en: string[], pt: string[] }` — a three-element array
(`span`, `strong`, `small`), not an object.
- `skillFiles[id]` mixes `icon`/`title` (unlocalized) with `en`/`pt` strings.
- `providers[id].tiers` is `[string, string, Localized][]`.
Read the real `app.js` for each. Do not infer a shape from a sibling.
## Rules that bite here
- **Both `en` and `pt` are mandatory on every localized field.** A missing `pt`
must fail the build. Never make a field optional to clear a validation error —
find the real Portuguese string in `app.js`.
- Copy strings **verbatim**. This is a move, not a rewrite. No fixed typos, no
improved phrasing, nothing translated that is already translated. Several
strings contain inline HTML; keep it exactly as-is.
- Prove it. Diff each migrated string against the `app.js` original
programmatically and report the result — task 15b did this for the prompt
bodies and it is the standard here.
- Do not delete `interactiveCopy` from `app.js`. The legacy page still runs on
it until task 20 cuts over, and `verify.mjs` still asserts against it.
- Do not migrate a page or touch a component. 15d owns that.
- Do not edit `verify.mjs`, `tokens.css`, or `astro.config.mjs`.
## Done when
- [ ] Six collections defined and exported from `config.ts`
- [ ] Entries for every id in `interactiveCopy`, both locales
- [ ] Shapes match `GuideSelectorData`, or the divergence is argued in the
report
- [ ] Every string proven byte-identical to its `app.js` original
- [ ] `pnpm run gate` green — the full gate, not `verify` + `audit-ui` alone
- [ ] 42 assertions intact
+18 -60
View File
@@ -1,64 +1,22 @@
# Task 15 — Full guide
# Task 15 — Full guide — **SUPERSEDED, split into 15a15e**
**Agent**: `page-migrator` · **Model**: **Codex** — hardest task in the plan
**Depends on**: 05, 10, 13 · **Parallel with**: 16 **Worktree**:
`.agents/scripts/worktree.sh start 15 page-full-guide`
Do not work this brief. It is kept because other documents link to it.
## Goal
Task 15 was attempted twice on Codex and produced no usable commit either time.
The second report's own words: _"this is an incomplete scaffold, not the real
migration requested."_ `MODEL-ROUTING.md` says a task needing more than two
models' worth of hand-holding is too big — split it. So it is split, along the
seams that made it hard: the nine-fold selector duplication, the clipboard
fallback, and the selector-map language toggle that cannot survive the port.
`/full-guide/` → Astro. 22 KB of HTML, a 50 KB script, 30 KB of CSS, twelve
render functions, bilingual throughout.
| Brief | What | Depends on |
| ---------------------------------- | ------------------------------------------- | --------------------- |
| [15a](task-15a-guide-selector.md) | one generic selector island for nine groups | 05, 10 |
| [15b](task-15b-copy-prompt.md) | copy-prompt buttons, reading progress | 05 |
| [15c](task-15c-language-toggle.md) | language toggle — needs a decision first | 05 |
| [15d](task-15d-page-full-guide.md) | assemble `/full-guide/` | 10, 13, 15a, 15b, 15c |
| [15e](task-15e-responsive-css.md) | prove `responsive.css` dead, delete it | 15d, 16 |
## What `app.js` actually is
Not application code — a **bilingual content database** (task 05 already moved
it) plus ~12 `render*` functions that swap `innerHTML` on tab clicks. Once
content is a collection, the remaining JS is small: tab state and a language
toggle.
## Islands
Only these hydrate. Everything else is server-rendered.
| Island | Directive | Why |
| -------------------------------------------------------- | ---------------- | -------------------------------------------------- |
| Phase tabs | `client:visible` | click-driven panel swap |
| Tree / worker / route / model / effort / skill selectors | `client:visible` | same pattern; consider one generic selector island |
| Language toggle | `client:idle` | page-wide, not urgent |
| Copy-prompt buttons | `client:visible` | clipboard |
If you end up with 12 separate islands you have missed the pattern — they are
one selector component with different data.
## Asserted by verify.mjs — all must survive
`const phases`, `const handsOnPrompts`, `const modelGuide`,
`const skillSources`, `const skillInstallPrompts`, `render('plan')`,
`renderTree`, `renderWorker`, `renderRoute`, `renderModelProvider`,
`renderEffort`, `renderSkillFile`, `renderSkillWorkflow`, `renderCommonSkill`,
`renderHandsOn`, `copyPrompt`.
These are **implementation-detail assertions** — they look deletable and are
not. Each pins a feature. Coordinate with task 19 to replace each with an
output-level assertion of the same behaviour. **Never delete one yourself.**
Also: `data-copy-target="prompt-install-skills|prompt-basic|prompt-skills"`,
`hands-on/starter/`, `additional-reading.md`, `role="tablist"`, `<table>`.
## Watch for
- `copyPrompt` uses `navigator.clipboard` with a `document.execCommand`
fallback. Keep both — the fallback exists for non-secure contexts.
- The hands-on prompt strings are copy-pasted by attendees into an agent. Exact
whitespace and line breaks matter.
- `responsive.css` (30 KB) mostly serves this page. Port what is needed, prove
the rest dead, delete it. Screenshots are the proof.
## Done when
- [ ] Snapshot diff empty
- [ ] Every interaction works: all tabs, both languages, all copy buttons
- [ ] Keyboard: arrow keys move between tabs; focus visible throughout
- [ ] JS payload **smaller** than today's 50 KB (content is now static)
- [ ] Screenshots match at four widths; checklist complete; `pnpm run gate`
green
15a, 15b and 15c run in parallel. 15d is assembly and must not invent islands.
Downstream tasks that said "depends on 15" now depend on **15d** (18, 19) or
**15e** (20).
@@ -0,0 +1,86 @@
# Task 15a — The one guide selector island
**Agent**: `component-builder` · **Model**: **Codex** **Depends on**: 05, 10 ·
**Parallel with**: 15b · **Blocks**: 15d **Worktree**:
`.agents/scripts/worktree.sh start 15a guide-selector`
## Why this task exists
Task 15 was attempted twice on Codex and produced no usable commit either time.
Its own second report said "this is an incomplete scaffold, not the real
migration requested". `MODEL-ROUTING.md` says a task that needs more than two
models' worth of hand-holding is too big — so it is split. This is the first
piece: **the interactive machinery, with no page migration in it.**
## Scope
One new island under `src/components/islands/`. You import nothing into a page
and you migrate no page — tasks 07 through 11 shipped components ahead of their
pages the same way, and the gate is happy with an unimported component.
## What you are replacing
`app.js` has nine functions that are the same function nine times:
| Function | Trigger attribute | Detail panel target |
| --------------------- | --------------------- | ---------------------- |
| `render` | `data-phase` | `#phase-panel` |
| `renderWorker` | `data-worker` | `#worker-detail` |
| `renderTree` | `data-tree` | `#tree-detail` |
| `renderRoute` | `data-route` | `#route-detail` |
| `renderModelProvider` | `data-model-provider` | `#provider-detail` |
| `renderEffort` | `data-effort` | `#effort-detail` |
| `renderSkillFile` | `data-skill-file` | `#skill-detail` |
| `renderSkillWorkflow` | `data-skill-step` | `#builder-detail` |
| `renderCommonSkill` | `data-common-skill` | `#common-skill-detail` |
Each: click a button in a group, mark it active, swap the panel's `innerHTML`
from a content object. **Build one island, not nine.** If your diff has nine
components you have missed the point of the task.
The panels' inner markup differs per group (`#route-detail` draws a `--score`
meter, `#tree-detail` draws owner/checkout/note/command). Take the shape from
the slot or from a per-group layout, not from nine islands.
## The one coupling that is not uniform
`data-model-provider` clicks also re-run `renderEffort` with the currently
active effort. Preserve that. Everything else is independent.
## Asserted by verify.mjs — all nine names must survive
`render('plan')`, `renderTree`, `renderWorker`, `renderRoute`,
`renderModelProvider`, `renderEffort`, `renderSkillFile`, `renderSkillWorkflow`,
`renderCommonSkill`. Also `role="tablist"`.
These are implementation-detail assertions. They look deletable and are not —
each pins a feature. **Never delete one.** If a name genuinely cannot survive
the new shape, stop and report it; task 19 re-points assertions, you do not.
## Also deliver
- Keyboard: Arrow keys, Home and End move between buttons in a group; focus
visible throughout. `role="tablist"` groups follow the ARIA tabs pattern.
- Server-render the initially active panel. The panel must not be empty before
hydration.
- `client:visible`.
## Do not
- Migrate `/full-guide/` or create `src/pages/full-guide.astro`. That is 15d.
- Touch the language toggle or the copy buttons. Those are 15c and 15b.
- Touch `src/content/config.ts`, `verify.mjs`, `tokens.css`, `responsive.css`.
- Reformat `app.js`. It is in `.prettierignore`; keep it that way.
- Substitute a near-miss design token for a legacy value. Mark it:
`/* token-gap: <reason>; owner design-system-keeper */`. See
`.agents/rules/gates.md`.
## Done when
- [ ] One island, driven by the collections task 05 filled
- [ ] All nine behaviours reachable through it, including the provider/effort
coupling
- [ ] Keyboard and focus complete
- [ ] `pnpm run gate` green — the **full** gate, not just `verify` + `audit-ui`
- [ ] 42 assertions intact
- [ ] Report names the island's props so 15d can wire it without guessing
@@ -0,0 +1,55 @@
# Task 15b — Copy-prompt buttons and reading progress
**Agent**: `component-builder` · **Model**: MiniMax-M3 **Depends on**: 05 ·
**Parallel with**: 15a, 15c · **Blocks**: 15d **Worktree**:
`.agents/scripts/worktree.sh start 15b copy-prompt`
## Scope
Two small pieces of `app.js`, as islands. No page migration.
### 1. `copyPrompt`
Reads `#${button.dataset.copyTarget}`'s `textContent` and copies it.
- Keeps `navigator.clipboard.writeText` **and** the `document.execCommand`
textarea fallback. The fallback exists because the site is served over plain
HTTP in workshop settings, where `navigator.clipboard` is undefined. Deleting
it silently breaks the lab for attendees. Keep both paths.
- Writes a bilingual result string into `#copy-status`.
- On success, swaps the button's `<span>` to COPIED / COPIADO and back after
1800 ms.
Targets asserted by `verify.mjs`:
`data-copy-target="prompt-install-skills|prompt-basic|prompt-skills"`.
The prompt bodies are copy-pasted by attendees straight into an agent. **Exact
whitespace and line breaks matter** — verify what lands on the clipboard is
byte-identical to today's, not merely visually similar.
### 2. Reading progress
The `scroll` listener that sets `.reading-progress span`'s width. It is
`{ passive: true }` today; keep it passive.
## Language
Both pieces read `currentLanguage`. Task 15c owns how language is held. Do not
invent a second mechanism — take the language as a prop or read the document's
`lang`, and say in your report which you chose so 15c and 15d can align.
## Do not
- Create `src/pages/full-guide.astro`. That is 15d.
- Touch `verify.mjs`, `tokens.css`, `src/content/config.ts`.
- Reformat `app.js`.
- Substitute a near-miss token; mark gaps with
`/* token-gap: <reason>; owner design-system-keeper */`.
## Done when
- [ ] Both clipboard paths present and the fallback actually exercised
- [ ] `#copy-status` bilingual, and announced (it is a live region)
- [ ] Clipboard payload byte-identical to today's for all three targets
- [ ] `pnpm run gate` green — the full gate
- [ ] 42 assertions intact
@@ -0,0 +1,62 @@
# Task 15c — The language toggle
**Agent**: `content-i18n-migrator` · **Model**: **Codex** **Depends on**: 05 ·
**Parallel with**: 15a, 15b · **Blocks**: 15d **Worktree**:
`.agents/scripts/worktree.sh start 15c language-toggle`
## Why this is its own task
This is the part of the full guide that does not survive a mechanical port, and
it is the most likely reason task 15 failed twice.
Today `applyLanguage` walks a `translations.pt` map of **CSS selector →
Portuguese HTML** and overwrites `innerHTML` at each selector. It keeps an
`originals` Map to restore English. That design cannot survive the migration:
the selectors are page-structure coupling, and once the content is a collection
the Portuguese string already lives beside the English one.
## Deliver a decision, then an implementation
Write the approach down in `.agents/context/content-i18n.md` (or the rule file
it points at) **before** you build, because tasks 15d, 16, and 20 all depend on
it and there is currently no stated answer.
The realistic options:
1. **Server-render both locales, toggle visibility.** Simple, no hydration cost
for text, doubles the HTML.
2. **Server-render the saved locale, islands re-render on toggle.** Smaller
HTML; every island then needs both strings client-side anyway.
3. **Separate routes per locale.** Cleanest, but changes URLs, which touches
publishing and every internal link — out of scope unless you argue for it and
the report flags it as a plan change.
Pick one, say why, and note what it costs.
## Behaviour that must not regress
- `localStorage` key `ai-for-dummies-language`, wrapped in try/catch — previews
disable storage and an unguarded read throws.
- `document.documentElement.lang` becomes `pt-BR` or `en`.
- `[data-lang]` buttons get `.active` and `aria-pressed`.
- Toggling language re-renders the active phase panel and every selector panel.
Coordinate with 15a: the island must expose a way to do this.
- `client:idle` — page-wide, not urgent.
## Do not
- Create `src/pages/full-guide.astro`. That is 15d.
- Edit `src/content/config.ts` schemas belonging to other collections beyond
what the toggle genuinely needs; if a schema is wrong, report it.
- Touch `verify.mjs`.
- Translate, rewrite, or "improve" any string. Both locales already exist in the
collections. This is plumbing, not copywriting.
## Done when
- [ ] Approach written down where 15d, 16 and 20 will find it
- [ ] Toggle island built, `client:idle`, storage guarded
- [ ] Both locales verified on a real rendered page, not just in theory
- [ ] `pnpm run gate` green — the full gate
- [ ] 42 assertions intact
- [ ] Report states the contract 15d must satisfy
@@ -0,0 +1,102 @@
# Task 15d — Assemble /full-guide/
**Agent**: `page-migrator` · **Model**: **Codex** **Depends on**: 05b, 10, 13,
15a, 15b, 15c · **Parallel with**: 16 · **Blocks**: 15e, 18, 19 **Worktree**:
`.agents/scripts/worktree.sh start 15d page-full-guide`
## Goal
`src/pages/full-guide.astro`. 22 KB of HTML, bilingual throughout, everything
interactive already built by 15a/15b/15c and every block already built by
task 10. **This task is assembly.** If you find yourself writing a new island,
stop — it belongs to one of the earlier briefs and you should report the gap
instead.
Read the reports from 15a, 15b and 15c first. They state their props and the
language contract.
## Islands and nothing else
| Island | Directive | From |
| ------------------------------- | ---------------- | ---- |
| Guide selector (one, ×9 groups) | `client:visible` | 15a |
| Copy-prompt buttons + progress | `client:visible` | 15b |
| Language toggle | `client:idle` | 15c |
Everything else is server-rendered.
## Asserted by verify.mjs — all must survive
`const phases`, `const handsOnPrompts`, `const modelGuide`,
`const skillSources`, `const skillInstallPrompts`, `render('plan')`,
`renderTree`, `renderWorker`, `renderRoute`, `renderModelProvider`,
`renderEffort`, `renderSkillFile`, `renderSkillWorkflow`, `renderCommonSkill`,
`renderHandsOn`, `copyPrompt`, plus
`data-copy-target="prompt-install-skills|prompt-basic|prompt-skills"`,
`hands-on/starter/`, `additional-reading.md`, `role="tablist"`, `<table>`.
Implementation-detail assertions, deliberately. **Never delete one.** Task 19
re-points them to output-level checks; you do not.
## Watch for
- `hands-on/starter/` is a **lab fixture**. Link to it, ship it as a static
asset, do not componentize it. Same for `hands-on/rules/`.
- `renderHandsOn` takes no argument — it is not part of 15a's selector pattern.
Check whether 15a covered it; if not, it is yours, and say so in the report.
- Do not delete `responsive.css` here. That is 15e, and it needs screenshots.
## Done when
- [ ] Snapshot diff against `.agents/snapshots/` empty
- [ ] Every interaction works: all nine selector groups, both languages, all
three copy buttons
- [ ] Keyboard: arrows move between tabs; focus visible throughout
- [ ] JS payload **smaller** than today's 50 KB — content is static now
- [ ] Screenshots match at 560 / 800 / 1100 / 1600 px
- [ ] `pnpm run gate` green — the full gate, not `verify` + `audit-ui` alone
- [ ] 42 assertions intact
## What 15a, 15b and 15c actually shipped
Read `.agents/context/content-i18n.md` first — it is the language contract and
it is binding on this task.
- **`src/components/islands/GuideSelector.astro`** — one island, all nine
groups. Render it as
`<GuideSelector rootSelector="#full-guide" data={...} />`. `data` needs
`phases`, `workers`, `trees`, `routes`, `providers`, `efforts`, `skillFiles`,
`skillWorkflow`, `commonSkills` and a bilingual `labels` object; the
`GuideSelectorData` interface at the top of the file is the exact shape. You
server-render each group's shell and its initial detail panel, keeping today's
`data-*` hooks and `.active` state. Mark each group `role="tablist"` and its
controls `role="tab"` or the keyboard handler will not bind.
- **`src/components/islands/LanguageToggle.astro`** — render each localized
fragment twice, with `data-language-content="en"` or `"pt"` on the outer
element. The toggle flips `hidden` on those, sets `<html lang>`, and fires
`ai-for-dummies:languagechange` on `window`. It is a plain `.astro` island
that defers its own setup with `requestIdleCallback`; **do not** put
`client:idle` on it — that directive is framework-components only.
- **`src/components/islands/CopyPrompt.astro`** — one per button. Pass
`target="prompt-install-skills" | "prompt-basic" | "prompt-skills"`. Render
`<p id="copy-status" role="status" aria-live="polite">` exactly once on the
page; the island writes into it. Fill the `<pre><code id="prompt-…">` bodies
from the `handsOnPrompts` and `skillInstallPrompts` collections — 15b verified
those are byte-identical to the legacy `app.js` constants, and the clipboard
copies whatever you render, so do not reformat them.
- **`src/components/islands/ReadingProgress.astro`** — replaces the legacy
`<div class="reading-progress">` at the top of the page.
Nothing else is missing. If you are about to write an island, you are doing
another task's work — report the gap instead.
## Two things to expect
- **The snapshot will not match by construction.** Dual-locale rendering emits
both languages into the HTML where today's page emits English plus a
Portuguese map inside `app.js`. Compare _rendered, language-filtered_ output
against today's page, and if `.agents/snapshots/` needs regenerating, say so
explicitly in your report with what changed and why — do not quietly rewrite a
snapshot to make a diff go away.
- **`renderHandsOn` takes no argument** and is not part of GuideSelector's
nine-group pattern. It is yours. Its name is asserted by `verify.mjs`.
@@ -0,0 +1,42 @@
# Task 15e — Retire responsive.css
**Agent**: `design-system-keeper` · **Model**: `agy` (Gemini 3.1 Pro — vision)
**Depends on**: 15d, 16 · **Blocks**: 20 **Worktree**:
`.agents/scripts/worktree.sh start 15e responsive-css`
## Goal
`responsive.css` is 30 KB and mostly served `/full-guide/`. Once 15d and 16 have
landed, port what the Astro pages still need into component styles or
`tokens.css`, prove the remainder dead, and delete it.
**Proof is screenshots, not reading.** A rule that looks unused because no
selector matches at 1600 px may be the only thing holding the 560 px layout
together.
## Method
1. Build. Screenshot every migrated route at 560 / 800 / 1100 / 1600 px.
2. Remove `responsive.css` from the build entirely.
3. Screenshot again. Every diff is a rule you must port.
4. Port it into the owning component's `<style>`, or — if it is a real token —
into `tokens.css`, which **you own**. No other agent may add tokens.
5. Repeat until the diffs are empty.
## Constraints
- Allowed breakpoints are 560 / 800 / 1100 / 1600 / 2200 px.
`.agents/scripts/check-tokens.mjs` rejects others.
- Do not delete the file while any legacy page still loads it. Check which
routes have actually been migrated at the time you run; 20 is the cutover.
- No raw hex, no `font-size: Npx` outside `tokens.css`.
- You are also the owner of the ~190 accumulated `/* token-gap: ... */` markers.
Resolving them is **not** in this brief — do not start. Report the count so it
can be scheduled.
## Done when
- [ ] Screenshot diffs empty at all four widths without `responsive.css`
- [ ] Ported rules live with the component that needs them, or in `tokens.css`
- [ ] `responsive.css` deleted, and nothing references it
- [ ] `pnpm run gate` green; 42 assertions intact
+205
View File
@@ -0,0 +1,205 @@
---
// CopyPrompt — copy-to-clipboard island for full-guide. One per button.
//
// Click reads `#${target}`'s textContent, copies it via
// `navigator.clipboard.writeText`, and falls back to a `document.execCommand`
// textarea when the clipboard API is unavailable (the workshop serves the
// site over plain HTTP in some venues, where `navigator.clipboard` is
// undefined — deleting the fallback silently breaks the lab for attendees).
// Both paths are kept on purpose; this is the contract the legacy app.js
// copyPrompt function uses for the three full-guide prompts.
//
// On success, writes a bilingual result string to the page-owned
// `#copy-status` (live region, `role="status"` `aria-live="polite"`) and
// swaps the button's <span> to COPIED / COPIADO for 1800 ms before
// restoring it.
//
// Language comes from `document.documentElement.lang` — set today by
// app.js's `applyLanguage` and tomorrow by task 15c's language-toggle
// island. A `MutationObserver` on `<html lang>` keeps the button label
// in step with whatever flips it; this island does not own the toggle
// and does not invent a second mechanism. Task 15c and 15d should align
// on this — see the task report.
interface Props {
// The id (without `#`) of the <pre><code> element whose textContent
// is copied. Matches `data-copy-target="…"` on the rendered button,
// which is the token `scripts/verify.mjs` asserts in the legacy
// `full-guide/index.html`.
target: 'prompt-install-skills' | 'prompt-basic' | 'prompt-skills' | string;
}
const { target } = Astro.props;
---
<button class="copy-prompt-button" data-copy-target={target} data-copy-prompt-button type="button">
<span data-copy-prompt-label>COPY</span>
<i aria-hidden="true"></i>
</button>
<script is:inline>
// CopyPrompt island — click handler.
// Wire-once guard: a page that uses <CopyPrompt> three times renders
// three inline script tags. Each one would otherwise attach three
// click listeners per button; this flag makes the second and third
// calls a no-op.
(function () {
if (window.__copyPromptWired) return;
window.__copyPromptWired = true;
const STRINGS = {
en: {
button: 'COPY',
copied: 'COPIED',
success: 'Prompt copied. Paste it into a fresh agent session.',
failure: 'Copy unavailable. Select the text manually.',
},
pt: {
button: 'COPIAR',
copied: 'COPIADO',
success: 'Prompt copiado. Cole em uma nova sessão de agente.',
failure: 'Não foi possível copiar. Selecione o texto manualmente.',
},
};
function currentLang() {
return document.documentElement.lang === 'pt-BR' ? 'pt' : 'en';
}
function labels() {
return STRINGS[currentLang()];
}
function setStatus(copied) {
const status = document.querySelector('#copy-status');
if (!status) return;
const l = labels();
status.textContent = copied ? l.success : l.failure;
}
function fallbackCopy(value) {
// The textarea dance is straight from the legacy app.js: a fixed,
// invisible, readonly <textarea> selected synchronously, so
// document.execCommand('copy') sees a real selection. Removing
// any of those three bits makes the fallback fail on at least
// one browser the workshop covers.
const helper = document.createElement('textarea');
helper.value = value;
helper.setAttribute('readonly', '');
helper.style.position = 'fixed';
helper.style.opacity = '0';
document.body.appendChild(helper);
helper.select();
let copied = false;
try {
copied = document.execCommand('copy');
} catch {
copied = false;
}
helper.remove();
return copied;
}
function onResult(button, copied) {
setStatus(copied);
if (!copied) return;
const l = labels();
const span = button.querySelector('span');
if (span) span.textContent = l.copied;
button.classList.add('copied');
// 1800ms matches the legacy copyPrompt. Long enough for a
// sighted user to read COPIED, short enough not to feel sticky
// if the next click follows fast.
window.setTimeout(function () {
button.classList.remove('copied');
if (span) span.textContent = labels().button;
}, 1800);
}
function copyPrompt(button) {
const id = button.dataset.copyTarget;
if (!id) return;
const target = document.querySelector('#' + id);
if (!target) return;
const value = target.textContent || '';
if (
typeof navigator !== 'undefined' &&
navigator.clipboard &&
typeof navigator.clipboard.writeText === 'function'
) {
// navigator.clipboard.writeText is async; resolve to true on
// success, fall back to the textarea dance on rejection
// (insecure context, missing permission, etc).
navigator.clipboard.writeText(value).then(
function () {
onResult(button, true);
},
function () {
onResult(button, fallbackCopy(value));
},
);
return;
}
onResult(button, fallbackCopy(value));
}
function syncLabels() {
const l = labels();
const buttons = document.querySelectorAll('[data-copy-prompt-button]');
for (let i = 0; i < buttons.length; i++) {
const btn = buttons[i];
// Mid-feedback buttons (1800ms COPIED state) keep their
// swapped label until the timer fires; otherwise the toggle
// would visibly flicker the COPIED text back to the default.
if (btn.classList.contains('copied')) continue;
const span = btn.querySelector('span');
if (span) span.textContent = l.button;
}
}
const buttons = document.querySelectorAll('[data-copy-prompt-button]');
for (let i = 0; i < buttons.length; i++) {
buttons[i].addEventListener(
'click',
(function (button) {
return function () {
copyPrompt(button);
};
})(buttons[i]),
);
}
// Pick up external language changes (task 15c's toggle) without
// owning the toggle itself. attributeFilter keeps the observer
// from firing on unrelated <html> mutations.
if (typeof MutationObserver === 'function') {
new MutationObserver(syncLabels).observe(document.documentElement, {
attributes: true,
attributeFilter: ['lang'],
});
}
// Sync on first hydrate so a page rendered server-side as English
// picks up the user's stored PT preference without waiting for a
// click. app.js's applyLanguage has already set <html lang> by the
// time this script runs in the current page wiring, so this is a
// one-shot correction, not a race.
syncLabels();
})();
</script>
<style>
/* Component-scoped. The legacy full-guide/styles.css carries the
full visual treatment (button border, hover, `.copied` state,
etc.); 15d ports that wholesale when it migrates the page. This
block only ensures the button is keyboard-reachable and announces
the COPIED state — the rest lives in the page stylesheet so the
legacy file and the new page stay visually identical. */
.copy-prompt-button {
cursor: pointer;
}
.copy-prompt-button:focus-visible {
outline: 3px solid var(--gold);
outline-offset: 2px;
}
</style>
+294
View File
@@ -0,0 +1,294 @@
---
// GuideSelector — one client:visible controller for the nine independent
// selectors on /full-guide/. The page owns the server-rendered shells; this
// leaf only swaps their already-present detail panels after they are visible.
interface Localized {
en: string;
pt: string;
}
interface GuideSelectorData {
phases: Record<string, { model: Localized; title: Localized; copy: Localized; code: Localized }>;
workers: Record<string, { en: string[]; pt: string[] }>;
trees: Record<string, { owner: Localized; path: string; note: Localized; command: string }>;
routes: Record<string, { score: number; label: Localized; why: Localized }>;
providers: Record<
string,
{
label: string;
source: string;
title: Localized;
copy: Localized;
tiers: [string, string, Localized][];
config: string;
}
>;
efforts: Record<string, { en: string[]; pt: string[] }>;
skillFiles: Record<string, { icon: string; title: string; en: string; pt: string }>;
skillWorkflow: Record<
string,
{
number: string;
title: Localized;
question: Localized;
action: Localized;
output: Localized;
proof: Localized;
}
>;
commonSkills: Record<
string,
{
number: string;
kind: Localized;
title: string;
rule: Localized;
use: Localized;
example: Localized;
caution: Localized;
source: string;
}
>;
labels: {
context: Localized;
owner: Localized;
reasoningLoad: Localized;
officialSource: Localized;
skillFileHint: Localized;
workflow: { question: Localized; action: Localized; artifact: Localized; proof: Localized };
commonSkill: {
whenToUse: Localized;
example: Localized;
watchOut: Localized;
source: Localized;
};
};
}
interface Props {
/** The page root that contains the nine static selector shells. */
rootSelector: string;
/** All bilingual selector data, supplied by the page from content collections. */
data: GuideSelectorData;
}
const { rootSelector, data } = Astro.props;
---
<!-- client:visible equivalent: bind only after the page-owned root is visible. -->
<span aria-hidden="true" data-guide-selector-hydration="visible"></span>
<script define:vars={{ rootSelector, data }}>
(() => {
const root = document.querySelector(rootSelector);
if (!root) return;
const language = () => (document.documentElement.lang === 'pt-BR' ? 'pt' : 'en');
const item = (group, id) => data[group][id];
const activeId = (selector, key, fallback) =>
root.querySelector(`${selector}.active`)?.dataset[key] || fallback;
function selectButtons(selector, activeValue, key) {
root.querySelectorAll(selector).forEach((button) => {
const active = button.dataset[key] === activeValue;
button.classList.toggle('active', active);
button.setAttribute(
button.hasAttribute('aria-selected') ? 'aria-selected' : 'aria-pressed',
String(active),
);
});
}
function replace(target, markup) {
const panel = root.querySelector(target);
if (panel) panel.innerHTML = markup;
}
// Names deliberately match legacy functions until task 19 updates its checks.
function render(id) {
const phase = item('phases', id);
if (!phase) return;
const locale = language();
replace(
'#phase-panel',
`<div class="phase-meta"><span>${phase.model[locale]}</span><small>${data.labels.context[locale]}</small></div><h3>${phase.title[locale]}</h3><p>${phase.copy[locale]}</p><code>${phase.code[locale]}</code>`,
);
selectButtons('[data-phase]', id, 'phase');
}
function renderWorker(id) {
const worker = item('workers', id);
if (!worker) return;
const [label, detail, result] = worker[language()];
replace(
'#worker-detail',
`<span>${label}</span><strong>${detail}</strong><small>${result}</small>`,
);
selectButtons('[data-worker]', id, 'worker');
}
function renderTree(id) {
const tree = item('trees', id);
if (!tree) return;
const locale = language();
replace(
'#tree-detail',
`<div><span>${data.labels.owner[locale]}</span><strong>${tree.owner[locale]}</strong></div><div><span>CHECKOUT</span><strong>${tree.path}</strong></div><p>${tree.note[locale]}</p><code>${tree.command}</code>`,
);
selectButtons('[data-tree]', id, 'tree');
}
function renderRoute(id) {
const route = item('routes', id);
if (!route) return;
const locale = language();
replace(
'#route-detail',
`<div class="route-meter"><span style="--score:${route.score}%"></span></div><div><small>${data.labels.reasoningLoad[locale]} · ${route.score}</small><strong>${route.label[locale]}</strong><p>${route.why[locale]}</p></div>`,
);
selectButtons('[data-route]', id, 'route');
}
function renderModelProvider(id) {
const provider = item('providers', id);
if (!provider) return;
const locale = language();
const tiers = provider.tiers
.map(
([kind, name, note]) =>
`<div><span>${locale === 'pt' ? { STRONG: 'FORTE', BALANCED: 'EQUILÍBRIO', FAST: 'RÁPIDO' }[kind] || kind : kind}</span><strong>${name}</strong><small>${note[locale]}</small></div>`,
)
.join('');
replace(
'#provider-detail',
`<header><span>${provider.label}</span><a href="${provider.source}" target="_blank" rel="noopener">${data.labels.officialSource[locale]}</a></header><h3>${provider.title[locale]}</h3><p>${provider.copy[locale]}</p><div class="model-ladder">${tiers}</div>`,
);
selectButtons('[data-model-provider]', id, 'modelProvider');
}
function renderEffort(id) {
const effort = item('efforts', id);
if (!effort) return;
const [label, copy, code] = effort[language()];
const provider = item(
'providers',
activeId('[data-model-provider]', 'modelProvider', 'openai'),
);
replace(
'#effort-detail',
`<span>${label}</span><p>${copy}</p><code>${provider?.config || code}</code>`,
);
selectButtons('[data-effort]', id, 'effort');
}
function renderSkillFile(id) {
const file = item('skillFiles', id);
if (!file) return;
const locale = language();
replace(
'#skill-detail',
`<span>${file.icon}</span><div><strong>${file.title}</strong><p>${file[locale]}</p><small>${data.labels.skillFileHint[locale]}</small></div>`,
);
selectButtons('[data-skill-file]', id, 'skillFile');
}
function renderSkillWorkflow(id) {
const step = item('skillWorkflow', id);
if (!step) return;
const locale = language();
const labels = data.labels.workflow;
replace(
'#builder-detail',
`<header><span>${step.number}</span><small>${labels.question[locale]}</small></header><h3>${step.title[locale]}</h3><blockquote>${step.question[locale]}</blockquote><div class="builder-action"><span>${labels.action[locale]}</span><p>${step.action[locale]}</p></div><footer><div><span>${labels.artifact[locale]}</span><strong>${step.output[locale]}</strong></div><div><span>${labels.proof[locale]}</span><strong>${step.proof[locale]}</strong></div></footer>`,
);
selectButtons('[data-skill-step]', id, 'skillStep');
}
function renderCommonSkill(id) {
const skill = item('commonSkills', id);
if (!skill) return;
const locale = language();
const labels = data.labels.commonSkill;
replace(
'#common-skill-detail',
`<header><span>${skill.number}</span><small>${skill.kind[locale]}</small></header><h3>${skill.title}</h3><blockquote>${skill.rule[locale]}</blockquote><div class="common-skill-notes"><div><span>${labels.whenToUse[locale]}</span><p>${skill.use[locale]}</p></div><div><span>${labels.example[locale]}</span><p>${skill.example[locale]}</p></div><div><span>${labels.watchOut[locale]}</span><p>${skill.caution[locale]}</p></div></div><a class="skill-source" href="${skill.source}" target="_blank" rel="noopener">${labels.source[locale]}</a>`,
);
selectButtons('[data-common-skill]', id, 'commonSkill');
}
const renderers = [
['[data-phase]', 'phase', 'plan', render],
['[data-worker]', 'worker', 'ui', renderWorker],
['[data-tree]', 'tree', 'main', renderTree],
['[data-route]', 'route', 'plan', renderRoute],
['[data-model-provider]', 'modelProvider', 'openai', renderModelProvider],
['[data-effort]', 'effort', 'medium', renderEffort],
['[data-skill-file]', 'skillFile', 'skill', renderSkillFile],
['[data-skill-step]', 'skillStep', 'observe', renderSkillWorkflow],
['[data-common-skill]', 'commonSkill', 'ponytail', renderCommonSkill],
];
function renderAll() {
const phaseId = activeId('[data-phase]', 'phase', 'plan');
if (phaseId === 'plan') render('plan');
else render(phaseId);
renderers
.slice(1)
.forEach(([selector, key, fallback, renderer]) =>
renderer(activeId(selector, key, fallback)),
);
}
function moveTab(event) {
if (!['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key))
return;
const tabs = [...event.currentTarget.querySelectorAll('[role="tab"]')];
if (!tabs.length) return;
event.preventDefault();
const current = tabs.indexOf(document.activeElement);
const offset = event.key === 'ArrowRight' || event.key === 'ArrowDown' ? 1 : -1;
const next =
event.key === 'Home'
? 0
: event.key === 'End'
? tabs.length - 1
: (current + offset + tabs.length) % tabs.length;
tabs[next].click();
tabs[next].focus();
}
function bind() {
renderers.forEach(([selector, key, fallback, renderer]) =>
root.querySelectorAll(selector).forEach((button) =>
button.addEventListener('click', () => {
renderer(button.dataset[key] || fallback);
if (key === 'modelProvider')
renderEffort(activeId('[data-effort]', 'effort', 'medium'));
}),
),
);
root
.querySelectorAll('[role="tablist"]')
.forEach((list) => list.addEventListener('keydown', moveTab));
new MutationObserver((changes) => {
if (changes.some((change) => change.attributeName === 'lang')) renderAll();
}).observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] });
window.addEventListener('ai-for-dummies:languagechange', renderAll);
renderAll();
}
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
observer.disconnect();
bind();
}
});
observer.observe(root);
} else bind();
})();
</script>
<style>
/* Verbatim from `responsive.css`, where every selector button in these nine
groups shares one focus ring. Scoped to the groups this island drives so it
does not restyle buttons the island has nothing to do with. */
:global([data-worker]:focus-visible),
:global([data-tree]:focus-visible),
:global([data-route]:focus-visible),
:global([data-model-provider]:focus-visible),
:global([data-effort]:focus-visible),
:global([data-skill-file]:focus-visible),
:global([data-skill-step]:focus-visible),
:global([data-common-skill]:focus-visible),
:global([data-phase]:focus-visible) {
outline: 3px solid var(--gold);
outline-offset: -3px;
}
</style>
@@ -0,0 +1,68 @@
---
// LanguageToggle — idle-hydrated, full-guide-only locale control.
//
// The guide server-renders both locales. This leaf owns the persisted locale
// and announces changes so selector islands can rebuild their active panels
// from collection data without knowing about this control's markup.
---
<div class="lang-switch" aria-label="Language" data-language-toggle>
<button class="active" data-lang="en" aria-pressed="true">EN</button>
<span>/</span>
<button data-lang="pt" aria-pressed="false">PT</button>
</div>
<script>
type Language = 'en' | 'pt';
function setup(root: HTMLElement) {
const buttons = root.querySelectorAll<HTMLButtonElement>('[data-lang]');
function renderLanguage(next: Language) {
document.documentElement.lang = next === 'pt' ? 'pt-BR' : 'en';
buttons.forEach((button) => {
const isActive = button.dataset.lang === next;
button.classList.toggle('active', isActive);
button.setAttribute('aria-pressed', String(isActive));
});
document.querySelectorAll<HTMLElement>('[data-language-content]').forEach((node) => {
node.hidden = node.dataset.languageContent !== next;
});
window.dispatchEvent(
new CustomEvent('ai-for-dummies:languagechange', { detail: { language: next } }),
);
}
function selectLanguage(value: string | undefined): Language {
return value === 'pt' ? 'pt' : 'en';
}
buttons.forEach((button) => {
button.addEventListener('click', () => {
const language = selectLanguage(button.dataset.lang);
renderLanguage(language);
try {
localStorage.setItem('ai-for-dummies-language', language);
} catch {
// Preview environments can disable storage.
}
});
});
let savedLanguage = 'en';
try {
savedLanguage = localStorage.getItem('ai-for-dummies-language') || 'en';
} catch {
// Preview environments can disable storage.
}
renderLanguage(selectLanguage(savedLanguage));
}
document.querySelectorAll<HTMLElement>('[data-language-toggle]').forEach((root) => {
if ('requestIdleCallback' in window) {
window.requestIdleCallback(() => setup(root));
} else {
setTimeout(() => setup(root), 0);
}
});
</script>
@@ -0,0 +1,57 @@
---
// ReadingProgress — scroll-position bar. One instance per page.
//
// Renders the same markup the legacy `full-guide/index.html` line 13
// carried: `<div class="reading-progress" aria-hidden="true"><span></span></div>`.
// The scroll listener is registered with `{ passive: true }`, matching
// app.js line 406 and the rule in `.agents/rules/animation.md` that a
// passive listener is required for any handler bound to a frequently
// fired event like scroll. Animating `width` here is fine — the bar
// is a 3px element at the very top of the viewport, so layout cost
// is negligible compared to the alternative of subscribing to
// IntersectionObserver and computing progress from a single sentinel
// per section.
//
// Language: this island does not read language. It has no string
// labels and the same progress semantics apply to EN and PT.
interface Props {
// Optional override selector. Defaults to `.reading-progress span`
// to match the legacy app.js query, in case a page renders the bar
// with a different class name.
target?: string;
}
const { target = '.reading-progress span' } = Astro.props;
---
<div class="reading-progress" aria-hidden="true">
<span data-reading-progress-span></span>
</div>
<script is:inline define:vars={{ selector: target }}>
// ReadingProgress island — passive scroll listener.
// Wire-once guard mirrors CopyPrompt: a page that renders the bar
// twice would otherwise stack two scroll handlers on `window`.
(function () {
if (window.__readingProgressWired) return;
window.__readingProgressWired = true;
const span = document.querySelector(selector);
if (!span) return;
function update() {
const height = document.documentElement.scrollHeight - window.innerHeight;
span.style.width = (height > 0 ? (window.scrollY / height) * 100 : 0) + '%';
}
// `{ passive: true }` is non-negotiable. The legacy app.js binds
// this listener passive, and a non-passive scroll handler on the
// top edge of the document is exactly the kind of input latency
// that fails INP. See `.agents/rules/animation.md`.
window.addEventListener('scroll', update, { passive: true });
// Set initial state — important when the user navigates with
// #hash deep links and lands partway down a long page.
update();
})();
</script>