docs: add .agents workspace and the Astro refactor plan
Adds the agent-facing workspace and a 20-task plan for migrating the site to Astro. Nothing here implements the refactor; these are briefs, rules and templates that the task agents read. - .agents/ holds context, rules, checklists, skills, specialist agents, component/page/config templates and gate scripts. It is vendor-neutral so MiniMax, Gemini and Codex can all read it; CLAUDE.md just points at AGENTS.md. - .husky/ plus .lintstagedrc.json wire the three gate tiers. gate.sh locks on the shared git-common-dir so parallel worktrees serialise, and guards the assertion count in scripts/verify.mjs against a coverage drop. - plans/astro-refactor/ carries the phase graph, per-task briefs and the model-routing recommendation. These files must be tracked before fanning out: a worktree only checks out tracked files, so an untracked plan is invisible to every agent working in one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
---
|
||||
// Grid-group template — for a set of sibling cards separated by hairlines.
|
||||
//
|
||||
// Note the separator technique: `gap: 1px` over a coloured parent background.
|
||||
// That is DELIBERATE house style throughout this site, not a workaround.
|
||||
// Do not "fix" it into `border`.
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
/** Number of columns at the widest breakpoint. */
|
||||
columns?: number;
|
||||
}
|
||||
|
||||
const { label, columns = 3 } = Astro.props;
|
||||
---
|
||||
|
||||
<section class="group" aria-label={label}>
|
||||
<div class="grid" style={`--columns: ${columns}`}>
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--columns), 1fr);
|
||||
gap: 1px; /* hairline separators, drawn by the parent background */
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
/* Children paint their own background, which is what makes the 1px show. */
|
||||
.grid > :global(*) {
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
// Interactive island template. Use ONLY when the component genuinely needs
|
||||
// client-side behaviour, and write the justification in your PR description.
|
||||
//
|
||||
// Islands are LEAVES. Do not wrap static children that could have been
|
||||
// server-rendered — hydrate the tab panel, not the page.
|
||||
//
|
||||
// Hydration preference, in order:
|
||||
// (none) → client:visible → client:idle → client:load
|
||||
//
|
||||
// Usage: <Island client:visible items={items} />
|
||||
|
||||
interface Props {
|
||||
items: { id: string; label: string; body: string }[];
|
||||
initialId?: string;
|
||||
}
|
||||
|
||||
const { items, initialId = items[0]?.id } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="island" data-initial={initialId}>
|
||||
<div class="tabs" role="tablist" aria-label="Sections">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
role="tab"
|
||||
id={`tab-${item.id}`}
|
||||
aria-controls={`panel-${item.id}`}
|
||||
aria-selected={item.id === initialId}
|
||||
data-tab={item.id}
|
||||
>{item.label}</button>
|
||||
))}
|
||||
</div>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={`panel-${item.id}`}
|
||||
aria-labelledby={`tab-${item.id}`}
|
||||
data-panel={item.id}
|
||||
hidden={item.id !== initialId}
|
||||
>{item.body}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Scoped to this island's own root so multiple instances never collide.
|
||||
document.querySelectorAll<HTMLElement>('.island').forEach((root) => {
|
||||
const tabs = root.querySelectorAll<HTMLButtonElement>('[data-tab]');
|
||||
const select = (id: string) => {
|
||||
tabs.forEach((tab) => tab.setAttribute('aria-selected', String(tab.dataset.tab === id)));
|
||||
root.querySelectorAll<HTMLElement>('[data-panel]').forEach((panel) => {
|
||||
panel.hidden = panel.dataset.panel !== id;
|
||||
});
|
||||
};
|
||||
tabs.forEach((tab) => tab.addEventListener('click', () => select(tab.dataset.tab!)));
|
||||
|
||||
// Arrow-key navigation is required for role="tablist" — see
|
||||
// .agents/rules/accessibility.md
|
||||
root.querySelector('[role="tablist"]')?.addEventListener('keydown', (event) => {
|
||||
const key = (event as KeyboardEvent).key;
|
||||
if (key !== 'ArrowRight' && key !== 'ArrowLeft') return;
|
||||
const list = [...tabs];
|
||||
const current = list.findIndex((tab) => tab.getAttribute('aria-selected') === 'true');
|
||||
const next = list[(current + (key === 'ArrowRight' ? 1 : -1) + list.length) % list.length];
|
||||
select(next.dataset.tab!);
|
||||
next.focus();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.tabs { display: grid; gap: 8px; }
|
||||
|
||||
button {
|
||||
padding: 12px;
|
||||
color: var(--ink);
|
||||
background: transparent;
|
||||
border: 1px solid var(--line);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
/* transform/opacity only — never animate layout properties */
|
||||
transition: background 180ms cubic-bezier(.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
button[aria-selected='true'] {
|
||||
color: var(--paper);
|
||||
background: var(--ink);
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 3px solid var(--red);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
button { transition-duration: .01ms; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
// Static block template — the default. Ships zero JavaScript.
|
||||
// Copy to src/components/blocks/<Name>.astro and replace everything marked TODO.
|
||||
//
|
||||
// Before using this, confirm the block earns extraction: it appears three times,
|
||||
// or it has a name a person says out loud. See .agents/rules/componentization.md
|
||||
|
||||
interface Props {
|
||||
/** TODO: describe each prop. Required by default; optional needs a reason. */
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
body: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
const { eyebrow, title, body, href } = Astro.props;
|
||||
---
|
||||
|
||||
<article class="block">
|
||||
<span class="eyebrow">{eyebrow}</span>
|
||||
<h2>{title}</h2>
|
||||
<p>{body}</p>
|
||||
{href && <a href={href}>Open →</a>}
|
||||
<slot />
|
||||
</article>
|
||||
|
||||
<style>
|
||||
/* Tokens only. No raw hex, no px font sizes, no ad-hoc breakpoints.
|
||||
.agents/scripts/check-tokens.mjs enforces this. */
|
||||
.block {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 22px;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* The house eyebrow: uppercase monospace, wide tracking. One class, not
|
||||
fifteen repetitions. */
|
||||
.eyebrow {
|
||||
color: var(--accent);
|
||||
font: var(--font-eyebrow);
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--step-5);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -.06em; /* tight display tracking is a signature of this design */
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--blue);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:focus-visible {
|
||||
outline: 3px solid var(--red);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.block { padding: 18px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
dist
|
||||
node_modules
|
||||
public/hands-on
|
||||
submitted-skills
|
||||
skill-reviews
|
||||
vote-service
|
||||
.agents/snapshots
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"plugins": ["prettier-plugin-astro"],
|
||||
"overrides": [
|
||||
{ "files": "*.astro", "options": { "parser": "astro" } },
|
||||
{ "files": "*.md", "options": { "proseWrap": "always", "printWidth": 80 } }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": ["stylelint-config-standard"],
|
||||
"ignoreFiles": [
|
||||
"dist/**",
|
||||
"public/hands-on/**",
|
||||
"submitted-skills/**"
|
||||
],
|
||||
"rules": {
|
||||
"custom-property-pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$",
|
||||
"declaration-property-value-disallowed-list": {
|
||||
"/^transition/": ["/width/", "/height/", "/^top/", "/^left/", "/margin/"],
|
||||
"/^animation/": ["/width/", "/height/"]
|
||||
},
|
||||
"media-feature-name-no-unknown": true,
|
||||
"no-descending-specificity": null,
|
||||
"selector-class-pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$"
|
||||
},
|
||||
"_comments": {
|
||||
"declaration-property-value-disallowed-list": "Animating layout properties forces reflow every frame and fails the 200ms INP budget. transform and opacity only — see .agents/rules/animation.md",
|
||||
"ignoreFiles": "hands-on/ is a lab fixture and submitted-skills/ is other people's work; neither is ours to restyle"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Flat config (ESLint 9+). Copy to the repository root in task 01.
|
||||
import js from '@eslint/js';
|
||||
import astro from 'eslint-plugin-astro';
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
...astro.configs.recommended,
|
||||
{
|
||||
ignores: [
|
||||
'dist/**',
|
||||
'public/hands-on/**', // lab fixtures ship verbatim — linting them would
|
||||
// invite "fixes" that break the exercise
|
||||
'submitted-skills/**', // other people's work, reproduced as submitted
|
||||
'skill-reviews/**', // generated from skills-review/catalog.js
|
||||
'vote-service/**', // Go service, separate lifecycle
|
||||
],
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||
eqeqeq: ['error', 'always'],
|
||||
'no-var': 'error',
|
||||
'prefer-const': 'error',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,39 @@
|
||||
# Tier 3 gate. Copy to .gitea/workflows/verify.yml in task 01.
|
||||
#
|
||||
# Known trap: this Gitea's act-runner registration lives in an emptyDir, so a
|
||||
# pod restart silently kills CI. If the site stops updating, check the runner
|
||||
# BEFORE debugging the workflow.
|
||||
name: verify
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # gate.sh compares assertion counts against origin/main
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- run: npm ci --prefer-offline
|
||||
- run: npm run lint
|
||||
- run: ./.agents/scripts/gate.sh
|
||||
|
||||
# Tier 3 only: too slow for pre-push, essential before publishing.
|
||||
- name: visual regression
|
||||
run: |
|
||||
npx playwright install --with-deps chromium
|
||||
node .agents/scripts/visual-regression.mjs
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: screenshots
|
||||
path: .agents/snapshots/diff/
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"_note": "Merge these into package.json in task 01. `prepare` is what installs husky; without it every hook is inert.",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"check": "astro check",
|
||||
"lint": "eslint . --max-warnings=0 && stylelint '**/*.css' --max-warnings=0",
|
||||
"format": "prettier --write .",
|
||||
"verify": "node scripts/verify.mjs && node scripts/audit-ui.mjs && node .agents/scripts/check-tokens.mjs",
|
||||
"gate": "./.agents/scripts/gate.sh",
|
||||
"snapshot": "node .agents/scripts/snapshot-route.mjs",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"astro": "^5",
|
||||
"@eslint/js": "^9",
|
||||
"eslint": "^9",
|
||||
"eslint-plugin-astro": "^1",
|
||||
"husky": "^9",
|
||||
"lint-staged": "^16",
|
||||
"prettier": "^3",
|
||||
"prettier-plugin-astro": "^0.14",
|
||||
"stylelint": "^16",
|
||||
"stylelint-config-standard": "^39"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
// Chapter page template — for the numbered chapters (models, agents, skills,
|
||||
// rules). These ship ZERO JavaScript today and must continue to.
|
||||
//
|
||||
// Copy to src/pages/<slug>.astro. The route must match the existing URL
|
||||
// exactly, trailing slash included.
|
||||
|
||||
import ChapterLayout from '../layouts/ChapterLayout.astro';
|
||||
import GridGroup from '../components/blocks/GridGroup.astro';
|
||||
import StaticBlock from '../components/blocks/StaticBlock.astro';
|
||||
import { getEntry } from 'astro:content';
|
||||
|
||||
// Content comes from a collection, never hard-coded in the page.
|
||||
// Both `en` and `pt` are required by the schema.
|
||||
const chapter = await getEntry('chapters', 'models');
|
||||
const lang = 'en'; // TODO: wire to the language toggle decision (task 03)
|
||||
---
|
||||
|
||||
<ChapterLayout
|
||||
number={chapter.data.number}
|
||||
title={chapter.data.title[lang]}
|
||||
description={chapter.data.description[lang]}
|
||||
>
|
||||
<section class="hero">
|
||||
<p class="eyebrow">{chapter.data.eyebrow[lang]}</p>
|
||||
<h1 set:html={chapter.data.heading[lang]} />
|
||||
<p class="lede">{chapter.data.lede[lang]}</p>
|
||||
</section>
|
||||
|
||||
<GridGroup label="Chapter sections" columns={3}>
|
||||
{chapter.data.sections.map((section) => (
|
||||
<StaticBlock
|
||||
eyebrow={section.eyebrow[lang]}
|
||||
title={section.title[lang]}
|
||||
body={section.body[lang]}
|
||||
/>
|
||||
))}
|
||||
</GridGroup>
|
||||
</ChapterLayout>
|
||||
|
||||
<style>
|
||||
/* Page-level layout only. Anything reusable belongs in a component. */
|
||||
.hero { max-width: 780px; padding: clamp(75px, 12vh, 145px) 0 85px; }
|
||||
|
||||
h1 {
|
||||
margin: 16px 0 24px;
|
||||
font-size: var(--step-display);
|
||||
line-height: .86;
|
||||
letter-spacing: -.08em;
|
||||
}
|
||||
|
||||
/* Georgia is a real system font and DOES render — unlike Manrope/DM Mono.
|
||||
See .agents/context/design-system.md */
|
||||
h1 :global(em) {
|
||||
color: var(--blue);
|
||||
font-family: Georgia, serif;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.lede { max-width: 570px; color: var(--muted); line-height: 1.65; }
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
// Interactive page template — for pages that genuinely need client-side
|
||||
// behaviour (the full guide, the review desk).
|
||||
//
|
||||
// The page itself is still server-rendered. Only the islands hydrate.
|
||||
// If you are copying this for a page that has no interaction, use
|
||||
// chapter.astro instead.
|
||||
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Island from '../components/islands/Island.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
const entries = await getCollection('guide');
|
||||
const lang = 'en'; // TODO: wire to the language toggle decision (task 03)
|
||||
|
||||
const items = entries.map((entry) => ({
|
||||
id: entry.id,
|
||||
label: entry.data.title[lang],
|
||||
body: entry.data.copy[lang],
|
||||
}));
|
||||
---
|
||||
|
||||
<BaseLayout title="Full field guide" description="TODO">
|
||||
<!-- Static content is server-rendered. No hydration cost. -->
|
||||
<section class="hero">
|
||||
<p class="eyebrow">The full guide</p>
|
||||
<h1>Ship the <em>system.</em></h1>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
client:visible, not client:load — this is below the fold and the page must
|
||||
stay interactive-free until it matters. Justification belongs in the PR:
|
||||
"tab panel requires click-driven state; no server equivalent."
|
||||
-->
|
||||
<Island client:visible items={items} />
|
||||
|
||||
<!-- More static content after the island. Islands are leaves, not wrappers. -->
|
||||
<slot />
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.hero { max-width: 780px; padding: clamp(75px, 12vh, 145px) 0 85px; }
|
||||
h1 { font-size: var(--step-display); line-height: .86; letter-spacing: -.08em; }
|
||||
h1 em { color: var(--blue); font-family: Georgia, serif; font-weight: 400; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user