Merge branch 'main' into refactor/task-05b-guide-interactive-data

This commit is contained in:
Marcos Paulo
2026-09-05 19:24:23 +00:00
21 changed files with 981 additions and 56 deletions
+7 -5
View File
@@ -20,11 +20,13 @@ palettes and a broken `@font-face`. **Load skills**: `design-tokens`,
`--paper`, `--muted`, `--line`, `--gold` likewise. Most deltas are `--paper`, `--muted`, `--line`, `--gold` likewise. Most deltas are
sub-perceptual and can be canonicalized. `--blue` (`#527f9f` vs `#215675`) is sub-perceptual and can be canonicalized. `--blue` (`#527f9f` vs `#215675`) is
visibly different — screenshot both and get a human decision. visibly different — screenshot both and get a human decision.
2. **The fonts have never rendered.** The `@font-face` in `styles.css:1` points 2. ~~**The fonts have never rendered.**~~ **Settled 2026-09-05 — do not
`src:` at a Google Fonts _stylesheet_, so Manrope and DM Mono have always reopen.** The malformed `@font-face` was escalated and the human chose the
fallen back to Arial and generic monospace. Self-hosting them is a redesign, real fonts. Manrope and DM Mono are self-hosted in `public/fonts/`, wired
not a refactor. Default: delete the dead rule, declare the stacks that through `public/fonts/fonts.css`, which `BaseLayout.astro` links and the
actually render. Escalate if someone wants the real fonts. legacy root `styles.css` `@import`s. **Do not delete these faces and do not
replace the stacks with `Arial`/`ui-monospace`** — that instruction is
obsolete. You may add `--font-sans` / `--font-mono` tokens pointing at them.
## You own ## You own
+47 -45
View File
@@ -8,18 +8,18 @@ files, not assumed.
The same semantic names carry different values depending on which stylesheet The same semantic names carry different values depending on which stylesheet
loaded them: loaded them:
| Token | `styles.css`, `rules/styles.css` | `chapters.css`, `skills-review/styles.css` | `hands-on/*/styles.css` | | Token | `styles.css`, `rules/styles.css` | `chapters.css`, `skills-review/styles.css` | `hands-on/*/styles.css` |
| --- | --- | --- | --- | | ---------- | -------------------------------- | ------------------------------------------ | ----------------------- |
| `--paper` | `#f5f4f1` | `#f6f3ed` | `#f4f3ef` | | `--paper` | `#f5f4f1` | `#f6f3ed` | `#f4f3ef` |
| `--ink` | `#172f42` | `#122534` | `#173044` | | `--ink` | `#172f42` | `#122534` | `#173044` |
| `--muted` | `#697b89` | `#65717a` | `#687d8c` | | `--muted` | `#697b89` | `#65717a` | `#687d8c` |
| `--line` | `#d8dee2` | `#d0d5d2` | `#d5dde1` | | `--line` | `#d8dee2` | `#d0d5d2` | `#d5dde1` |
| `--blue` | `#527f9f` | `#215675` | `#5683a1` | | `--blue` | `#527f9f` | `#215675` | `#5683a1` |
| `--gold` | `#efc76b` | `#ebbf58` | `#efc86d` | | `--gold` | `#efc76b` | `#ebbf58` | `#efc86d` |
| `--accent` | `#7c78a8` | — | — | | `--accent` | `#7c78a8` | — | — |
| `--deep` | `#102536` | — | — | | `--deep` | `#102536` | — | — |
| `--red` | — | `#a7483f` (chapters only) | — | | `--red` | — | `#a7483f` (chapters only) | — |
| `--violet` | — | `#6b668f` (review desk only) | — | | `--violet` | — | `#6b668f` (review desk only) | — |
Most deltas are a few units per channel — drift, not intent. `--blue` is the Most deltas are a few units per channel — drift, not intent. `--blue` is the
exception: `#527f9f` vs `#215675` is a visible difference and may be deliberate. exception: `#527f9f` vs `#215675` is a visible difference and may be deliberate.
@@ -34,35 +34,36 @@ exception: `#527f9f` vs `#215675` is a visible difference and may be deliberate.
Do not "just pick one" silently in the middle of another task. This is its own Do not "just pick one" silently in the middle of another task. This is its own
reviewed change with visual diffs attached. reviewed change with visual diffs attached.
## The typography you see is not the typography that was written ## The typography — fixed 2026-09-05
`styles.css` line 1: `styles.css` line 1 used to read:
```css ```css
@font-face{font-family:Manrope;src:url('https://fonts.googleapis.com/css2?family=DM+Mono&family=Manrope:wght@400;600;700;800&display=swap')} @font-face {
font-family: Manrope;
src: url('https://fonts.googleapis.com/css2?family=DM+Mono&family=Manrope:wght@400;600;700;800&display=swap');
}
``` ```
`src:` points at a **CSS stylesheet**, not a font file. No browser can load a `src:` in an `@font-face` must point at a font binary. That URL returns a CSS
font from that, so: stylesheet, so no browser could load a face from it. For the whole life of the
site, every `font-family:Manrope,Arial,sans-serif` rendered as **Arial** and
every `font:… 'DM Mono',monospace` rendered as the **generic monospace** face —
`'DM Mono'` was never declared as a family at all.
- every `font-family:Manrope,Arial,sans-serif` renders as **Arial** **This was escalated and the human chose the real fonts.** Manrope and DM Mono
- every `font:… 'DM Mono',monospace` renders as the **generic monospace** face are now self-hosted in `public/fonts/`, latin and latin-ext subsets only, under
- there are no `@font-face` blocks anywhere else and zero font files in the repo the SIL Open Font License. One `fonts.css` serves both trees: Astro links it
- `scripts/audit-ui.mjs` only rejects external `<link>`/`<script>` tags, so this from `BaseLayout.astro`, the legacy root `styles.css` `@import`s it. Self-hosted
slipped through the "dependency-free" audit rather than linked from Google because `scripts/audit-ui.mjs` rejects any
external `<link>`/`<script>`, and because the site is presented in workshop
rooms with unreliable networks.
**This is a trap for the refactor.** Self-hosting Manrope and DM Mono in Astro **This changed how every page renders**, deliberately. It is the one sanctioned
is the obvious "fix" — and it would change how every page looks, violating visual change in the migration. Screenshots taken before 2026-09-05 show Arial
"maintain the same styles". Treat it as an explicit product decision: and are no longer a valid baseline.
- **Keep current rendering**: delete the dead `@font-face`, replace the font `Georgia, serif` for emphasis (`h1 em`, `.hero em`) is untouched and still real.
stacks with what actually renders today (`Arial, sans-serif` /
`ui-monospace, monospace`). Zero visual change. Honest CSS.
- **Adopt the intended fonts**: self-host the woff2 files in `public/fonts/`,
add real `@font-face` with `font-display:swap`. Better-looking, but it is a
redesign and needs sign-off plus fresh screenshots.
Default to the first unless a human says otherwise.
## Type scale ## Type scale
@@ -71,13 +72,13 @@ real — it is a system font, so it does render. Keep it.
Sizes are all `clamp()`, roughly: Sizes are all `clamp()`, roughly:
| Role | Value | | Role | Value |
| --- | --- | | --------------- | --------------------------------------------------------- |
| Display / `h1` | `clamp(56px,9vw,126px)` | | Display / `h1` | `clamp(56px,9vw,126px)` |
| Section `h2` | `clamp(36px,5vw,65px)` | | Section `h2` | `clamp(36px,5vw,65px)` |
| Sub-head | `clamp(24px,3vw,38px)` | | Sub-head | `clamp(24px,3vw,38px)` |
| Pull-quote | `clamp(22px,3vw,36px)` | | Pull-quote | `clamp(22px,3vw,36px)` |
| Body | `15px/1.6``18px` | | Body | `15px/1.6``18px` |
| Eyebrow / label | `1011px` monospace, `letter-spacing:.08.1em`, uppercase | | Eyebrow / label | `1011px` monospace, `letter-spacing:.08.1em`, uppercase |
There are 14+ distinct clamp triples doing near-identical jobs. Collapse to a There are 14+ distinct clamp triples doing near-identical jobs. Collapse to a
@@ -87,11 +88,12 @@ should be unchanged within a pixel or two at common viewports.
## Breakpoints ## Breakpoints
Sixteen distinct max-widths are in use: 420, 520, 530, 560, 600, 620, 720, 800, Sixteen distinct max-widths are in use: 420, 520, 530, 560, 600, 620, 720, 800,
850, 880, 900, 1000, 1050, 1100 — plus `min-width:1600px` and `min-width:2200px`. 850, 880, 900, 1000, 1050, 1100 — plus `min-width:1600px` and
`min-width:2200px`.
Collapse to a named set (suggested: 560 / 800 / 1100 / 1600 / 2200) and prove Collapse to a named set (suggested: 560 / 800 / 1100 / 1600 / 2200) and prove
equivalence with screenshots at the *old* breakpoint values, since that is equivalence with screenshots at the _old_ breakpoint values, since that is where
where regressions will hide. regressions will hide.
`@media(prefers-reduced-motion:reduce)` is already respected in several `@media(prefers-reduced-motion:reduce)` is already respected in several
stylesheets. Keep it — see [`../rules/animation.md`](../rules/animation.md). stylesheets. Keep it — see [`../rules/animation.md`](../rules/animation.md).
@@ -100,8 +102,8 @@ stylesheets. Keep it — see [`../rules/animation.md`](../rules/animation.md).
The visual identity is editorial-print: flat colour blocks, hairline `1px` The visual identity is editorial-print: flat colour blocks, hairline `1px`
rules, uppercase monospace eyebrows with wide tracking, very tight negative rules, uppercase monospace eyebrows with wide tracking, very tight negative
letter-spacing on display type (`-.06em``-.08em`), grid layouts with `gap:1px` letter-spacing on display type (`-.06em``-.08em`), grid layouts with
over a background colour to fake borders, and near-zero border-radius. `gap:1px` over a background colour to fake borders, and near-zero border-radius.
That last trick (`gap:1px` + parent background) is used everywhere. It is That last trick (`gap:1px` + parent background) is used everywhere. It is
intentional. Do not replace it with `border`. intentional. Do not replace it with `border`.
+29
View File
@@ -0,0 +1,29 @@
dist
node_modules
hands-on
public/hands-on
submitted-skills
public/submitted-skills
skill-reviews
vote-service
# Legacy site sources, slated for deletion at cutover (task 20). Same list and
# same reasoning as .prettierignore: these are minified, single-line
# stylesheets. stylelint's `declaration-block-single-line-max-declarations`
# fires once per rule in them — ~180 errors for `styles.css` alone — so staging
# one to change a single declaration blocks the commit outright. The rule is
# about hand-written source readability and says nothing useful about minified
# output that is about to be deleted.
#
# `public/fonts/fonts.css` is deliberately NOT here: it is new, hand-written,
# and must stay linted.
#
# Root-anchored on purpose: a bare `rules` would also swallow .agents/rules/.
/styles.css
/landing.css
/chapters.css
/responsive.css
/skills-review/
/rules/
/skills/
/full-guide/
+2
View File
@@ -45,6 +45,8 @@ Phase 4 polish 18 ∥ 19, then 20
| --- | ----------------------------------------------------------------- | --------------------- | ------------------ | ------------- | | --- | ----------------------------------------------------------------- | --------------------- | ------------------ | ------------- |
| 01 | [scaffold + gates](task-01-scaffold.md) | astro-architect | — | — | | 01 | [scaffold + gates](task-01-scaffold.md) | astro-architect | — | — |
| 02 | [design tokens](task-02-tokens.md) | design-system-keeper | 01 | 03, 04 | | 02 | [design tokens](task-02-tokens.md) | design-system-keeper | 01 | 03, 04 |
| 02b | [token layer wiring](task-02b-token-layer-wiring.md) | design-system-keeper | 02 | 03, 04 |
| 02c | [token-gap queue](task-02c-token-gaps.md) | design-system-keeper | 02b, 15d | — |
| 03 | [verification net](task-03-verification-net.md) | verification-engineer | 01 | 02, 04 | | 03 | [verification net](task-03-verification-net.md) | verification-engineer | 01 | 02, 04 |
| 04 | [content schema](task-04-content-schema.md) | content-i18n-migrator | 01 | 02, 03 | | 04 | [content schema](task-04-content-schema.md) | content-i18n-migrator | 01 | 02, 03 |
| 05 | [guide content](task-05-content-guide.md) | content-i18n-migrator | 04 | 06 | | 05 | [guide content](task-05-content-guide.md) | content-i18n-migrator | 04 | 06 |
+7 -5
View File
@@ -50,7 +50,7 @@ walk into.
## Do not ## Do not
- Do not add a webfont. - Do not add a webfont. **(Reversed 2026-09-05 — see Decisions.)**
- Do not rename tokens to numeric scales (`--color-neutral-900`). - Do not rename tokens to numeric scales (`--color-neutral-900`).
- Do not convert the `gap:1px` over a coloured parent trick into `border` — it - Do not convert the `gap:1px` over a coloured parent trick into `border` — it
is deliberate house style and appears everywhere. is deliberate house style and appears everywhere.
@@ -60,7 +60,9 @@ walk into.
- **Palette Consolidation:** The three drifted palettes were canonicalized into - **Palette Consolidation:** The three drifted palettes were canonicalized into
one. For `--blue`, `#527f9f` (from styles.css) was chosen over `#215675` (from one. For `--blue`, `#527f9f` (from styles.css) was chosen over `#215675` (from
chapters.css) to unify the look. chapters.css) to unify the look.
- **Fonts:** The broken `@font-face` rule pointing to a Google Fonts stylesheet - **Fonts (superseded 2026-09-05):** task 02 recorded "delete the dead rule,
was noted to be deleted in future component tasks. The font stacks will be fall back to `Arial, sans-serif` / `ui-monospace, monospace`, zero visual
replaced with `Arial, sans-serif` and `ui-monospace, monospace` to match what change", and deferred even that deletion to "future component tasks", which
has actually been rendering all along. Zero visual change. never picked it up. The human has since chosen the opposite: Manrope and DM
Mono are now self-hosted in `public/fonts/` and really render. The "Do not add
a webfont" line above no longer applies.
@@ -0,0 +1,107 @@
# Task 02b — Wire the token layer in
**Agent**: `design-system-keeper` · **Model**: `agy` (Gemini 3.1 Pro — needs
vision for the screenshot diffs) **Depends on**: 02 · **Blocks**: 02c
**Worktree**: `.agents/scripts/worktree.sh start 02b token-layer-wiring`
## Why this task exists
Task 02 built the token layer and it was never connected to anything. This is
not a criticism of that task — its brief and its done-when list never asked for
the wiring, and `check-tokens.mjs` only looks for _violations_ in `src/`, so
nothing could have caught it.
Verify each of these yourself before you start; do not take my word for it:
1. **`src/styles/tokens.css` is imported by nothing.** The only reference in the
repo is `src/styles/base.css:2`, and `base.css` is itself imported nowhere.
The whole layer is dead code.
2. **The pages import the legacy stylesheets instead**, drifted `:root` blocks
and all:
- `src/layouts/ChapterLayout.astro:16``../../chapters.css?url`
- `src/pages/skills.astro:11``../../skills/styles.css?url`
- `src/pages/skills-review.astro:4,5``skills-review/styles.css`,
`change-lens.css`
3. **So task 02's `--blue` decision never reached a page.** It chose `#527f9f`
and wrote it down. The built CSS today ships both values:
```
dist/_astro/chapters.*.css --blue:#215675
dist/_astro/rules.*.css --blue: #527f9f
dist/_astro/styles.*.css --blue:#215675
```
## Scope
`src/styles/tokens.css`, `src/styles/base.css`,
`.agents/scripts/check-tokens.mjs` — you are the only writer of these — plus the
four import sites listed above and whatever layout work the wiring needs.
## Deliver
### 1. One loaded token layer
Make `tokens.css` actually load on every route. Either fix `base.css` and import
it from the layouts, or drop `base.css` and import `tokens.css` directly — your
call, but say which and why. A file that exists and is never loaded is worse
than no file, because it reads as done.
### 2. The legacy stylesheet imports reconciled
Those three `?url` imports are how the drifted palettes reach production. You do
not have to delete them in this task — much of that CSS is still load-bearing
until task 15e and task 20 — but the `:root` blocks inside them must stop
overriding the canonical tokens. Make the layer authoritative and prove with
screenshots that nothing moved except what you intend.
`--blue` is the one real difference: `#527f9f` vs `#215675` is visible. Task 02
already decided `#527f9f`. Apply that decision; do not re-open it. If applying
it looks wrong on a real page, screenshot it and escalate rather than quietly
picking the other value.
### 3. Font tokens
Manrope and DM Mono are **real now** — self-hosted in `public/fonts/`, wired
through `public/fonts/fonts.css` (linked by `BaseLayout.astro`, `@import`ed by
the legacy root `styles.css`). Read `.agents/context/design-system.md`; the old
"delete the dead rule, fall back to Arial" instruction is **superseded and must
not be re-applied**.
Add `--font-sans` and `--font-mono` and point the stacks at them. Do not touch
`public/fonts/fonts.css` itself, and do not remove the faces.
### 4. A check so this cannot recur
The gap that let a dead token layer ship is that nothing asserts the tokens are
_loaded_. Add that check — a build-output assertion that every route resolves
the canonical palette, or an equivalent. Put it in `check-tokens.mjs`, which you
own. **Do not touch `scripts/verify.mjs`**; it stays at 42 assertions and only
the verification-engineer may change it.
## Screenshots: your baseline is HEAD, not history
The self-hosted fonts landed in `7f11b6e` and **changed how every page
renders**, deliberately — that is the one sanctioned visual change in this
migration. Any screenshot taken before it shows Arial and is not a valid
baseline. Capture your own "before" from HEAD at the start of this task.
Widths: 560 / 800 / 1100 / 1600, plus the eight breakpoints task 02 removed
(520, 530, 600, 620, 720, 850, 880, 900) — that is where regressions hide.
## Do not
- Do not resolve the ~164 `token-gap:` markers. That is task 02c, and doing it
here would collide with task 15d's page work. Report the count.
- Do not delete `responsive.css`. That is 15e.
- Do not convert the `gap:1px` over a coloured parent trick into `border` — it
is deliberate house style and appears everywhere.
- Do not rename tokens to numeric scales (`--color-neutral-900`).
- Never use `--no-verify`; never set `core.hooksPath`.
## Done when
- [ ] `tokens.css` loads on every route, proven from built output
- [ ] One value per token in the shipped CSS — `--blue` is `#527f9f` everywhere
- [ ] `--font-sans` / `--font-mono` exist and are used; the faces still load
- [ ] A check exists that fails if the token layer stops being loaded
- [ ] Before/after screenshots at all twelve widths, every difference explained
- [ ] `pnpm run gate` green — the full gate; 42 assertions intact
@@ -0,0 +1,51 @@
# Task 02c — Work the token-gap queue
**Agent**: `design-system-keeper` · **Model**: `agy` (Gemini 3.1 Pro) **Depends
on**: 02b, 15d · **Blocks**: 19 **Worktree**:
`.agents/scripts/worktree.sh start 02c token-gaps`
## Why this task exists
`check-tokens.mjs` bans raw hex, `font-size: Npx` and ad-hoc breakpoints outside
the token layer. Until the escape hatch was added it offered no legal
alternative, so agents told both "keep the site identical" and "get the gate
green" cheated — writing px through the `font:` shorthand the regex misses, or
substituting the nearest token and shipping a silent redesign (`12px` and `14px`
both became `var(--step-1)`, 15px; a diff-added green became `var(--accent)`,
purple).
The `/* token-gap: <reason>; owner design-system-keeper */` marker replaced that
with an honest, visible queue. **You are the owner it names.** There are ~164
markers on `main`; get the real count yourself from `check-tokens.mjs` output.
## The work
For each marked value, one of three outcomes — and the choice is yours to make,
not to defer:
1. **The scale should cover it.** Extend `--step-*` and point the site at it.
Most of the queue is this: `24px`, `25px`, `20px`, `17px`, `13px` fixed sizes
from `chapters.css` that fall between existing steps.
2. **It is genuinely one-off.** Keep the literal value, delete the marker, and
leave a comment saying why it is exempt.
3. **It was already wrong.** Some markers record a near-miss substitution an
earlier task made under duress. Restoring the true legacy value is a visible
change — screenshot it and say so.
Work in batches by source file and commit per batch. A single 164-site commit is
unreviewable.
## Do not
- Do not weaken `check-tokens.mjs` to make markers disappear. Removing a check
is not resolving a gap.
- Do not touch `scripts/verify.mjs` — 42 assertions, verification-engineer only.
- Do not leave a marker whose reason you have satisfied.
## Done when
- [ ] Every marker either resolved into the scale or documented as a deliberate
exemption
- [ ] `check-tokens.mjs` reports zero unexplained gaps
- [ ] Screenshots for every change that moved a pixel, differences explained
- [ ] `pnpm run gate` green; 42 assertions intact
+14
View File
@@ -0,0 +1,14 @@
# Font licences
Both families here are licensed under the SIL Open Font License, Version 1.1,
which permits redistribution and self-hosting.
| Family | Designer | Source |
| ------- | ---------------------------- | -------------------------------------- |
| Manrope | Mikhail Sharanda | https://github.com/sharanda/manrope |
| DM Mono | Colophon Foundry for Deja Vu | https://github.com/googlefonts/dm-mono |
Full licence text: https://openfontlicense.org/open-font-license-official-text/
The `.woff2` files are the latin and latin-ext subsets as served by Google
Fonts. They are unmodified.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+100
View File
@@ -0,0 +1,100 @@
/* Manrope and DM Mono, self-hosted.
*
* `styles.css` line 1 used to carry this:
*
* @font-face{font-family:Manrope;src:url('https://fonts.googleapis.com/css2?...')}
*
* `src:` in an @font-face must point at a font binary. That URL returns a CSS
* stylesheet, so no browser could ever load a face from it: every
* `font-family:Manrope,Arial,sans-serif` fell through to Arial, and 'DM Mono'
* was never declared at all, so it fell through to the generic monospace face.
* The intended typography has never rendered. This file is the fix.
*
* Self-hosted rather than linked from fonts.googleapis.com because
* `scripts/audit-ui.mjs` rejects any external <link>/<script>, and because the
* site is shown in workshop rooms with unreliable networks.
*
* Both families are SIL Open Font License 1.1 — see OFL.md in this directory.
* Subsets are latin and latin-ext only: the site is EN and PT-BR, so the
* cyrillic, greek and vietnamese subsets Google also serves are dropped.
*
* The url()s are relative on purpose. Both consumers resolve them against this
* file's own location:
* - Astro pages: <link> in BaseLayout.astro, served from `${base}fonts/`
* - legacy pages: @import at the top of the root `styles.css`
*/
/* DM Mono 400 — latin */
@font-face {
font-family: 'DM Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('dm-mono-400-latin.woff2') format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* DM Mono 400 — latin-ext */
@font-face {
font-family: 'DM Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('dm-mono-400-latin-ext.woff2') format('woff2');
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
U+A720-A7FF;
}
/* DM Mono 500 — latin */
@font-face {
font-family: 'DM Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('dm-mono-500-latin.woff2') format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* DM Mono 500 — latin-ext */
@font-face {
font-family: 'DM Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('dm-mono-500-latin-ext.woff2') format('woff2');
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
U+A720-A7FF;
}
/* Manrope 400 800 — latin */
@font-face {
font-family: Manrope;
font-style: normal;
font-weight: 400 800;
font-display: swap;
src: url('manrope-var-latin.woff2') format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Manrope 400 800 — latin-ext */
@font-face {
font-family: Manrope;
font-style: normal;
font-weight: 400 800;
font-display: swap;
src: url('manrope-var-latin-ext.woff2') format('woff2');
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
U+A720-A7FF;
}
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,306 @@
---
// SkillPackageExplorer — the four-file skill-package picker. The interactive
// version of the `data-package-file` buttons that the vanilla skills page
// shipped, rewritten for the Astro chapter surface. The four package entries
// mirror skills/app.js so the migration preserves content 1:1; the buttons
// carry `data-skill-file` and the click handler swaps the preview panel
// contents client-side.
//
// Page is `client:visible` rather than `client:load`: the picker sits below
// the hero and grid; deferring until it scrolls into view keeps initial JS
// to zero for the above-the-fold content. The page ships zero JS for the
// hero/grid/footer parts — only the picker island hydrates.
interface PackageFile {
id: 'skill' | 'references' | 'scripts' | 'assets';
prefix: '├── ' | '└── ';
label: string;
/** Small caption under the file label, mirrors the vanilla source. */
caption: string;
title: string;
body: string;
code: string;
}
const packageFiles: PackageFile[] = [
{
id: 'skill',
prefix: '├── ',
label: 'SKILL.md',
caption: 'trigger + workflow',
title: 'The operating contract',
body: 'The one file that should always be loaded. Define the exact trigger, the ordered workflow, safety limits, and the evidence the agent returns.',
code: '---\nname: review-ui\ndescription: Review a changed UI for focus, reflow, and motion.\n---\n\n1. Inspect the changed interaction.\n2. Run the UI checks.\n3. Return findings with evidence.',
},
{
id: 'references',
prefix: '├── ',
label: 'references/',
caption: 'conditional facts',
title: 'Facts, only when needed',
body: 'Keep conditional detail out of the main instruction. A dialog pattern, framework caveat, or accessibility checklist belongs here when it is not needed for every review.',
code: 'references/\n└── accessibility.md\n ├── keyboard interaction patterns\n └── focus and reflow checklist',
},
{
id: 'scripts',
prefix: '├── ',
label: 'scripts/',
caption: 'deterministic checks',
title: 'Mechanics that should not depend on memory',
body: 'Turn deterministic checks into runnable tools. The agent still judges the result, but it should not have to recreate a viewport test or filename rule by hand.',
code: 'scripts/\n└── check-reflow.mjs\n └── checks 320px, 1280px, and 4K widths',
},
{
id: 'assets',
prefix: '└── ',
label: 'assets/',
caption: 'templates + examples',
title: 'Starting material, not hidden instructions',
body: 'Use assets for templates and examples a person or agent can copy. Keep them clearly named so package readers can choose the right starting point.',
code: 'assets/\n├── review-report.md\n└── focus-test-fixture.html',
},
];
---
<div class="package-workbench" data-package-workbench>
<div class="package-tree" role="tablist" aria-label="Files in the review-ui skill package">
<p>REVIEW-UI / SKILL PACKAGE</p>
{
packageFiles.map((file, index) => (
<button
class:list={[{ active: index === 0 }]}
data-skill-file={file.id}
role="tab"
aria-selected={index === 0 ? 'true' : 'false'}
>
<code>{`${file.prefix}${file.label}`}</code>
<small>{file.caption}</small>
</button>
))
}
</div>
<article class="package-preview" id="package-preview" aria-live="polite"></article>
</div>
<script type="application/json" data-skill-files set:html={JSON.stringify(packageFiles)} />
<script is:inline>
(function () {
const preview = document.querySelector('#package-preview');
const buttons = document.querySelectorAll('[data-skill-file]');
const dataNode = document.querySelector('[data-skill-files]');
if (!preview || !dataNode) return;
const files = JSON.parse(dataNode.textContent || '[]');
function renderPackage(id) {
const item = files.find((entry) => entry.id === id);
if (!item) return;
preview.classList.remove('is-swapping');
void preview.offsetWidth;
preview.classList.add('is-swapping');
preview.innerHTML =
'<span>SELECTED / ' +
item.label +
'</span>' +
'<h3>' +
item.title +
'</h3>' +
'<p>' +
item.body +
'</p>' +
'<pre><code>' +
item.code +
'</code></pre>';
buttons.forEach(function (button) {
const active = button.dataset.skillFile === id;
button.classList.toggle('active', active);
button.setAttribute('aria-selected', String(active));
});
}
buttons.forEach(function (button) {
button.addEventListener('click', function () {
renderPackage(button.dataset.skillFile);
});
});
renderPackage('skill');
})();
</script>
<style>
.package-workbench {
display: grid;
grid-template-columns: minmax(190px, 0.85fr) minmax(0, 1.3fr);
min-width: 0;
background: var(--ink);
border: 1px solid var(--ink);
box-shadow: 10px 10px 0 color-mix(in srgb, var(--gold) 55%, transparent);
}
.package-tree {
padding: 22px 16px;
/* token-gap: legacy 1px solid #426070 over --ink; no token matches; owner design-system-keeper */
border-right: 1px solid #426070;
min-width: 0;
}
.package-tree > p,
.package-preview > span {
margin: 0 0 14px;
color: var(--gold);
/* token-gap: source uses 11px monospace; no --step-* covers 11px on this surface; design-system-keeper */
font:
700 11px / 1.35 ui-monospace,
monospace;
letter-spacing: 0.1em;
}
.package-tree button {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
width: 100%;
padding: 12px 8px;
border: 0;
border-left: 2px solid transparent;
background: transparent;
/* token-gap: legacy #d6e1e4 over --ink tree buttons; no token matches; design-system-keeper */
color: #d6e1e4;
text-align: left;
cursor: pointer;
transition:
background 0.2s ease,
border-color 0.2s ease,
transform 0.2s ease;
}
.package-tree button:hover,
.package-tree button:focus-visible,
.package-tree button.active {
border-left-color: var(--gold);
/* token-gap: legacy #1f3a4b active/hover on --ink tree; no token matches; design-system-keeper */
background: #1f3a4b;
outline: 0;
}
.package-tree button:hover {
transform: translateX(3px);
}
.package-tree code {
min-width: 0;
overflow-wrap: anywhere;
font:
700 13px / 1.4 ui-monospace,
monospace;
}
.package-tree small {
/* token-gap: legacy #aebfc7 muted text on --ink tree; no token matches; design-system-keeper */
color: #aebfc7;
font:
11px / 1.25 Arial,
sans-serif;
text-align: right;
}
.package-preview {
min-width: 0;
padding: 26px;
/* token-gap: legacy #173245 preview surface (between --ink and --blue); no token matches; design-system-keeper */
background: #173245;
color: var(--paper);
}
.package-preview h3 {
margin: 0 0 8px;
font-size: clamp(24px, 3vw, 38px);
line-height: 1.02;
letter-spacing: -0.045em;
}
.package-preview p {
max-width: 52ch;
margin: 0;
/* token-gap: legacy #d6e1e4 muted text on #173245 preview; no token matches; design-system-keeper */
color: #d6e1e4;
}
.package-preview pre {
max-width: 100%;
margin: 20px 0 0;
padding: 15px;
overflow: auto;
/* token-gap: legacy #466274 rule on preview pre border; no token matches; design-system-keeper */
border: 1px solid #466274;
/* token-gap: legacy #102837 fill on preview pre background; no token matches; design-system-keeper */
background: #102837;
/* token-gap: legacy #d6e1e4 code text on #102837; no token matches; design-system-keeper */
color: #d6e1e4;
font:
12px / 1.55 ui-monospace,
monospace;
}
.package-preview.is-swapping {
animation: package-preview-in 0.34s ease both;
}
@keyframes package-preview-in {
from {
opacity: 0.25;
transform: translateY(7px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 800px) {
.package-workbench {
grid-template-columns: 1fr;
}
.package-tree {
border-right: 0;
/* token-gap: legacy #426070 mobile layout rule over --ink; no token matches; design-system-keeper */
border-bottom: 1px solid #426070;
}
.package-preview {
padding: 22px;
}
}
/* token-gap: legacy 520px breakpoint from skills/styles.css (mobile phone), not in named set 560/800/1100/1600/2200; design-system-keeper */
@media (max-width: 520px) {
.package-tree {
padding: 18px 10px;
}
.package-tree button {
padding: 12px 6px;
}
.package-tree small {
display: none;
}
.package-preview {
padding: 18px;
}
.package-preview pre {
/* token-gap: legacy 11px mobile font-size from skills/styles.css; no --step-* covers 11px; design-system-keeper */
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
</style>
+6
View File
@@ -6,6 +6,11 @@ interface Props {
} }
const { title, description, lang = 'en' } = Astro.props; const { title, description, lang = 'en' } = Astro.props;
// Self-hosted Manrope and DM Mono. Lives in `public/fonts/` rather than the
// bundled CSS because the legacy stylesheets @import the same file, so there
// is exactly one copy of the faces. See public/fonts/fonts.css.
const fonts = `${import.meta.env.BASE_URL}fonts/fonts.css`.replace('//', '/');
--- ---
<!doctype html> <!doctype html>
@@ -15,6 +20,7 @@ const { title, description, lang = 'en' } = Astro.props;
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title> <title>{title}</title>
<meta name="description" content={description} /> <meta name="description" content={description} />
<link rel="stylesheet" href={fonts} />
<slot name="styles" /> <slot name="styles" />
</head> </head>
<body> <body>
+106
View File
@@ -0,0 +1,106 @@
---
// /agents/ — chapter page. Migrated from agents/index.html in task 13.
// Identical URL (/agents/), zero client JS, copy lives in
// src/content/chapters/agents.json.
import { getEntry } from 'astro:content';
import ChapterLayout from '../layouts/ChapterLayout.astro';
import ChapterHero from '../components/blocks/ChapterHero.astro';
// Required-field guard. The chapters schema marks section eyebrow /
// panelLabel / panelCode / steps / copy as optional because the schema
// does not know which page consumes which shape. These four pages do
// consume them — fail loudly here rather than rendering a blank section.
function requireField<T>(value: T | undefined, name: string): T {
if (value === undefined) {
throw new Error(`agents chapter: missing required field "${name}"`);
}
return value;
}
const base = import.meta.env.BASE_URL;
const chapter = await getEntry('chapters', 'agents');
if (!chapter) {
throw new Error('agents chapter: missing collection entry');
}
const lede = chapter.data.lede.en;
const title = chapter.data.title.en;
const eyebrow = chapter.data.eyebrow.en;
const cards = chapter.data.cards ?? [];
const sections = chapter.data.sections ?? [];
const treeSection = sections[0];
const handoffSection = sections[1];
if (!treeSection) {
throw new Error('agents chapter: missing sections[0]');
}
if (!handoffSection) {
throw new Error('agents chapter: missing sections[1]');
}
// Narrow the section fields this page renders. Each `requireField` either
// returns a non-null value or throws — TS narrows from `T | undefined` to `T`.
const treeEyebrow = requireField(treeSection.eyebrow, 'sections[0].eyebrow');
const treePanelLabel = requireField(treeSection.panelLabel, 'sections[0].panelLabel');
const treePanelCode = requireField(treeSection.panelCode, 'sections[0].panelCode');
const handoffEyebrow = requireField(handoffSection.eyebrow, 'sections[1].eyebrow');
const handoffSteps = requireField(handoffSection.steps, 'sections[1].steps');
---
<ChapterLayout title="AI For Dummies — Agents and trees" description={lede.replace(/<[^>]+>/g, '')}>
<a slot="top-previous" href={`${base}summary/`}> ROUTE MAP</a>
<span slot="top-center">02 / AGENTS & TREES</span>
<a slot="top-next" href={`${base}full-guide/`}>field guide </a>
<ChapterHero eyebrow={eyebrow}>
<span slot="title" set:html={title} />
<p>{lede}</p>
</ChapterHero>
<section class="pipeline">
<div>
<p class="eyebrow">{treeEyebrow.en}</p>
<h2 set:html={treeSection.title.en} />
</div>
<div class="panel">
<strong>{treePanelLabel.en}</strong>
<code>{treePanelCode.en}</code>
</div>
</section>
<section class="grid">
{
cards.map((card) => (
<article class="card">
<b>{card.label.en}</b>
<h2>{card.title.en}</h2>
<p>{card.copy.en}</p>
</article>
))
}
</section>
<section class="practice">
<div>
<p class="eyebrow">{handoffEyebrow.en}</p>
<h2 set:html={handoffSection.title.en} />
</div>
<div class="steps">
{
handoffSteps.map((step, index) => (
<article>
<b>{String(index + 1).padStart(2, '0')}</b>
<div>
<strong>{step.label.en}</strong>
<span>{step.copy.en}</span>
</div>
</article>
))
}
</div>
</section>
<nav slot="footer-links" class="links" aria-label="Chapter navigation">
<a href={`${base}models/`}>Previous: models </a>
<a href={`${base}rules/`}>Rules case study </a>
<a href={`${base}hands-on/rules/`}>Try the rules lab </a>
</nav>
</ChapterLayout>
+105
View File
@@ -0,0 +1,105 @@
---
// /models/ — chapter page. Migrated from models/index.html in task 13.
// Identical URL (/models/), zero client JS, copy lives in
// src/content/chapters/models.json. The grid + panel + steps sections
// render the chapter-collections content with bilingual `en` strings.
import { getEntry } from 'astro:content';
import ChapterLayout from '../layouts/ChapterLayout.astro';
import ChapterHero from '../components/blocks/ChapterHero.astro';
// Required-field guard. The chapters schema marks section eyebrow /
// panelLabel / panelCode / steps / copy as optional because the schema
// does not know which page consumes which shape. These four pages do
// consume them — fail loudly here rather than rendering a blank section.
function requireField<T>(value: T | undefined, name: string): T {
if (value === undefined) {
throw new Error(`models chapter: missing required field "${name}"`);
}
return value;
}
const base = import.meta.env.BASE_URL;
const chapter = await getEntry('chapters', 'models');
if (!chapter) {
throw new Error('models chapter: missing collection entry');
}
const lede = chapter.data.lede.en;
const title = chapter.data.title.en;
const eyebrow = chapter.data.eyebrow.en;
const cards = chapter.data.cards ?? [];
const sections = chapter.data.sections ?? [];
// First section carries the routing-rule panel, second carries the steps.
const ruleSection = sections[0];
const sequenceSection = sections[1];
if (!ruleSection) {
throw new Error('models chapter: missing sections[0]');
}
if (!sequenceSection) {
throw new Error('models chapter: missing sections[1]');
}
const ruleEyebrow = requireField(ruleSection.eyebrow, 'sections[0].eyebrow');
const rulePanelLabel = requireField(ruleSection.panelLabel, 'sections[0].panelLabel');
const rulePanelCode = requireField(ruleSection.panelCode, 'sections[0].panelCode');
const sequenceEyebrow = requireField(sequenceSection.eyebrow, 'sections[1].eyebrow');
const sequenceSteps = requireField(sequenceSection.steps, 'sections[1].steps');
---
<ChapterLayout title="AI For Dummies — Models" description={lede.replace(/<[^>]+>/g, '')}>
<a slot="top-previous" href={`${base}summary/`}> ROUTE MAP</a>
<span slot="top-center">01 / MODELS</span>
<a slot="top-next" href={`${base}full-guide/`}>field guide </a>
<ChapterHero eyebrow={eyebrow}>
<span slot="title" set:html={title} />
<p>{lede}</p>
</ChapterHero>
<section class="grid">
{
cards.map((card) => (
<article class="card">
<b>{card.label.en}</b>
<h2>{card.title.en}</h2>
<p>{card.copy.en}</p>
</article>
))
}
</section>
<section class="model">
<div>
<p class="eyebrow">{ruleEyebrow.en}</p>
<h2 set:html={ruleSection.title.en} />
</div>
<div class="panel">
<strong>{rulePanelLabel.en}</strong>
<code>{rulePanelCode.en}</code>
</div>
</section>
<section class="practice">
<div>
<p class="eyebrow">{sequenceEyebrow.en}</p>
<h2 set:html={sequenceSection.title.en} />
</div>
<div class="steps">
{
sequenceSteps.map((step, index) => (
<article>
<b>{String(index + 1).padStart(2, '0')}</b>
<div>
<strong>{step.label.en}</strong>
<span>{step.copy.en}</span>
</div>
</article>
))
}
</div>
</section>
<nav slot="footer-links" class="links" aria-label="Chapter navigation">
<a href={`${base}agents/`}>Next: agents & trees </a>
<a href={`${base}rules/`}>Rules case study </a>
</nav>
</ChapterLayout>
+93
View File
@@ -0,0 +1,93 @@
---
// /skills/ — chapter page. Migrated from skills/index.html in task 13.
// Identical URL (/skills/). The package-anatomy picker is an Astro island
// (SkillPackageExplorer) — the only JS the page ships. Copy lives in
// src/content/chapters/skills.json.
import { getEntry } from 'astro:content';
import ChapterLayout from '../layouts/ChapterLayout.astro';
import ChapterHero from '../components/blocks/ChapterHero.astro';
import SkillPackageExplorer from '../components/islands/SkillPackageExplorer.astro';
import skillsStylesheet from '../../skills/styles.css?url';
// Required-field guard. The chapters schema marks section eyebrow /
// panelLabel / panelCode / steps / copy as optional because the schema
// does not know which page consumes which shape. These four pages do
// consume them — fail loudly here rather than rendering a blank section.
function requireField<T>(value: T | undefined, name: string): T {
if (value === undefined) {
throw new Error(`skills chapter: missing required field "${name}"`);
}
return value;
}
const base = import.meta.env.BASE_URL;
const chapter = await getEntry('chapters', 'skills');
if (!chapter) {
throw new Error('skills chapter: missing collection entry');
}
const lede = chapter.data.lede.en;
const title = chapter.data.title.en;
const eyebrow = chapter.data.eyebrow.en;
const sections = chapter.data.sections ?? [];
const anatomySection = sections[0];
const createSection = sections[1];
if (!anatomySection) {
throw new Error('skills chapter: missing sections[0]');
}
if (!createSection) {
throw new Error('skills chapter: missing sections[1]');
}
const anatomyEyebrow = requireField(anatomySection.eyebrow, 'sections[0].eyebrow');
const anatomyCopy = requireField(anatomySection.copy, 'sections[0].copy');
const createEyebrow = requireField(createSection.eyebrow, 'sections[1].eyebrow');
const createSteps = requireField(createSection.steps, 'sections[1].steps');
---
<ChapterLayout title="AI For Dummies — Skills" description={lede.replace(/<[^>]+>/g, '')}>
<link slot="styles" rel="stylesheet" href={skillsStylesheet} />
<a slot="top-previous" href={`${base}summary/`}> ROUTE MAP</a>
<span slot="top-center">03 / SKILLS</span>
<a slot="top-next" href={`${base}skills-review/`}>review desk </a>
<ChapterHero eyebrow={eyebrow}>
<span slot="title" set:html={title} />
<p set:html={lede} />
</ChapterHero>
<section class="pipeline package-anatomy">
<div>
<p class="eyebrow">{anatomyEyebrow.en}</p>
<h2 set:html={anatomySection.title.en} />
<p class="package-hint">{anatomyCopy.en}</p>
</div>
<SkillPackageExplorer />
</section>
<section class="practice">
<div>
<p class="eyebrow">{createEyebrow.en}</p>
<h2 set:html={createSection.title.en} />
</div>
<div class="steps">
{
createSteps.map((step, index) => (
<article>
<b>{String(index + 1).padStart(2, '0')}</b>
<div>
<strong>{step.label.en}</strong>
<span>{step.copy.en}</span>
</div>
</article>
))
}
</div>
</section>
<nav slot="footer-links" class="links" aria-label="Chapter navigation">
<a href={`${base}agents/`}>Agents & trees </a>
<a href={`${base}rules/`}>Rules case study </a>
<a href={`${base}skills-review/`}>Review submitted skills </a>
<a href={`${base}full-guide/#create-skill`}>Full guide: skill forge </a>
</nav>
</ChapterLayout>
+1 -1
View File
File diff suppressed because one or more lines are too long