Pattern Builder 2.1: the patternbuilderwp.com cloud module - #47
Conversation
Connect to patternbuilderwp.com from Appearance → Pattern Builder via OAuth (PKCE); browse My Cloud Library and the public Pattern Directory with live iframe previews; upload local patterns (theme or user, images bundled) with update-vs-new handling via a site link map; download any cloud pattern as a user or theme pattern with assets imported and markup re-sanitized. All cloud traffic flows through site-side proxy endpoints — the token never reaches the browser. New PHP components: Pattern_Builder_Cloud (token store + PKCE + HTTP), Pattern_Builder_Cloud_Porter (local ↔ PBP conversion), and Pattern_Builder_Cloud_Controller (the /cloud/* proxy REST routes). New JS: src/cloud/CloudBrowser.js wired into the browse app's rail. Includes porter unit tests; wp-phpunit bumped to ^7.1 to match current WordPress in the test environment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
- POST (not PUT) for cloud-copy updates: PHP only parses multipart on POST. - Whitelist the configured service origin through core's hardened URL validation (http_request_host_is_external + http_allowed_safe_ports) so development services on nonstandard local ports work. - Scale cloud card preview iframes with a measured ResizeObserver transform (CSS cqw inside scale() is invalid and was dropped). - Porter constructor docblock; CLAUDE.md gains the cloud-module section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
|
The Generated by Claude Code |
- New 'Generate with AI' rail view (CloudBrowser): prompt and/or
screenshot in, a cloud-library pattern out — submit through the proxy,
poll the job, then the result card with the usual add-as-user/theme
actions. Credits meter and the service's mock-provider notice come
from /me; non-Pro accounts see an upgrade prompt; disconnected users
get the connect CTA.
- Proxy routes: POST /cloud/generate (JSON, or multipart when a
screenshot is attached — via a new generic Cloud::form_request) and
GET /cloud/generate/{id}; /cloud/status passes through upgrade_url
and the service's AI state.
- Service error data (upgrade_url) now survives parse_response, so
premium and pro-required errors can link straight to upgrading.
All gating remains server-side on the service; the client only mirrors.
Four new controller tests against a mocked service (103 total).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
|
Root cause found for the failing The Generated by Claude Code |
Pattern_Builder_Cloud_Tokens (client half of architecture §4a): - Upload: collect() scans serialized markup for every preset reference — named attributes, var:preset|… style paths, var(--wp--preset--…) custom properties, and derived has-… classes (generic support classes excluded) — and resolves each slug against this site's merged global settings (user > theme > core), fluid font sizes included. The porter bundles the result into the package automatically. - Download: the destination's definitions always win. Only missing tokens prompt — a modal lists them (swatches for colors) and asks once, for the batch, where to define them: Site styles (the theme's user Global Styles post; recommended, revertable in the editor) or the active theme's theme.json. Values are re-validated locally with the same strict grammar the service enforces before anything is written; the theme-JSON cache is busted after. - Proxy routes: POST /cloud/tokens/check (what's missing) and a tokenDestination parameter on /cloud/download; responses report tokensWritten and the UI confirms via snackbar. Verified live: a Twenty Twenty-Five pattern uploaded with 4 resolved tokens (fluid clamp() font size included), downloaded onto Twenty Twenty-Three — the modal offered exactly the two accent colors TT23 lacks (its own spacing/font-size definitions won), wrote them to the user palette, and the pattern imported. 9 new tests (112 total). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
The constant outranks the pattern_builder_cloud_url option so declarative dev setups — a .wp-env.json "config" block, which writes wp-config constants and survives wp-env clean — can point at a local service without a post-start CLI step. The filter still runs last. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
The redirect-to-service OAuth hand-off is gone from the product flow. The connect state is now a real form (sign in / create account toggle): credentials post to new nonce+capability-gated proxy routes (/cloud/login, /cloud/signup) whose PHP relays them to the service's /auth endpoints and stores only the returned token — credentials are never logged or stored, and the browser never visits the service. Removes the client-side PKCE machinery (start/complete, callback URL handling, pkce user meta) and the callback landing special-case in the browse app. Errors from the service (bad password, existing email, rate limit) surface inline in the form. 4 new tests (116 total); verified live: wrong-password error, demo-pro sign-in, and a fresh signup all from the panel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
Connected users get a Cloud section wherever a pattern's side panel
appears — the browse screen's details sidebar and the editor's document
panels (edit-post boot and the Site Editor's wp_block route alike).
Three states: not uploaded ("Upload to the cloud"), changed since upload
("Update pattern on the cloud"), and a quiet up-to-date line with the
upload age. Hidden entirely while disconnected.
"Changed since upload" is a raw-content md5 stored in the cloud-link map
at upload/download time (set_link grows hash + uploadedAt; links from
before the hash read as changed, so they offer an update). The new
GET /cloud/pattern-state answers from the link map and a fresh local
hash only — no service round trip, cheap enough to call per selection.
Uploads reuse the existing /cloud/upload proxy, so the library modal's
update-vs-new flow and linkage behavior are unchanged.
6 new tests (122 total). Verified live end to end: hidden when
disconnected, upload from the sidebar, out-of-band edit flips to
update, update returns to up to date, panel present in the Site Editor.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
…nfigured origin Two changes to the cloud browsing flow: Installed state. The details card for a selected cloud pattern now checks whether it's already on this site (link-map reverse lookup through GET /cloud/pattern-state?cloudId, with a liveness check so a deleted local copy reads as not installed). When it is, the download actions give way to "Installed on this site as a theme/user pattern" and an Edit pattern button that opens the local copy's editor; a download flips the card to that state in place. Applies to the library, directory, and AI-result cards alike. Asset origin. Package asset URLs are now re-rooted onto the configured service origin before fetching instead of being host-matched against it. The service builds asset URLs on the origin it self-identifies as, which legitimately differs from the URL a site reaches it by (dev setups, proxies) — that mismatch used to fail downloads with pb_cloud_foreign_asset. Re-rooting keeps the guarantee absolute: the client only ever fetches from the one configured origin, so a package pointing anywhere else 404s there rather than being followed. Unfetchable references (no path) still reject. 9 tests new or reworked (125 total). Verified live: installed cards in library and directory, Edit opening the Site Editor, and a two-image directory download succeeding under a deliberate host mismatch that reproduced the reported foreign-asset error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
Both run on a revoked CLAUDE_CODE_OAUTH_TOKEN secret, so every PR gets a failing claude-review check and an error comment before any review happens. Per Jason: out for now; CI comes back when he's ready for it. (release.yml is untouched.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
The card iframe was a fixed 934px-tall window onto the preview document, so anything below that — like the oversized heading in TT25's "Cover with big heading" — silently vanished from cloud cards while local grid cards showed it. The preview document now reports its real height (pbwp-preview-size postMessage; the service half of this), and the card sizes the iframe to it and contain-fits: scale = min(width-fit, height-fit), centered both ways. Falls back to the old width-fit window until a report arrives. Verified live with a replica of the TT25 pattern: the card renders image + heading, iframe sized to the reported 1227px. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
Narrative blocks that restated what the code does are gone; the comments that remain state non-obvious constraints (multipart-on-POST, asset re-rooting, regex exclusions, legacy links reading as changed) in a line or two. Also corrects the Pattern_Builder_Cloud class docblock, which still described the removed PKCE flow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
Reshapes the browse screen around the four collections a pattern can come from, and folds the cloud affordances into the places they belong. - Header: the Pattern Builder mark and wordmark top left, then the User / Theme / Uploaded / Community tabs, search, and Create Pattern. Each tab keeps its own search and its own category rail — local pattern categories for User/Theme, cloud collections for the other two — so the cloud views are no longer rail entries. - Details sidebar is always present, with a "No pattern selected" state, and its Save and Edit actions move above the panels. - The per-pattern cloud control (upload / update / up to date) moves into the Pattern Source panel, so it rides along wherever that panel renders (browse sidebar and editor alike); the separate Cloud panel and its editor document panel are gone. - Create with AI is no longer a rail view: the create-pattern modal grows an optional prompt and screenshot, and Create either generates, imports to the chosen destination and opens the result in the editor, or makes a blank pattern as before. Hidden while disconnected, upgrade-gated for free accounts. - New mark drawn as a currentColor SVG, reused as the editor sidebar's open icon. Verified live: tabs and per-tab rails, empty and selected sidebar states, cloud control in Pattern Source, and a generated pattern landing in the editor from the create modal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
Cloud collections now behave like the local ones, and every pattern is edited in the same place. - Uploaded and Community render into the browser's own main and details slots, so all four tabs share one grid/sidebar split and the same "No pattern selected" state. - Cloud details use the local sidebar shell: title, kind, and actions on top. Save asks whether the pattern should land as a User or Theme pattern, then runs the existing token-aware download; Edit appears once the pattern is installed here. - The "Upload a pattern" button is gone: uploading starts from a pattern in the User or Theme collection, through the Pattern Source panel. - Local and cloud cards share one shell — same frame, preview box, and title row. - Every pattern opens in the page editor (`&type=user` boots the same edit-post editor against wp_block), so the editing experience no longer differs by pattern kind. Its canvas title field and the editor's own summary block are hidden, since the name lives in Pattern Metadata. - Pattern Metadata gains Name and Slug for both kinds; renaming a theme pattern rewrites its file and removes the old one. A new Pattern Actions panel duplicates, exports (JSON or markup), and deletes. Verified live across all four tabs, both editors, and the cloud save flow. Cloud category counts come from the companion server change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
The shared card shell still left the two grids looking different: cloud previews were contain-fitted (whole pattern, letterboxed) while local ones filled the card width and cropped whatever fell below it — the "Cover with big heading" card lost its heading exactly the way the cloud cards used to. Local previews now measure their laid-out height and scale again to contain it, centered on both axes, matching what the cloud card does with its iframe. They also render at the cloud preview width (1400) rather than the pattern's declared viewport, so the same pattern lays out the same in every grid. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
The two grids drifted because they measured different things: local cards measured the laid-out preview, cloud cards waited for the preview document to report a height over postMessage — and that report could only ever grow, so a short pattern filled its card instead of sitting centred in it. Neither grid measures anything now. Every preview renders at one design width and is scaled into a fixed square tile by a constant the stylesheet computes from the two sizes, so a short pattern is centred and a tall one is cropped at the same point whichever grid it is in. The tile is the Site Editor's: square, clipped, 4px radius, hairline ring, title beneath. Fixing the tile size is what makes the constant possible — a fluid tile would need something to measure it, which is the machinery this removes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
Three fixes to the one editor patterns are edited in: User patterns opened from the Site Editor went to its own canvas, so the two kinds of pattern were edited in two different editors. The Site Editor's `onNavigateToEntityRecord` navigates that canvas rather than swapping an entity in place, so it is no longer used there — a pattern opened from the Site Editor lands in the post editor like every other. The block inspector was empty because the rule meant to hide the document tab's post card matched the first child of whichever tab was showing. It now anchors on a class only the card has, leaving the Block tab alone. The canvas title stayed visible because it renders inside the canvas iframe, which the admin page's stylesheet never reaches; it is hidden by an editor style instead, and only while a pattern is being edited. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
Two faults, both on the delete path. The delete request encoded the pattern's id, turning the slash in a theme pattern's name into %2F — which servers that refuse encoded slashes reject before WordPress sees it, and the HTML they return back is what surfaced as "The response is not a valid JSON response". It goes through the entity layer now, addressing the record exactly as core's own save does, which also drops the deleted record and its edits from the store. The traversal check compared a file's resolved real path against theme directories it never resolved, so a theme reached through a symlink — a local dev setup, say — read as an attempt to escape the theme, and no pattern in it could be deleted or written. Both sides resolve now. Deleting the pattern an editor is showing then left the user editing something that no longer exists; that editor now returns to the browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
Downloading a pattern that brings design tokens asked where to put the pattern, then asked again where to put its tokens. The second question has one sensible answer — the same place — so the modal now says where they are going instead of asking: theme.json for a theme pattern, Global Styles (revertable in the editor) for a user one. The request carries `addTokens` in place of `tokenDestination`, so the server writes them to the destination it already has and the two can no longer disagree. Only the tokens this site lacks are ever written, which apply() has always re-checked for itself; a token the site defines keeps its own value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
An upload the exporter left pointing at this site is refused by the service — "Patterns may only reference images uploaded with them" — and refused without naming the image, so there was nothing to act on. The scan now matches what the service checks (src, a block attribute's url, CSS url()) and resolves each URL by host and path rather than by string prefix, so a `?ver=` query string or an https URL on an http site no longer hides an image that was there to be bundled. Paths are resolved and checked against their root before anything is read, since a URL is not a promise about where it points. What genuinely cannot travel now fails here, where the URL can be named: an image hosted on another site, and any type a package cannot carry (JPEG, PNG, GIF, WebP). Where the service still objects, the violations it names are relayed instead of dropped at the proxy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
Matching the service's rule: a block attribute named `url` is only a media reference when it names a media file. Without this the exporter refuses a pattern carrying a social link — the same false positive, moved earlier and dressed up as advice about adding an image to the media library. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
Markup a block type would not have written itself renders perfectly on the front end and reads as "unexpected or invalid content" the moment any editor opens it. Uploading it puts that on somebody else's site, where they have no idea what it was meant to be. Only a browser can make this call: a block is validated by re-running its save() and diffing, and save() is JavaScript — WP_Block_Type has no equivalent and serialize_block() replays what it parsed rather than regenerating it. The cloud panel is already in a browser with the block types loaded, so it parses the saved markup with @wordpress/blocks, disables the upload button and names the blocks at fault. A block type this site doesn't have parses to core/missing and is left alone; that is the service's allowlist to rule on, not a fault in the markup. The service approximates the same check in PHP as a backstop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
A pattern that fills another pattern's slots rendered wrong on both of this plugin's own screens: in the browse grid every reference read "The pattern … is not available", and in the pattern editor the references resolved but showed the referenced pattern's placeholder copy instead of the words the page supplies. Three things core's editor screens do for themselves, which a screen that boots its own editor has to do too: - The server-registered block bindings sources, printed exactly as edit-form-blocks.php prints them. This is the one that fails silently: the client half of a source carries no label, and `registerBlockBindingsSource()` refuses a source with neither its own label nor one on an already-registered stub — so without the stubs the editor's own `core/pattern-overrides` registration bails with a console warning, and every bound attribute falls back to placeholder copy. This is what the pattern editor was missing. - The read half of that source on the browse screen, which never boots an editor at all. Registering the working half is a private API no plugin can call, and with only the server's stub in place a bound attribute renders the source's *label* — the tiles read "Pattern Overrides" where the words should be. Core's `getValues`, and only that: nothing on that screen edits a block. - The registered patterns, on the preview provider's settings. Core stopped putting patterns in editor settings and every editor fetches them itself; this screen now does too, and waits for the fetch rather than rendering a grid of warnings and correcting it a moment later. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
Downloading somebody else's pattern links it — that link is what recognizes it as already installed — and the sidebar read the link as "you uploaded this", so changing the pattern produced an Update button whose only possible outcome was "That pattern belongs to another account." The link map now records whose the cloud copy is. An upload is ours by definition, as is a download from our own library; a download from the directory takes the answer the service gave when it listed the pattern (`mine`, which the summary now reports). The panel says where the pattern came from instead of offering an update, and the service stays the authority: a link made before any of this was recorded still reads as ours, and the first refusal corrects it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
It was a loose script in the service repo's bin/, driving this plugin's classes against a live patternbuilderwp.com. It belongs here, and it belongs with the tests: every automated test of the upload/download path mocks `pre_http_request`, so nothing but this exercises the real multipart upload, the service's own sanitization and asset rehosting, or the download that fetches those assets back. It cannot join the PHPUnit suite — it needs a second WordPress — so it stays a documented manual utility. Now takes the pattern id as an argument instead of hard-coding one site's fixture, and fails loudly: each thing that should hold about what landed is a named check, and any that don't are what the error says. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
Buying happens on Freemius, in another tab, and the licence reaches the account by a webhook to the service — so nothing about paying passes through this screen. Until now that meant paying, coming back, and still reading "Free" until a reload. Two watchers, because the timing is not ours. Opening the upgrade link starts a bounded poll, since the webhook lands a moment after the payment rather than with it; and the panel re-checks whenever the tab is looked at again, which catches somebody who took their time. Both stop on their own. The account line that only said "Connected as … (Free)" now carries what an account actually has: patterns stored against the cap, AI credits left on Pro, and the one action that fits — Go Pro, or Manage billing for somebody who already did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
Three prettier violations and an unused `Text` in the synced-status panel, left over from splitting the panels up. `npm run lint:js` is clean again but for one useEffect warning that predates all of this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
The capability stays on patternbuilderwp.com — `/ai/generations`, the job pipeline and the providers are untouched there — but nothing in the plugin reaches for it now. How generation should work from inside wp-admin is worth deciding on its own, and a half-wired path through the create modal is the wrong place to leave that question. Gone: the Create with AI section and the generation path through the Create button, `src/cloud/generate.js`, the `/cloud/generate` proxy routes and their handlers, and the `ai` key the status endpoint passed through for the gate that read it. The Create button loses a busy state that only the generation path ever set. The account strip no longer advertises AI credits, which the plugin can no longer spend. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
WordPress core now has an Abilities API: a registry of machine-readable capabilities with JSON Schema either side, a permission callback, and annotations, exposed over REST at wp-abilities/v1. Anything that can authenticate to a site can discover and call them, and a bridge onto MCP serves every plugin that registers rather than just this one — which is the argument for registering here instead of building an agent interface of our own. Five reads and two writes. The reads are the things an agent cannot know about a site it did not build: the design system as WordPress actually resolves it (rather than the agent parsing theme.json, merging the parent's, and applying a variation), the block types registered here (markup for anything else parses to core/missing), and the patterns already present. The writes take finished markup and store it. Nothing takes a prompt. An execute_callback that turned a description into a pattern would need a model behind it, and that is the business this plugin has just left; the judgement of what a good pattern is belongs to whoever is calling, and travels as prose. Validation is absent for a harder reason: save() is JavaScript, so the check most worth offering is the one no server can perform, and the caller has to run it before asking us to store anything. Three of core's behaviours are load-bearing and none are obvious. show_in_rest must be set or an ability registers and is unreachable. Annotations select the HTTP method — readonly is GET, destructive with idempotent is DELETE, everything else POST — so update-pattern is not marked destructive: the word means delete-like, not "changes data", and marking it so would leave an update callable only over DELETE. Input arrives under an `input` key rather than at the top level. Registration is conditional. The API postdates this plugin's 6.8 floor and nothing here depends on it, so an older site is unaffected and the REST controller stays the way in. wp-env moves to WP_ENVIRONMENT_TYPE=local because core offers Application Passwords — how an agent authenticates — only over HTTPS or on a local environment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
A first draft of the pattern-author skill: the judgement half of the system, where the abilities are the mechanism half. It carries what an agent cannot get from a site — what makes a pattern good, how the design/content split works and why its failures are silent, when compositing earns its indirection, how to read a screenshot into blocks. The part that makes it more than advice is the bundled validator. An LLM writing block markup by hand is doing precisely the thing that produces "unexpected or invalid content", and nothing about the result looks wrong: the front end prints stored markup faithfully, so a broken pattern renders perfectly until an editor opens it. The script runs the editor's own parser in Node — no Docker, no browser — so the skill can check its own output before anything reaches a theme. Its limits are documented because they matter more than its coverage. Tested against the real parser: structural mistakes are caught (a heading's tag against its level, a group's class, a button's anchor, an unclosed comment, a block the site lacks). Classes contributed by block *supports* are not — `backgroundColor` with no `has-…-background-color` stays valid and simply renders unstyled, because the filters that add those classes only run inside an editor. That failure is quieter than an invalid block and reads as a design mistake rather than a bug, so the skill names it and gives the attribute-to-class table to check by hand. Written against the real conventions rather than from memory: the markup examples come from the patternbuilderwp theme, and the worked example was validated by both this script and that project's own validate:blocks, slot lint included. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
The create modal asked for a title, a place to put it, and a synced
toggle — every knob, no guidance, and nothing about what any of it was
for. It now starts from a kind of pattern, listed down the left, with
that kind's description, its remaining fields, and Create This Pattern
on the right.
A kind is a starting point rather than a stored property: it fixes the
metadata its job implies, so the modal asks only for what it leaves
open on top of the name and description every kind takes.
Design Pattern unsynced; asks where to store it
Synced Design Pattern synced; asks where to store it
Starter Pattern core/post-content, so WordPress offers it when
new content is created; asks which post types
A starter pattern is always a theme pattern — the headers that place it
are pattern-file headers a wp_block has nowhere to put — so that kind
never asks where to live. Nothing a kind decides is locked in; it all
stays editable afterwards in the pattern's own metadata panels.
The kinds and the request each one turns into live apart from the UI in
patternKinds.js, which is what the new unit tests exercise. The same
component still serves the editor sidebar, where the two panes stack.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5aPoK1RRrBgyhwUnziqdo
Three gaps in the first draft, all of them things the skill assumed the model already knew. **Which blocks may be used is a policy question, not a craft one, and it is decided by where the pattern is going.** Core-only for anything that leaves the site — the wp.org directory, a shared library, a theme other people install — because markup for a block the receiving site lacks parses to core/missing and renders as a grey box: it does not degrade, it breaks, and it breaks somewhere you cannot see it. Theme blocks and registered block styles travel with the theme that ships them. Plugin blocks are fine only for patterns that stay put. The safe default when nobody says is core-only. **Which block is right for a job** was missing entirely. The new reference lists the current core vocabulary by purpose — read off a live 7.1 install, so it includes accordion, tabs, icon and math, which are newer than a model's default assumptions — and says where hand-building something core provides goes wrong: media-text rather than columns plus an image, cover rather than a group with a background, blockGap rather than a spacer between every child. **Slots can now be verified.** They fail silently in both directions: a misspelled key in a page pattern ships the design pattern's placeholder as though it were the client's words, and a lost brace in the design pattern leaves a valid block that has quietly stopped being a slot. Block validation sees neither, because in both cases the markup is exactly what save() would write. check-slots.php renders the reference and reports which slots took their value and which still show the placeholder — demonstrated against the theme's own page-pricing.php, and against a deliberate typo, which it names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
WordPress will offer a pattern when a particular block is inserted and still empty, and from that block's toolbar as a replacement — an untouched Query Loop or Cover asking which design to start from. That is the Block Types header, and it is the same header the page starter kind already writes as core/post-content; this kind just lets the user name the block. Blocks need a different control than post types: there are a handful of post types and sixty-odd blocks. BlockTypePicker is core's token field over the registry — type to narrow, click to browse all of them — talking in block titles, since that is what the block is called everywhere else, while the pattern file records core/cover. A title two blocks share carries its name too, and a name typed straight in is kept: a pattern may name a block this site does not have. Blocks that only exist inside another block, and blocks the inserter hides, are left out — a pattern is offered where a block is inserted, which those never are on their own. The pane that holds all this became a real column: the fields sit in a flex layout of their own, and the storage note is said once for whichever kind has no say in where it lives, rather than repeated per field. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R5aPoK1RRrBgyhwUnziqdo
Two more places WordPress will offer a pattern, both of them handbook headers the plugin already stored but never made discoverable. A Template Pattern is a whole template — an archive, a home page, a 404 — offered in the Site Editor when someone creates a template of that type. It writes Template Types, and Inserter: no, because a whole template is noise in the block inserter and the themes that ship these keep it out (Twenty Twenty-Five's template patterns are the model). A Template Part Pattern is a block type pattern underneath: the area decides both the block type that offers it and the category it files under, exactly as the handbook's example does — Block Types: core/template-part/header with Categories: header. Header and footer are the only areas WordPress supports, so the field offers those two and nothing else. Both kinds take a 1400px Viewport Width, the width they are designed against and the width the pattern grid renders at. Six kinds is a list, so the rail now has two headings: Design for the patterns made to be used, Starter for the patterns made to be offered somewhere. Under that heading a bare "Starter Pattern" no longer says what it starts, so it is a Page Starter Pattern now — the handbook's own word for it. Template types are core's default block template types, a fixed vocabulary shared with the associations panel, which had its own copy of the list until now. They are checkboxes like post types are; blocks stay a token field, because there are sixty of those and sixteen of these. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R5aPoK1RRrBgyhwUnziqdo
Under the Starter heading the word "starter" was doing no work in three of the four names, and the handbook calls this one a page pattern. Its key follows its name, alongside template and template-part; Block Starter Pattern keeps its own, since "Block Pattern" is what WordPress calls every pattern there is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R5aPoK1RRrBgyhwUnziqdo
The create modal now opens on a kind of pattern, and the kinds turn out to be a better first question than the one the skill was asking. It began with "decide the pattern's shape" and a heuristic — look at the existing patterns, infer whether the project uses slots. That reads the answer off the surroundings. The kind asks the thing directly, and settles more: where the pattern can live, which headers place it, whether it belongs in the inserter at all. Six of them, each with a job. Design and Synced Design are the building blocks, and the second is the design half of the design/content split. A Page Pattern is offered when new content is created and is the content half. A Block Starter belongs to a block, offered when an empty Query Loop or Cover asks what to start from. A Template Pattern is a whole archive or 404, kept out of the inserter because a whole template is noise there. A Template Part Pattern is a header or a footer, and WordPress supports only those two areas. Two consequences the skill now states rather than leaves to be discovered. The four starter kinds are always theme patterns, because their placement lives in pattern-file headers and a wp_block has nowhere to put them — so a request for a database pattern that WordPress offers for new pages is contradictory, and saying so beats silently picking a half. And a Template Part Pattern is a Block Starter underneath: the area picks both the block type that offers it and the category it files under. Read off patternKinds.js and the file store rather than the commit messages, so the headers named here are the ones actually written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
Every starter kind ended with the same muted line about being stored in the theme, and the template part kind had a second one under its control. A note repeated under four kinds is chrome, so the fact moved up into each kind's description, where it is one clause of an explanation the reader is already in the middle of. The description says which header does it, so it says something different for each kind: post types for a page pattern, blocks for a block starter, template types for a template, the part itself for a template part. The Theme and User help text under the design kinds stays. There the note is not repeating a fact, it is explaining the choice sitting right above it, and the choice is real for those two kinds only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R5aPoK1RRrBgyhwUnziqdo
The other abilities say what is true about this site and take finished markup. None of them says how to write a good pattern, and that was the half only a Claude skill carried — which made the judgement Claude-only while the mechanism was open to anything that speaks HTTP. `get-authoring-guide` closes that. It serves the same documents as Markdown over the same interface everything else uses, so whatever is calling can put them where its own harness reads instructions from: a SKILL.md, a rules file, a system prompt, an AGENTS.md. Prose turns out to be the most portable artifact in the system. It answers with an index by default and one guide on request. The full set is about six thousand words, and an ability that pushed all of that into a caller's context uninvited would be a poor guest. `all` concatenates for anyone who does want it. The guides had to move for this to mean anything. They lived under `.claude/`, which `.distignore` excludes, so a shipped plugin would have served nothing. They now live in `guides/pattern-author/` and `.claude/skills/pattern-author` is a symlink to it — one copy, read by Claude Code and by the ability alike, with no build step and no chance of the two drifting. The main guide keeps its skill front matter, which the ability strips, since it means nothing to any other caller. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
The shipped guides describe WordPress, not your project. What an agent most needs on top of them is the part general documentation cannot carry: which blocks this build has settled on, how its copy reads, why a section is composed the way it is. A theme knows those; the plugin never can. So the guide set is loaded, filtered through pattern_builder_authoring_guides, and only then served. A theme can append to a shipped guide or add one of its own, and it reaches every route the ability offers — the index, by name, and "all" — which turns the guide from documentation into project policy. The filter deals in text rather than file paths on purpose: a supplied guide needs no filesystem access, and no caller can steer a read outside the plugin. A filter is somebody else's code, so a malformed entry is dropped and a filter that returns no array at all leaves an empty shelf rather than a fatal. Verified over the wire against a running site as well as in the suite: an mu-plugin filter showed up in the index with its title, came back by name, and landed inside "all". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
Block validation was asking parse(), which is tolerant on purpose: every
block keeps its old save() implementations for backward compatibility
(core/paragraph has six), and markup matching any of them is accepted and
silently migrated. validateBlock() — public API in @wordpress/blocks — asks
whether this is what the block writes today. The gap between the two is
where a pattern quietly rots.
That gap is not academic. A deprecation match means the file on disk is
missing what the current block would write, almost always a block-supports
class, and the front end renders the file rather than the editor's idea of
it: {"backgroundColor":"primary"} with no has-primary-background-color
renders with no background at all, reads as a design mistake, and survives
review. Worse, a migration treats the markup as authoritative and can drop
an authored attribute outright — a heading with "fontSize":"xx-large" and no
matching class comes back with no fontSize, perfectly self-consistent and
silently plainer than it was written. Comparing the raw serialization parse
against the full one finds those.
Every case the reference documented as unreachable is now caught, using
nothing but core's own code — no reimplemented rule table to drift.
Two exemptions, both found by running this over real patterns rather than
reasoning about it:
- An attribute core *relocated* is not one it dropped. Block library 10.5
moved text alignment out of a paragraph's `align` and a heading's
`textAlign` and into a typography support, migrating the value to
style.typography.textAlign. Flagging that would have been noise on every
theme built against a newer WordPress than the one in node_modules.
- A block carrying Pattern Overrides bindings takes its content from the
binding source at render, and core reserves room in the saved markup for
that value, so the file and a save computed from the file's own attributes
are not comparable. Slots are checked by rendering them instead.
The upload gate keeps its nerve about severity: parse() decides what is
invalid and disables the button, while the strict result only warns, since
that markup renders and no editor will complain about it.
Verified against the patternbuilderwp theme — 26 files, 239 blocks of real
editor-written markup — which comes back clean, and against fixtures for
each failure it is meant to catch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
The two repositories had drifted a major apart on the block packages — block-library 9.22 here against 10.5 on the service — and they genuinely disagreed about what "current" meant. 9.22 predates core moving text alignment out of a paragraph's `align` and a heading's `textAlign` and into a typography support, so the same pattern file got different verdicts from the same validator depending on which repository it ran in. Aligning them is what makes that check trustworthy. Everything is now on the latest published versions, matched across both repositories: scripts 34.2, blocks 15.27, block-library 10.5, block-editor 17.0, env 11.14. Nothing about what ships changed. The three generated .asset.php dependency arrays are byte-identical to before the bump, so the plugin still asks WordPress for exactly the script handles it did, and the 6.8 floor holds. The upgrade brought three things that needed answering rather than suppressing: - Jest could not load the suite at all. Much of the dependency tree now ships ESM only — `uuid` inside `@wordpress/components`, `@wordpress/ui` and `@wordpress/theme` as `.mjs` — and Jest cannot `require()` an ES module before Node 24.9, while transforming nothing in node_modules and not matching `.mjs` at all. jest-unit.config.js compiles both. Naming the offending packages was tried first and is a losing game: they are nested, they pull each other in, and the list changes with every bump. - ESLint 9 wants a flat config and enforces import/no-extraneous-dependencies. It was right: 27 WordPress packages were imported and 13 declared. The rest are declared now. The four complaints left were in `src/runtime/`, which is vendored and must stay logic-identical to synced-patterns-for-themes, so eslint.config.cjs turns those two rules off there and nowhere else rather than introducing drift for a JSDoc type name. - Registering a block at API version 1 is deprecated as of 6.9 and now warns, which fails the console assertions. The test fixtures declare version 3. Both new config files are added to .distignore; they are development tooling and have no business in a wp.org zip. Verified: 66 JS tests, 163 PHP tests, lint (one pre-existing warning), stylelint, and a production build. The pattern validator run against the patternbuilderwp theme now returns exactly what that repository's own validator returns, which was the point. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
The dependency bump rewrote it through a JSON serializer, which quietly reindented the whole file to spaces and buried the fourteen lines that actually changed under seventy-two that did not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
A consumer of this plugin had no way to run the validator. It ships with them, but it needed six npm packages a theme repository does not have, and `npx -p` does not help: npx puts binaries on PATH and leaves NODE_PATH unset, so the requires still fail. Bundling those packages alongside the script was the obvious answer and is a bad one. Measured, it is 21MB — and 21MB whether you include one block or all 120, because every block's `save()` imports `@wordpress/block-editor`, whose index registers the supports hooks as a side effect. There is no seam to cut along: core keeps each support's `addSaveProps` in the same module as that support's editor interface, so deep-importing the hooks pulls the component barrel anyway. Stubbing the edit-time halves out is the reimplementation trap in a different costume — a build where arbitrary core modules are no-ops, needing re-verification every release. None of it is necessary. Every WordPress install already carries this code, about 4MB under wp-includes/js/dist, and it is the *exact* version the pattern is destined for — which matters more than the download, because whether markup is what a block writes today is a question only a specific block library can answer, and 10.5 disagrees with 9.22 about text alignment. So wp-core.mjs loads the install's own scripts. Two details make it work. The load order comes from core's generated script-loader-packages.php, so nothing has to boot WordPress to ask — though the manifest does not record what the vendor handles need, and the JSX runtime reads globalThis.React as it loads, so that one edge is declared here. And the files go in as real script elements rather than through eval, because they are strict-mode and a strict eval keeps its own `var` declarations to itself, which loses ReactJSXRuntime and every JSX call in the editor bundles with it — silently. The install is found from --wp, WP_PATH, the working directory, or the script's own location, that last one because this ships inside a plugin inside wp-content. The first line of output names what was used, since a validation result nobody can attribute is not worth much. `--npm` keeps the old path for a repository with no WordPress anywhere. jsdom remains, as the one thing WordPress cannot supply: its editor code is browser code and expects a document to exist as it loads. One package instead of six. Verified both sources produce byte-identical findings — on the fixtures for each failure kind, and across the 26 pattern, template and part files of the patternbuilderwp theme. Also verified: auto-discovery from a theme directory, from an unrelated working directory with the script inside an install, the WP_PATH route, stdin, exit codes, and the message when there is nothing to validate against at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
The sidebar had the whole modal stacked into one column, so the form sat under the list of kinds and the two scrolled together. Picking a kind now goes to that kind's screen, the way every other row in that sidebar already works: Create Pattern lists the kinds, each with a chevron, and the kind's screen carries its name, a back button, and the form. The panel splits in two to do it. PatternKindList and PatternCreateForm are the same pieces the modal composes side by side, and the sidebar mounts them on /create and /create/:kind, taking the kind from the route. The form owns its own values now, which is why the kind it is given can change under it in the modal without losing what the user has typed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R5aPoK1RRrBgyhwUnziqdo
The guides tell an agent to validate before it stores anything. For an agent with a shell on the machine that instruction resolves to a path on disk. For an agent that arrived over HTTP — which is the caller the Abilities API exists to serve — it resolved to nothing at all: no copy of the script, no way to get one. The instruction was unfollowable by precisely the audience it was written for. Two reads fix that. get-validator hands over the script itself, because no server can run the check (save() is JavaScript) but any server can hand over the thing that can. get-editor-scripts hands over this site's own block editor script URLs in load order. The second one is less obvious and is the reason this needs the site at all. WordPress already serves its editor scripts to anyone — they are just files under wp-includes — but it does not serve the graph: core's dependency manifest is a PHP file, so a request for it executes and returns zero bytes. Only the site can say what loads in what order, and the order is unforgiving in a way that gives no clue: the JSX runtime reads globalThis.React as it loads, so React arriving late costs every JSX call in the editor bundles, and all you see is a missing function. So wp-core.mjs grows a URL path beside its filesystem one, sharing the boot, caching each script by a digest of its URL. The URLs carry version strings, which means an upgraded site fetches afresh rather than trusting a stale cache. First run downloads about 4MB; after that a check takes under two seconds. What is still required, and always will be, is a JavaScript runtime: Node, plus jsdom to play the browser WordPress's editor code expects. PHP cannot run save() and no amount of plumbing changes that. The abilities now remove every other obstacle, and say so plainly rather than implying otherwise. Verified the whole flow the way an agent would run it, against a live site over HTTP with nothing local to start from: fetch both abilities, write the files, npm i jsdom, validate. Findings are identical to the filesystem route on the same fixtures, the cache is reused on a second run, and the served usage text is the recipe that actually works. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
An agent that calls create-pattern directly never reads a guide, so every word about validating first was in a document it had no reason to open. The index is the one thing anyone asking "what should I read" fetches, which makes it the only place that instruction reliably lands. So it carries a `validate` block: why (a block is valid only if re-running its save() reproduces the markup, save() is JavaScript, and so nothing here can check it), what to call (get-validator, get-editor-scripts), and what that costs (node, jsdom). Structured rather than prose, because an agent should not have to read a paragraph to find two ability names. A test asserts those names are registered abilities. A pointer to something that does not exist would be worse than saying nothing at all, and that is exactly the kind of rot a rename causes quietly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U
The client half of Pattern Builder 2.1 — companion to Twenty-Bellows/patternbuilderwp.com#2. Everything is additive: disconnected, the plugin behaves exactly as 2.0.
Connecting
Credential auth inside wp-admin, not OAuth. The connect panel posts credentials to this site's own proxy, which relays them server-side to the service's
/auth/login//auth/signupand stores only the returned bearer token in user meta. The browser never visits the service and credentials are never logged or stored. The PKCE surface still exists on the service as the protocol path for social sign-in and third parties; the product UI doesn't use it.The service URL is the
pattern_builder_cloud_urloption, overridable by aPATTERN_BUILDER_CLOUD_URLwp-config constant — the declarative choice for dev setups, since it survives DB resets — and filterable.PHP
Pattern_Builder_Cloud— per-WP-user connection state, authenticated + multipart HTTP to the service, and the cloud-link map (pattern_builder_cloud_linkssite option) that drives Update-vs-New on re-upload. The map records a content hash at upload time and whether the cloud copy is ours (owned), so a pattern downloaded from somebody else's library is linked but never offered an update. Whitelists the configured service origin only through core's hardened URL validation, so dev services on nonstandard localhost ports work.Pattern_Builder_Cloud_Porter— local pattern ↔ Portable Pattern Package. Export bundles local images aspbp-asset://placeholders + files, matching every reference the service checks (src, a block attribute'surl, CSSurl()) by host and path so a query string or an http/https difference can't hide one; it drops attachment ids andwp-image-Nclasses on the way out, and fails loudly naming any image it can't carry. Import fetches assets only from the configured service origin (asset URLs are re-rooted onto it, so a service that self-identifies by a different URL still works), re-sanitizes the markup (KSES + scheme checks — never trust the wire, even our own service), then lands as awp_blockor flows throughPattern_File_Store::update_theme_pattern()for theme destinations.Pattern_Builder_Cloud_Tokens— design tokens that travel with a pattern. A download carrying tokens this site lacks shows them first, then writes only the missing ones into the destination already chosen for the pattern: theme.json for a theme pattern, Global Styles for a user one.Pattern_Builder_Cloud_Controller— the/pattern-builder/v1/cloud/*proxy routes (edit_theme_options+ REST nonce): status, login/signup/disconnect, library + directory + collections, links, pattern-state, upload, download, tokens/check, delete.Pattern_Builder_Abilities— eight abilities underpattern-builder/*on core's Abilities API, so any agent that can authenticate to the site can read its design system, block types and patterns, store finished markup, and fetch the pattern-authoring guides. Nothing takes a prompt. The guide set is filtered (pattern_builder_authoring_guides) so a theme can add its own house rules to what an agent is told.JS
src/cloud/CloudBrowser.jsis mounted behind the browse app's Uploaded and Community tabs. The browse screen is a Site-Editor-style library: four collection tabs (User / Theme / Uploaded / Community), each with its own search and category rail, over a grid of fixed-size square tiles.Tile scaling is CSS-only. Every preview, local or cloud, renders at one design width (1400px) and is scaled by a constant the stylesheet computes from the two sizes (
src/_pattern-tiles.scss) — nothing measures anything in JavaScript, so a short pattern is centred and a tall one cropped at the same point in both grids.Because that grid renders block previews on a screen that never boots an editor, it does two things core's editor screens do for themselves: the page prints the server-registered block bindings sources (without those label-bearing stubs core's own
registerBlockBindingsSource()refuses every source silently, and a pattern filling another's slots renders the other pattern's placeholder copy), and the browse app registers the read half ofcore/pattern-overridesitself (src/admin/preview-bindings.js), since the working half is a private API only an editor boot can reach.Editing is one editor for every pattern. Both kinds open in core's edit-post editor — a post editor already on screen swaps the entity into its canvas; everywhere else the Appearance page boots
wp.editPost.initializeEditoragainst it, with a validatedbackURL. The Site Editor's canvas is never used, because theme patterns can never enter it.Uploading is gated on the editor's own block validation (
src/utils/blockValidity.js). Only a browser can make this call, because a block'ssave()is JavaScript and no server can re-run it (WP_Block_Typehas nosave;serialize_block()replays what it parsed). Core answers two questions here and the panel asks both:parse()decides what is invalid — markup a block type would not have written itself, which renders fine but reads as "unexpected or invalid content" the moment any editor opens it. That disables the upload button and names the offending blocks.validateBlock()decides what is stale.parse()is tolerant on purpose: every block keeps its oldsave()implementations (core/paragraphhas six) and markup matching any of them is accepted and silently migrated. What that hides is a block-supports class missing from the file —{"backgroundColor":"primary"}with nohas-primary-background-colorrenders with no background at all — and, by comparing the authored attributes against the parsed ones, a setting the migration threw away outright. This only warns: the markup renders and no editor will complain, so it is worth saying and not worth blocking.Two cases are exempt by design: an attribute core relocated rather than dropped (block library 10.5 moved text alignment into a typography support, so the value lives on under
style.typography.textAlign), and any block carrying Pattern Overrides bindings, whose content comes from the binding source at render and so cannot be held to a save computed from the file's own attributes.The same two checks back the
pattern-authorskill'svalidate-pattern.mjs, and the service approximates the invalid half in PHP as a backstop.Not included: AI generation
The service has it (
/ai/generations, the job pipeline, both providers) and keeps it. The plugin doesn't call it — the create modal makes blank patterns only, and there is no/cloud/generateproxy. Pulled deliberately, to be designed properly later rather than shipped half-wired.Testing
tests/e2e/cloud-roundtrip.php— the round trip over the wire (upload → update → download) against a live service, run withwp eval-file. Manual because it needs a second WordPress: every automated test of this path mockspre_http_request, so nothing else exercises the real multipart upload, the service's sanitization and asset rehosting, or the download that fetches those assets back.lint:js(one pre-existing warning),lint:css, PHPCS, and a production build all clean.Notes for review
wp-phpunitbumped^6.6 → ^7.1to match current WordPress in test environments.69561e3.