diff --git a/CLAUDE.md b/CLAUDE.md index 7a7edd1..8999796 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,187 +1,176 @@ # CLAUDE.md -This file provides guidance to Claude Code and other AI coding agents when working with code in this repository. - -## Project Overview - -**Pattern Builder** is a WordPress plugin developed by [Twenty Bellows](https://twentybellows.com). It allows WordPress users to create, edit, organize, and manage block patterns directly in the admin interface — unifying theme patterns (PHP files) and user-created patterns (`wp_block` posts) in a single, intuitive UI with visual editing, live preview, metadata management, and conversion between the two. - -- **Version:** 2.0.0 -- **Repository:** https://github.com/twenty-bellows/pattern-builder -- **Issue Tracker:** GitHub Issues — https://github.com/twenty-bellows/pattern-builder/issues -- **Plugin URI:** https://www.twentybellows.com/pattern-builder/ -- **License:** GPL-2.0-or-later -- **WordPress Requires:** 6.8+ -- **PHP Requires:** 7.4+ - -## Architecture (Key Design Decisions) - -Version 2.0 removed the 1.x DB-mirror + REST-hijacking design entirely. Theme pattern files are the single source of truth; nothing is mirrored into the database and no core REST route is intercepted. - -**Theme patterns are file-backed REST entities.** A rowless post type `pb_pattern` (registered like core's `wp_template` — zero DB rows) hangs `Pattern_Builder_REST_Patterns_Controller` off core routing at `/pattern-builder/v1/patterns`. Theme patterns have string IDs (their namespaced name, e.g. `my-theme/hero`), templates-style. Reads come from the pattern files (child + parent theme); writes go back to the files (`Pattern_File_Store`). Because the type is `show_in_rest`, the block editor auto-creates a matching client-side entity from `/wp/v2/types`, which gives theme patterns entity-powered editing (undo, dirty tracking, save flow) for free. - -**Editing surfaces — one editor for every pattern.** Both kinds are edited in the post editor: a post editor already on screen swaps either entity into its canvas via `onNavigateToEntityRecord`; everywhere else — the Site Editor included — a pattern opens Appearance → Pattern Builder's edit mode (`&pattern={id}`, plus `&type=user` for a `wp_block`), which boots core's own edit-post editor (`wp.editPost.initializeEditor`) against the entity, with a validated `back` URL whose Back button returns to the originating screen. The Site Editor's canvas is never used, even for user patterns it could host, because theme patterns can never enter it (core hard-codes the entity types its canvas binds and keeps route registration private) and editing both kinds in the same editor is worth more than the nicer canvas for half of them. In that editor a pattern is only its blocks: core's canvas title field is hidden by an editor style (it lives inside the canvas iframe, where page styles never reach) and the document tab's post card by a rule anchored on `.editor-post-card-panel__title` — the pattern's name and description are edited in the Pattern Metadata panel instead. The Appearance page's browse mode is a Site-Editor-style library: a header carrying the Pattern Builder mark and four collection tabs (User / Theme / Uploaded / Community — the last two served by the cloud browser), each with its own search and its own category rail (local pattern categories, or cloud collections); a grid of fixed-size square pattern tiles — the Site Editor's pattern grid. **Every tile is a document drawn by a server**: a cloud tile is the service's preview document, a local tile is this site's own front-end render of the pattern (`Pattern_Builder_Preview::serve_tile()`, `?pattern_builder_tile={id}&v={key}` on the home URL), and both are framed at one design width (1400px) and scaled into the tile by a constant the stylesheet computes from the two sizes (`src/_pattern-tiles.scss`); the tile document centres its own content, so a short pattern is centred and a tall one is cropped at the same point in both grids, with nothing measuring anything in JavaScript. Drawing local tiles on the server is what makes them show what the site shows. The in-browser `BlockPreview` the grid used before could not apply **block style variations** at all — core styles each block that carries one individually, from global-styles data only an editor boot supplies (a private settings key), and leaves them out of the site stylesheet — and it needed its own workarounds for bindings and for pattern references, which are gone with it. The tile is a front-end GET rather than a REST call because an iframe can send the login cookie but not the REST nonce, and a nonce in the URL would change every twelve hours and take the browser's cache with it; it is safe without one because it changes nothing, answers only a user who can `edit_posts`, and may be framed only by this site (`Content-Security-Policy: frame-ancestors`, the admin origin included). It carries no scripts (stripped, and the frame is sandboxed) and no admin bar. **The cache key** (`src/utils/tileKey.js`) hashes the pattern's markup, the markup of every pattern it places at any depth (`core/pattern` by name, `core/block` by id — a page's tile follows its sections) and the `designVersion` the page prints (`Pattern_Builder_Preview::design_version()`: theme.json, the style partials, `style.css` and `functions.php` of the theme and its parent, Global Styles, WordPress, this plugin and the active plugins); a versioned tile is `Cache-Control: private, max-age=31536000, immutable`, so the browser redraws only the tiles whose render could have changed. The page still prints the server-registered **block bindings sources** (`Pattern_Builder_Admin`, as `edit-form-blocks.php` does), because the pattern editor needs them: without those label-bearing stubs core's own `registerBlockBindingsSource()` refuses every source, silently, and a pattern that fills another's slots renders the *other* pattern's placeholder copy. The grid also has an always-present details sidebar whose Save and Edit actions sit at the top above the panels the editor also shows (staged on the entity, persisted by Save; Edit opens the pattern's editor). Creating a pattern starts from a **kind**, listed down the left of one modal under two headings — Design (Design Pattern, Synced Design Pattern) and Starter (Page, Block Starter, Template, Template Part) — in `src/components/patternKinds.js`, which holds the kinds and the request each turns into; `PatternCreatePanel` is the UI. A kind is a starting point, not a stored property: it fixes the metadata its job implies, following the theme handbook's pattern pages, so the modal asks only for what the kind leaves open on top of the name and description every kind takes. A design pattern asks where it is stored (theme file or `wp_block`) and is the only kind that does; the four starter kinds are always theme patterns, because everything that places them is a pattern-file header a `wp_block` has nowhere to put. Each starter kind is one of the handbook's headers: `Block Types: core/post-content` plus `Post Types` (offered when new content is created), a block's own name in `Block Types` (offered when that block is inserted still empty, and from its toolbar), `Template Types` (offered when a template of that type is created — plus `Inserter: no`, since a whole template is noise in the block inserter, as the themes that ship them do), and `Block Types: core/template-part/header|footer` with the matching `Categories`, which is the handbook's own example — those two areas are the only ones WordPress supports, so the field offers exactly two. The last two also take a 1400px `Viewport Width`, the width they are designed against and the width the pattern grid renders at. Blocks are picked with `BlockTypePicker`, core's token field over every registered block: it talks in block *titles* (a title two blocks share carries its name as well) while the file records `core/cover`, and a name typed straight in is kept, since a pattern may name a block this site does not have; post and template types are checkbox grids over fixed vocabularies (`TEMPLATE_TYPES` is core's default template types, shared with the associations panel). Nothing a kind decides is locked in: it all stays editable afterwards in the pattern's own metadata panels. The two panes are two components (`PatternKindList`, `PatternCreateForm`), because the editor sidebar has no room for both at once: there they are two Navigator screens, the kinds list and then the kind's own screen (`/create` and `/create/:kind`), which is what the sidebar's other rows do too. - -**Synced patterns via the `core/pattern` content runtime.** Synced theme patterns (`Synced: yes` file header) work exactly like Synced Patterns for Themes 2.0: `core/pattern` gets a `content` attribute + `pattern/overrides` context and a render callback that attaches the pattern's blocks as inner blocks (`Pattern_Block`); `Pattern_Resolver` composes editor-facing content; a synthesized `--synced-instance` companion entry puts a reference in the inserter. Inserted copies are plain `` — no post ID anywhere. - -**Companion plugin coexistence.** The runtime classes (`Pattern_Block`, `Pattern_Resolver`, `Block_Markup`, `Inner_HTML_Processor`, `Synced_Patterns`, `Editor_Support`, and the `src/runtime/` JS) are vendored from [`synced-patterns-for-themes`](https://github.com/Twenty-Bellows/synced-patterns-for-themes) and must stay logic-identical to it. Pattern Builder always registers the full stack; when both plugins are installed, the companion sees `PATTERN_BUILDER_VERSION` at `plugins_loaded` and stays entirely unloaded — one check in one place, no coordination anywhere else. Deactivate Pattern Builder and the companion takes over again with identical rendering (both read the same `Synced: yes` header; keeping the vendored runtime in sync at release time is what makes the hand-off invisible). Pattern Builder also clears the companion's transient after file writes so it never wakes to a stale cache. - -**Abilities (WordPress core's agent interface).** `Pattern_Builder_Abilities` registers nineteen abilities under `pattern-builder/*` (and `Pattern_Builder_Cloud_Abilities` seven more for the cloud, below) so any agent that can authenticate to the site can drive it — core exposes the registry over REST at `wp-abilities/v1`, and an MCP bridge over that surface gets these for free, which is why there is no bespoke agent API here. Eight reads (`get-authoring-guide` — the pattern-authoring documentation as Markdown, whose spine is a **factor** step: a pattern's worth is its reusability, so before any markup is written the guide asks for an inventory of what repeats, and the three levels (element, section, page) name what references what. It is prose rather than a tool, so it is made mechanical by producing an artifact — a table the writing step consumes — since a step with no output is a step nobody can see was skipped; so an agent whose harness has no notion of a "skill" can still be told how to do the job, and the same prose ships as a Claude skill and over the wire; the guides live under `guides/` because `.claude/` does not ship to wp.org, and each directory in `.claude/skills/` is a symlink to its counterpart there so there is one copy. They are **two skills, split by posture rather than by subject**: `pattern-author` is how a pattern gets written — the block vocabulary, the markup contract, validation, the factor step — and `design-reproduction` is what to build when the design already exists somewhere else and the job is to copy it. The cut is there because a reproduction has two failure modes authoring does not, and both look like a finished page: values approximated where they could have been read, and a source's structure transcribed rather than factored. So that skill opens by **classifying the source** — readable (a live page, theme files, a Figma file the agent has API access to) or inferred (a screenshot, a PDF, a Figma export) — because the answer decides how exact it may claim to be and whether the work can be checked by diffing at the end; then it installs the design system before writing any markup, in the one order that works (layout, tokens, styles, variations — a pattern referencing a preset that does not exist yet renders as no styling at all, and nothing says so), and verifies the design system numerically before it compares a rendered page. It loads `pattern-author` alongside rather than restating it, which is what keeps the authoring guide free of the reproduction-only judgement calls — the fidelity class, the disclosure a measured value obliges — that would otherwise be advice to every agent writing a pattern from scratch; the set is filtered before it is served (`pattern_builder_authoring_guides`) so a theme can amend a shipped guide or add its own house rules — which blocks this build settled on, the copy voice — the part general documentation cannot know; the filter deals in text rather than file paths, so a supplied guide needs no filesystem access and no caller can steer a read out of the plugin; `get-design-system` — the whole design system rather than half of it: the merged presets, the **`styles`** a pattern inherits (the root, the elements, the blocks), and the **`blockStyles`** registered here, each marked `portable` — true for one declared in a block's own `block.json`, which ships with WordPress, false for one this site registered, whose definition has to be carried. Presets alone left an agent able to see that a site defines a colour called `ink` and not that the theme paints every heading with it, and the safe move under that blindness is to over-specify: restate the font on each heading, the whole button on each button, none of which then follows the destination; `list-block-types` — what is registered *on this site*, since anything else parses to `core/missing`, and, when it is asked about named blocks, each one's **supports**: the contract deciding which classes its saved markup must carry, which is exactly the half no validator can check because those classes come from filters that only run inside an editor. Supports is about two and a half times the size of everything else in a listing, so naming blocks is what turns it on and a browse stays a catalogue; a name this site does not have comes back under `unknown` rather than as a shorter list; `list-patterns` — every pattern's summary with its placement headers (`blockTypes`, `postTypes`, `templateTypes`, `inserter`, `viewportWidth`) and any `origin` or `cloud` reference, since the *kind* is what an agent reading the site's existing patterns is trying to see, plus the pattern `categories` registered here, because the inserter files a pattern under a registered category or under Uncategorized and an agent that cannot see the list invents `my-theme_elements`; `get-pattern`, `render-pattern` — whose answer carries preview URLs beside the HTML (`Pattern_Builder_Preview`), because markup shows which classes landed where and nothing about what the CSS then does with them: `standalone` renders the pattern by itself with the site's stylesheets, `page` renders it inside the resolved page template, which is the only way to see whether an `alignfull` band escapes the content width or collapses into it, and `themes` renders the page context against each bundled lab theme (`blank-theme`, `opinionated-theme`) by way of the preview route's `theme` parameter, so the two-worlds check the guides prescribe is one call away. The page context needs a post to exist, so a stand-in is primed into the object cache for one request and never written; two things make it work and the second is not implied by the first — `core/post-content` checks `$block->context['postId']` and refuses without it, then calls `get_the_content()` with no arguments, which reads the *global* post and the `$pages` globals `setup_postdata()` fills, so the context makes the block agree to render and the globals decide what it renders — plus the two that answer what a pattern can *point* at — `find-media` and `list-fonts`) and nine writes: `create-pattern` and `update-pattern`, which take **finished markup** and persist it, `add-design-tokens`, which is where a pattern's colours, spacing steps and type stack go instead of into its markup — the same `Pattern_Builder_Cloud_Tokens::apply()` a cloud download uses, so the per-type value grammar and the never-overwrite rule are one implementation, with `destination` choosing between the active theme's `theme.json` and Global Styles, `set-global-styles` and `add-block-style-variation`, which are the other two thirds of a design system and are separate abilities for reasons that are not cosmetic — a preset is additive and inert, so a collision is skipped, while there is one `styles.elements.link.color.text` and setting it repaints every page at once, so `set-` replaces where `add-` never does, and folding them together would have meant an ability documented as never overwriting that always does; a variation is a third thing again, a named look applied with a class and scoped to the blocks carrying it, which is what lets it describe a second kind of button (an element style cannot) without fossilising one into the markup (attributes do), and **registration is a file rather than a theme.json key** — `styles.blocks.variations` only *styles* a variation something else registered, since core builds its valid list from the block style registry and `sanitize()` drops a node not in it, so what registers one without PHP is a `styles/{slug}.json` partial carrying a `blockTypes` key. **A partial cannot carry a block state**: `WP_Theme_JSON_Resolver::get_style_variations()` runs a partial through the whole-theme schema before it files the styles under the variation's node, and a whole-theme tree has no `:hover`, so the ability checks the file exactly as core reads it (a root `styles` sanitize, which is therefore not a defect), reports a state under `skipped`, and answers with a `note` giving the `set-global-styles` call that sets it — `styles.blocks.core/button.variations.{slug}.:hover`, the one place core (7.0+, `VALID_BLOCK_PSEUDO_SELECTORS`) accepts one, which `Pattern_Builder_Theme_Styles::sanitize()` keeps by asking the resolver for the theme's data first, since a partial's registration is lazy and an unregistered variation's node is dropped. A state set that way does not travel: the cloud carries the partial. `get-design-system`'s `blockStyles` also carries each partial-defined variation's `styles` — the `css` string included — so an agent can tell whether an existing look already does what it wants. **They part company over raw CSS.** WordPress does not sanitize a theme.json `css` property, it gates it on `edit_css`, and a string closing its own selector writes rules for the whole document — so `set-global-styles` refuses one outright, because a `css` at the root or on an element is scoped to nothing a pattern brought with it and a pattern that arrived from somewhere else must not repaint the site it arrived at. A variation is the exception, and `add-block-style-variation` accepts a `css` at the partial's top-level `styles.css` and nowhere deeper: its selector is a class the pattern's own markup carries, and without one a variation cannot express a pseudo-element, a descendant rule or a hover state, which is most of what a variation is for — every one of the patternbuilderwp.com theme's own variations uses it. What it accepts is decided by **`Safe_Css`**, a grammar rather than a filter: declarations first, then nested rules anchored on `&`, a strict subset of what `WP_Theme_JSON::process_blocks_custom_css()` parses *correctly* (that parser splits on `&`, strips every `}` and explodes on `{`, so a declaration after a rule, a second level of nesting and a `&` inside a string are all refused because core mis-reads them); no `@`, no comments, no `<` anywhere including quoted strings, no `,`, `+` or `~` in a selector (a comma escapes the variation's scope through `scope_selector()` and lands `body` at the top level), no backslash outside a quoted string (`u\72l(` spells `url(`) and inside one only where an escape belongs, and an allow list for every `name(` in a value. Nothing is stripped or repaired — a repair is how a checker and a browser come to disagree — and a refusal names the rule and the fragment. There is deliberately **no `edit_css` gate**: a gate would say unvalidated CSS is acceptable from a privileged caller, and `edit_css` is super-admin-only on multisite, so it would stop an ordinary network administrator writing what they can already write by hand. The check runs three times over a pattern's life — author, service, destination — and the third is the one that counts, because it is the machine that will execute the CSS; a string it refuses there costs that one look rather than the download (`variationsRefused`). And what core's schema drops comes back as `skipped` rather than vanishing, since an agent that believes it set a property builds the rest of the design on one that is not there. `set-layout` is the fourth, and the one that is neither a preset nor a style: `settings.layout.contentSize` and `wideSize` are what every constrained band measures its children against, plus `useRootPaddingAwareAlignments`, which decides whether an `alignfull` band escapes the root padding or is inset by it. Settings are not reachable from either of the other writers, and a site left on the wrong measure makes every band restate its own width in markup — the quietest failure in the design system, since nothing errors and the copy merely wraps somewhere else. A width goes through the same value grammar a spacing preset does, because it lands in the same place: core writes it straight into a `max-width` and would quietly substitute `initial` for a value it distrusts, which reads as a layout that did not take. It reports the layout it *wrote* rather than reading it back, because `WP_Theme_JSON_Resolver` keys parsed theme.json files by path and `clean_cached_data()` leaves that cache alone — so within one request the file it has already read is the file it keeps, and a read-back would report the values from before the call. Then the three that put a *file* where a pattern can reach it — `add-asset`, `add-placeholder-image` and `add-font` (below). `add-design-tokens` exists because there was no other way to do it over the wire: `theme.json` is a file with no REST route, and an agent that cannot add a preset inlines the hex instead, which opts the pattern out of the site's palette, its dark mode and every future restyle. Agent input is normalized where a service-built package needed no normalizing — an unknown `type` is *refused* rather than dropped (`missing()` skips a type it does not know, so a silent drop would report writing nothing and the agent would go on to reference a preset that was never created), a slug goes through `sanitize_title()` because core derives the CSS custom property from it, and a missing `name` is filled from the slug because `merge_settings()` writes it unconditionally. **Nothing takes a prompt**: an `execute_callback` that turned a description into a pattern would need a model behind it, which is the inference business this plugin is deliberately out of — the judgement lives in whatever agent is calling, and travels as prose. Block *validity* is likewise absent and cannot be added: `save()` is JavaScript, so the one check most worth offering is the one no server can perform, and the agent must run it before calling `create-pattern`. What both writes do refuse, through `Pattern_Builder_Markup_Checks`, is the set of failures PHP can see and every one of them silent at render — attribute JSON that does not parse (core reads it as no attributes, so a slot that lost a brace is quietly no longer a slot), a heading or list contradicting its attributes, a block this site has not registered, a `core/pattern` reference resolving to nothing or to the pattern itself, and a Pattern Overrides slot nothing can fill (a `content` key naming no slot in the referenced pattern, a binding with no `metadata.name`, a binding on a block `get_block_bindings_supported_attributes()` does not list) — each named under the error's `problems`; a reference resolves through the registry *or* the theme's files, because a pattern written a request ago is registered only on the next `init`, which is what lets a page be stored in the same session as its sections and is what enforces the bottom-up order the guides prescribe. The same class of check `Pattern_Validator::check_block_markup()` runs on the service. `create-pattern` with `source: user` writes the whole of a `wp_block`'s metadata, not only its title and content — `synced` (`wp_pattern_sync_status`, whose absence means synced, so the default is unsynced as it is for a theme pattern), `description` (the excerpt), `categories` (`wp_pattern_category`) and `keywords` — and both writes report a category nothing on the site registered under `unregisteredCategories`. The validator itself is `guides/pattern-author/scripts/validate-pattern.mjs`, which loads **the site's own WordPress** (`wp-core.mjs`: the script order comes from core's generated `script-loader-packages.php`, so nothing boots WordPress, and the files go in as real `