# Spec: Divi 5 Capture Tools + Specimen Library

Status: draft for implementation
Target: `includes/tools/class-pcd-pc-tools-divi.php` (new methods) + `includes/library/` (new)

## Problem

The AI falls back to text/code modules because it has no trusted, fully-configured
examples of real Divi 5 modules to copy from. `divi_module_example` mines the
*current site* for examples — which is empty on exactly the sites that need it most
(fresh installs). Fix: ship a curated specimen library inside PressConduit, and add
the capture tooling needed to author it.

Three pieces:

1. `divi_layout_outline` — map a page's block tree so a subtree can be pointed at.
2. `divi_capture` — slice the pointed-at subtree out as portable block markup,
   with a compliance lint against the four-clause authoring standard.
3. Specimen library — shipped JSON files surfaced via `divi_specimen_list` /
   `divi_specimen_get`, wired into `divi_module_example`'s no-results path.

Existing machinery to reuse: `parse_blocks()` / `serialize_block()` round-trip,
`collect_blocks_in_tree()`, `score_block_attrs()` — all already proven in
`divi_module_example`.

---

## 1. `divi_layout_outline`

Read a post's Divi 5 content and return a compact tree map. This is the
"what's on this page" view the caller uses to choose a pointer for `divi_capture`.

### Registration

```php
PCD_PC_Tools::register(
    'divi_layout_outline',
    'Map the Divi 5 block structure of a post: every section/row/column/module with its tree path, admin label, CSS classes, and a text snippet. Use this to find the path for divi_capture.',
    [
        'type'       => 'object',
        'required'   => [ 'post_id' ],
        'properties' => [
            'post_id'   => [ 'type' => 'integer' ],
            'max_depth' => [ 'type' => 'integer', 'default' => 0, 'description' => '0 = unlimited. 2 = sections+rows only (cheap overview).' ],
        ],
    ],
    [ __CLASS__, 'layout_outline' ],
    [ 'cap' => 'edit_posts', 'category' => 'divi' ]
);
```

### Behavior

- `parse_blocks()` on post_content. **Unwrap `divi/placeholder`** if it is the sole
  root block (Divi 5.5+ wraps page content in it — seen on theezgbp.com but not on
  chirositespro.com). Paths are relative to the *unwrapped* tree so the same path
  works on both vintages.
- Skip non-divi blocks (`null` block names from whitespace) when assigning indices.
- Return a **flat array** (cheaper than nesting for the AI to scan), one entry per
  block:

```json
{
  "post_id": 637,
  "wrapped_in_placeholder": true,
  "blocks": [
    { "path": "0",     "block": "divi/section", "admin_label": "PRICING HERO", "class": "ezgbp-page ezgbp-section", "children": 1 },
    { "path": "0.0",   "block": "divi/row",     "admin_label": null,           "class": null,                       "children": 1 },
    { "path": "0.0.0", "block": "divi/column",  "admin_label": null,           "class": "ezgbp-section-head",       "children": 5 },
    { "path": "0.0.0.1", "block": "divi/heading", "admin_label": null, "class": null, "snippet": "The EzGBP vs Local Falcon" }
  ]
}
```

- `snippet`: first ~80 chars of the most human-meaningful text attribute
  (heading `title.innerContent`, text `content.innerContent` stripped of tags,
  button text). Leaf modules only.
- `admin_label` from `module.meta.adminLabel.desktop.value`; `class` from
  `module.advanced.htmlAttributes.desktop.value.class`. Null when absent.

---

## 2. `divi_capture`

Slice one subtree out of a post and return it as portable serialized block markup,
plus a compliance report against the authoring standard.

### Registration

```php
PCD_PC_Tools::register(
    'divi_capture',
    'Capture a Divi 5 subtree (section, row, group, or module) from a post as portable block markup. Point at it with path (from divi_layout_outline), admin_label, or block+nth. Returns the markup plus a portability lint: theme-CSS class dependencies, hard-coded colors, preset reliance.',
    [
        'type'       => 'object',
        'required'   => [ 'post_id' ],
        'properties' => [
            'post_id'     => [ 'type' => 'integer' ],
            'path'        => [ 'type' => 'string',  'description' => 'Dot-index path from divi_layout_outline, e.g. "3.1.0". Most precise pointer.' ],
            'admin_label' => [ 'type' => 'string',  'description' => 'Case-insensitive substring match on module admin labels, e.g. "PRICING HERO".' ],
            'block'       => [ 'type' => 'string',  'description' => 'Block name, e.g. "divi/group" or "group".' ],
            'nth'         => [ 'type' => 'integer', 'default' => 1, 'description' => '1-based occurrence when using block, in document order.' ],
        ],
    ],
    [ __CLASS__, 'capture' ],
    [ 'cap' => 'edit_posts', 'category' => 'divi' ]
);
```

### Pointer resolution (exactly one of)

Priority: `path` > `admin_label` > `block`(+`nth`). Passing none → `WP_Error`
telling the caller to run `divi_layout_outline` first.

- **path**: walk indices into the unwrapped tree. Out of range → error including
  the valid range at the failing depth.
- **admin_label**: substring match (case-insensitive). **Exactly one match
  required.** Multiple matches → no markup; return the match list
  (`[{path, block, admin_label}]`) so the caller re-points with `path`.
  This is how "the second card" gets disambiguated: outline → pick path.
- **block + nth**: normalize bare names to `divi/` prefix (reuse
  `module_example`'s logic). nth counts document order, depth-first.

### Response

```json
{
  "post_id": 637,
  "path": "3.1.0.0",
  "block": "divi/group",
  "admin_label": null,
  "markup": "<!-- wp:divi/group {...} -->\n<!-- wp:divi/heading {...} /-->\n...\n<!-- /wp:divi/group -->",
  "modules": { "divi/group": 1, "divi/heading": 1, "divi/text": 4, "divi/button": 1 },
  "attribute_richness": 23,
  "compliance": {
    "portable": false,
    "warnings": [
      { "rule": "styles-must-travel", "severity": "error",
        "detail": "htmlAttributes class \"ezgbp-price-card\" on divi/group at 3.1.0.0 — look likely lives in theme CSS and will not transfer." },
      { "rule": "styles-must-travel", "severity": "error",
        "detail": "Inline style attribute inside innerContent of divi/heading at 3.1.0.0.1." },
      { "rule": "tokenize-colors", "severity": "warn",
        "detail": "Hard-coded color #14b8a6 in title.decoration.font at 3.1.0.0.1 — use a global color reference (gcid-*) so a palette swap restyles it." },
      { "rule": "no-site-presets", "severity": "warn",
        "detail": "modulePreset [\"d8kq2\"] on divi/button at 3.1.0.0.6 — site-local preset will not exist on the target site." }
    ]
  }
}
```

`markup` is produced by `serialize_block()` on the captured node — same
round-trip already trusted by `divi_module_example`.

### Compliance lint (the four-clause standard, mechanized)

Walk every node in the captured subtree:

| Rule | Trigger | Severity |
|---|---|---|
| `styles-must-travel` | any `htmlAttributes` / `attributes` class value | error |
| `styles-must-travel` | `style=` attribute inside any `innerContent` HTML | error |
| `styles-must-travel` | `<style>` tag inside a non-code module's `innerContent` | error |
| `tokenize-colors` | color-valued attr matching `#hex` / `rgb(a)` (vs a `gcid-*` global-color ref) | warn |
| `no-site-presets` | `modulePreset` containing anything other than `"default"` | warn |
| `escape-hatch` | subtree contains `divi/code` or `divi/fullwidth-code` | info |

Notes:
- Clause 3 of the standard ("everything else travels as explicit design settings")
  is not lintable as an error — it's reported as `attribute_richness` (reuse
  `score_block_attrs()`); a low score on a specimen means it's leaning on defaults.
- `"default"` preset reliance can't be linted away (every module carries it);
  it's covered by the authoring rule: author on a clean site with untouched
  default presets.
- A `divi/code` module whose innerContent ships its own `<style>` is *portable*
  but flagged `escape-hatch` info so specimens using it get labeled as such.
- `portable: true` only when zero errors.

The lint is the falsifiable check for clauses 1, 2, and 4 — but the *proof*
remains deploy-to-fresh-site render comparison (see Verification below).

---

## 3. Specimen library

### Storage

Shipped in the plugin repo: `includes/library/specimens/<name>.json`.
The repo is the source of truth; capture output gets hand-finished (annotations,
tokenization) and committed. No DB storage in v1.

### Specimen file format

```json
{
  "name": "pricing-cards",
  "title": "Pricing cards (composed)",
  "unit": "section",
  "description": "Three-card pricing row with a featured middle card. Composed from group + heading + text + button for full design control.",
  "modules": [ "divi/section", "divi/row", "divi/column", "divi/group", "divi/heading", "divi/text", "divi/button" ],
  "divi_min_version": "5.5",
  "captured_from": { "site": "authoring", "post_id": 0, "date": "2026-06-10" },
  "markup": "<!-- wp:divi/section ... -->",
  "tokens": {
    "colors": {
      "accent":  "Global color slot for highlights / featured card border",
      "heading": "Global color slot for headings",
      "body":    "Global color slot for body text"
    }
  },
  "annotations": {
    "brand_variable": [
      "heading font family + the accent global color",
      "button style (primary/ghost treatment)",
      "featured-card emphasis (border/badge)"
    ],
    "structural_do_not_touch": [
      "column flexType 8_24 x3 and tablet/phone stacking overrides",
      "group as the card container (not column styling) — enables reuse outside this row",
      "row maxWidth + section vertical padding rhythm"
    ],
    "notes": "Native divi/pricing-tables exists. Use it only when the stock look is acceptable; this composed pattern is the professional default because the native module's design control is limited."
  }
}
```

Authoring pipeline per specimen: build on clean test site → `divi_capture` →
fix every lint error (strip classes, tokenize colors) → re-capture until
`portable: true` → add annotations → commit JSON → verification (below).

### v1 specimen list (locked)

1. `hero` — kicker + display heading + sub + button pair
2. `feature-grid` — 3-up `divi/blurb` with icons
3. `pricing-cards` — composed group-based cards, featured variant
4. `testimonials` — 2-up `divi/testimonial`
5. `faq` — `divi/accordion` + `accordion-item` children
6. `contact` — `divi/contact-form` + `contact-field` children, split row
7. `final-cta` — closer section
8. `comparison-table` — `divi/code` escape hatch, labeled as exactly that

### Surfacing tools

```php
PCD_PC_Tools::register(
    'divi_specimen_list',
    'List the built-in library of verified, portable Divi 5 section examples (hero, pricing cards, FAQ, contact form, ...). Each is a known-good pattern to copy and adapt — prefer these over inventing structure from docs.',
    [ 'type' => 'object', 'properties' => new stdClass() ],
    [ __CLASS__, 'specimen_list' ],
    [ 'cap' => 'edit_posts', 'category' => 'divi' ]
);
// Returns name/title/unit/description/modules per specimen — no markup (cheap).

PCD_PC_Tools::register(
    'divi_specimen_get',
    'Fetch one built-in Divi 5 specimen: full block markup, color-token slots, and annotations marking which attributes are brand-variable vs structural. Copy the markup, swap tokens/content, do not restructure.',
    [
        'type'       => 'object',
        'required'   => [ 'name' ],
        'properties' => [ 'name' => [ 'type' => 'string' ] ],
    ],
    [ __CLASS__, 'specimen_get' ],
    [ 'cap' => 'edit_posts', 'category' => 'divi' ]
);
```

### Wiring (kills the original bug)

1. `divi_module_example` no-results / low-richness note (currently "ask the user
   to configure one in the Visual Builder") changes to: *first* point at any
   library specimen containing that module (match against each specimen's
   `modules` list), and only fall back to the ask-the-user note when no specimen
   covers it.
2. `divi_layout_skeleton` / `divi5_docs` guidance text gains one line:
   "For common sections (hero, pricing, FAQ, contact...), call
   `divi_specimen_list` and adapt a specimen instead of composing from scratch."
3. Global colors: specimens reference token slots; at build time the AI maps
   slots to the target site's palette via `divi_global_colors_get`/`set`.

---

## Verification (definition of done, per specimen)

Lint passing is necessary, not sufficient. Each specimen ships only after:

1. `divi_capture` reports `portable: true`.
2. Markup is written to a page on a **fresh throwaway site** (default Divi,
   untouched presets, no child theme) via the existing layout tools.
3. Render comparison: screenshot authoring site vs fresh site
   (`wp_page_rendered_html` + visual check). Differences mean a clause leaked —
   back to authoring.

## Implementation notes

- All new code in `class-pcd-pc-tools-divi.php`, following existing static-method
  + `PCD_PC_Tools::register()` conventions; Divi 5 only (`engine` guard like
  `module_list`).
- Shared private helpers: `unwrap_placeholder( array $blocks ): array`,
  `walk_with_paths( array $blocks, callable $visit )`, `lint_subtree( array $block ): array`.
- Specimen loader reads `includes/library/specimens/*.json` with a static cache;
  `specimen_get` validates `name` against the directory listing (no path input).
- Outline/capture need only `edit_posts` (read-only), unlike the
  `edit_theme_options` write tools.
- Size guard: cap `divi_capture` markup at ~150KB; refuse with a "capture a
  smaller subtree" error above that.
