Skip to content

Pattern Builder 2.1: the patternbuilderwp.com cloud module - #47

Merged
pbking merged 48 commits into
2.1from
claude/pattern-builder-2-1-architecture-nwbvgz
Sep 1, 2026
Merged

Pattern Builder 2.1: the patternbuilderwp.com cloud module#47
pbking merged 48 commits into
2.1from
claude/pattern-builder-2-1-architecture-nwbvgz

Conversation

@pbking

@pbking pbking commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

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.

The branch has moved a long way since it was opened, and this description has been rewritten to match the current head. Several commit titles describe designs that were later replaced — in particular the OAuth/PKCE connect flow, the two-entry cloud rail, and AI generation in the create modal. The history is left as it happened; what follows is what the code actually does now.

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/signup and 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_url option, overridable by a PATTERN_BUILDER_CLOUD_URL wp-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_links site 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 as pbp-asset:// placeholders + files, matching every reference the service checks (src, a block attribute's url, CSS url()) by host and path so a query string or an http/https difference can't hide one; it drops attachment ids and wp-image-N classes 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 a wp_block or flows through Pattern_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 under pattern-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.js is 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 of core/pattern-overrides itself (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.initializeEditor against it, with a validated back URL. 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's save() is JavaScript and no server can re-run it (WP_Block_Type has no save; 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 old save() implementations (core/paragraph has 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 no has-primary-background-color renders 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-author skill's validate-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/generate proxy. Pulled deliberately, to be designed properly later rather than shipped half-wired.

Testing

  • PHPUnit — suites for the porter, cloud auth, design tokens, pattern-state, and the abilities (including the authoring-guide filter).
  • JS unit tests, including the block-validity gate — invalid, old-form, dropped-attribute, and both exemptions — and the preview bindings registration.
  • tests/e2e/cloud-roundtrip.php — the round trip over the wire (upload → update → download) against a live service, run with wp eval-file. Manual because it needs a second WordPress: every automated test of this path mocks pre_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-phpunit bumped ^6.6 → ^7.1 to match current WordPress in test environments.
  • Update uploads go over POST, not PUT: PHP only parses multipart bodies on POST.
  • The Claude Code review/responder workflows are deleted in 69561e3.

claude added 2 commits August 30, 2026 01:35
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

claude Bot commented Aug 30, 2026

Copy link
Copy Markdown

Claude encountered an error —— View job


I'll analyze this and get back to you.

pbking commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

The claude-review job on this PR errored inside the review action's own runtime (run — the model step failed before producing a report; the report step shows skipped). Nothing in this PR's diff feeds that workflow, so there's no in-branch fix. I don't have permission to re-run workflows here (403); if a bot review is wanted, a maintainer can hit Re-run failed jobs on that run. I'm watching the PR and will address any review findings that land.


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

claude Bot commented Aug 30, 2026

Copy link
Copy Markdown

Claude encountered an error —— View job


I'll analyze this and get back to you.

pbking commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Root cause found for the failing claude-review check (both run 31 and run 32) — this supersedes my earlier "runtime error" diagnosis. The action's model step dies with:

Error: 401 {"type":"authentication_error","message":"OAuth access token has been revoked."}

The CLAUDE_CODE_OAUTH_TOKEN secret this repo's claude-code-review.yml uses has been revoked, so every review run will fail until it's re-minted (claude setup-token locally, then update the repository secret). Not related to this PR's diff — it's the only workflow in this repo, so nothing else gates the PR.


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

claude Bot commented Aug 30, 2026

Copy link
Copy Markdown

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

claude Bot commented Aug 30, 2026

Copy link
Copy Markdown

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

claude Bot commented Aug 30, 2026

Copy link
Copy Markdown

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

claude Bot commented Aug 30, 2026

Copy link
Copy Markdown

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

claude Bot commented Aug 30, 2026

Copy link
Copy Markdown

Claude encountered an error —— View job


I'll analyze this and get back to you.

claude added 12 commits August 30, 2026 17:42
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
claude and others added 26 commits August 31, 2026 15:37
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
@pbking
pbking changed the base branch from main to 2.1 September 1, 2026 19:09
@pbking
pbking marked this pull request as ready for review September 1, 2026 19:09
@pbking
pbking merged commit d4ede5f into 2.1 Sep 1, 2026
@pbking
pbking deleted the claude/pattern-builder-2-1-architecture-nwbvgz branch September 1, 2026 19:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants