`, the editor strips it:
+
+```html
+
+
+ ...
+
+
+```
+
+Navigation uses hand-authored `wp:navigation-link` with `kind:"custom"` and hash URLs (this is the one place hand-authored links are correct, vs the usual `wp:page-list`):
+
+```html
+
+
+```
+
+Section ids MUST match the nav hashes exactly (case-sensitive) — a typo breaks the click silently. Final nav item is usually a CTA `wp:button` anchored to `#signup`.
+
+---
+
+## 4. Magazine grid (the homepage IS the archive)
+
+Editorial themes: thin masthead, a lead story at high weight, then a uniform 3-column card grid. A single query loop can't make "the first item bigger," so use **two `wp:query` blocks**: first `perPage:1` (lead), second `perPage:6` with `offset:1` (grid). CSS keys off two class hooks: `is-style-lead-story` and `is-style-loop-magazine`.
+
+```css
+.wp-site-blocks > header.wp-block-template-part { border-bottom: 1px solid var(--wp--preset--color--rule); }
+.wp-site-blocks > header.wp-block-template-part > .wp-block-group {
+ display: flex; align-items: center; justify-content: space-between;
+ padding-top: var(--wp--preset--spacing--30); padding-bottom: var(--wp--preset--spacing--30);
+}
+
+.wp-block-query.is-style-lead-story .wp-block-post-template { display: block; }
+.wp-block-query.is-style-lead-story .wp-block-post-featured-image { aspect-ratio: 16 / 9; margin-bottom: var(--wp--preset--spacing--40); }
+.wp-block-query.is-style-lead-story .wp-block-post-featured-image img { width: 100%; height: 100%; object-fit: cover; }
+.wp-block-query.is-style-lead-story .wp-block-post-title { font-size: clamp(2rem, 4vw, 3rem); line-height: 1.1; }
+
+.wp-block-query.is-style-loop-magazine .wp-block-post-template {
+ display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--wp--preset--spacing--50);
+}
+.wp-block-query.is-style-loop-magazine .wp-block-post-template > li { display: flex; flex-direction: column; }
+.wp-block-query.is-style-loop-magazine .wp-block-post-featured-image { aspect-ratio: 4 / 3; margin-bottom: var(--wp--preset--spacing--30); }
+.wp-block-query.is-style-loop-magazine .wp-block-post-date { margin-top: auto; } /* pins byline to card bottom */
+
+@media (max-width: 960px) { .wp-block-query.is-style-loop-magazine .wp-block-post-template { grid-template-columns: repeat(2, 1fr); } }
+@media (max-width: 600px) { .wp-block-query.is-style-loop-magazine .wp-block-post-template { grid-template-columns: 1fr; } }
+
+.wp-block-query.is-style-lead-story + .wp-block-query.is-style-loop-magazine {
+ margin-top: var(--wp--preset--spacing--80);
+ padding-top: var(--wp--preset--spacing--60);
+ border-top: 1px solid var(--wp--preset--color--rule);
+}
+```
+
+theme.json must set `core/post-template.spacing.blockGap` (without it the grid items collapse) and tighten `contentSize` to ~720px:
+
+```json
+{
+ "settings": { "layout": { "contentSize": "720px", "wideSize": "1280px" } },
+ "styles": { "blocks": {
+ "core/post-template": { "spacing": { "blockGap": "var:preset|spacing|50" } },
+ "core/query": { "spacing": { "blockGap": "var:preset|spacing|60" } }
+ } }
+}
+```
+
+The home template opens with the two query blocks — NEVER `wp:post-content`. Bylines render in a mono font, uppercase, letter-spaced. Homepage usually doesn't paginate ("see all" links to category archives); if needed add `wp:query-pagination` inside the second query, styled small/uppercase/mono — never large pill buttons.
+
+---
+
+## 5. Floating chrome / canvas (imagery owns the viewport)
+
+Photography portfolios, galleries, lookbooks: every image reaches all four viewport edges; chrome is just a floating wordmark + menu via `position: fixed`. Wrong shape for text-heavy sites.
+
+Root padding must be zero here (and only here) so full-bleed means literally the viewport edge, not edge-minus-gutter:
+
+```json
+{
+ "styles": { "spacing": { "padding": { "top": "0", "bottom": "0", "left": "0", "right": "0" } } },
+ "settings": { "useRootPaddingAwareAlignments": true, "layout": { "contentSize": "760px", "wideSize": "1280px" } }
+}
+```
+
+Keep `useRootPaddingAwareAlignments: true` so text pages can still constrain to `contentSize`.
+
+```css
+.wp-site-blocks > header.wp-block-template-part {
+ position: fixed; top: 0; left: 0; right: 0; z-index: 100;
+ pointer-events: none; /* let clicks pass through the band */
+ padding: var(--wp--preset--spacing--40) var(--wp--preset--spacing--50);
+}
+.wp-site-blocks > header.wp-block-template-part > .wp-block-group {
+ pointer-events: auto; /* restore on actual chrome */
+ display: flex; justify-content: space-between; align-items: flex-start;
+ mix-blend-mode: difference; /* legible against any image */
+ color: white; /* difference flips this per backdrop — do NOT use literal #000/#fff */
+}
+.canvas-hero .wp-block-image, .canvas-hero .wp-block-cover { width: 100vw; height: 100vh; margin: 0; }
+.canvas-hero .wp-block-image img, .canvas-hero .wp-block-cover img { width: 100%; height: 100%; object-fit: cover; }
+.canvas-caption { /* captions BETWEEN images, never overlaid */
+ padding: var(--wp--preset--spacing--40) var(--wp--preset--spacing--50);
+ max-width: var(--wp--style--global--content-size);
+ font-family: var(--wp--preset--font-family--mono);
+ font-size: var(--wp--preset--font-size--x-small);
+ text-transform: uppercase; letter-spacing: 0.1em;
+ color: var(--wp--preset--color--muted);
+}
+.wp-block-navigation__responsive-container { /* hamburger overlay reads cleanly */
+ background: var(--wp--preset--color--background);
+ color: var(--wp--preset--color--foreground);
+ mix-blend-mode: normal;
+}
+```
+
+The `mix-blend-mode: difference` + `color: white` combo keeps the wordmark/menu legible over any photo (white over black = white, white over white = black). The `pointer-events` flip is mandatory — without `auto` on children the fixed band swallows every click. Homepage `
` uses `layout.type:"default"` with zero padding so the hero reaches the edges; text pages (about/contact) restore `constrained` layout with comfortable padding. The nav uses `overlayMenu:"always"` (the hamburger IS the menu on every viewport). Register a `has-floating-chrome` body class via `body_class`.
+
+---
+
+## 6. Scroll motion catalog (progressive enhancement only)
+
+Use one or two effects, tastefully. NO libraries (GSAP, Lenis, ScrollMagic, AOS), NO scroll-jacking (`wheel` + `preventDefault`, scroll-snap on the body), NO `position: fixed` headers (use sticky), NO `background-attachment: fixed` parallax. Animate only `opacity`, `transform`, `filter`, `background-color` (never `width`/`height`/`top`/`padding` — they trigger layout).
+
+Every effect respects reduced motion. CSS-only effects wrap in `@media (prefers-reduced-motion: no-preference)`; JS effects early-return on `matchMedia('(prefers-reduced-motion: reduce)').matches`. **CSS defines the final visible state; JS adds the initial hidden state** so a JS failure leaves content visible.
+
+Motion CSS that sets `opacity: 0` must NOT load in the editor iframe (`add_editor_style` would blank the canvas). Keep reveal/hidden-state CSS in a separate `assets/motion.css` enqueued front-end-only via `wp_enqueue_scripts`, NOT in `style.css`. Frontend-only body-class effects (e.g. `body.is-scrolled`) are safe in `style.css` since the class only toggles on the front end.
+
+### A. Section reveal on enter (IntersectionObserver) — the always-on default
+
+```css
+@media (prefers-reduced-motion: no-preference) {
+ .reveal-on-scroll { opacity: 0; transform: translateY(24px); transition: opacity 0.7s ease, transform 0.7s ease; }
+ .reveal-on-scroll.is-visible { opacity: 1; transform: translateY(0); }
+}
+```
+
+Add `className:"reveal-on-scroll"` to each top-level section group (NOT the hero — it's visible on load). `assets/reveal-on-scroll.js` (write + enqueue):
+
+```js
+(function () {
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
+ var els = document.querySelectorAll('.reveal-on-scroll');
+ if (!('IntersectionObserver' in window) || !els.length) return;
+ var io = new IntersectionObserver(function (entries) {
+ entries.forEach(function (e) {
+ if (e.isIntersecting) { e.target.classList.add('is-visible'); io.unobserve(e.target); }
+ });
+ }, { rootMargin: '0px 0px -10% 0px' });
+ els.forEach(function (el) { io.observe(el); });
+})();
+```
+
+### B. Hero scroll-fade (CSS scroll-driven, no JS)
+
+```css
+@media (prefers-reduced-motion: no-preference) {
+ @supports (animation-timeline: scroll()) {
+ .hero-content {
+ opacity: 1; transform: translateY(0);
+ animation: hero-fade linear both;
+ animation-timeline: scroll(root);
+ animation-range: 0px 60vh;
+ }
+ @keyframes hero-fade { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(-40px); } }
+ }
+}
+```
+
+Apply `hero-content` to the inner content group of the hero `wp:cover` (not the cover itself). First hero only. Browsers without the API get a static hero (acceptable).
+
+### C. Scroll progress bar (CSS scroll-driven) — longform/editorial only
+
+```css
+@media (prefers-reduced-motion: no-preference) {
+ @supports (animation-timeline: scroll()) {
+ .scroll-progress {
+ position: fixed; top: 0; left: 0; right: 0; height: 3px;
+ background: var(--wp--preset--color--accent, currentColor);
+ transform-origin: left center; transform: scaleX(0); z-index: 1000;
+ animation: scroll-progress-grow linear; animation-timeline: scroll(root);
+ }
+ @keyframes scroll-progress-grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }
+ }
+}
+```
+
+Emit `` at the top of the page body (it's decorative — screen readers must ignore it).
+
+### D. Sticky narrative + scrolling visuals (NYT-style) — case studies only
+
+```css
+@media (prefers-reduced-motion: no-preference) {
+ .narrative-pin > .wp-block-column:first-child {
+ position: sticky; top: var(--wp--custom--scroll-padding-top, 96px); align-self: flex-start;
+ }
+}
+```
+
+`wp:columns {"className":"narrative-pin"}`: first column = short pinned text (must fit one viewport, else nothing to pin), second column = the long scrolling stack.
+
+### E. Counter on enter (IntersectionObserver) — only with 2–4 real stat numbers
+
+Markup: `0
`. Write `assets/counter.js`: early-return on reduced motion (render final value immediately), IntersectionObserver to a cubic ease-out count-up, `toLocaleString()` for separators.
+
+### Header on-scroll variants (pick AT MOST one)
+
+`assets/header-scroll.js` toggles `body.is-scrolled` past a 60px threshold and (under no-preference) `body.header-hidden` while scrolling down. Variants:
+
+1. **Shrink** — section 1's `.is-shrunk` pattern.
+2. **Invert** — transparent over hero, solid past fold (needs the picked palette tokens, so this CSS goes in `style.css` keyed off `body.is-scrolled`):
+ ```css
+ @media (prefers-reduced-motion: no-preference) {
+ .wp-site-blocks > header.wp-block-template-part > .wp-block-group { transition: background-color 0.3s ease, color 0.3s ease, backdrop-filter 0.3s ease; }
+ body.is-scrolled .wp-site-blocks > header.wp-block-template-part > .wp-block-group {
+ background-color: var(--wp--preset--color--background); color: var(--wp--preset--color--primary); backdrop-filter: blur(8px);
+ }
+ }
+ ```
+3. **Hide on scroll-down, show on scroll-up** (longform):
+ ```css
+ @media (prefers-reduced-motion: no-preference) {
+ .wp-site-blocks > header.wp-block-template-part { transition: transform 0.3s ease; }
+ body.header-hidden .wp-site-blocks > header.wp-block-template-part { transform: translateY(-100%); }
+ }
+ ```
+4. **Active-anchor underline** (landing-page only) — `assets/active-anchor.js` watches each `` and toggles `.is-active` on the matching `` (use `rootMargin: -40% 0px -40% 0px` to track the viewport's middle band):
+ ```css
+ @media (prefers-reduced-motion: no-preference) {
+ .wp-block-navigation a { position: relative; }
+ .wp-block-navigation a::after { content: ''; position: absolute; left: 0; right: 0; bottom: -4px; height: 2px; background: currentColor; transform: scaleX(0); transform-origin: left center; transition: transform 0.3s ease; }
+ .wp-block-navigation a.is-active::after { transform: scaleX(1); }
+ }
+ ```
+
+### Per-page budget
+
+- **Homepage**: section reveal (A) is mandatory and free; pick 1–2 of B/C/D/E and at most ONE header variant. Zero rich effects is also valid for minimal themes.
+- **Other pages**: section reveal (A) only.
+- **CPT single entries**: none.
+
+Enqueue each motion JS in the footer from `functions.php`:
+
+```php
+add_action( 'wp_enqueue_scripts', function () {
+ wp_enqueue_script(
+ 'myprefix-reveal-on-scroll',
+ get_theme_file_uri( 'assets/reveal-on-scroll.js' ),
+ array(), wp_get_theme()->get( 'Version' ), true
+ );
+} );
+```
+
+---
+
+## 7. Query-loop layouts (front-end archives and listings)
+
+Content is seeded into the live DB (via WP-CLI / the seed_content tool), and CPTs/meta come from the companion plugin. The theme's job is the **loop layout** in templates. Every `wp:query` loop is three decisions: (1) per-item data composition, (2) loop shape, (3) page composition around it. Don't stamp one shape on every CPT.
+
+### The single hard rule
+
+**Never put `wp:post-content` inside an archive's `wp:post-template`.** It renders the full single-page body for every entry — a wall of full posts. Compose from post-* primitives: `wp:post-featured-image`, `wp:post-title`, `wp:post-excerpt`, `wp:post-date`, `wp:post-author-name`. `wp:post-content` is correct only in `single-.html` (one detail page, not a loop).
+
+### Rendering structured meta (price, role, year, date)
+
+There is NO `wp:post-meta` block — emitting it renders nothing. Use **block bindings** on a paragraph or heading (the companion plugin registers each meta key with `show_in_rest => true`, which bindings require):
+
+```html
+
+
+
+```
+
+For a labelled value ("Maker: …"), pair a static paragraph and a bound paragraph in a flex `wp:group`. The empty `` is a placeholder replaced at render time.
+
+### Picking a shape (decide in this order)
+
+1. **Page role** is decisive.
+ - Homepage preview (archive lives elsewhere) → horizontal rail or compact 3-col grid, capped 4–6. NOT editorial list / zigzag / cover-hero — giant single-column rows look broken on a homepage.
+ - Hero archive (the loop IS the page) → richest shapes: editorial list, zigzag, magazine, featured+rest.
+ - Secondary listing → grid or list. Mid-page band → rail, compact list, simple grid.
+2. **Entry count** (dedicated archives): 3–6 → editorial list / zigzag / cover-hero; 6–12 → grid (2–3 col) / featured+rest; 12–30 → dense grid / compact list; 30+ → compact list / pagination / sibling loops.
+3. **Visual weight per entry**: portraits → grid; long detail → editorial list / zigzag; single value (price) → compact list; photographic → cover-hero; prose → magazine / featured+rest.
+4. **Brand voice**: editorial → list/magazine; minimal → consistent-chrome grid; lookbook → cover-hero/magazine; brutalist → hairline-border grid/compact list.
+5. **Natural data axis**: chronological → timeline / date-ordered; tiered → featured+rest; two slices → sibling loops; geographic → compact list with location meta.
+
+Pick by **domain purpose, not slug** — a CPT listing dentists is People (portrait + name + role), not "Default."
+
+### Shape: Card grid (equal-weight uniform grid)
+
+```html
+
+
+
+```
+
+### Shape: Editorial list / vertical stack (image-side rows, detail-heavy)
+
+Two-column rows (image 40% / text 60%), `align:"wide"`, `className:"is-style-loop-list"` (CSS adds row dividers). Use `wp:columns` with `verticalAlignment:"center"`; surface an overline meta paragraph, a large `wp:post-title`, and a longer `wp:post-excerpt`.
+
+### Shape: Horizontal rail (scrollable strip, "more elsewhere")
+
+`className:"is-style-loop-rail"`, `align:"full"`, `post-template` layout `flex`/`flexWrap:"nowrap"`. CSS adds `overflow-x: auto` + scroll-snap; cards get a fixed width. Cap 4–8 entries.
+
+### Shape: Cover-hero (featured image as background, title overlaid)
+
+`wp:cover {"useFeaturedImage":true,"dimRatio":40,"minHeight":420,"isLink":true}` inside a grid `post-template`. Title + meta go in `wp-block-cover__inner-container` with `textColor:"background"` (text over the dimmed image). Image-dominant — galleries, photo menus.
+
+### Shape: Featured + rest (one promoted, the rest in a grid)
+
+Needs **two queries**: first `perPage:1` rendered as a large cover-hero; second `perPage:6,"offset":1` as a 3-col grid (offset skips the featured entry). No labelling comment between them.
+
+### Shape: Compact list, zigzag, timeline, magazine
+
+- **Compact list** (`is-style-loop-list`): one flex row per entry, title + meta, no images. Long indexes (press, episodes, jobs).
+- **Zigzag** (`is-style-loop-zigzag`): full-width image-text rows; CSS flips column order on `:nth-child(even)`. Portfolio walks, recipes.
+- **Timeline** (`is-style-loop-timeline`): `orderBy:"meta_value"` on a date key; CSS adds a vertical line + node dots via `::before`. Events, milestones.
+- **Magazine** (`is-style-loop-magazine`): grid where CSS makes the first child span 2 columns. Editorial homepages.
+
+When a shape needs a hook, add `className:"is-style-loop-"` to the `wp:query` and ship the matching rule in `style.css`. The shapes are starting points — invent new ones with the post-* primitives + standard layout blocks when the data calls for it (e.g. a status board, a comparison rail).
+
+### Page composition around the loop
+
+Surround the loop with the page's voice: intro + loop + CTA (most pages); manifesto + loop (about/team); hero feature + archive (editorial homepages); loop as one band among full-bleed bands; or loop + sibling loop (two queries slicing the same CPT — "upcoming / past" via `metaQuery` on a date key, "featured / recent" via `offset`). Each sibling loop can pick its own shape.
+
+---
+
+## Cross-cutting checklist
+
+1. Custom classNames only on outer block wrappers; full-bleed via `align:full` outer group.
+2. `backgroundColor` always paired with `textColor`; nav gets the full color set.
+3. Sticky on the `.wp-block-template-part` wrapper; audit ancestor `overflow` if it fails.
+4. Grid layouts: `min-width: 0` on text grid items; explicit `grid-row: 1` on sidebar shells.
+5. Motion: final state in CSS, initial hidden state added by JS; every effect respects reduced motion; reveal CSS stays out of `style.css` (out of the editor iframe).
+6. Never `wp:post-content` in a loop's `post-template`; render meta via block bindings (no `wp:post-meta`).
+7. No emojis, no decorative HTML comments. Plain-JS motion files in `assets/`, enqueued front-end-only — no bundler, no Interactivity API.
diff --git a/apps/cli/ai/skills/site-generator/SKILL.md b/apps/cli/ai/skills/site-generator/SKILL.md
new file mode 100644
index 0000000000..55043db53b
--- /dev/null
+++ b/apps/cli/ai/skills/site-generator/SKILL.md
@@ -0,0 +1,110 @@
+---
+name: site-generator
+description: Generate a complete WordPress site — a pure-presentation theme plus a companion plugin — from a description. Load FIRST when the user wants to build a whole new site or theme. Orchestrates spec, design selection, parallel theme generation, companion plugin, content seeding, AI imagery, and validation.
+user-invokable: true
+---
+
+# Site Generator
+
+This is the orchestrator for building a complete WordPress site end to end. It
+uses dedicated generation tools that run many model calls in parallel and write
+whole packages to disk in one call — far faster and more complete than writing
+files one per turn. Your job is to drive the pipeline in order and verify the
+result, not to hand-author theme files.
+
+## Output model (read this first)
+
+Every generated site is TWO packages:
+
+- **Theme** — pure presentation: `theme.json`, `style.css`, `templates/`,
+ `parts/`, `patterns/`, `assets/`. Minimal `functions.php`. No behaviour.
+- **Companion plugin** — all behaviour: custom post types, taxonomies, post
+ meta, REST routes, and build-less plain-JS blocks. Survives a theme switch.
+
+Page content is seeded into the **live database**, never baked into the theme.
+For background, the `theme-architecture`, `companion-plugin`, `layout-patterns`,
+`data-persistence`, and `wp-best-practices` skills hold the doctrine the
+generators apply; load them if you need to reason about a result or fix it.
+
+## Pipeline
+
+### 1. Resolve the site
+
+- If the user is building a brand-new site, run the `site-spec` skill to gather
+ the site name and any layout preference, then call **`site_create`**.
+- If they want to use an existing/active site, use that one (`site_info`).
+
+### 2. Build the site spec (JSON)
+
+Synthesize a JSON spec string you will pass to every generation tool. Use the
+`theme-architecture` skill's layout-mode and content-mode taxonomy to choose
+sensible values, and the `visual-design` skill for aesthetic direction. Shape:
+
+```json
+{
+ "name": "Ember & Oak",
+ "type": "restaurant",
+ "audience": "local diners looking for a special evening",
+ "tone": "warm, refined, unfussy",
+ "topic": "a wood-fired neighbourhood restaurant in Lisbon",
+ "layoutPreference": "landing-page or vertical-stack",
+ "pages": ["Home", "Menu", "About", "Reservations", "Contact"],
+ "features": ["reservation form"]
+}
+```
+
+Keep it concise but specific; the topic and tone drive design quality.
+
+### 3. Generate and choose a design direction
+
+Call **`generate_design_previews`** with `nameOrPath` and `spec`. It writes
+several first-fold HTML previews to `/design/` and opens them. Show the
+user the directions and use **AskUserQuestion** to let them pick one (or ask for
+a regenerate). Keep the chosen preview's HTML — you pass it next.
+
+### 4. Generate the theme
+
+Call **`generate_theme`** with `nameOrPath`, `spec`, and `design` (the chosen
+preview's HTML or its brief). It generates the whole theme in parallel, writes
+it, activates it, and returns a **MANIFEST** JSON block at the end of its output.
+**Copy that manifest verbatim** — the next tools need it.
+
+### 5. Generate the companion plugin (only if needed)
+
+If the manifest's `companionPlugin.needed` is `true`, call
+**`generate_companion_plugin`** with `nameOrPath`, `spec`, and the `manifest`.
+It generates CPTs, REST routes, and build-less plain-JS blocks, then activates
+the plugin. A brochure site with no forms/CPTs/interactive blocks skips this.
+
+### 6. Seed content
+
+Call **`seed_content`** with `nameOrPath`, `spec`, and the `manifest`. It
+generates each page's block markup, fills AI_IMAGE placeholders with generated
+imagery, publishes the pages into the database, and sets the home page as the
+static front page.
+
+### 7. Fill theme imagery
+
+Call **`generate_image`** with `nameOrPath` and `themeSlug` (the manifest's
+`themeSlug`) to fill any `AI_IMAGE:` placeholders left in theme templates/parts.
+
+### 8. Verify and fix
+
+- Run **`validate_and_fix_blocks`** (with `nameOrPath` and the relevant
+ content/filePath) on generated block content; rewrite anything it flags.
+- Run **`take_screenshot`** with `viewport: "all"` on the site URL. Check the
+ navigation, hero, full-width sections (they must span the viewport — fix in
+ markup with `align: "full"`, not CSS), color contrast, and spacing. Fix issues
+ with `Edit`/`wp_cli` and re-verify.
+
+## Rules
+
+- The generation tools each run for a while (many parallel model calls). That is
+ expected — let them complete; do not try to hand-write the files yourself.
+- Always pass the SAME `spec` string and the SAME `manifest` through steps 4–7.
+- Never put behaviour in the theme or content in theme files — the tools already
+ enforce the split; don't undo it with manual `wp_cli`/`Write` edits.
+- WordPress.com login is required (AI generation + imagery route through the
+ WordPress.com AI proxy). If a tool reports a login error, tell the user to run
+ `/login`.
+- No emojis in any generated content.
diff --git a/apps/cli/ai/skills/site-generator/generators/_shared.md b/apps/cli/ai/skills/site-generator/generators/_shared.md
new file mode 100644
index 0000000000..3d1a152aa4
--- /dev/null
+++ b/apps/cli/ai/skills/site-generator/generators/_shared.md
@@ -0,0 +1,64 @@
+# Shared generation rules
+
+These rules govern EVERY file you generate for this site, regardless of which specific generator invoked you. They are absolute unless the site spec or the user's original request explicitly overrides one of them. Read them before writing a single line, and re-read the relevant section before you declare colors, choose a layout, or write copy.
+
+## Two-package architecture (never blur the line)
+
+A generated site is always TWO packages with a hard separation of concerns:
+
+- **The theme** (`/wp-content/themes//`) is PURE PRESENTATION: `theme.json`, `style.css`, `templates/`, `parts/`, `patterns/`, `assets/`, and a minimal `functions.php` that does nothing but enqueue `style.css` on the front end and call `add_editor_style`. The theme NEVER registers custom post types, taxonomies, post meta, REST routes, or blocks, and NEVER seeds content.
+- **The companion plugin** (`/wp-content/plugins/-functionality/`) owns ALL behavior: custom post types, taxonomies, post meta, REST API routes, and any custom Gutenberg blocks.
+- **Content** is never baked into files. Pages, posts, and CPT entries are seeded into the LIVE WordPress database (via WP-CLI / the seed-content tool), not written as `*.html` content files in the theme.
+
+When a generator asks for theme markup, do not reach for behavior. When it asks for the plugin, do not reach for presentation. Keep the two clean.
+
+## Composition and block markup
+
+- **No decorative HTML comments.** Only WordPress block delimiter comments (`` ... ``) are allowed. Never insert labelling comments like ``, ``, or any `` that is not a block delimiter. Section identity lives in `className` and block attributes, not in comments.
+- **Output fully expanded block markup.** Never emit `` placeholders or any shorthand — write the complete nested markup inside every block.
+- **Prefer core blocks for content.** Compose with `wp:group`, `wp:columns`, `wp:cover`, `wp:media-text`, `wp:heading`, `wp:paragraph`, `wp:buttons`, `wp:image`, `wp:quote`, `wp:details`, `wp:gallery`, `wp:list`, `wp:navigation`, `wp:site-title`, etc. Reach for a custom block (in the companion plugin) only when a feature is genuinely interactive or data-backed and no core block expresses it. `wp:html` is reserved for tiny exotic embeds only — never for heroes, navs, card grids, forms, testimonials, CTAs, sidebars, or any section a core block can express.
+- **One dominant semantic wrapper per section.** Treat every section as self-contained: a single outer `wp:group` (or `wp:cover`) that owns the section, with content nested inside it.
+- **Full-bleed sections use an outer group with `align:full`.** A section that bleeds edge-to-edge (hero, photographic band, footer band, full-bleed CTA strip) is an outer `wp:group`/`wp:cover` with `"align":"full"`. Sections at the theme's wide width use `"align":"wide"` (feature grids, query loops, most sections). Default content alignment is reserved for INNER content holders only — a constrained group nested inside a section that holds readable copy. A top-level section with no `align` will inherit body root padding and render as a narrow column; that is the visual symptom of forgetting this.
+- **Section container vs inner holder.** The outer section container (which owns the background, padding, and `align`) uses `"layout":{"type":"default"}` or omits `layout` — never `constrained` (constrained clamps width and breaks edge-to-edge backgrounds). The inner content holder nested inside it MAY be `constrained` so readable copy sits at content width. The constrained group inside a full-bleed container is the canonical pattern.
+- **Horizontal page gutter lives in `theme.json` root padding only.** Do not add left/right padding to `` wrappers, top-level page groups, template shells, or the page root — that double-pads. Sections break out of root padding via `align`, never via wrapper padding.
+- **Custom classNames go ONLY on the outermost block wrapper**, via the block's `className` attribute (e.g. `{"className":"site-hero"}`). Never put custom classes on inner DOM elements or on nested blocks. Section identity and any custom CSS hooks attach to the outer wrapper; everything inside is styled through that hook or through block attributes.
+- **Sticky positioning goes on the `.wp-block-template-part` wrapper, not the inner group.** When a header (or any part) is sticky, the sticky rule targets the template-part wrapper element, never the inner `wp:group` inside the part.
+
+## Color pairing discipline (read before declaring any block colors)
+
+Inheritance in WordPress block themes is unreliable. A child block whose text color falls through to the body default renders invisible against any parent surface that is not the body background. Defend against it at the block level:
+
+- **Whenever a block declares `backgroundColor` (or `style.color.background`), it MUST also declare `textColor` (or `style.color.text`).** No exceptions, at every level. A tinted `wp:group` that omits its own text color passes the inheritance burden to its children and the chain breaks the moment one child also omits it.
+- **`wp:button` MUST declare BOTH text and background colors** at the block level. `is-style-outline` buttons (transparent background) MUST declare `textColor` AND `borderColor` together.
+- **When a block declares `style.border.width`, it MUST also declare `borderColor`** as a palette slug, so borders never inherit `currentColor`.
+- **`wp:navigation` MUST declare all of: `textColor`, `style.elements.link.color.text`, `overlayBackgroundColor`, `overlayTextColor`, and `style.spacing.blockGap`.** Missing any one breaks at least one of the desktop / hover / mobile-overlay / item-spacing render modes. Mobile overlay defaulting to invisible is the canonical nav bug.
+- **Any block with `layout.type:"grid"` or `layout.type:"flex"` MUST declare `style.spacing.blockGap`** (post-template grids, columns, custom flex groups, `wp:buttons`, `wp:navigation`). WordPress flex/grid layouts have no gap by default. The structural test: right after you write `layout.type:"grid"` or `layout.type:"flex"`, the next attribute should be `style.spacing.blockGap`.
+
+## Design token discipline
+
+- **Reference `theme.json` tokens by slug in block markup.** Use the declared palette, font-size, font-family, and spacing presets: `{"textColor":"primary"}`, `{"fontSize":"large"}`, `{"style":{"spacing":{"padding":{"top":"var:preset|spacing|40"}}}}`. Never introduce hardcoded hex colors, raw px values, or font names in block attributes.
+- **CSS in `style.css` references tokens via CSS variables** — `var(--wp--preset--color--primary)`, `var(--wp--preset--font-family--body)`, `var(--wp--preset--spacing--40)` — rather than hardcoding values. Custom CSS is reserved for polish (typographic detail, link treatments, button variants, image effects, animation states), not for re-implementing layout that block attributes already express.
+- **Fonts are declared in `theme.json`** via `settings.typography.fontFamilies` with `fontFace` entries pointing at the bundled font files. Do NOT enqueue fonts from PHP and do not create a `fonts.php`.
+- **Typography restraint.** Body text around 1rem with line-height 1.5–1.65. Headings scale modestly; cap display text near 3.5rem (e.g. `clamp(2.5rem, 4vw, 3.5rem)`). Never go below line-height 1.0 on any text; heading line-heights stay between 1.1 and 1.3.
+
+## Scroll animation and motion
+
+- **Progressive enhancement only.** CSS defines the FINAL visible state (the element is fully visible and correctly positioned with CSS alone, no JS). JavaScript ADDS the initial hidden state and removes it on scroll/intersection, so that with JS disabled the content is still fully visible. Never let the visible state depend on JS running.
+- **Every animation respects reduced motion.** Wrap motion in `@media (prefers-reduced-motion: reduce)` so that users who prefer reduced motion see the final state with no transition.
+- Keep motion subtle and purposeful; it supports the content, it does not perform.
+
+## Plain JavaScript only (when behavior is involved)
+
+- Any interactive or stateful behavior (countdowns, filters, sliders, calculators, form submission, scroll reveals) is implemented in **plain JavaScript** using standard DOM APIs (`querySelector`, `addEventListener`, `classList`, `dataset`, `fetch`).
+- **Never use the WordPress Interactivity API** (`@wordpress/interactivity`, `data-wp-*` directives, its store/state system).
+- **Custom blocks are build-less plain JS**: a `block.json` plus a plain `view.js`/`editor.js` that calls `wp.blocks.registerBlockType` using `wp.element.createElement` — NO JSX, NO `@wordpress/scripts`, NO npm build step. Blocks are registered server-side with `register_block_type` from the companion plugin.
+
+## Content quality
+
+- **NO EMOJIS anywhere** — not in headings, paragraphs, button labels, navigation, footer text, code comments, or any visible string. Avoid glyphs WordPress auto-converts to emoji (`:)`, `<3`, etc.). If you need iconography, use inline custom SVG.
+- **Realistic, domain-specific copy.** When the spec or request names a business or domain, all body text must reflect it: a bakery's team page is about bakers, a dental clinic's services page lists dental services. Never fall back to generic SaaS/agency/consulting filler.
+- **Cohesive imagery.** Within a logical group (all team photos, all product shots, all entries of one CPT) keep aspect ratio and photographic style consistent, following the image conventions the per-file generator specifies.
+
+---
+
+The specific generator instructions for the file you are about to produce, followed by the site spec JSON and the chosen design direction, follow below — apply every rule above to that work.
diff --git a/apps/cli/ai/skills/site-generator/generators/block.md b/apps/cli/ai/skills/site-generator/generators/block.md
new file mode 100644
index 0000000000..78c269f8ae
--- /dev/null
+++ b/apps/cli/ai/skills/site-generator/generators/block.md
@@ -0,0 +1,223 @@
+# Generator: Custom Gutenberg Block (build-less, plain JS)
+
+You generate ONE custom Gutenberg block for a generated WordPress site. The block lives in the
+site's **companion plugin** — never in the theme. The theme is pure presentation (theme.json,
+templates, parts, patterns, style.css); ALL behavior (custom post types, taxonomies, post meta,
+REST routes, and every custom block) lives in the companion plugin at
+`/wp-content/plugins/-functionality/`. This generator produces the source for a single
+block under that plugin's `blocks//` directory.
+
+The block MUST require **no build step**. There is no wp-scripts, no npm, no webpack, no JSX, no
+`src/` → `build/` compilation. You write plain JavaScript files that the browser loads directly. The
+plugin registers the block server-side with `register_block_type()` pointing at the directory that
+holds your `block.json`.
+
+## Input
+
+The task line gives you the block spec:
+
+- **slug** — the block slug, e.g. `availability-checker`. This is the exact slug the directory is
+ named after and the suffix of the block name. Do not rename it or reformat it.
+- **title** — the human-readable title shown in the inserter, e.g. "Availability Checker".
+- **purpose** — what the block does: the interactive behavior, the data it persists or fetches, the
+ default content, the copy and labels.
+
+You will also be given the theme's design tokens (color slugs, font-size slugs, spacing slugs from
+theme.json) and, for data-backed blocks, the companion plugin's registered post type slug, its meta
+keys, and the REST route the plugin exposes. Use those verbatim — do not invent post type slugs,
+meta keys, or routes. The plugin's CPT/REST registration and your block's `fetch()` target must
+match exactly, or submissions fail at runtime with "missing parameter" errors.
+
+## When this generator runs
+
+This generator runs only for **named, interactive or data-backed features** — a discrete noun that
+implies state, computation, persistence, or custom editor controls: booking form, contact form,
+reservation widget, countdown timer, calculator, quote builder, pricing configurator, before/after
+slider, RSVP form, newsletter signup, availability checker, review submission.
+
+It does NOT run for content sections. Heroes, testimonials, feature grids, pricing displays, team
+sections, FAQs, galleries, and CTAs are composed from CORE blocks in templates/patterns, not built
+as custom blocks. If the spec describes layout or content arrangement rather than a stateful
+feature, no custom block is needed.
+
+## The block name
+
+The block name in `block.json` MUST be `/`, where `` is the
+companion plugin's prefix (the same prefix used for its REST namespace and its block registrations).
+Use the prefix you are given verbatim. Do not derive a different prefix from the theme name or the
+site title. Every block in the plugin shares the one prefix.
+
+## Build-less file shapes
+
+You emit a small, flat set of files for the block directory (NO `src/`, NO `build/`):
+
+| File | Required when |
+|------|---------------|
+| `block.json` | always |
+| `editor.js` | always (editor registration) |
+| `view.js` | the block has front-end interactivity (interactive or form-backed blocks) |
+| `render.php` | the block is dynamic (server-rendered output) |
+
+There is no `index.js`, no `edit.js` + `save.js` split, no `style.scss`/`editor.scss`. Styling comes
+from the theme's design tokens referenced inline in markup (block attributes) and, where needed, plain
+CSS shipped by the plugin or inline `