Compare commits
69 Commits
dc01460ee3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 61de2f6270 | |||
| ef5e8e8b42 | |||
| 25ef5af63e | |||
| be271f65c1 | |||
| 2ecfdf1d67 | |||
| 6b4f2e6bd0 | |||
| dc6cb5a0a3 | |||
| 9015e7bd1d | |||
| d88d8b89eb | |||
| b119b92948 | |||
| 580293867d | |||
| 9cb1e0d242 | |||
| 12c32d2fd7 | |||
| 5c76a13b4d | |||
| 91e8d380d0 | |||
| 0a12e9cbcc | |||
| e3ce8abe89 | |||
| 138d7c5e4e | |||
| 33ce544a1e | |||
| f53159f0b4 | |||
| 0c02780dd9 | |||
| aa28158534 | |||
| 429b4e2e87 | |||
| c9796a3e7f | |||
| 9486188983 | |||
| 2560437bfa | |||
| bf46363ea9 | |||
| 18862e8e13 | |||
| 6974800d2e | |||
| bd3805f883 | |||
| 8f82304758 | |||
| a97c2a4034 | |||
| a1eb1e79ea | |||
| c2b35d5355 | |||
| 8c7ca2aded | |||
| c7d06a120d | |||
| aefbd206f9 | |||
| 0bca43cbfd | |||
| 47fff7d289 | |||
| 2b621843a2 | |||
| d15c301fdb | |||
| 9ca3f48def | |||
| 12bc1dc067 | |||
| 71c74fb7f1 | |||
| edd03f9716 | |||
| 6bfae2033e | |||
| af1133f019 | |||
| c2a046d6f7 | |||
| 64a7f2e4c5 | |||
| 83c956c2b6 | |||
| 208ec9447b | |||
| 054393f7af | |||
| c022a93302 | |||
| ea5178c3da | |||
| aa49218fc8 | |||
| 2b49106b7f | |||
| 38b92bc66c | |||
| b46d8008fd | |||
| db3ffdf6b0 | |||
| cac1115035 | |||
| a5d9630dd8 | |||
| 715f4f80b1 | |||
| f7985bfe2a | |||
| 677c511979 | |||
| 98ab6db3de | |||
| 6d669d760a | |||
| 0b4f2dd403 | |||
| be2cf2d1c5 | |||
| 64b506aa32 |
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: motion-designer
|
name: motion-designer
|
||||||
description: Adds and audits animation — transitions, state changes, optional view transitions. Use for task 17 and any change involving movement. Do not use for static layout or styling work.
|
description:
|
||||||
|
Adds and audits animation — transitions, state changes, optional view
|
||||||
|
transitions. Use for task 17 and any change involving movement. Do not use for
|
||||||
|
static layout or styling work.
|
||||||
tools: Read, Write, Edit, Bash, Grep, Glob
|
tools: Read, Write, Edit, Bash, Grep, Glob
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: reviewer
|
name: reviewer
|
||||||
description: Merge gate. Reviews a task branch diff against its brief and the project rules. Use before merging any refactor task. Never writes features or fixes findings itself.
|
description:
|
||||||
|
Merge gate. Reviews a task branch diff against its brief and the project
|
||||||
|
rules. Use before merging any refactor task. Never writes features or fixes
|
||||||
|
findings itself.
|
||||||
tools: Read, Grep, Glob, Bash
|
tools: Read, Grep, Glob, Bash
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -8,7 +11,8 @@ You are the merge gate. You read diffs and report. **You do not write features
|
|||||||
and you do not fix what you find** — you name it precisely enough that the
|
and you do not fix what you find** — you name it precisely enough that the
|
||||||
owning agent can.
|
owning agent can.
|
||||||
|
|
||||||
**Read**: the task file, then every rule in `.agents/rules/` relevant to the diff.
|
**Read**: the task file, then every rule in `.agents/rules/` relevant to the
|
||||||
|
diff.
|
||||||
|
|
||||||
## Order of checks — highest-value first
|
## Order of checks — highest-value first
|
||||||
|
|
||||||
|
|||||||
@@ -1,44 +1,47 @@
|
|||||||
# Context: architecture, current and target
|
# Context: architecture
|
||||||
|
|
||||||
## Current (no build step)
|
## Current (Astro, static output)
|
||||||
|
|
||||||
Ten hand-written HTML pages, each linking its own CSS and one ES module:
|
Ten routes, one `src/pages/` entry each, built to `dist/`:
|
||||||
|
|
||||||
| Route | Page | Script | Stylesheets |
|
| Route | Page | Islands |
|
||||||
| --- | --- | --- | --- |
|
| -------------------- | ------------------------------- | ----------------------------------------------- |
|
||||||
| `/` | `index.html` | — | `chapters.css`, `landing.css` |
|
| `/` | `src/pages/index.astro` | — |
|
||||||
| `/full-guide/` | `full-guide/index.html` | `app.js` (50 KB) | `styles.css`, `responsive.css`, `audit.css` |
|
| `/full-guide/` | `src/pages/full-guide.astro` | `GuideSelector`, `LanguageToggle`, `CopyPrompt` |
|
||||||
| `/summary/` | `summary/index.html` | — | `chapters.css` |
|
| `/summary/` | `src/pages/summary.astro` | — |
|
||||||
| `/models/` | `models/index.html` | — | `chapters.css` |
|
| `/models/` | `src/pages/models.astro` | — |
|
||||||
| `/agents/` | `agents/index.html` | — | `chapters.css` |
|
| `/agents/` | `src/pages/agents.astro` | — |
|
||||||
| `/skills/` | `skills/index.html` | `skills/app.js` | `skills/styles.css` |
|
| `/skills/` | `src/pages/skills.astro` | `SkillPackageExplorer` |
|
||||||
| `/rules/` | `rules/index.html` | `rules/app.js` | `rules/styles.css` |
|
| `/rules/` | `src/pages/rules.astro` | `RulesInteractive` |
|
||||||
| `/skills-review/` | `skills-review/index.html` | `skills-review/app.js` | `skills-review/styles.css`, `change-lens.css` |
|
| `/skills-review/` | `src/pages/skills-review.astro` | `legacy/skills-review/app.js` |
|
||||||
| `/hands-on/starter/` | lab fixture | own | own |
|
| `/hands-on/starter/` | `public/` lab fixture | own |
|
||||||
| `/hands-on/rules/` | lab fixture | own | own |
|
| `/hands-on/rules/` | `public/` lab fixture | own |
|
||||||
|
|
||||||
Weight is concentrated: `app.js` 50 KB, `responsive.css` 30 KB,
|
## What is still unmigrated
|
||||||
`skills-review/catalog.js` 27 KB, `skills-review/submitted-catalog.js` 18 KB.
|
|
||||||
|
|
||||||
### What each big file actually is
|
`legacy/` holds the parts the migration did not componentize. They are not dead
|
||||||
|
files — the pages listed above import them, and the build fails without them.
|
||||||
|
|
||||||
- **`app.js`** — not really application code. It is a **bilingual content
|
- **`legacy/styles/guide.css`** (was `styles.css`) — the editorial visual
|
||||||
database** (`phases`, `handsOnPrompts`, `modelGuide`, `skillSources`,
|
system, imported by `full-guide.astro`.
|
||||||
`skillInstallPrompts`, each keyed `{en, pt}`) plus ~12 small `render*`
|
- **`legacy/styles/audit.css`** (was `full-guide/audit.css`) — responsive audit
|
||||||
functions that swap `innerHTML` on tab clicks. ~50 `en:` keys. The content
|
overrides, imported by `full-guide.astro`.
|
||||||
should become data; only the tab behaviour is interactive.
|
- **`legacy/styles/chapters.css`** — imported by `ChapterLayout.astro`.
|
||||||
- **`responsive.css`** — a 30 KB append-only layer of overrides bolted on top of
|
- **`legacy/styles/skills.css`**, **`skills-review.css`**, **`change-lens.css`**
|
||||||
`styles.css`. Expect large parts to be dead once layout moves into components.
|
— imported by their respective pages.
|
||||||
Do not port it verbatim.
|
- **`legacy/skills-review/`** — `app.js` and the module graph under it
|
||||||
- **`skills-review/catalog.js`** — the real data model of the review desk: one
|
(`catalog.js`, `submitted-catalog.js`, `files.js`, `submitted-files.js`,
|
||||||
entry per submitted skill with `id`, `author`, `title`, `status`, `focus`,
|
`vote.js`). `catalog.js` + `submitted-catalog.js` are the review desk's real
|
||||||
`wins[]`, `improve[]`, `extras`, `improved` (full markdown). 24 entries across
|
data model, 24 entries; they are a content collection in all but name.
|
||||||
`catalog.js` + `submitted-catalog.js`. This is already a content collection in
|
|
||||||
all but name.
|
|
||||||
- **`skills-review/files.js` / `submitted-files.js`** — generated file manifests.
|
|
||||||
- **`vote.js`** — the vote widget island; talks to `vote-service/`.
|
|
||||||
|
|
||||||
## Target (Astro)
|
These sit outside `src/` deliberately: `check-tokens.mjs` sweeps `src`, and
|
||||||
|
these files are full of raw hex and unnamed breakpoints. Moving one into `src/`
|
||||||
|
means migrating it to tokens in the same change, not adding an exclusion.
|
||||||
|
|
||||||
|
`responsive.css`, `landing.css`, `app.js`, `rules/app.js`, `rules/styles.css`,
|
||||||
|
and `skills/app.js` were deleted at cutover: their content lives in components.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
@@ -51,15 +54,15 @@ public/
|
|||||||
hands-on/ lab fixtures copied verbatim, never processed
|
hands-on/ lab fixtures copied verbatim, never processed
|
||||||
```
|
```
|
||||||
|
|
||||||
### Non-negotiables for the target
|
### Non-negotiables
|
||||||
|
|
||||||
- **URLs do not change.** `/full-guide/`, `/skills-review/`, `/hands-on/starter/`
|
- **URLs do not change.** `/full-guide/`, `/skills-review/`,
|
||||||
and the rest must resolve exactly as they do now, trailing slash included.
|
`/hands-on/starter/` and the rest must resolve exactly as they do now,
|
||||||
Existing links (including `docs/`, SilverBullet, and shared URLs with
|
trailing slash included. Existing links (including `docs/`, SilverBullet, and
|
||||||
`?author=…&skill=…&view=…` query params) must keep working.
|
shared URLs with `?author=…&skill=…&view=…` query params) must keep working.
|
||||||
- **Zero JS by default.** Seven of the ten pages ship no JavaScript today.
|
- **Zero JS by default.** Seven of the ten pages ship no JavaScript. They must
|
||||||
They must still ship none. Islands are opt-in, per component, and justified.
|
still ship none. Islands are opt-in, per component, and justified.
|
||||||
- **`hands-on/` stays vanilla.** It goes in `public/` untouched. It is a lab
|
- **`hands-on/` stays vanilla.** It lives in `public/` untouched. It is a lab
|
||||||
fixture, not a component.
|
fixture, not a component.
|
||||||
- **No external runtime requests.** `audit-ui.mjs` enforces this and it is part
|
- **No external runtime requests.** `audit-ui.mjs` enforces this and it is part
|
||||||
of the site's thesis. Self-host anything you add.
|
of the site's thesis. Self-host anything you add.
|
||||||
@@ -69,7 +72,12 @@ public/
|
|||||||
|
|
||||||
## Companion service
|
## Companion service
|
||||||
|
|
||||||
`vote-service/` is a Go API on its own Kubernetes deploy cycle, reached by the
|
The vote API is a Go service on its own Kubernetes deploy cycle, reached by the
|
||||||
review desk over `window.SKILLS_REVIEW_VOTE_API`. The refactor does not touch
|
review desk over `window.SKILLS_REVIEW_VOTE_API`. Its source left this
|
||||||
it. Keep the global, or replace it with a build-time `PUBLIC_VOTE_API` env var —
|
repository on 2026-09-06; the deployed service is unchanged, and the review desk
|
||||||
but if you do, update `vote-service/README.md` in the same change.
|
still calls it. Keep the global, or replace it with a build-time
|
||||||
|
`PUBLIC_VOTE_API` env var — but if you do, update the service's own README in
|
||||||
|
the same change.
|
||||||
|
|
||||||
|
Its one-vote-per-IP assertion left `verify.mjs` with it. See
|
||||||
|
[`assertion-removals.md`](assertion-removals.md).
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Assertion removal ledger
|
||||||
|
|
||||||
|
`scripts/verify.mjs` may only lose an assertion by adding an entry here. The
|
||||||
|
gate counts the `## ` headings in this file and allows exactly that many
|
||||||
|
removals below the recorded floor — so a reduction is impossible without a
|
||||||
|
written reason landing in the same commit, as a visible diff.
|
||||||
|
|
||||||
|
Adding an entry is not a formality. An assertion pins a real contract; removing
|
||||||
|
one means that contract is now unverified. Say where it moved, or say plainly
|
||||||
|
that nothing checks it any more.
|
||||||
|
|
||||||
|
## vote-service one-vote-per-IP contract
|
||||||
|
|
||||||
|
**Removed:** 2026-09-06, when `vote-service/` was taken out of this repository.
|
||||||
|
|
||||||
|
**What it asserted:** that `vote-service/main.go` contained both
|
||||||
|
`X-Forwarded-For` and `one active vote per skill` — the review desk's only
|
||||||
|
anti-abuse control, one vote per visitor enforced server-side by source IP.
|
||||||
|
|
||||||
|
**Why it went:** there is no file left to read. The check was a substring match
|
||||||
|
against source that now lives elsewhere.
|
||||||
|
|
||||||
|
**Where it must be re-asserted:** in whichever repository holds the service. The
|
||||||
|
deployed service still enforces the contract; nothing in this repository proves
|
||||||
|
it. If `vote-service/` ever comes back here, restore the assertion and delete
|
||||||
|
this entry.
|
||||||
@@ -49,4 +49,5 @@ likely to fail; verify before shipping.
|
|||||||
## Bilingual content
|
## Bilingual content
|
||||||
|
|
||||||
`<html lang>` must change with the language toggle, not just the text. Screen
|
`<html lang>` must change with the language toggle, not just the text. Screen
|
||||||
readers pick pronunciation from it. This already works today — do not regress it.
|
readers pick pronunciation from it. This already works today — do not regress
|
||||||
|
it.
|
||||||
|
|||||||
@@ -20,12 +20,16 @@ Several current stylesheets already honour it. Every new animation must:
|
|||||||
|
|
||||||
```css
|
```css
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
* { animation-duration: .01ms !important; animation-iteration-count: 1 !important;
|
* {
|
||||||
transition-duration: .01ms !important; scroll-behavior: auto !important; }
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Reduced motion means *reduced*, not *broken*: the end state must still be
|
Reduced motion means _reduced_, not _broken_: the end state must still be
|
||||||
correct and the interface still usable. Test it — in DevTools, Rendering →
|
correct and the interface still usable. Test it — in DevTools, Rendering →
|
||||||
Emulate `prefers-reduced-motion`.
|
Emulate `prefers-reduced-motion`.
|
||||||
|
|
||||||
@@ -35,7 +39,7 @@ Emulate `prefers-reduced-motion`.
|
|||||||
Animating `width`, `height`, `top`, `left`, or `margin` forces layout on every
|
Animating `width`, `height`, `top`, `left`, or `margin` forces layout on every
|
||||||
frame and will show up as a failed INP.
|
frame and will show up as a failed INP.
|
||||||
- `will-change` only on an element about to animate, removed after. Leaving it
|
- `will-change` only on an element about to animate, removed after. Leaving it
|
||||||
on permanently costs memory and can *hurt* performance.
|
on permanently costs memory and can _hurt_ performance.
|
||||||
- Prefer CSS transitions. Reach for the Web Animations API only for sequencing
|
- Prefer CSS transitions. Reach for the Web Animations API only for sequencing
|
||||||
that CSS cannot express. Do not add an animation library — it is a runtime
|
that CSS cannot express. Do not add an animation library — it is a runtime
|
||||||
dependency on a site whose thesis is having none.
|
dependency on a site whose thesis is having none.
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ this site's ten pages ship no JS today and must continue to.
|
|||||||
|
|
||||||
Only these need interactivity. Anything else claiming island status is wrong:
|
Only these need interactivity. Anything else claiming island status is wrong:
|
||||||
|
|
||||||
| Island | Why | Directive |
|
| Island | Why | Directive |
|
||||||
| --- | --- | --- |
|
| --------------------------------- | ---------------------------------- | ---------------- |
|
||||||
| Guide phase/tab switchers | click-driven panel swap | `client:visible` |
|
| Guide phase/tab switchers | click-driven panel swap | `client:visible` |
|
||||||
| Review desk catalog + file viewer | search, filter, fetch source files | `client:load` |
|
| Review desk catalog + file viewer | search, filter, fetch source files | `client:load` |
|
||||||
| Vote widget | talks to `vote-service/` | `client:visible` |
|
| Vote widget | talks to the vote API | `client:visible` |
|
||||||
| Language toggle | swaps EN/PT across the page | `client:idle` |
|
| Language toggle | swaps EN/PT across the page | `client:idle` |
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
@@ -31,8 +31,11 @@ Only these need interactivity. Anything else claiming island status is wrong:
|
|||||||
// 3. destructure Astro.props
|
// 3. destructure Astro.props
|
||||||
// 4. derived values — no side effects, no fetch in components
|
// 4. derived values — no side effects, no fetch in components
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- markup -->
|
<!-- markup -->
|
||||||
<style>/* component-scoped */</style>
|
<style>
|
||||||
|
/* component-scoped */
|
||||||
|
</style>
|
||||||
```
|
```
|
||||||
|
|
||||||
- Typed props always: `interface Props { … }`, then `const { … } = Astro.props`.
|
- Typed props always: `interface Props { … }`, then `const { … } = Astro.props`.
|
||||||
@@ -60,13 +63,13 @@ almost one-to-one — do that rather than importing a 27 KB JS file.
|
|||||||
The site is served from `/ai-for-dummies/`. Set `base` in `astro.config.mjs` and
|
The site is served from `/ai-for-dummies/`. Set `base` in `astro.config.mjs` and
|
||||||
never hand-write an absolute internal path. Use `import.meta.env.BASE_URL`.
|
never hand-write an absolute internal path. Use `import.meta.env.BASE_URL`.
|
||||||
|
|
||||||
Existing routes are load-bearing and must not change, including trailing
|
Existing routes are load-bearing and must not change, including trailing slashes
|
||||||
slashes and the review desk's query params.
|
and the review desk's query params.
|
||||||
|
|
||||||
## Never
|
## Never
|
||||||
|
|
||||||
- No UI framework (React/Vue/Svelte) unless a task brief explicitly calls for it.
|
- No UI framework (React/Vue/Svelte) unless a task brief explicitly calls for
|
||||||
Astro components plus a little vanilla JS cover everything here.
|
it. Astro components plus a little vanilla JS cover everything here.
|
||||||
- No CSS framework. This site has a hand-built visual identity — see
|
- No CSS framework. This site has a hand-built visual identity — see
|
||||||
[`theming.md`](theming.md).
|
[`theming.md`](theming.md).
|
||||||
- No external runtime requests. Self-host. `audit-ui.mjs` enforces it.
|
- No external runtime requests. Self-host. `audit-ui.mjs` enforces it.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## Match what is there
|
## Match what is there
|
||||||
|
|
||||||
This codebase has a real voice: dense one-liner CSS, terse ES modules, comments
|
This codebase has a real voice: dense one-liner CSS, terse ES modules, comments
|
||||||
that explain *why* and never *what*. Do not reformat it into someone else's
|
that explain _why_ and never _what_. Do not reformat it into someone else's
|
||||||
house style as a side effect of a task.
|
house style as a side effect of a task.
|
||||||
|
|
||||||
The one exception is CSS minification-by-hand — `styles.css` is single-line and
|
The one exception is CSS minification-by-hand — `styles.css` is single-line and
|
||||||
@@ -44,5 +44,5 @@ overrides, and the temptation during migration will be to port it wholesale
|
|||||||
|
|
||||||
## Commits
|
## Commits
|
||||||
|
|
||||||
Present tense, lowercase, `type: subject`, matching the existing log
|
Present tense, lowercase, `type: subject`, matching the existing log (`feat:`,
|
||||||
(`feat:`, `fix:`, `docs:`). The body explains why, and states what you did not do.
|
`fix:`, `docs:`). The body explains why, and states what you did not do.
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
## When to make a component
|
## When to make a component
|
||||||
|
|
||||||
Extract when the same markup appears **three times**, or when a block has a name
|
Extract when the same markup appears **three times**, or when a block has a name
|
||||||
a person would use out loud ("the eyebrow", "the route card", "the phase panel").
|
a person would use out loud ("the eyebrow", "the route card", "the phase
|
||||||
|
panel").
|
||||||
|
|
||||||
Do not extract on the second occurrence. Two similar blocks often diverge; the
|
Do not extract on the second occurrence. Two similar blocks often diverge; the
|
||||||
premature abstraction costs more than the duplication.
|
premature abstraction costs more than the duplication.
|
||||||
|
|||||||
@@ -55,5 +55,5 @@ hand-rolled client-side renderer. That deletes code and improves fidelity.
|
|||||||
|
|
||||||
Careful: `skill-reviews/improved/**/SKILL.md` is **generated** from those
|
Careful: `skill-reviews/improved/**/SKILL.md` is **generated** from those
|
||||||
entries by `scripts/build-skill-review.mjs`, and the generated files are
|
entries by `scripts/build-skill-review.mjs`, and the generated files are
|
||||||
committed. Keep that generator working, or replace it and update every
|
committed. Keep that generator working, or replace it and update every reference
|
||||||
reference to it.
|
to it.
|
||||||
|
|||||||
@@ -104,4 +104,46 @@ if (findings.length) {
|
|||||||
findings.forEach((finding) => console.error(` ${finding}`));
|
findings.forEach((finding) => console.error(` ${finding}`));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { basename } from 'node:path';
|
||||||
|
|
||||||
|
if (existsSync('dist')) {
|
||||||
|
const builtCss = walk('dist').filter((p) => p.endsWith('.css'));
|
||||||
|
const tokensBuilt = builtCss.find((p) => /[\\/]tokens\.[^\\/]+\.css$/.test(p));
|
||||||
|
|
||||||
|
if (!tokensBuilt) {
|
||||||
|
console.error('token check failed — tokens.css was not built into dist/');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokensContent = readFileSync(tokensBuilt, 'utf8');
|
||||||
|
if (!tokensContent.includes('#527f9f')) {
|
||||||
|
console.error(
|
||||||
|
'token check failed — built tokens.css does not contain the canonical --blue value #527f9f',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const htmlFiles = walk('dist').filter(
|
||||||
|
(p) =>
|
||||||
|
p.endsWith('.html') &&
|
||||||
|
!p.includes('/hands-on/') &&
|
||||||
|
!p.includes('\\hands-on\\') &&
|
||||||
|
!p.includes('/submitted-skills/') &&
|
||||||
|
!p.includes('\\submitted-skills\\'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokenChunkName = basename(tokensBuilt);
|
||||||
|
for (const html of htmlFiles) {
|
||||||
|
const content = readFileSync(html, 'utf8');
|
||||||
|
if (!content.includes(tokenChunkName)) {
|
||||||
|
console.error(
|
||||||
|
`token check failed — ${html} does not load the token layer (${tokenChunkName})`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log('token check passed');
|
console.log('token check passed');
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Compare the *computed* styles of a legacy page against its Astro
|
||||||
|
// replacement, at several viewport widths.
|
||||||
|
//
|
||||||
|
// node .agents/scripts/computed-style-diff.mjs full-guide
|
||||||
|
// node .agents/scripts/computed-style-diff.mjs full-guide --widths 560,880,1050
|
||||||
|
//
|
||||||
|
// Why this exists: a ported media query can sit in the built stylesheet,
|
||||||
|
// match the viewport, and still do nothing. Astro scopes a component's rules
|
||||||
|
// as `.tree-node[data-astro-cid-lsutp3lb]` (specificity 0,2,0); a rule ported
|
||||||
|
// verbatim as `.tree-node` (0,1,0) loses to it and never applies. Task 15e
|
||||||
|
// attempt 4 shipped exactly that: `@media (max-width: 1050px) .tree-node
|
||||||
|
// { width: 145px }` was present in dist and the node stayed 180px wide.
|
||||||
|
//
|
||||||
|
// Checking that the breakpoint *appears* in the built CSS cannot catch this.
|
||||||
|
// Only asking the browser what it actually computed can.
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { cpSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||||
|
import { createServer } from 'node:net';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
|
const route = process.argv[2];
|
||||||
|
if (!route) {
|
||||||
|
console.error('usage: computed-style-diff.mjs <route> [--widths a,b,c]');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const widthsArg = process.argv.indexOf('--widths');
|
||||||
|
const widths =
|
||||||
|
widthsArg === -1
|
||||||
|
? [520, 560, 600, 620, 720, 800, 880, 1050, 1100, 1600]
|
||||||
|
: process.argv[widthsArg + 1].split(',').map(Number);
|
||||||
|
|
||||||
|
// The selectors worth checking are the ones the responsive layer moves at a
|
||||||
|
// breakpoint, so read them out of the legacy stylesheet's @media blocks only.
|
||||||
|
// Taking every class in the file buries the signal under generic ones like
|
||||||
|
// `.active`, whose state the islands own anyway.
|
||||||
|
const responsive = readFileSync(new URL('../../responsive.css', import.meta.url), 'utf8');
|
||||||
|
const mediaBlocks = [];
|
||||||
|
for (const match of responsive.matchAll(/@media[^{]*\{/g)) {
|
||||||
|
let depth = 0;
|
||||||
|
for (let i = match.index; i < responsive.length; i += 1) {
|
||||||
|
if (responsive[i] === '{') depth += 1;
|
||||||
|
else if (responsive[i] === '}') {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) {
|
||||||
|
mediaBlocks.push(responsive.slice(match.index + match[0].length, i));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const selectors = [...new Set(mediaBlocks.join('\n').match(/\.[a-z][a-z0-9-]*/g) || [])].sort();
|
||||||
|
|
||||||
|
// Properties a responsive rule actually moves. Comparing every property would
|
||||||
|
// drown the signal in font stacks and inherited colour.
|
||||||
|
const PROPERTIES = [
|
||||||
|
'display',
|
||||||
|
'grid-template-columns',
|
||||||
|
'grid-template-rows',
|
||||||
|
'flex-direction',
|
||||||
|
'width',
|
||||||
|
'height',
|
||||||
|
'max-width',
|
||||||
|
'padding',
|
||||||
|
'margin',
|
||||||
|
'gap',
|
||||||
|
'font-size',
|
||||||
|
'position',
|
||||||
|
'inset',
|
||||||
|
'overflow',
|
||||||
|
];
|
||||||
|
|
||||||
|
// The legacy pages were deleted at cutover; run this from a pre-cutover
|
||||||
|
// worktree, or the legacy side will 404.
|
||||||
|
const legacyPath = route === 'index' ? 'index.html' : `${route}/index.html`;
|
||||||
|
const astroPath = route === 'index' ? '' : `${route}/`;
|
||||||
|
|
||||||
|
const staging = mkdtempSync(join(tmpdir(), 'af-csd-'));
|
||||||
|
cpSync('dist', join(staging, 'ai-for-dummies'), { recursive: true });
|
||||||
|
|
||||||
|
const freePort = () =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const probe = createServer();
|
||||||
|
probe.on('error', reject);
|
||||||
|
probe.listen(0, '127.0.0.1', () => {
|
||||||
|
const { port } = probe.address();
|
||||||
|
probe.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const legacyPort = await freePort();
|
||||||
|
const astroPort = await freePort();
|
||||||
|
const serve = (dir, port) =>
|
||||||
|
spawn('python3', ['-m', 'http.server', String(port), '-d', dir], { stdio: 'ignore' });
|
||||||
|
const servers = [serve('.', legacyPort), serve(staging, astroPort)];
|
||||||
|
const stop = () => {
|
||||||
|
servers.forEach((s) => s.kill());
|
||||||
|
rmSync(staging, { recursive: true, force: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Every element matching each selector, so a rule that applies to the first
|
||||||
|
// node and not the rest cannot pass.
|
||||||
|
const collect = ([selectors, properties]) => {
|
||||||
|
const out = {};
|
||||||
|
for (const selector of selectors) {
|
||||||
|
const nodes = [...document.querySelectorAll(selector)];
|
||||||
|
out[selector] = nodes.map((node) => {
|
||||||
|
const style = getComputedStyle(node);
|
||||||
|
return properties
|
||||||
|
.map((property) => `${property}:${style.getPropertyValue(property)}`)
|
||||||
|
.join(';');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
try {
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const read = async (url, width) => {
|
||||||
|
const page = await browser.newPage({ viewport: { width, height: 900 } });
|
||||||
|
const response = await page.goto(url, { waitUntil: 'load' });
|
||||||
|
if (!response || !response.ok()) {
|
||||||
|
throw new Error(`${url} returned ${response ? response.status() : 'no response'}`);
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
const styles = await page.evaluate(collect, [selectors, PROPERTIES]);
|
||||||
|
await page.close();
|
||||||
|
return styles;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const width of widths) {
|
||||||
|
const legacy = await read(`http://localhost:${legacyPort}/${legacyPath}`, width);
|
||||||
|
const astro = await read(`http://localhost:${astroPort}/ai-for-dummies/${astroPath}`, width);
|
||||||
|
|
||||||
|
for (const selector of selectors) {
|
||||||
|
const before = legacy[selector];
|
||||||
|
const after = astro[selector];
|
||||||
|
if (before.length === 0 && after.length === 0) continue;
|
||||||
|
if (before.length !== after.length) {
|
||||||
|
console.log(
|
||||||
|
`${width}px ${selector} legacy ${before.length} nodes, astro ${after.length}`,
|
||||||
|
);
|
||||||
|
failures += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let reported = 0;
|
||||||
|
before.forEach((expected, index) => {
|
||||||
|
if (expected === after[index]) return;
|
||||||
|
failures += 1;
|
||||||
|
// Three examples is enough to identify a rule that did not apply.
|
||||||
|
reported += 1;
|
||||||
|
if (reported > 3) return;
|
||||||
|
const differing = expected
|
||||||
|
.split(';')
|
||||||
|
.filter((pair, i) => pair !== after[index].split(';')[i]);
|
||||||
|
const got = after[index].split(';').filter((pair, i) => pair !== expected.split(';')[i]);
|
||||||
|
console.log(`${width}px ${selector}[${index}]`);
|
||||||
|
console.log(` legacy ${differing.join(' ')}`);
|
||||||
|
console.log(` astro ${got.join(' ')}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log(failures === 0 ? 'computed styles match' : `${failures} computed-style differences`);
|
||||||
|
process.exitCode = failures === 0 ? 0 : 1;
|
||||||
|
} finally {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
@@ -43,7 +43,20 @@ step "types"
|
|||||||
pnpm exec astro check
|
pnpm exec astro check
|
||||||
|
|
||||||
step "build"
|
step "build"
|
||||||
pnpm run build
|
# `astro build` exits 0 even when vite fails to resolve an asset: the cutover
|
||||||
|
# left a stale `@import` in a moved stylesheet and every gate stayed green for
|
||||||
|
# it. Treat a logged error as a failed build.
|
||||||
|
build_log=$(mktemp)
|
||||||
|
if ! pnpm run build 2>&1 | tee "$build_log"; then
|
||||||
|
rm -f "$build_log"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q '\[ERROR\]' "$build_log"; then
|
||||||
|
echo "gate: astro build logged an error and still exited 0. See above." >&2
|
||||||
|
rm -f "$build_log"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -f "$build_log"
|
||||||
|
|
||||||
step "content contracts"
|
step "content contracts"
|
||||||
pnpm run verify
|
pnpm run verify
|
||||||
@@ -51,8 +64,19 @@ pnpm run verify
|
|||||||
# The assertion count is the thing agents are most tempted to "fix" downward.
|
# The assertion count is the thing agents are most tempted to "fix" downward.
|
||||||
# Compare against origin/main and refuse a silent reduction.
|
# Compare against origin/main and refuse a silent reduction.
|
||||||
step "assertion coverage"
|
step "assertion coverage"
|
||||||
|
# Task 19 restored the 42 legacy facts and added ten output snapshots: 84 is the
|
||||||
|
# floor, in addition to whatever origin/main currently requires.
|
||||||
|
#
|
||||||
|
# A removal is allowed only by writing a reason into the ledger. The gate counts
|
||||||
|
# its entries and lowers the bar by exactly that many, so the bar cannot move
|
||||||
|
# without a visible diff explaining why. Deleting an entry to buy headroom is
|
||||||
|
# the same offence as deleting the assertion was.
|
||||||
|
ledger=.agents/context/assertion-removals.md
|
||||||
current=$(grep -c 'throw new Error' scripts/verify.mjs)
|
current=$(grep -c 'throw new Error' scripts/verify.mjs)
|
||||||
baseline=$(git show origin/main:scripts/verify.mjs 2>/dev/null | grep -c 'throw new Error' || echo "$current")
|
allowed=$(grep -c '^## ' "$ledger" 2>/dev/null || echo 0)
|
||||||
|
baseline=$(git show origin/main:scripts/verify.mjs 2>/dev/null | grep -c 'throw new Error' || echo 0)
|
||||||
|
if [ "$baseline" -lt 84 ]; then baseline=84; fi
|
||||||
|
baseline=$((baseline - allowed))
|
||||||
if [ "$current" -lt "$baseline" ]; then
|
if [ "$current" -lt "$baseline" ]; then
|
||||||
echo "gate: verify.mjs coverage fell from $baseline to $current assertions." >&2
|
echo "gate: verify.mjs coverage fell from $baseline to $current assertions." >&2
|
||||||
echo " Only verification-engineer may reduce it, with a reason per removal." >&2
|
echo " Only verification-engineer may reduce it, with a reason per removal." >&2
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build the site and publish it to the `pages` branch.
|
||||||
|
#
|
||||||
|
# .agents/scripts/publish-pages.sh # publish
|
||||||
|
# .agents/scripts/publish-pages.sh --dry-run # build and report, push nothing
|
||||||
|
# .agents/scripts/publish-pages.sh --pending X # main is *about* to become X
|
||||||
|
#
|
||||||
|
# `--pending` exists for the pre-push hook. Git has no post-push hook, so the
|
||||||
|
# hook necessarily runs before main lands on the remote and the usual "HEAD must
|
||||||
|
# equal origin/main" check cannot hold yet. The caller asserts the SHA the push
|
||||||
|
# will create, and the hook only asserts it after confirming the push is a
|
||||||
|
# fast-forward.
|
||||||
|
#
|
||||||
|
# `pages` is what the Gitea Pages Server actually serves. Publishing overwrites
|
||||||
|
# the live site. There is no staging environment between here and visitors.
|
||||||
|
#
|
||||||
|
# This never checks `pages` out. It writes a tree straight from `dist/` with
|
||||||
|
# plumbing (`write-tree` + `commit-tree`), so your working tree is untouched and
|
||||||
|
# a failure halfway through leaves nothing behind. The commit is parented on the
|
||||||
|
# current `pages`, so the branch keeps its history and rollback is one push.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
dry_run=0
|
||||||
|
pending=''
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--dry-run) dry_run=1 ;;
|
||||||
|
--pending)
|
||||||
|
shift
|
||||||
|
pending="${1:-}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "publish-pages: unknown argument '$1'" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "publish-pages: $1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Publishing a build made from uncommitted work means the live site shows
|
||||||
|
# something no commit describes, and nobody can reproduce it later.
|
||||||
|
[ -z "$(git status --porcelain)" ] || fail 'working tree is dirty; commit or stash first'
|
||||||
|
|
||||||
|
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||||
|
[ "$branch" = 'main' ] || fail "publishing from '$branch'; only main is publishable"
|
||||||
|
|
||||||
|
git fetch --quiet origin pages
|
||||||
|
head=$(git rev-parse HEAD)
|
||||||
|
if [ -n "$pending" ]; then
|
||||||
|
[ "$head" = "$(git rev-parse "$pending")" ] ||
|
||||||
|
fail "HEAD is $head but the pending push is $pending"
|
||||||
|
else
|
||||||
|
git fetch --quiet origin main
|
||||||
|
[ "$head" = "$(git rev-parse origin/main)" ] ||
|
||||||
|
fail 'HEAD is not origin/main; push main first so the site matches a pushed commit'
|
||||||
|
fi
|
||||||
|
|
||||||
|
previous=$(git rev-parse origin/pages)
|
||||||
|
|
||||||
|
echo "publish-pages: building $head"
|
||||||
|
# `astro build` exits 0 even when vite fails to resolve an asset, so the exit
|
||||||
|
# code alone is not enough to know the build is whole. The gate greps for this
|
||||||
|
# too; repeat it here because this script is also run by hand.
|
||||||
|
build_log=$(mktemp)
|
||||||
|
trap 'rm -f "$build_log"' EXIT
|
||||||
|
pnpm run build >"$build_log" 2>&1 || {
|
||||||
|
cat "$build_log" >&2
|
||||||
|
fail 'astro build failed'
|
||||||
|
}
|
||||||
|
if grep -q '\[ERROR\]' "$build_log"; then
|
||||||
|
cat "$build_log" >&2
|
||||||
|
fail 'astro build logged an error and still exited 0; refusing to publish'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A build can succeed and still emit a stub -- that is exactly how this site
|
||||||
|
# would go down. Check the routes exist before overwriting anything live.
|
||||||
|
for route in index full-guide/index summary/index models/index agents/index \
|
||||||
|
skills/index rules/index skills-review/index \
|
||||||
|
hands-on/starter/index hands-on/rules/index; do
|
||||||
|
[ -s "dist/$route.html" ] || fail "dist/$route.html missing or empty; refusing to publish"
|
||||||
|
done
|
||||||
|
|
||||||
|
# GIT_INDEX_FILE must name a path that does not exist yet: git reads an existing
|
||||||
|
# empty file as a truncated index and dies with "index file smaller than
|
||||||
|
# expected". mktemp -d gives a private directory to put that path in.
|
||||||
|
index_dir=$(mktemp -d)
|
||||||
|
index="$index_dir/index"
|
||||||
|
trap 'rm -rf "$index_dir"; rm -f "$build_log"' EXIT
|
||||||
|
# `--force` because the repository .gitignore lists `dist`; here `dist` *is* the
|
||||||
|
# work tree, so those rules would otherwise exclude everything we mean to ship.
|
||||||
|
GIT_INDEX_FILE="$index" git --work-tree=dist add --all --force .
|
||||||
|
tree=$(GIT_INDEX_FILE="$index" git write-tree)
|
||||||
|
|
||||||
|
if [ "$tree" = "$(git rev-parse "$previous^{tree}")" ]; then
|
||||||
|
echo "publish-pages: dist is identical to the published tree; nothing to do"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
subject="chore: publish $(git rev-parse --short "$head")"
|
||||||
|
commit=$(git commit-tree "$tree" -p "$previous" -m "$subject
|
||||||
|
|
||||||
|
Built from main $head
|
||||||
|
$(git log -1 --format=%s "$head")")
|
||||||
|
|
||||||
|
if [ "$dry_run" -eq 1 ]; then
|
||||||
|
echo "publish-pages: would push $commit to pages (previous $previous)"
|
||||||
|
echo "publish-pages: dry run, nothing pushed"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "publish-pages: rollback point is $previous"
|
||||||
|
echo " git push --force origin $previous:refs/heads/pages"
|
||||||
|
|
||||||
|
# AF_PUBLISHING stops the pre-push hook recursing into this script.
|
||||||
|
AF_PUBLISHING=1 git push --force origin "$commit:refs/heads/pages"
|
||||||
|
echo "publish-pages: published $commit"
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Compare the *rendered* text of a legacy page against its Astro replacement.
|
||||||
|
//
|
||||||
|
// node .agents/scripts/rendered-text-diff.mjs full-guide
|
||||||
|
// node .agents/scripts/rendered-text-diff.mjs full-guide --pt
|
||||||
|
//
|
||||||
|
// Why this exists: scripts/verify.mjs reads the legacy files, so a migrated
|
||||||
|
// page can drop half its content and still pass the gate. Task 15d shipped
|
||||||
|
// /full-guide/ missing 86 rendered spans -- the entire verification section,
|
||||||
|
// the hands-on exercise brief, and both "Clone from Gitea" links -- and every
|
||||||
|
// check was green.
|
||||||
|
//
|
||||||
|
// Static HTML comparison is useless here: the guide's tab panels are injected
|
||||||
|
// by an island at runtime, so half the legacy page's markup has no static
|
||||||
|
// counterpart. This walks the live DOM instead and skips anything the browser
|
||||||
|
// is not painting -- which also drops the hidden Portuguese half of each
|
||||||
|
// bilingual pair, so the two sides line up.
|
||||||
|
//
|
||||||
|
// Requires playwright (devDependency) and two static servers; it starts both.
|
||||||
|
//
|
||||||
|
// The legacy pages were deleted at cutover, so this needs a pre-cutover tree:
|
||||||
|
// git worktree add /tmp/vanilla <pre-cutover-sha>
|
||||||
|
// and run from there, or run it from a checkout that still has them.
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { cpSync, mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { createServer } from 'node:net';
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
|
// `index` is the landing page: it lives at the repository root, not in a
|
||||||
|
// directory of its own, so it needs a different path on the legacy side.
|
||||||
|
const route = process.argv[2];
|
||||||
|
if (!route) {
|
||||||
|
console.error('usage: rendered-text-diff.mjs <route> [--pt] e.g. full-guide, or index');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `--pt` clicks the language toggle on both pages first. English parity is
|
||||||
|
// only half the contract: a page can render every English string and still
|
||||||
|
// leave a restored block untranslated, because the Portuguese half is a
|
||||||
|
// separate set of nodes. Only /full-guide/ and /rules/ have a toggle.
|
||||||
|
const portuguese = process.argv.includes('--pt');
|
||||||
|
const legacyPath = route === 'index' ? 'index.html' : `${route}/index.html`;
|
||||||
|
const astroPath = route === 'index' ? '' : `${route}/`;
|
||||||
|
|
||||||
|
// The built site expects to be served under the configured base path.
|
||||||
|
const staging = mkdtempSync(join(tmpdir(), 'af-rtd-'));
|
||||||
|
cpSync('dist', join(staging, 'ai-for-dummies'), { recursive: true });
|
||||||
|
|
||||||
|
// Ask the kernel for a free port rather than pinning one. Back-to-back runs
|
||||||
|
// used to collide: the previous run's server was still holding the fixed port
|
||||||
|
// while its staging directory had already been deleted, so every page came
|
||||||
|
// back as a 404 and the diff reported the whole route missing.
|
||||||
|
const freePort = () =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const probe = createServer();
|
||||||
|
probe.on('error', reject);
|
||||||
|
probe.listen(0, '127.0.0.1', () => {
|
||||||
|
const { port } = probe.address();
|
||||||
|
probe.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const legacyPort = await freePort();
|
||||||
|
const astroPort = await freePort();
|
||||||
|
|
||||||
|
const serve = (dir, port) =>
|
||||||
|
spawn('python3', ['-m', 'http.server', String(port), '-d', dir], { stdio: 'ignore' });
|
||||||
|
const servers = [serve('.', legacyPort), serve(staging, astroPort)];
|
||||||
|
const stop = () => {
|
||||||
|
servers.forEach((s) => s.kill());
|
||||||
|
rmSync(staging, { recursive: true, force: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Visible text nodes, in document order, whitespace collapsed.
|
||||||
|
const visibleText = () => {
|
||||||
|
const out = [];
|
||||||
|
const walk = (node) => {
|
||||||
|
for (const child of node.childNodes) {
|
||||||
|
if (child.nodeType === Node.TEXT_NODE) {
|
||||||
|
const text = child.textContent.replace(/\s+/g, ' ').trim();
|
||||||
|
if (text) out.push(text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
||||||
|
if (child.tagName === 'SCRIPT' || child.tagName === 'STYLE') continue;
|
||||||
|
const style = getComputedStyle(child);
|
||||||
|
if (child.hidden || style.display === 'none' || style.visibility === 'hidden') continue;
|
||||||
|
walk(child);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(document.body);
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const grab = async (url) => {
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1400, height: 1000 } });
|
||||||
|
const response = await page.goto(url, { waitUntil: 'load' });
|
||||||
|
// A 404 renders as four spans of python's error page and the diff then
|
||||||
|
// reports the entire route as missing, which reads exactly like a real
|
||||||
|
// regression. Fail loudly instead.
|
||||||
|
if (!response || !response.ok()) {
|
||||||
|
throw new Error(`${url} returned ${response ? response.status() : 'no response'}`);
|
||||||
|
}
|
||||||
|
// The islands hydrate and render their initial panel on load; without this
|
||||||
|
// every panel's copy reads as missing.
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
if (portuguese) {
|
||||||
|
const toggle = await page.$('[data-lang="pt"]');
|
||||||
|
if (!toggle) throw new Error(`no language toggle on ${url}`);
|
||||||
|
await toggle.click();
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
}
|
||||||
|
// Islands hydrate at their own pace, and the language toggle repaints in
|
||||||
|
// more than one frame. A single read after a fixed wait is flaky, so read
|
||||||
|
// until two consecutive reads agree.
|
||||||
|
let spans = await page.evaluate(visibleText);
|
||||||
|
for (let i = 0; i < 10; i += 1) {
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const next = await page.evaluate(visibleText);
|
||||||
|
if (next.length === spans.length && next.every((span, j) => span === spans[j])) {
|
||||||
|
spans = next;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
spans = next;
|
||||||
|
}
|
||||||
|
await page.close();
|
||||||
|
return spans;
|
||||||
|
};
|
||||||
|
|
||||||
|
const legacy = await grab(`http://localhost:${legacyPort}/${legacyPath}`);
|
||||||
|
const astro = await grab(`http://localhost:${astroPort}/ai-for-dummies/${astroPath}`);
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
// Count occurrences, not membership. A set comparison reports zero when a
|
||||||
|
// string the legacy page paints four times is painted three times here --
|
||||||
|
// exactly the kind of near-miss that got past the earlier checks.
|
||||||
|
const tally = (spans) => {
|
||||||
|
const counts = new Map();
|
||||||
|
for (const span of spans) counts.set(span, (counts.get(span) || 0) + 1);
|
||||||
|
return counts;
|
||||||
|
};
|
||||||
|
|
||||||
|
const legacyCounts = tally(legacy);
|
||||||
|
const astroCounts = tally(astro);
|
||||||
|
|
||||||
|
const missing = [];
|
||||||
|
for (const [span, count] of legacyCounts) {
|
||||||
|
const short = count - (astroCounts.get(span) || 0);
|
||||||
|
for (let i = 0; i < short; i += 1) missing.push(span);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both directions. A string the Astro page paints and the legacy page does
|
||||||
|
// not is just as wrong: it means a translation was invented, or an English
|
||||||
|
// string was left standing where the legacy page swaps it.
|
||||||
|
const extra = [];
|
||||||
|
for (const [span, count] of astroCounts) {
|
||||||
|
const over = count - (legacyCounts.get(span) || 0);
|
||||||
|
for (let i = 0; i < over; i += 1) extra.push(span);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order counts too. Both pages can paint the same strings while a block
|
||||||
|
// sits in the wrong place -- the Portuguese eyebrow, or a reordered card
|
||||||
|
// deck -- and a count-only comparison calls that clean.
|
||||||
|
const firstOutOfOrder = legacy.findIndex((span, i) => astro[i] !== span);
|
||||||
|
|
||||||
|
const mode = portuguese ? 'pt' : 'en';
|
||||||
|
console.log(
|
||||||
|
`${mode} · legacy ${legacy.length} spans · astro ${astro.length} spans · missing ${missing.length} · extra ${extra.length}`,
|
||||||
|
);
|
||||||
|
for (const span of missing) console.log(` - ${span}`);
|
||||||
|
for (const span of extra) console.log(` + ${span}`);
|
||||||
|
if (firstOutOfOrder !== -1) {
|
||||||
|
console.log(` order diverges at span ${firstOutOfOrder}`);
|
||||||
|
console.log(` legacy: ${legacy[firstOutOfOrder]}`);
|
||||||
|
console.log(` astro: ${astro[firstOutOfOrder]}`);
|
||||||
|
}
|
||||||
|
process.exitCode = missing.length === 0 && extra.length === 0 && firstOutOfOrder === -1 ? 0 : 1;
|
||||||
|
} finally {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
@@ -27,8 +27,12 @@ const text = html
|
|||||||
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||||
.replace(/<!--[\s\S]*?-->/g, '')
|
.replace(/<!--[\s\S]*?-->/g, '')
|
||||||
.replace(/<[^>]+>/g, '\n')
|
.replace(/<[^>]+>/g, '\n')
|
||||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
.replace(/&/g, '&')
|
||||||
.replace(/"/g, '"').replace(/�?39;/g, "'").replace(/ /g, ' ')
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/�?39;/g, "'")
|
||||||
|
.replace(/ /g, ' ')
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|||||||
@@ -12,8 +12,12 @@ description:
|
|||||||
|
|
||||||
The snapshot is the only objective evidence that no content was lost.
|
The snapshot is the only objective evidence that no content was lost.
|
||||||
|
|
||||||
|
The vanilla site was deleted at cutover. To compare against it, check the
|
||||||
|
pre-cutover tree out into a scratch worktree first:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm run serve & # vanilla site on :4173
|
git worktree add /tmp/vanilla <pre-cutover-sha>
|
||||||
|
(cd /tmp/vanilla && python3 -m http.server 4173) &
|
||||||
node .agents/scripts/snapshot-route.mjs http://localhost:4173/models/ \
|
node .agents/scripts/snapshot-route.mjs http://localhost:4173/models/ \
|
||||||
> .agents/snapshots/models.txt
|
> .agents/snapshots/models.txt
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: content-migration
|
name: content-migration
|
||||||
description: Move bilingual copy out of app.js and catalog.js into typed Astro content collections without losing or altering a single string. Use for any task that relocates user-visible text.
|
description:
|
||||||
|
Move bilingual copy out of app.js and catalog.js into typed Astro content
|
||||||
|
collections without losing or altering a single string. Use for any task that
|
||||||
|
relocates user-visible text.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Content migration
|
# Content migration
|
||||||
@@ -9,8 +12,8 @@ description: Move bilingual copy out of app.js and catalog.js into typed Astro c
|
|||||||
|
|
||||||
- `app.js` — ~50 `{ en, pt }` keys across `phases`, `handsOnPrompts`,
|
- `app.js` — ~50 `{ en, pt }` keys across `phases`, `handsOnPrompts`,
|
||||||
`modelGuide`, `skillSources`, `skillInstallPrompts`
|
`modelGuide`, `skillSources`, `skillInstallPrompts`
|
||||||
- `skills-review/catalog.js` + `submitted-catalog.js` — 24 entries with
|
- `skills-review/catalog.js` + `submitted-catalog.js` — 24 entries with `id`,
|
||||||
`id`, `author`, `title`, `status`, `focus`, `wins[]`, `improve[]`, `extras`,
|
`author`, `title`, `status`, `focus`, `wins[]`, `improve[]`, `extras`,
|
||||||
`improved` (full markdown)
|
`improved` (full markdown)
|
||||||
|
|
||||||
These are hand-written translations with deliberate tone. **Copy them. Never
|
These are hand-written translations with deliberate tone. **Copy them. Never
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ a bug.
|
|||||||
```bash
|
```bash
|
||||||
python3 - <<'PY'
|
python3 - <<'PY'
|
||||||
import re
|
import re
|
||||||
files=['styles.css','chapters.css','landing.css','rules/styles.css','skills/styles.css',
|
files=['legacy/styles/guide.css','legacy/styles/chapters.css','legacy/styles/skills.css',
|
||||||
'skills-review/styles.css','hands-on/starter/styles.css','hands-on/rules/styles.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']
|
||||||
seen={}
|
seen={}
|
||||||
for f in files:
|
for f in files:
|
||||||
for m in re.finditer(r'--([a-z-]+):\s*([^;}]+)', open(f).read()):
|
for m in re.finditer(r'--([a-z-]+):\s*([^;}]+)', open(f).read()):
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: motion
|
name: motion
|
||||||
description: Add or review animation on the ai-for-dummies site — transitions, state changes, view transitions. Use when any element moves, fades, or transforms, or when auditing existing motion for performance and reduced-motion support.
|
description:
|
||||||
|
Add or review animation on the ai-for-dummies site — transitions, state
|
||||||
|
changes, view transitions. Use when any element moves, fades, or transforms,
|
||||||
|
or when auditing existing motion for performance and reduced-motion support.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Motion
|
# Motion
|
||||||
@@ -22,10 +25,14 @@ If there is no answer, ship it static. That is a legitimate, common outcome.
|
|||||||
|
|
||||||
```css
|
```css
|
||||||
.panel {
|
.panel {
|
||||||
transition: opacity 180ms cubic-bezier(.2,0,0,1),
|
transition:
|
||||||
transform 180ms cubic-bezier(.2,0,0,1);
|
opacity 180ms cubic-bezier(0.2, 0, 0, 1),
|
||||||
|
transform 180ms cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
.panel[data-state='entering'] {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
}
|
}
|
||||||
.panel[data-state='entering'] { opacity: 0; transform: translateY(6px); }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`transform` and `opacity` only.** Animating `width`/`height`/`top`/`left`
|
- **`transform` and `opacity` only.** Animating `width`/`height`/`top`/`left`
|
||||||
@@ -39,14 +46,18 @@ If there is no answer, ship it static. That is a legitimate, common outcome.
|
|||||||
|
|
||||||
```css
|
```css
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
* { animation-duration: .01ms !important; animation-iteration-count: 1 !important;
|
* {
|
||||||
transition-duration: .01ms !important; scroll-behavior: auto !important; }
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Then **test it**: DevTools → Rendering → Emulate `prefers-reduced-motion:
|
Then **test it**: DevTools → Rendering → Emulate
|
||||||
reduce`. The end state must still be correct and the UI still usable. Reduced,
|
`prefers-reduced-motion: reduce`. The end state must still be correct and the UI
|
||||||
not broken.
|
still usable. Reduced, not broken.
|
||||||
|
|
||||||
## Page transitions
|
## Page transitions
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: verify-contract
|
name: verify-contract
|
||||||
description: Evolve scripts/verify.mjs across the Astro migration without losing coverage. Use whenever a verify assertion fails because of a refactor, or when adding checks for new architecture.
|
description:
|
||||||
|
Evolve scripts/verify.mjs across the Astro migration without losing coverage.
|
||||||
|
Use whenever a verify assertion fails because of a refactor, or when adding
|
||||||
|
checks for new architecture.
|
||||||
---
|
---
|
||||||
|
|
||||||
# The verification contract
|
# The verification contract
|
||||||
@@ -29,13 +32,13 @@ report. Nobody else may reduce coverage.
|
|||||||
|
|
||||||
## Translating assertions
|
## Translating assertions
|
||||||
|
|
||||||
| Kind | Old | New |
|
| Kind | Old | New |
|
||||||
| --- | --- | --- |
|
| --------------------- | ------------------------------------ | ----------------------------------------------------------------------------- |
|
||||||
| Content presence | `html.includes('data-phase="plan"')` | same token, read from `dist/full-guide/index.html` |
|
| Content presence | `html.includes('data-phase="plan"')` | same token, read from `dist/full-guide/index.html` |
|
||||||
| Implementation detail | `js.includes('renderTree')` | assert the rendered output has the tree UI, not that a function is named that |
|
| Implementation detail | `js.includes('renderTree')` | assert the rendered output has the tree UI, not that a function is named that |
|
||||||
| Asset version | `'app.js?v=20260904-vote-widget'` | assert the built HTML references a hashed asset |
|
| Asset version | `'app.js?v=20260904-vote-widget'` | assert the built HTML references a hashed asset |
|
||||||
|
|
||||||
Implementation-detail assertions are the dangerous ones: they *look* deletable.
|
Implementation-detail assertions are the dangerous ones: they _look_ deletable.
|
||||||
They are pinning a feature. Replace with an output-level assertion of the same
|
They are pinning a feature. Replace with an output-level assertion of the same
|
||||||
feature; never drop.
|
feature; never drop.
|
||||||
|
|
||||||
@@ -54,9 +57,9 @@ Commit the snapshots. They are the migration's regression net.
|
|||||||
|
|
||||||
## Extend audit-ui.mjs
|
## Extend audit-ui.mjs
|
||||||
|
|
||||||
It rejects external `<script>`/`<link>` but **misses external URLs inside CSS** —
|
It rejects external `<script>`/`<link>` but **misses external URLs inside CSS**
|
||||||
which is exactly how the broken Google Fonts `@font-face` in `styles.css:1` got
|
— which is exactly how the broken Google Fonts `@font-face` in `styles.css:1`
|
||||||
into a "dependency-free" site. Add:
|
got into a "dependency-free" site. Add:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
if (/@import|src:\s*url\(['"]?https?:|url\(['"]?https?:/i.test(css))
|
if (/@import|src:\s*url\(['"]?https?:|url\(['"]?https?:/i.test(css))
|
||||||
|
|||||||
@@ -33,8 +33,9 @@ with sync_playwright() as p:
|
|||||||
browser.close()
|
browser.close()
|
||||||
```
|
```
|
||||||
|
|
||||||
Run once against the vanilla site (`pnpm run serve`), once against
|
Run once against `pnpm run preview`. To compare against the vanilla site, serve
|
||||||
`pnpm run preview`. Keep both sets.
|
a pre-cutover worktree on :4173 first — those files are no longer on `main`.
|
||||||
|
Keep both sets.
|
||||||
|
|
||||||
## Compare
|
## Compare
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 377 KiB |
|
Before Width: | Height: | Size: 444 KiB |
|
Before Width: | Height: | Size: 341 KiB |
|
Before Width: | Height: | Size: 342 KiB |
|
Before Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 465 KiB |
|
Before Width: | Height: | Size: 485 KiB |
|
Before Width: | Height: | Size: 438 KiB |
|
Before Width: | Height: | Size: 458 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 377 KiB |
|
Before Width: | Height: | Size: 444 KiB |
|
Before Width: | Height: | Size: 341 KiB |
|
Before Width: | Height: | Size: 342 KiB |
|
Before Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 465 KiB |
|
Before Width: | Height: | Size: 485 KiB |
|
Before Width: | Height: | Size: 438 KiB |
|
Before Width: | Height: | Size: 458 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 96 KiB |