Compare commits
60 Commits
a5d9630dd8
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 715f4f80b1 | |||
| 677c511979 |
@@ -1,6 +1,9 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
| --- | --- | --- | --- |
|
||||
| `/` | `index.html` | — | `chapters.css`, `landing.css` |
|
||||
| `/full-guide/` | `full-guide/index.html` | `app.js` (50 KB) | `styles.css`, `responsive.css`, `audit.css` |
|
||||
| `/summary/` | `summary/index.html` | — | `chapters.css` |
|
||||
| `/models/` | `models/index.html` | — | `chapters.css` |
|
||||
| `/agents/` | `agents/index.html` | — | `chapters.css` |
|
||||
| `/skills/` | `skills/index.html` | `skills/app.js` | `skills/styles.css` |
|
||||
| `/rules/` | `rules/index.html` | `rules/app.js` | `rules/styles.css` |
|
||||
| `/skills-review/` | `skills-review/index.html` | `skills-review/app.js` | `skills-review/styles.css`, `change-lens.css` |
|
||||
| `/hands-on/starter/` | lab fixture | own | own |
|
||||
| `/hands-on/rules/` | lab fixture | own | own |
|
||||
| Route | Page | Islands |
|
||||
| -------------------- | ------------------------------- | ----------------------------------------------- |
|
||||
| `/` | `src/pages/index.astro` | — |
|
||||
| `/full-guide/` | `src/pages/full-guide.astro` | `GuideSelector`, `LanguageToggle`, `CopyPrompt` |
|
||||
| `/summary/` | `src/pages/summary.astro` | — |
|
||||
| `/models/` | `src/pages/models.astro` | — |
|
||||
| `/agents/` | `src/pages/agents.astro` | — |
|
||||
| `/skills/` | `src/pages/skills.astro` | `SkillPackageExplorer` |
|
||||
| `/rules/` | `src/pages/rules.astro` | `RulesInteractive` |
|
||||
| `/skills-review/` | `src/pages/skills-review.astro` | `legacy/skills-review/app.js` |
|
||||
| `/hands-on/starter/` | `public/` lab fixture | own |
|
||||
| `/hands-on/rules/` | `public/` lab fixture | own |
|
||||
|
||||
Weight is concentrated: `app.js` 50 KB, `responsive.css` 30 KB,
|
||||
`skills-review/catalog.js` 27 KB, `skills-review/submitted-catalog.js` 18 KB.
|
||||
## What is still unmigrated
|
||||
|
||||
### 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
|
||||
database** (`phases`, `handsOnPrompts`, `modelGuide`, `skillSources`,
|
||||
`skillInstallPrompts`, each keyed `{en, pt}`) plus ~12 small `render*`
|
||||
functions that swap `innerHTML` on tab clicks. ~50 `en:` keys. The content
|
||||
should become data; only the tab behaviour is interactive.
|
||||
- **`responsive.css`** — a 30 KB append-only layer of overrides bolted on top of
|
||||
`styles.css`. Expect large parts to be dead once layout moves into components.
|
||||
Do not port it verbatim.
|
||||
- **`skills-review/catalog.js`** — the real data model of the review desk: one
|
||||
entry per submitted skill with `id`, `author`, `title`, `status`, `focus`,
|
||||
`wins[]`, `improve[]`, `extras`, `improved` (full markdown). 24 entries across
|
||||
`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/`.
|
||||
- **`legacy/styles/guide.css`** (was `styles.css`) — the editorial visual
|
||||
system, imported by `full-guide.astro`.
|
||||
- **`legacy/styles/audit.css`** (was `full-guide/audit.css`) — responsive audit
|
||||
overrides, imported by `full-guide.astro`.
|
||||
- **`legacy/styles/chapters.css`** — imported by `ChapterLayout.astro`.
|
||||
- **`legacy/styles/skills.css`**, **`skills-review.css`**, **`change-lens.css`**
|
||||
— imported by their respective pages.
|
||||
- **`legacy/skills-review/`** — `app.js` and the module graph under it
|
||||
(`catalog.js`, `submitted-catalog.js`, `files.js`, `submitted-files.js`,
|
||||
`vote.js`). `catalog.js` + `submitted-catalog.js` are the review desk's real
|
||||
data model, 24 entries; they are a content collection in all but name.
|
||||
|
||||
## 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/
|
||||
@@ -51,15 +54,15 @@ public/
|
||||
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/`
|
||||
and the rest must resolve exactly as they do now, trailing slash included.
|
||||
Existing links (including `docs/`, SilverBullet, and shared URLs with
|
||||
`?author=…&skill=…&view=…` query params) must keep working.
|
||||
- **Zero JS by default.** Seven of the ten pages ship no JavaScript today.
|
||||
They must still ship none. Islands are opt-in, per component, and justified.
|
||||
- **`hands-on/` stays vanilla.** It goes in `public/` untouched. It is a lab
|
||||
- **URLs do not change.** `/full-guide/`, `/skills-review/`,
|
||||
`/hands-on/starter/` and the rest must resolve exactly as they do now,
|
||||
trailing slash included. Existing links (including `docs/`, SilverBullet, and
|
||||
shared URLs with `?author=…&skill=…&view=…` query params) must keep working.
|
||||
- **Zero JS by default.** Seven of the ten pages ship no JavaScript. They must
|
||||
still ship none. Islands are opt-in, per component, and justified.
|
||||
- **`hands-on/` stays vanilla.** It lives in `public/` untouched. It is a lab
|
||||
fixture, not a component.
|
||||
- **No external runtime requests.** `audit-ui.mjs` enforces this and it is part
|
||||
of the site's thesis. Self-host anything you add.
|
||||
@@ -69,7 +72,12 @@ public/
|
||||
|
||||
## Companion service
|
||||
|
||||
`vote-service/` is a Go API on its own Kubernetes deploy cycle, reached by the
|
||||
review desk over `window.SKILLS_REVIEW_VOTE_API`. The refactor does not touch
|
||||
it. Keep the global, or replace it with a build-time `PUBLIC_VOTE_API` env var —
|
||||
but if you do, update `vote-service/README.md` in the same change.
|
||||
The vote API is a Go service on its own Kubernetes deploy cycle, reached by the
|
||||
review desk over `window.SKILLS_REVIEW_VOTE_API`. Its source left this
|
||||
repository on 2026-09-06; the deployed service is unchanged, and the review desk
|
||||
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
|
||||
|
||||
`<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
|
||||
@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 →
|
||||
Emulate `prefers-reduced-motion`.
|
||||
|
||||
@@ -35,7 +39,7 @@ Emulate `prefers-reduced-motion`.
|
||||
Animating `width`, `height`, `top`, `left`, or `margin` forces layout on every
|
||||
frame and will show up as a failed INP.
|
||||
- `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
|
||||
that CSS cannot express. Do not add an animation library — it is a runtime
|
||||
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:
|
||||
|
||||
| Island | Why | Directive |
|
||||
| --- | --- | --- |
|
||||
| Guide phase/tab switchers | click-driven panel swap | `client:visible` |
|
||||
| Review desk catalog + file viewer | search, filter, fetch source files | `client:load` |
|
||||
| Vote widget | talks to `vote-service/` | `client:visible` |
|
||||
| Language toggle | swaps EN/PT across the page | `client:idle` |
|
||||
| Island | Why | Directive |
|
||||
| --------------------------------- | ---------------------------------- | ---------------- |
|
||||
| Guide phase/tab switchers | click-driven panel swap | `client:visible` |
|
||||
| Review desk catalog + file viewer | search, filter, fetch source files | `client:load` |
|
||||
| Vote widget | talks to the vote API | `client:visible` |
|
||||
| Language toggle | swaps EN/PT across the page | `client:idle` |
|
||||
|
||||
## Structure
|
||||
|
||||
@@ -31,8 +31,11 @@ Only these need interactivity. Anything else claiming island status is wrong:
|
||||
// 3. destructure Astro.props
|
||||
// 4. derived values — no side effects, no fetch in components
|
||||
---
|
||||
|
||||
<!-- markup -->
|
||||
<style>/* component-scoped */</style>
|
||||
<style>
|
||||
/* component-scoped */
|
||||
</style>
|
||||
```
|
||||
|
||||
- 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
|
||||
never hand-write an absolute internal path. Use `import.meta.env.BASE_URL`.
|
||||
|
||||
Existing routes are load-bearing and must not change, including trailing
|
||||
slashes and the review desk's query params.
|
||||
Existing routes are load-bearing and must not change, including trailing slashes
|
||||
and the review desk's query params.
|
||||
|
||||
## Never
|
||||
|
||||
- No UI framework (React/Vue/Svelte) unless a task brief explicitly calls for it.
|
||||
Astro components plus a little vanilla JS cover everything here.
|
||||
- No UI framework (React/Vue/Svelte) unless a task brief explicitly calls for
|
||||
it. Astro components plus a little vanilla JS cover everything here.
|
||||
- No CSS framework. This site has a hand-built visual identity — see
|
||||
[`theming.md`](theming.md).
|
||||
- No external runtime requests. Self-host. `audit-ui.mjs` enforces it.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Match what is there
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
Present tense, lowercase, `type: subject`, matching the existing log
|
||||
(`feat:`, `fix:`, `docs:`). The body explains why, and states what you did not do.
|
||||
Present tense, lowercase, `type: subject`, matching the existing log (`feat:`,
|
||||
`fix:`, `docs:`). The body explains why, and states what you did not do.
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
## When to make a component
|
||||
|
||||
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
|
||||
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
|
||||
entries by `scripts/build-skill-review.mjs`, and the generated files are
|
||||
committed. Keep that generator working, or replace it and update every
|
||||
reference to it.
|
||||
committed. Keep that generator working, or replace it and update every reference
|
||||
to it.
|
||||
|
||||
@@ -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
|
||||
|
||||
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"
|
||||
pnpm run verify
|
||||
@@ -51,8 +64,19 @@ pnpm run verify
|
||||
# The assertion count is the thing agents are most tempted to "fix" downward.
|
||||
# Compare against origin/main and refuse a silent reduction.
|
||||
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)
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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(/<!--[\s\S]*?-->/g, '')
|
||||
.replace(/<[^>]+>/g, '\n')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/�?39;/g, "'").replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/�?39;/g, "'")
|
||||
.replace(/ /g, ' ')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -12,8 +12,12 @@ description:
|
||||
|
||||
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
|
||||
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/ \
|
||||
> .agents/snapshots/models.txt
|
||||
```
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
---
|
||||
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
|
||||
@@ -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`,
|
||||
`modelGuide`, `skillSources`, `skillInstallPrompts`
|
||||
- `skills-review/catalog.js` + `submitted-catalog.js` — 24 entries with
|
||||
`id`, `author`, `title`, `status`, `focus`, `wins[]`, `improve[]`, `extras`,
|
||||
- `skills-review/catalog.js` + `submitted-catalog.js` — 24 entries with `id`,
|
||||
`author`, `title`, `status`, `focus`, `wins[]`, `improve[]`, `extras`,
|
||||
`improved` (full markdown)
|
||||
|
||||
These are hand-written translations with deliberate tone. **Copy them. Never
|
||||
|
||||
@@ -21,8 +21,10 @@ a bug.
|
||||
```bash
|
||||
python3 - <<'PY'
|
||||
import re
|
||||
files=['styles.css','chapters.css','landing.css','rules/styles.css','skills/styles.css',
|
||||
'skills-review/styles.css','hands-on/starter/styles.css','hands-on/rules/styles.css']
|
||||
files=['legacy/styles/guide.css','legacy/styles/chapters.css','legacy/styles/skills.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={}
|
||||
for f in files:
|
||||
for m in re.finditer(r'--([a-z-]+):\s*([^;}]+)', open(f).read()):
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
---
|
||||
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
|
||||
@@ -22,10 +25,14 @@ If there is no answer, ship it static. That is a legitimate, common outcome.
|
||||
|
||||
```css
|
||||
.panel {
|
||||
transition: opacity 180ms cubic-bezier(.2,0,0,1),
|
||||
transform 180ms cubic-bezier(.2,0,0,1);
|
||||
transition:
|
||||
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`
|
||||
@@ -39,14 +46,18 @@ If there is no answer, ship it static. That is a legitimate, common outcome.
|
||||
|
||||
```css
|
||||
@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:
|
||||
reduce`. The end state must still be correct and the UI still usable. Reduced,
|
||||
not broken.
|
||||
Then **test it**: DevTools → Rendering → Emulate
|
||||
`prefers-reduced-motion: reduce`. The end state must still be correct and the UI
|
||||
still usable. Reduced, not broken.
|
||||
|
||||
## Page transitions
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
---
|
||||
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
|
||||
@@ -29,13 +32,13 @@ report. Nobody else may reduce coverage.
|
||||
|
||||
## Translating assertions
|
||||
|
||||
| Kind | Old | New |
|
||||
| --- | --- | --- |
|
||||
| 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 |
|
||||
| Asset version | `'app.js?v=20260904-vote-widget'` | assert the built HTML references a hashed asset |
|
||||
| Kind | Old | New |
|
||||
| --------------------- | ------------------------------------ | ----------------------------------------------------------------------------- |
|
||||
| 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 |
|
||||
| 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
|
||||
feature; never drop.
|
||||
|
||||
@@ -54,9 +57,9 @@ Commit the snapshots. They are the migration's regression net.
|
||||
|
||||
## Extend audit-ui.mjs
|
||||
|
||||
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
|
||||
into a "dependency-free" site. Add:
|
||||
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 into a "dependency-free" site. Add:
|
||||
|
||||
```js
|
||||
if (/@import|src:\s*url\(['"]?https?:|url\(['"]?https?:/i.test(css))
|
||||
|
||||
@@ -33,8 +33,9 @@ with sync_playwright() as p:
|
||||
browser.close()
|
||||
```
|
||||
|
||||
Run once against the vanilla site (`pnpm run serve`), once against
|
||||
`pnpm run preview`. Keep both sets.
|
||||
Run once against `pnpm run preview`. To compare against the vanilla site, serve
|
||||
a pre-cutover worktree on :4173 first — those files are no longer on `main`.
|
||||
Keep both sets.
|
||||
|
||||
## 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 |
|
Before Width: | Height: | Size: 99 KiB |