Implementation roadmap

Cairnstone: block-builder foundation plan.

Eighteen phases. Phases 1 (demolition + CPT bootstrap + PHP 8.2 bump), 2 (inspector shell), 3 (Style panel, gap-filling universal controls + gcStyle attribute end-to-end), 4 (Visibility recipe panel, 18 plain-language recipes + universal CC injection), 4.1 (custom rule builder, recursive AND / OR tree with nested groups, Pro-tier gated), and 5 (save → register block type chain, Preview-live route, breaking-change UX with working Cancel + Migrate + Publish-as-new-block paths) shipped. Phase 6 shipped through Batch D: A (library scaffold + Author Bio Card v2.1 + Social Profiles), B (Trust Badges, Testimonial, Section Divider, FAQ, Table of Contents), C (Hero + Pricing Table), and D (the first mixed-tier batch: Affiliate Disclosure, Pro-locked placeholder, Discount Banner, and Pricing Pro). The v2.2 release line then added Badge as the thirteenth library block plus a Pricing Pro craft + a11y pass, library-wide CTA polish, Affiliate Disclosure site-wide overrides, and the Cairnstone Free / Cairnstone Pro inserter split. After the 2.2.72 Pro-discovery cleanup removed the Pro-locked placeholder, fourteen library blocks live in the inserter, thirteen free + one Pro (How-to and Image added in the Phase 16 library pass). Phase 8 (state-driven controls) shipped 2026-05-20 (Pass 6 closes the phase, commits 45ba07c → 5283bda, v2.2.82). Phase 7 (Repeater primitive + parent/child rules) shipped 2026-05-14. Phase 7b (the locked sidebar transplant) was folded into the library-upgrade phase on 2026-05-20 after transplanting 5 of 15 shipped blocks (Badge, Section Divider, FAQ, Testimonial, Pricing Table); the 10 remaining transplants ride along with the Video embed Pro block and the six state+interaction-driven blocks when library work resumes. Phases 10–17 were renumbered on 2026-05-20 so every sidebar / infrastructure phase ships before any new blocks: the new order is Phase 9 (interaction system, shipped 2026-05-21 at 20/20 audit), Phase 10 (Schema panel, shipped 2026-05-21 at 20/20 audit + extended through v2.2.138 with voice polish and the Rich Results Test footer link), Phase 11 (Block Bindings, shipped 2026-05-27 across v2.3.13 → v2.3.28, full four-surface UI layer, four-of-four Cairnstone source families, set-property runtime bridge), Phase 12 (import / export, shipped 2026-05-27 across v2.3.29 → v2.3.52, Pass A wire format, Pass B REST, Pass C WP-CLI, Pass D across sidebar slot, list-view chrome, bulk zip, and Import modal, plus harden + polish + reload-after-commit fix), Phases 13–15 (AI integration), Phase 16 (library upgrade pass, first block-building phase, against a now-stable sidebar), Phase 17 (third-party block verification). Phase 16 is in progress: v2.3.150–v2.3.171 shipped the shared image engine, two new library blocks (How-to, Image), and the in-canvas editing pilot; v2.3.298–v2.3.310 completed the in-canvas text arc (full WP toolbar on every content field, inline-formatting law corrected, Node managed-link contract settled). The six interaction blocks, Video embed Pro, and the remaining sidebar transplants are still open. Phase 17 was verified 2026-06-07 against Kadence, GenerateBlocks, and Stackable on the floor: panels attach, Conditional Content gates, Look/Style merges, no Cairnstone-caused errors. License plumbing is Phase 18 and is always the last phase by convention. Each phase is independent enough that stopping between them leaves the product in a coherent state.

Recent changes#

The v2.2 release line shipped on top of Phase 6 Batch D: Badge as the thirteenth library block, a Pricing Pro craft + a11y pass (per-tier icon picker, radiogroup toggle, sticky-header offset for the WP admin bar), universal library polish (44px tap targets + distinct focus-visible on every CTA, compact ColorDropdown across all per-instance colour pickers), Affiliate Disclosure site-wide template overrides via Settings, and the inserter category split into Cairnstone Free + Cairnstone Pro. A CairnstoneBlock module hardening pass on 2026-05-14 cleaned up the breaking-change resolution paths without changing behaviour. Two later releases changed the standing state: 2.2.71 delegated GeoIP entirely to Gillish Node (Cairnstone now makes no external requests and ships no location database), and 2.2.72 removed the vestigial Pro-locked placeholder. Twelve library blocks live in the inserter, eleven free + one Pro. Phase 7 (Repeater primitive + parent/child rules) shipped 2026-05-14; Phase 8 (state-driven controls) shipped 2026-05-20 (Pass 6 closes the phase, v2.2.82); Phase 7b (the locked sidebar transplant) was folded into the library-upgrade phase on 2026-05-20 after transplanting 5 of 15 shipped blocks; the roadmap back half was renumbered the same day so every sidebar / infrastructure phase ships before any new blocks (Phase 9 interaction system → Phase 10 Schema panel → Phase 11 Block Bindings → Phase 12 import / export → Phases 13–15 AI integration → Phase 16 library upgrade pass with the 15-transplant alignment + Video embed Pro + six state+interaction-driven blocks → Phase 17 third-party block verification → Phase 18 license plumbing). Phase 11 (Block Bindings) closed 2026-05-27 across v2.3.13 → v2.3.28 with the four-surface UI layer (Cairnstone-data inspector tab, editor-toolbar marker pill, author-context Block content form, Phase 9 set-property un-dim), the four-of-four Cairnstone-registered binding source families (post-meta, term-meta, block-attribute, node-link), and the JS Interactivity API-driven runtime bridge that POSTs set-property writes against a new cap-gated REST controller. Phase 12 (import / export) closed 2026-05-27 across v2.3.29 → v2.3.52 with the Pass A wire-format service, Pass B four-route REST controller, Pass C four-subcommand WP-CLI surface, Pass D-1 sidebar export slot on the CPT editor, Pass D-2 list-view chrome (row-hover Export, bulk Export selected, top-row Export all), Pass D-3 Import modal (file-picker + drop overlay + status-pill preview + commit Snackbar + modal-internal drop-target), plus a wp.org-submission harden sweep (zip-bomb pre-extract count, Windows zip-slip realpath boundary, sanitize_file_name on Content-Disposition headers, pretty-print on the wire, error-code prefix sweep) and a v3-critique polish pass closing twelve P2/P3 findings across REST + modal + list-actions + CSS. Phase 13 (AI integration, Abilities API base) closed 2026-05-30 across v2.3.90 → v2.3.93 in three slices: 13a discovery (five read-only cairnstone/* abilities + a 30-day audit log), 13b mutation (create / update / delete / import, draft-only, behind an “AI assistants” Settings opt-in), and 13c provenance (the AI activity audit page + the “AI draft” list marker). The planned Free mutation quota and per-hour rate limit (13b.2) were dropped as wp.org trialware with no real abuse vector to close. A post-close runtime-audit sweep (v2.3.99–v2.3.101) then fixed four defects a consolidated Chrome-MCP run surfaced: AI-created blocks were invisible in the editor (composition stored in post-meta but not post_content), GC_NODE_ACTIVE froze false in the default plugin-load order so list-node-links never registered, and the import-block payload contract + a publish-refusal message were corrected. Phase 14 (AI richer introspection) closed 2026-05-30 across v2.3.102 → v2.3.108 with the per-block semanticType tags, the cairnstone/validate-composition dry-run validator (A1 manifest-attribute, A2 nesting, A3 Conditional-Content + a content-stripped warning), the cairnstone/list-style-controls universal style catalogue (the honest form of the planned per-block style introspection, since gcStyle is universal by design), and two follow-on hardenings: the validator now runs before any AI save so a non-working block is refused with a human-readable reason that names the block as a designer sees it. The whole twelve-ability surface was re-verified end-to-end against the floor install and pinned as a runtime baseline. Phase 15 (preview rendering + batch) closed 2026-05-31 across v2.3.110 → v2.3.111 with server-side preview-as (cairnstone/preview-block), dry-run on every mutation, and the cairnstone/batch multi-op ability, plus a contract-parity harden; the AI-integration arc (Phases 13–15) is complete. Phase 16 (library upgrade pass) is in progress (v2.3.150 → v2.3.171: the shared image engine, the How-to and Image blocks, and the in-canvas editing pilot; v2.3.298 → v2.3.310: the in-canvas text arc, the inline-formatting law corrected to full vanilla WP toolbar, and the Gillish Node managed-link cross-plugin contract); Phase 17 (third-party block verification) was verified 2026-06-07. A self-adapting layout-primitives arc (Columns / Grid / Container + a Layout wizard + an opt-in per-device responsive system with owner-set breakpoints) shipped 2026-06-17/18 across v2.3.317 → v2.3.332 (see the card below).

· self-adapting layout primitives + a per-device responsive system with owner-set breakpoints + a Layout wizard · v2.3.317 → v2.3.332

Layout primitives: self-adapting Columns / Grid / Container, then a full per-device responsive system

The layout blocks were rebuilt around a self-adapting responsive model (no fixed device list): Columns wrap and stack by the space available, the new free Grid block auto-fits as many equal items per row as the space allows (no count cap), and Container gained a free section-width control (Normal / Wide / Full / custom, including beyond-100% bleed with no horizontal page scroll). A first-run Layout wizard drops the right primitive from a recipe gallery and replaces itself. Vertical alignment, a gap-aware column basis, and RTL-correct drag handles landed alongside.

On top of that simple default sits an opt-in per-device tier, under the standing rule “default simple, advanced always available.” Each column can carry a different width per device (Desktop / Tablet / Phone, set from the column’s own panel); each Grid can carry a fixed items-per-row count per device (for example 4 / 2 / 1). Crucially, where the devices switch is the site owner’s choice: two Responsive breakpoints numbers under Settings → Site defaults (default desktop above 1024px, phone at or below 600px). Because a CSS media query cannot read a custom property, the per-device viewport bands are generated server-side from those two numbers and attached inline to the frontend stylesheet. A content-width block is the same pixel width on a tablet and a desktop, so the device split must be a viewport query, while the “too narrow, stack” safety net stays a container query (correct for nested rows). The whole arc is floor-verified at real viewport widths and pinned by source-assertion contract tests.

· text fields to canvas + inline-formatting law corrected + regression pin + Node managed-link contract · v2.3.298 → v2.3.310

In-canvas text editing: the full WordPress toolbar on every content field

Five library blocks had their text fields moved from sidebar inputs to in-canvas authoring in v2.3.298–305: FAQ (canvas Q/A + per-row controls + “Add question” button), Author Bio (name, title, bio), Testimonial (quote, name, role), Affiliate Disclosure (disclosure body), Section Divider (label). Images on these blocks moved back to the sidebar per the design law; typography support landed on all five at the same time.

The first cut used a curated allowedFormats list (bold, italic, link) that hid underline, strikethrough, code, and any third-party or cross-plugin format button. The law was corrected: where a function matches WordPress, it should be WordPress. Every in-canvas text field now omits allowedFormats entirely (full vanilla WP toolbar, including third-party and Node-plugin formats), renders through wp_kses_post on save, and strips to plain text via wp_strip_all_tags wherever the value feeds JSON-LD. v2.3.306–309 applied the corrected law to all migrated blocks and to every schema-feeding field already in the canvas (FAQ question, How-to heading / step title / instructions, Hero heading / subheading, Image caption). Hero CTA labels keep an empty format list by design: a button label cannot host a nested link. InCanvasToolbarContractTest (v2.3.310) guards against three regression classes: format re-restriction, a render reverting to esc_html, and a schema field dropping its strip pass. The cross-plugin contract is settled: Gillish Node surfaces its managed-link button as a standard RichTextToolbarButton; the full toolbar covers it automatically with no Cairnstone registration required.

· verified against Kadence Blocks 3.7.5, GenerateBlocks 2.2.1, Stackable 3.19.9 on the floor · no code change

Phase 17 verified: Cairnstone’s universal capabilities work on third-party blocks

Conditional Content, Look/Style, and the inspector panels were confirmed on one representative block per plugin (kadence/advancedheading, generateblocks/text, stackable/card). In the editor the Visibility + Interactions + Schema panels attach after each plugin’s own panels (the editor.BlockEdit HOC hooks at the registration layer, not per block-type); on the frontend render_block gates a logged_in rule (the hidden instance is removed from the page entirely) and StyleAttribute::style_block merges gcStyle inline into each block’s root element. The only console output was third-party / Gutenberg-dev-build noise, none Cairnstone-caused.

· the shared image engine + the Image block + six consumers + an audit pass · v2.3.150 → v2.3.171

Phase 16 progress: the shared image engine, the Image block, and the in-canvas editing migration

A run of Phase 16 library work: the How-to block (v2.3.150) and the in-canvas editing pilot (v2.3.151); the shared image engine: Shared\Image::render() + render_set() (engine-owned lightbox grouping), Shared\Lightbox (a “Classic” overlay with a Settings kill-switch + Pro seam), and Shared\ImageSizes (v2.3.152–167); the new Image library block (v2.3.161, the fourteenth block); and the migration of How-to, Hero, Trust Badges, Testimonial, and Author Bio onto the engine so every block loads the right registered size with srcset + intrinsic dimensions for CLS (v2.3.153–169). A the-fool red-team plus an /impeccable audit on the completed engine drove three follow-up fixes (a sizes arg for fixed-box photos, a linked-badge accessible-name fallback, and a dead-CSS sweep) at v2.3.171, all visual-verified on the floor across landscape / square / portrait / real-face content. The rest of Phase 16 (six interaction blocks, Video embed Pro, the remaining sidebar transplants) is still open.

· server-side preview-as + dry-run on every mutation + the cairnstone/batch multi-op ability + a contract-parity harden · v2.3.110 → v2.3.111

Phase 15 closed: AI preview rendering + batch (the AI-integration arc, Phases 13–15, is done)

cairnstone/preview-block renders a saved block as a chosen visitor profile would see it, with a per-block show/hide trace that names the deciding signal and flags a signal a fixture cannot set. create-block / update-block / import-block gained dry_run: true (run every check, return the resolved artifact marked committed: false, write nothing), reachable over the wire after a schema fix declared the flag under the schemas’ additionalProperties: false. cairnstone/batch applies an ordered list of mixed create / update / delete / import in one call, partial-success by default with an atomic all-or-nothing mode (validate-all-dry first, compensating rollback as a backstop) and a max_items cap of 100; a follow-on harden brought it to contract parity with the single ops (dry_run, an always-present committed, failed_indexes on an atomic failure, an empty-batch rejection). Every ability was runtime-verified over the wire against the floor install via the Abilities REST endpoint.

· seven versions: semantic tags + validate-composition A1–A3 + style catalogue + pre-save gate + human refusal messages · v2.3.102 → v2.3.108

Phase 14 closed: richer AI introspection (semantic tags, the dry-run validator, the style catalogue)

cairnstone/list-library-blocks now returns a semanticType tag per block; cairnstone/validate-composition dry-runs a proposed tree through manifest-attribute, parent/child nesting, and Conditional-Content checks (plus a content_stripped warning), returning a structured { valid, findings } report with a precise path per finding; and cairnstone/list-style-controls exposes the universal gcStyle catalogue, the honest form of per-block style introspection, since the styles are universal by design. Two follow-on hardenings then ran the validator before every AI save, so a block that cannot work is refused before it reaches the draft list, and made the refusal name the block as a designer sees it: The “Column” block (gillish-cairnstone/column) can only be placed directly inside the “Columns” block (gillish-cairnstone/columns). The cairnstone/* surface is now twelve abilities, re-run end-to-end against the floor install and pinned as a runtime baseline.

· four-slice close, then a post-close runtime-audit hardening sweep · v2.3.90 → v2.3.93, then v2.3.99 → v2.3.101

Phase 13 closed: the cairnstone/* Abilities API base surface (discovery + mutation + provenance)

Cairnstone registers WordPress Abilities API capabilities under cairnstone/* so any MCP-speaking AI assistant can introspect and build blocks. Three slices: 13a discovery (five read-only abilities + list-node-links when Gillish Node is active), 13b mutation (create / update / delete / import, draft-only, behind an “AI assistants” Settings opt-in, with five security mitigations), and 13c provenance (the AI activity 30-day audit page + the “AI draft” list marker). The planned Free quota + per-hour rate limit (13b.2) were dropped as wp.org trialware with no real abuse vector to close. A consolidated Chrome-MCP run after the close found four defects PHPUnit could not reach, all fixed in v2.3.99–v2.3.101: AI blocks were editor-invisible (create-block left post_content empty), GC_NODE_ACTIVE froze false in WordPress’s default plugin-load order so list-node-links never registered, and two error-contract gaps (the import payload docs + a publish-refusal message).

· twenty-four versions across Pass A → Pass D-3 + harden + polish + reload fix · commits 695c281732a35d (v2.3.29 → v2.3.52)

Phase 12 closed: import / export end-to-end across REST + CLI + sidebar slot + list-view chrome + Import modal

Phase 12 (import / export of built blocks) is fully shipped. Twenty-four versions across one calendar day, with three /impeccable critique cycles green and a wp.org-submission-bound harden sweep folded into the closure. The shape brief plans/CANVAS-PHASE-12-SHAPE.md is retired into plans/DONE-WORK.md on the same closure-routine pattern Phase 11 used.

Pass A (v2.3.29): wire format. Modules/CanvasBlock/ImportExportService carries the canonical .gcblock.json contract: six top-level fields (formatVersion, slug, title, composition, panelConfig, schemaVersion) per brief §10 item 8. export_block() builds the export shape; validate_payload() returns the per-file row the import modal renders (new / renamed / rejected_newer_format / rejected_invalid / migrating); import_payload() commits as a gc_cairnstone_block draft via wp_insert_post; export_blocks_zip() (v2.3.41) bundles N CPT posts into a single ZipArchive with one entry per slug. Slug collisions resolve through WP-canonical wp_unique_post_slug; the v2.3.48 fix changed the third arg from 'draft' to 'publish' to force the SQL collision lookup that the early-return path was skipping (Chrome MCP smoke confirmed RENAMED pill + sub-line render for existing slug).

Pass B (v2.3.30 + v2.3.38 + v2.3.41): REST controller. Four routes under /wp-json/gillish/cairnstone/v1/blocks/*: GET /<slug>/export (single-block download, pretty-printed JSON on the wire via rest_pre_serve_request), POST /preview (multi-file validation, no DB writes, partial-imports-land contract per §6 state d2), POST /import (commit step), POST /export-bulk (zip stream with Content-Disposition filename <host>-cairnstone-blocks-YYYY-MM-DD.zip). All four cap-gated on manage_options per §10 item 10 (the v2.3.38 cap-callback split was reverted in v2.3.40 after live verify revealed the CPT’s capabilities override already remapped every edit_* cap to manage_options, making the split structurally a no-op). Multi-file shape detection in file_params() handles four $_FILES shapes (flat-single, fan-out, field-wrapped fan-out, pre-normalised dict-list); the shape-2.5 detector landed in v2.3.47 after a Chrome MCP smoke caught a “No files attached” 400 that PHPUnit hadn’t exercised.

Pass C (v2.3.32): WP-CLI surface. Four subcommands under the gillish-cairnstone namespace: list-blocks [--format=...] (table / json / csv / yaml / count / ids via WP_CLI\Utils\format_items), export-block <slug> (canonical JSON to stdout, composable with | jq / > redirect), import-block [--from=<file>] (reads --from=<path> or stdin), wipe-blocks --yes (trashes every CPT post, recoverable from trash, requires explicit --yes flag per brief §6 state q).

Pass D-1 (v2.3.35 + v2.3.36 + v2.3.37 + v2.3.39 + v2.3.40): sidebar slot. Block export PluginPostStatusInfo slot between Phase 11’s “Block content” and the standard Permalink panel on the CPT editor. One quiet ghost button reading “Export this block (.gcblock.json)”; the click flows through window.location.href with the wp_rest nonce appended as ?_wpnonce=<nonce> (v2.3.39 closed the F3 P0: top-level browser navigation cannot set X-WP-Nonce header, so the cookie-auth REST request would have authenticated as anonymous and 403’d every export click on plain-permalink installs, broken since v2.3.35 ship). The v2.3.36 polish slice closed five post-D-1 critique findings (single-tag button-swap on draft → publish transition so keyboard focus survives, title typography against Gutenberg’s panel-header cascade, REST trash-status whitelist, redundant aria-label, .gcblock.json in helper line not button label). The v2.3.37 cascade-fix slice closed the two-class selector vs Gutenberg’s .interface-complementary-area h3 rule for both Phase 11 + Phase 12 sidebar slot titles.

Pass D-2 (v2.3.41 + v2.3.42 + v2.3.43 + v2.3.44 + v2.3.49): list-view chrome. Three filter callbacks on the gc_cairnstone_block CPT list table (post_row_actions per-row Export link, bulk_actions-edit-... Export selected dropdown entry, handle_bulk_actions-edit-... stream-and-exit dispatcher) plus a JS chrome layer (Import peer button + drop-target overlay + gc:cairnstone:import-files-ready CustomEvent the D-3 modal subscribes to). v2.3.44 polish cycle closed four P1 + one P2 from the v2 critique (JS ? vs & separator pick for plain-permalink installs, rest_pre_serve_request closure self-removes, register_shutdown_function temp-zip cleanup on client abort, handle_bulk_actions try/catch + defensive return, build_bulk_export_filename consolidated into the service). v2.3.49 added the top-row Export all button next to Import that mirrors the Import peer button position and downloads every block with a valid slug as a single .zip via a cap- + nonce-gated admin-post handler reusing the existing process_bulk_export_selected + stream_and_exit service pair.

Pass D-3 (v2.3.46 + v2.3.47 + v2.3.48 + v2.3.51 + v2.3.52): Import modal. Three sub-slices closed the modal surface: D-3.1 scaffold (wp.components.Modal at 560px max-width mounted into a body-level container via wp.element.createRoot, subscribes to the gc:cairnstone:import-files-ready CustomEvent), D-3.2 per-file preview rows with status pills (STATUS_PILL_MAP keyed on the locked five statuses, INVALID pill click-to-expand to field: reason, AbortController on the in-flight preview POST so Esc / Cancel mid-flight aborts), D-3.3 commit flow (Import button POSTs FormData to /blocks/import, dispatches a core/notices Snackbar reading “Imported N blocks as drafts.” with optional “(M files skipped)” suffix on partial imports, modal-internal drop-target re-dispatches the IMPORT_EVENT for symmetric append-on-open behaviour, in-flight commit guarded so Cancel disables and Esc / scrim-click refuse to close). v2.3.52 closed the post-D-3.3 user-feedback gap: the list table behind the modal stayed on its pre-import row count because WP-admin’s list table doesn’t subscribe to a data source for REST create-post events; the modal now schedules window.location.reload() 1500ms after the Snackbar dispatches so the user sees the confirmation, then the table refreshes with the new rows. v2.3.48 closed four P1 findings from the v3 critique (RENAMED-vs-NEW wp_unique_post_slug argument, modal close+reopen race, docblock typo, shape-2.5 PHPUnit regression-pin).

Harden sweep (v2.3.45): wp.org submission. Closed the carried-over P0/P1 findings the critique chain accumulated: zip-bomb pre-extract count via ZipArchive::numFiles + per-entry size check BEFORE unzip_file writes anything (caps at 100 entries, 5 MB per entry), Windows zip-slip via the two-layer pre-flight slash/backslash check + post-extraction realpath boundary (closes the gap validate_file leaves on backslash paths), sanitize_file_name on both Content-Disposition filename constructions, single-export pretty-print on the wire via rest_pre_serve_request (the v2.3.30 set_data path silently discarded the pretty-print intent), slug regex relaxed to [a-z0-9][a-z0-9-]* for digit-leading slugs, error-code prefix sweep on invalid_slug + json_encode_failed to the gillish_cairnstone_ namespace.

v3 critique deferred-12 polish (v2.3.50): Phase 12 reviewer-perfect close-out. Twelve P2/P3 findings carried forward to one polish cycle: rest_pre_serve_request single-export closure match narrowed (full payload-shape check, not just slug), HTML-fatal-as-SyntaxError diagnostic in modal preview fetch, error-state Try-again button, UNKNOWN pill fallback enum exposure with console.warn, INVALID pill aria-controls reference, long-slug row-head flex-wrap: nowrap, drop-overlay reset on window blur + Escape + dragend, bulk-action Apply is-busy state for the stream-and-exit window, helper-text #757575#50575e WCAG AA sweep, trash-list row-action skip (REST whitelist already rejected trash with 404), dual style handle consolidation (one STYLE_HANDLE shared with DocumentPanelAssets), dead-code triple-background declarations collapsed.

Verification trajectory: PHPStan level 9 = 0 errors at every commit. PHPUnit 596 tests / 1609 assertions green at v2.3.52 (a step up from 516 / 1339 at the Phase 11 closure as the service, the four REST routes, the CLI surface, the bulk-action handlers, and the Export-all admin-post handler all landed contract-test coverage). composer audit: 0 advisories at every push. a11y AA: 0 violations on every audit-gate run. Chrome MCP runtime-verify smokes caught four runtime defects PHPUnit + a11y structurally couldn’t see (F3 D-1 anonymous-REST 403, shape-2.5 fan-out detector, multi-permalink-shape ? separator, RENAMED-vs-NEW wp_unique_post_slug argument). The v2.3.40 procedural gate (Chrome MCP smoke required before any Pass D-N closes) is now part of the studio’s slice-closure ritual.

User documentation refreshed in the same session per the “designer / forfatter-visible features ship with docs” rule: new #import-export section in cairnstone/documentation/index.html walks through all five user-facing surfaces (sidebar Export slot, per-row Export, bulk Export selected, top-row Export all, Import modal with status-pill taxonomy + modal-internal drop + Snackbar) plus a CLI subsection covering the four wp gillish-cairnstone subcommands.

· ten craft / harden / polish / clarify commits closing §5(c) + §5(d) + three critique-cycle defect classes · commits ed4fc2a1d55615 (v2.3.19 → v2.3.28 + DESIGN.md alignment)

Phase 11 closed: four-surface UI complete, set-property runtime bridge live, three critique cycles green

Phase 11 (Block Bindings integration) is fully shipped. The two entries below this one track the source-registration layer (v2.3.2 → v2.3.12) and the first three UI surfaces (v2.3.13 → v2.3.18); this entry tracks the closure work that landed §5(c) author-context Block content form, §5(d) Phase 9 set-property un-dim + runtime bridge, and the three critique cycles that hardened the surface to ship-ready. The shape brief plans/CANVAS-PHASE-11-SHAPE.md was retired into plans/DONE-WORK.md and deleted per the planning-documents close-out routine.

v2.3.19, manifest cross-check closes the post-critique footgun. /impeccable critique after §5(b) flagged the bindableAttributes + defaultBindings cross-check gap: a manifest could declare a default binding for an attribute it hadn’t opted into the bindable list, the seeding pipeline would silently write a binding that the editor inspector then couldn’t surface. The cross-validator landed in ManifestSchema: every defaultBindings key must appear in bindableAttributes on the same manifest, or the registry rejects the manifest at boot with a developer-targeted message naming the orphan attribute.

v2.3.20: §5(b) marker-pill tooltip prose clarify. The hover tooltip prose was generic (“From Source”); the source-aware prose now reads “From Cairnstone post meta · gc_price” / “From Post Data · title” / etc. with the source label routed through the same attributeLabels + source-label registry the inspector section uses, so the toolbar pill and the inspector header agree on naming by construction.

v2.3.21: §5(c) author-context Block content form. A new editor module assets/js/gc-block-content-panel.js mounts at Gutenberg’s PluginPostStatusInfo slot (the document-sidebar surface, narrowed by design to block.name.indexOf('gillish-cairnstone/') === 0 so third-party blocks don’t pollute the form). For every Cairnstone library block on the post, a row per bound attribute renders with the plain-language attribute label, a native input matched to the source’s key-name heuristic (URL field for *_url / *_link keys, number field for *_count / *_price, textarea for long-form keys, plain text otherwise), and a four-source dispatcher map that routes the write through the right WP-side API per source family (core/post-datacore.editPost; gillish-cairnstone/post-metacore.editPost({ meta: ... }); gillish-cairnstone/term-metacore.editEntityRecord; gillish-cairnstone/block-attributecore/block-editor.updateBlockAttributes).

v2.3.22 + v2.3.25 + v2.3.27: §5(c) follow-up polish. v2.3.22 wired the three Cairnstone-side source dispatchers (post-meta, term-meta, block-attribute) end-to-end so the form actually mutates each backing store, not just core/post-data titles. v2.3.25 narrowed the trigger heuristic (only blocks declaring bindableAttributes in their manifest show form rows; legacy library blocks without the manifest field stay silent until they opt in) and added the input-type dispatcher. v2.3.27 expanded the key-name heuristic to a richer suffix-and-stem table so cta_url, hero_image_link, price_amount, and description_long each pick the right native input on first render.

v2.3.23 + v2.3.24 + v2.3.26 + v2.3.28: §5(d) Phase 9 set-property un-dim, runtime bridge, cap-gate, i18n. v2.3.23 un-dimmed the long-disabled set-property row in the Interactions panel: the picker is no longer behind GC_DEV_MODE, an inline three-field sub-picker (sourcekeyvalue) renders below the action selector, and the picker shape is locked as v1 canonical (the original brief had promised WP-core’s BlockBindingsAttribute Connect UI but WP-core doesn’t expose it as a standalone primitive; the 3-field Cairnstone form is the correct shape for the runtime write surface). A BlockBindings::set_value() per-source write dispatcher handles post-meta + term-meta + core/post-data writable sources (block-attribute / node-link / core/term-data return false with documented deferral rationale). v2.3.24 closed the gap between the picker and the dispatcher: Shared\SetValueRestController exposes POST /wp-json/gillish/cairnstone/v1/set-value cap-gated on edit_others_posts, Shared\InteractionsRuntime renamed the setPropertyNoop handler to live setProperty, and the runtime module reads its config (setValueApiUrl + restNonce) via wp_interactivity_config('gillish-cairnstone', ...) mirroring the JS Interactivity API’s ESM-compatible config channel. v2.3.26 reconciled the JS picker cap-gate with the PHP write-scope (userCan.editOthersPosts flag publishes through to the picker; an author lacking the cap sees the set-property row but the value-write call returns 403 from the REST layer instead of half-disabling client-side). v2.3.28 registered the ten config_sp_* i18n keys for the sub-picker labels + placeholders + error strings.

Three critique cycles: the closure pattern that scales. Same audit-driven closure pattern that Phase 9 + Phase 10 used, run three times: (1) /impeccable critique after the §5(b) pill surfaced one P2 (the defaultBindings / bindableAttributes cross-check footgun) closed in v2.3.19; (2) /impeccable critique after the v2.3.23 §5(d) un-dim surfaced one P1 (the runtime bridge was missing: the dispatcher existed but nothing called it from the frontend) + four P2s closed across v2.3.24 → v2.3.26; (3) /impeccable critique on the full Phase 11 surface afterwards surfaced three more P2s (Connect-UI brief drift, i18n debt, deferred input types) closed via the brief update + v2.3.27 + v2.3.28. The pattern (critique → harden / polish / clarify → critique) caught five real defects the initial shape brief hadn’t pre-empted; the brief’s voice + anti-reference discipline caught the rest at write-time.

What was deferred and is NOT in Phase 11’s shipped surface: multi-attribute marker pill (v1 surfaces only the first bound attribute); templateLock="contentOnly" half of the §5(c) author-form trigger (Hero ships without it); array-bound RepeatingFieldEditor rows in the author form (lights up when a library block declares an array-shaped binding, today none do); multi-block grouping with subheads in the author form (single-Hero-per-post is the realistic shape); BlockBindings::set_value() for block-attribute / node-link / core/term-data sources (each returns false with documented rationale: block-attribute writes would mutate post_content, Node-link needs Node’s bridge filter, core/term-data needs taxonomy-attachment resolution); Snackbar success / failure feedback for the set-property runtime POST (brief doesn’t pin runtime feedback semantics; the runtime stays fire-and-forget per setVisitorState precedent); WP-core Connect UI as the set-property sub-picker (locked as a 3-field Cairnstone form per brief §10 item 10); camelCase coverage in pickInputComponent heuristics (snake_case-only by WordPress meta-key convention; the deferred per-key-type-hint seam is the right long-term fix).

Verification trajectory: PHPStan level 9 = 0 errors at every commit. PHPUnit 516 tests / 1339 assertions green at v2.3.28 (a step up from 496 / 1297 at v2.3.18 as the bindable-attrs cross-check, the REST controller, and the rewritten set-property contract tests all landed). composer audit: 0 advisories at every push. a11y AA: pa11y + axe-core both at 0 violations on the floor frontend scan and 0 violations on the editor scan, the local-only audit gate green across all ten pushes. Studio smoke via Chrome MCP confirmed the four UI surfaces end-to-end on a fresh CPT-editor session: Cairnstone data inspector tab renders with the chip-row picker, marker pill renders BOUND: HEADING on a fresh Hero block (the v2.3.18 first-default-binding seed), Block content form row writes back through to the post-meta backing store, and the set-property runtime POST round-trips through the cap-gated REST endpoint on click.

· five Phase 11 craft commits + one rename-completion cleanup that unblocked three library blocks · commits 1b887e7e334e41 (v2.3.13 → v2.3.18)

Phase 11 surface layer: three of four UI surfaces shipped, Hero seeds its first defaultBindings

The source layer closed at v2.3.12 (entry below). The UI layer is now three of four surfaces in. §5(a.3) first-open seeding shipped at v2.3.13, §5(a) library-block bindableAttributes opt-in seam shipped at v2.3.14 (Hero first opted in), §5(b) editor-canvas BOUND: PRICE marker pill shipped at v2.3.15, and Hero’s first defaultBindings declaration shipped at v2.3.18 alongside the Edit refactor that closes the WP 6.9 placeholder-object React-child crash. §5(c) author-context Block content form and §5(d) Phase 9 set-property un-dim remain.

v2.3.13: §5(a.3) first-open seeding closes for the platform layer. The Pass 5-8 mirror’s plumbing (manifest field → ManifestSchema::validate_default_bindingsShared\BlockBindings::$default_bindings registry → InspectorPanelsModule payload publish → editor read at window.gcInspectorPanels.defaultBindings) was already in place from v2.3.8. v2.3.13 closed the brief’s seeding behaviour in assets/js/gc-bindings-panel.js: a new useEffect in BindingsSectionPanel reads the published payload on each block-selection, walks the per-block defaultBindings map, and atomically writes seed entries into metadata.bindings.<attribute> only when no binding already exists. The “never re-seed once cleared” half of the contract is enforced via a Cairnstone-namespaced marker metadata.gcBindingsSeeded, an array of attribute names that survives saves and loads inside Gutenberg’s existing metadata serialisation, so the second-mount case after a × clear sees the marker and short-circuits.

v2.3.14 + v2.3.18, Hero opts in, then seeds its first source. v2.3.14 added the manifest seam bindableAttributes: ["heading", "subheading", "imageUrl"] with the matching ManifestSchema validator, the Shared\BlockBindings::$bindable_attributes registry, and the apply_bindable_attributes_filter callback that merges the declared list into WP 6.9’s block_bindings_supported_attributes filter. Hero opted in but withheld defaultBindings, the placeholder object {} that WP 6.9 substitutes for unresolved bound attrs crashed React the moment it landed as a JSX child (Objects are not valid as a React child (found: object with keys {})). v2.3.18 closed the gap with a safeBindable( v ) { return typeof v === 'string' ? v : ''; } coerce-at-render-boundary helper in Hero/editor.js, hoisting heading / subheading / imageUrl to local strings the entire render reads from. With the crash closed, Hero ships defaultBindings.heading = { source: "core/post-data", key: "title" }, the first library-block defaultBindings declaration to actually flow through the v2.3.13 seeding pipeline. Studio smoke via Chrome MCP confirmed metadata.bindings.heading seeds correctly on first inspect of a fresh Hero block, and gcBindingsSeeded: ["heading"] writes alongside so a later × clear stays cleared.

v2.3.15: §5(b) editor-canvas marker pill. A BOUND: <ATTR-LABEL> pill renders at the end of the Gutenberg block toolbar (BlockControls group other) whenever a block has any bindable attribute currently bound. The pill reads the same metadata.bindings + attributeLabels registries the inspector section header uses, so a heading attribute bound to core/post-data title surfaces as BOUND: HEADING and the two surfaces agree on which attribute the pill names by construction. Hover exposes a tooltip with the source qualifier (“From Post Data · title”) so the author has the full binding identity without opening the inspector. Typography matches DESIGN.md label (11px / 600 / tracked uppercase, --gc-quiet-slate text, no fill, no border) so the pill reads as a status indicator rather than a CTA. The marker mounts at the withCanvasInspector HOC level on every block (no-ops when no binding present), so any block participating in WP 6.9’s block_bindings_supported_attributes map gets the indicator, not just Cairnstone library blocks. v1 stops at hover-tooltip; click-to-unbind + Snackbar Undo from the §5(a) chip-row × path lands in a follow-up pass.

v2.3.16, the Canvas → Cairnstone JS-global rename completion (cleanup that unblocked three library blocks). The v2.3.0 namespace rebrand renamed window.gcCanvasBlockswindow.gcCairnstoneBlocks in the runtime bootstrap assets/js/gc-block-runtime.js and most consumers, but four editor files were missed and had been silently broken since: three library blocks (Badge, Pricing Table, Pricing Pro) failed their prereq guard and never registered their custom editorRender with the runtime, falling back to defaultEditorRender on every author insert, so the rich preset / silhouette / variant pickers never rendered in the editor. The fourth file, assets/js/gc-inspector-panels.js, read its manifest registry from the stale gcCanvasBlocks._manifests registry that never gets populated because BlockRuntimeAssets.php writes to the canonical gcCairnstoneBlocks._manifests. The mismatch meant sectionsFor() saw an empty registry and every Cairnstone block’s per-manifest globalAttributes opt-out (canvasStyle / conditionalContent / canvasInteractions / schemaEmission) silently fell through to default-true across the whole library, defeating the per-block opt-out seam. Hero was unstuck at v2.3.14 as a sibling fix during the §5(a) bindable-attrs work; the v2.3.14 entry tracked the remaining four as a cleanup task. v2.3.16 closes it with a wholesale replace_all gcCanvasBlocks → gcCairnstoneBlocks across the four files. No semantic change; the rename was always meant to be wholesale. Verified via Chrome MCP: 1 gc-library-badge-editor-wrap + 2 block-editor-block-variation-pickers rendered in the editor iframe after a fresh post insert; gcCairnstoneBlocks._manifests carries the full 15-block registry; asset URLs at ?ver=2.3.16; no gcCanvasBlocks references anywhere in console, DOM, or runtime.

v2.3.18 also swept a stroke-width hairlines bug that the rename had been hiding. Four library variation-icon helpers wrote SVG presentation attributes in their createElement calls using the kebab-case CSS form ('stroke-width', 'stroke-linecap') instead of React’s camelCase DOM form (strokeWidth, strokeLinecap). React rejected the kebab keys with the runtime warning “Invalid DOM property `stroke-width`. Did you mean `strokeWidth`?” and the rendered SVG silently dropped the attribute, so variation thumbnails rendered without their hairline strokes. Twelve sites swept across PricingTable (7: the bars helper + 6 inline el('line', …) calls for the comparison-table variant), PricingPro (1: the bars helper mirrored from PricingTable), DiscountBanner (1: the viewport outline rect), AffiliateDisclosure (3: the circle stroke, path stroke, and the same path’s stroke-linecapstrokeLinecap on the same object literal). The bug shipped with the original library authoring but was hidden until v2.3.16 unblocked Badge / PricingTable / PricingPro’s variantIcon path; DiscountBanner and AffiliateDisclosure shared the bug by copy-paste-pattern but their warnings sat behind the variation-picker open state.

Verification trajectory: PHPStan level 9 = 0 errors at every commit. PHPUnit 38 / 38 through v2.3.13, 496 / 1297 from v2.3.14 onwards (Hero bindableAttributes opt-in landed with 22 new tests covering the registry + filter callback). composer audit: 0 advisories at every push. a11y AA: 0 violations on the floor frontend scan + 0 violations on the editor scan (pa11y + axe-core via the Studio Playwright slot, run automatically before every git push origin main).

· eight craft commits + one hotfix bundled into the §5(a) commit · commits 7396361a566dbf (v2.3.2 → v2.3.12)

Phase 11 source-registration layer complete: four-of-four binding source families landed

The substrate Phase 11 needs ships in two halves. The source layer closes today: four register_block_bindings_source() calls, one shared orphan-label contract (resolve_value_with_fallback routes every callback), one JS-side bootstrap mirror per WP-core’s class-wp-customize-widgets.php pattern, one defaultBindings manifest-driven five-seam mirror matching Phase 10 Pass 5-8. The UI layer remains: §5(a.3) first-open seeding, §5(b) editor-canvas marker pill, §5(c) author-context Block content form, §5(d) Phase 9 set-property un-dim.

What shipped (source side): v2.3.8 (daa3c65) bundled the Pass 1 foundation (Shared\BlockBindings registry + ManifestSchema::validate_default_bindings + 16 tests) + RepeatingFieldEditor extraction to gc-controls.js + inject_default_bindings hooked into FILTER_REGISTER_BLOCK_ARGS + the defaultBindings payload publish + the first two source families (gillish-cairnstone/post-meta, gillish-cairnstone/term-meta) into one catch-up version after six craft commits shipped without per-commit bumps. v2.3.9 (34ad71d) added gillish-cairnstone/block-attribute with recursive block-tree walking on anchor / clientId. v2.3.12 (a566dbf) added the fourth and final family gillish-cairnstone/node-link via a new gillish_node_resolve_link bridge filter mirroring the GeoIp country-resolution pattern (Cairnstone declares the binding shape; Gillish Node owns the runtime resolution).

§5(a) inspector tab shipped + hotfixed (UI side, first surface): v2.3.10 landed assets/js/gc-bindings-panel.js as the first JS surface of Phase 11: a Cairnstone-owned inspector section peer to Style / Visibility / Schema / Interactions, lists bindable attributes (read from WP 6.9’s __experimentalBlockBindingsSupportedAttributes editor setting), status pill BOUND: PRICE in the section header, chip-row or flat-list SelectControl picker per attribute. v2.3.11 hotfixed a fields-list shape mismatch discovered during smoke: get_post_meta_fields_list and get_term_meta_fields_list originally returned an object-keyed map ({key => {label, value}}) but WP-core’s native BlockBindingsAttribute component (block-editor.js line 46522) calls source.data?.find() on the data field and expects the array-of-objects shape per core/post-meta ({label, args: {key}, type}). The mismatch crashed any block on binding-set because the React error boundary caught the resulting TypeError with the generic “This block has encountered an error and cannot be previewed.” The fix shipped both halves in one commit: PHP-side emits the WP-core-shape array; JS-side buildPickerOptions reads args.key + label off each entry; inline-script bootstrap defends with Array.isArray(cfg.fields) ? cfg.fields : [].

Verification trajectory: PHPStan level 9 = 0 errors at every commit. PHPUnit 37 / 37 through v2.3.11, 38 / 38 at v2.3.12 (one new test pins the FILTER_NODE_RESOLVE_LINK constant to prevent accidental cross-plugin renames). Studio smoke confirmed end-to-end on a fresh post-new.php tab: paragraph bound to gc_price post-meta renders cleanly with “Cairnstone post meta” placeholder (WP-core’s native fallback when no value is set); WP-core’s Attributes panel reads “Price” as the field label from the new array shape; Cairnstone’s own “dataBOUND: CONTENT” inspector section header renders alongside it; no React error boundary triggered; four Cairnstone sources visible in wp.blocks.getBlockBindingsSources() registry. composer audit: 0 advisories. a11y AA: 0 violations (puppeteer + axe-core scan against the gc_cairnstone_block CPT editor).

Two WP 6.9 quirks documented inline: (1) PHP register_block_bindings_source() does NOT auto-mirror to the editor’s JS wp.blocks registry, the native sources reach JS via hardcoded calls in wp-includes/js/dist/editor.js lines 31152-31155; third-party sources must publish their own JS-side bootstrap. (2) The PHP-side $allowed_source_properties allowlist accepts only label, get_value_callback, uses_context, passing get_fields_list silently fails (it’s JS-side only). The patterns landed canonical in BlockBindings::bootstrap_js_sources with full docblocks.

· nine craft-passes + four post-audit landings + voice polish + Rich Results micro-pass · commits f14013cf6a5b63 (v2.2.120 → v2.2.138)

Phase 10 closed at 20 of 20 on the impeccable audit

The Schema panel reaches the top of the rubric’s Excellent band. Same closure pattern as Phase 9 the morning before: nine craft-passes shipped a working surface, then a /impeccable audit cycle scored the surface at 16 of 20 (Good band) with five concrete findings: three P1 a11y gaps (aria-live region + focus management + status-badge ARIA wiring on the RepeatingFieldEditor), one P2 perf miss (readGcSchema cascading memo invalidation), one P2 touch-target gap (the repeating-row × button below 44×44 px), and four hard-coded theming leaks in the inspector CSS.

What shipped: v2.2.135 (0def8ee) landed harden + optimize + adapt-F5 + polish in one coordinated commit, closing all five named findings plus a bonus a11y fix (replacing redundant label: null + hideLabelFromVision with proper accessible names). v2.2.136 (2c62a27) extended the adapt-pass to five additional touch-target gaps the audit hadn’t explicitly enumerated: step indicator tabs, raw JSON-LD <summary>, the inherit override + revert links, and the repeating-row + Add button. The pseudo-element hit-area overlay pattern (math visible-height + 2 × |inset| = 44) is now Cairnstone’s house style for inspector chrome; six elements use it across the Phase 10 surface. v2.2.137 (7fdb063) is the post-audit voice polish: the toggle-off help text drops the “JSON-LD” acronym (now reads “Tell search engines what this block is so they can show rich results in search.”), the footer shifts to present tense (“Emits Product JSON-LD” replaces “Will emit”; “Not emitting” replaces “Emission disabled”). v2.2.138 (f6a5b63) closes the last deferred item from the shape brief: the Test in Rich Results ↗ link in the actively-emitting footer state, opening Google’s Rich Results Test in a new tab with a three-tier URL fallback (preview link → permalink → bare validator).

Audit trajectory: 16 / 20 → 20 / 20 (a11y / perf / theming / responsive all advanced 3 → 4; anti-patterns held at 4). 436 PHPUnit tests / 1210 assertions green at ship. PHPStan level 9 at 0 errors throughout the nine passes + four post-audit landings. Two P3-er parked (F-8 read_block_type_default per-request caching, sub-millisecond; F-10 dynamic-content aria-live polish on warn footer + Auto-filled tag transitions); neither blocks wp.org submission. The shape brief was retired to plans/DONE-WORK.md with a closure entry listing every (10): commit; the brief file was deleted from plans/ per the planning-documents close-out routine missed when Pass 9 first shipped.

Six process lessons codified: (1) the audit-driven closure pattern scales (Phase 9 + Phase 10 both closed via the same cycle); (2) the pseudo-element hit-area pattern is Cairnstone house style for inspector chrome (six elements use it); (3) single-commit multi-command coordination beats four separate landings when changes are coupled on the same files; (4) extend /impeccable adapt beyond the literal audit finding when the broader interpretation is clear (the five additional touch-target gaps were the unenumerated implication of one named F-5); (5) the manifest-driven five-seam pattern from Pass 5-8 is formal infrastructure (schemaDefault + schemaAutoMap + attributeLabels reused by Phase 11’s defaultBindings); (6) floor lifts mid-craft are cheap when test envs roll forward in lockstep (three WP lifts 6.6 → 6.8 → 6.9 plus one PHP lift 8.2 → 8.3 all absorbed without breaking Phase 10 momentum). The Phase 10 craft surface is now the second canonical instance of the audit-driven closure workflow; the template generalises.

· nine ship-passes + one hotfix · commits 669238e2efece9 (v2.2.111 → v2.2.119)

Phase 9 closed at 20 of 20 on the impeccable audit

The interaction system reaches the top of the rubric’s Excellent band. A post-Pass-11b audit + critique cycle scored the surface at 14 of 20 and 24 of 40 respectively, surfacing two WCAG-blocking P0s (single-click destroy on the row Delete button, drag-only reorder), three P1s (menu keyboard semantics, the fourteen-radio action picker overwhelming first-touch, icon-glyph drift from the Composing Bench register), and a fan of P2 / P3 polish items. Nine post-audit ship-passes plus one hotfix closed every finding across roughly four hours of focused work.

What shipped: snackbar Undo for the row Delete button (matches Gutenberg’s trash-post + remove-block convention; restores at the original index); keyboard reorder via Move up / Move down items in the meatball menu with wp.a11y.speak position announcements (closes WCAG 2.5.7 Dragging Movements); progressive-disclosure action picker (fourteen radios collapse into a five-row accordion, unwired families dim behind a GC_DEV_MODE gate); full WAI-ARIA menu keyboard semantics (Arrow / Home / End wrap-around + focus return to the meatball trigger); icon-glyph consistency via inline SVG components matching the @wordpress/icons paths (drag handle widens 20 → 24px to clear WCAG 2.5.8); threshold TextControl input race fixed with a debounce-commit buffer + inline dropped-value feedback; scroll-observer JSON precision rounded to 4 decimals + initial-state fire skipped so chains stop firing on page load. Plus a polish sweep closing five P2 / P3 findings in one commit, and the Pass 12g hotfix that rolled back a wp-icons script-dep regression (the handle isn’t registered in WP-core; relying on it silently blanked the entire Interactions panel).

Audit trajectory: 14 / 20 → 17 / 20 (after polish sweep) → 20 / 20 (final, after Pass 12i). 307 PHPUnit tests / 798 assertions green at ship. PHPStan level 9 at 0 errors throughout. Three CDP smoke-tests on the live build verified the snackbar Undo restore path, the meatball Arrow / Home / End wrap-around, and (the day’s most portable lesson) the icon-glyph regression that PHPUnit and node --check couldn’t see: missing script-handle registration only surfaces when WordPress actually tries to print the page. Cross-phase carry-overs (Class action runtime, Media action runtime, set-property runtime, Time + Index trigger runtimes) re-categorized to Phase 11 and Phase 16 scope rather than tracked as Phase 9 closure-debt; the consuming phase owns the runtime. Inspector trilogy advances: Style + Visibility + Interactions all shipped, Schema panel is next with the shape brief already locked.

· directive-driven roadmap reorganisation · FOUNDATION + CLAUDE.md commit 804982b

Phases 10–17 renumbered to front-load sidebar infrastructure before any new blocks

Every infrastructure phase that touches the editor sidebar is now pulled forward, so the inspector contract locks before any block-building work resumes. The reasoning Erlend named is the same one that paused Phase 7b in May: walking 13+ existing blocks’ sidebars after every infrastructure addition is the wasted work that compounded fastest. Locking the whole infrastructure stack first eliminates the repeat verification.

The new order after Phase 9 closes: Phase 10 Schema panel (third top-level inspector panel, shipped) → Phase 11 Block Bindings (binding picker on core blocks, shipped) → Phase 12 import / export (CPT-level toolbar, not block-edit sidebar, shipped) → Phases 13–15 AI integration (Abilities API base, richer introspection, preview + batch) → Phase 16 library upgrade pass (first block-building phase, against a now-stable sidebar) → Phase 17 third-party block verification. Phase 18 license plumbing stays last by convention.

Old → new mapping: 10 → 12 (Import/export), 11 → 16 (Library upgrade), 12 → 17 (Third-party verification), 13 → 10 (Schema panel), 14 → 13 (AI Abilities API), 15 → 14 (AI introspection), 16 → 15 (AI preview + batch), 17 → 11 (Block Bindings). License plumbing (18) unchanged. References in recent-change entries below this one use the pre-2026-05-20 numbering: map back via the table above when in doubt.

Consequence applied 2026-05-20: the 5 already-shipped Phase 7b transplants (Badge, Section Divider, FAQ, Testimonial, Pricing Table) face the same post-infrastructure drift risk as the 10 unshipped ones, strict reading of the same principle that motivated the renumbering. They stay live through Phases 10–15 (no UX regression) but get a sidebar-alignment pass in Phase 16 alongside the full transplant of the remaining 10. All 15 transplants now land in Phase 16.

· six decision-level landings across the design-substrate vault · FOUNDATION row 229 amendment e0b7f87 · PRODUCT.md amendment a1a6713 · CLAUDE.md amendment bd2a17a

Shape briefs locked for Phase 9 and Phase 4.X, plus better-together strategy and customer-extensibility positioning

Same-day landing tally after Phase 8 closed: two shape briefs locked, two advance-shape sketches written, one architectural cross-cutting decision, one strategic-direction landing, and a refresh of the public positioning story. Phase 4.X craft → Phase 9 craft is the natural shipping sequence (Phase 4.X ships the visitor-state primitive first so Phase 9’s set-visitor-state action wires live instead of as a registered no-op).

  • Phase 9 shape brief locked. The interaction system ships as a per-block Interactions PanelBody in the default Settings tab (sibling to Visibility + Schema), with a plain-language “When → Then” structure inside per-interaction popovers. Trigger taxonomy expands from the original five interaction-driven names to four axes: Interaction (click / hover / focus / keyboard), Time (media-time), Index (slide-change / step-change / tab-change), Scroll (scroll-progress continuous plus the existing discrete crossings). Action vocabulary in four families: State (toggle Phase 8 state classes), Class (author-named CSS hooks), Media (HTMLMediaElement methods), Property (Phase 11 Block Bindings writes including set-visitor-state). Click-to-target with hover-highlight + dropdown fallback for cross-block targets. Setup → Configure → Test lifecycle per interaction. Free tier on wp.org. Mobbin-1.5-backed (~60 screens across 14 reference products including Webflow Interactions, Framer interactions popover, Figma Prototype, Airtable Automations, Fibery, Qatalog, Intercom Proactive Support, Zapier, beehiiv, Relevance AI, Chatbase, PlayAI, Rive, Jitter).
  • Phase 4.X CC visitor-state primitive shape brief locked. Extends Cairnstone’s existing visitor-context primitive (visit count + sticky cookies + local hour) into a cross-block key-value store keyed by namespace + sub-key. Storage hybrid: anonymous → sticky cookie gc_visitor_<namespace>; logged-in → WP user meta. Write hybrid: client-side cookie writes for anonymous, REST endpoint for logged-in (with wp_rest nonce + read capability check + 60-writes-per-minute rate limit + 4 KB payload cap + 100-keys-per-namespace cap). Anonymous → logged-in migration on wp_login with a 10-minute cookie-wins / older-state-from-user-meta-wins conflict window. Additive cookie naming (the existing gc_cc_<blockId> CC stickiness contract preserved 100%). Unblocks the three Phase 9 no-op actions: set-visitor-state directly, set-property via Phase 11 Block Bindings, and the Chapter Router’s per-visitor bookmark / playhead-resume features via Phase 11.
  • Better-together = Pro-tier strategy landed. Cairnstone’s Pro-tier story past acquisition: any commercial or runtime-dynamic feature defaults to a Cairnstone + Node composition. Cairnstone declares form and structure; Node owns runtime decisions (GeoIP catalogue, GSC data, click stats, A/B engine, managed-link health, rank tracking, manifest / CRA status). When shaping any new Pro feature the default first question is “is there a Node-integration angle?” Three integration mechanics live: public hook filters (the shipped pattern), Phase 11 Block Bindings sources (the forward-looking mechanism), and REST + cron-cached tables for higher-volume queries. The catalogued Pro features (Pricing Pro geo-bound pricing, affiliate auto-fallback, A/B Variant Splitter, Search Impression Sink CC recipe, Volume-gated content container, Rank Tracker Booster, click-quota Discount Banner, CRA compliance indicator) are proof-of-concept examples; the product is the composition surface. PRODUCT.md gained a new “Pro-tier positioning” section; CLAUDE.md gained a strategic-direction pointer.
  • Customer-extensibility post-launch positioning. The catalogued Pro features will keep growing, but the strategic goal post-launch is not to anticipate every use case, it is to make the use cases customers have not surfaced yet authorable by them. Two extension paths: the builder UI (Phase 7 Repeater + Phase 8 states + Phase 9 triggers + Phase 11 Block Bindings against Node sources) for non-technical authors composing within the primitives, and the Phases 13–15 Abilities API surface for technical authors and AI agents constructing blocks / bindings / interactions programmatically. Phases 13–15 are reframed as the customer-extensibility surface, not as “AI features” in the bandwagon sense.
  • FOUNDATION row 229 expanded to mirror the Phase 9 shape brief: four trigger axes named, four action families named, three no-op-until-dependent-phase contracts surfaced (media-time → Phase 11, set-property → Phase 11, set-visitor-state → Phase 4.X). Plan-doc now consistent with the shape brief before Phase 9 craft starts.

· commit 5283bda (Pass 6 closes Phase 8, v2.2.82); earlier Phase 8 chain 45ba07c76096eb

Phase 8 closed: state-driven controls wired end-to-end

Phase 8 (state-driven controls, Default / Hover / Focus / Active / Disabled) is structurally complete. Pass 6 closed the last gap: the universal Style panel’s StyleBody (DropShadow geometry + background-colour / text-colour / padding / margin / font-size gap-fillers) now reads and writes the slice the state switcher at the top of the Styles tab points at, instead of always writing to gcStyle.default. Before Pass 6 the switcher was half-functional for the seven non-transplanted blocks and for any universal-control surface in the four transplanted blocks, picking Hover and adjusting the universal shadow silently edited Default. A new hasAnyStyleChanges( attributes ) helper drives the nuclear Reset Style button so it stays visible whenever ANY state slice carries edits, never stranding a Hover-only customisation when Default is empty.

The server-side substrate (src/Shared/StyleAttribute.php) emits each non-default state as a dual selector pair: :hover, .gc-hover; :focus-visible, .gc-focus; :active, .gc-active; :disabled, [disabled], .gc-disabled, all merged over default. The five state names are the shared vocabulary Phase 9 set-state targets, fixed by the Model D shape. The Pass 5 StateBindingDisciplineTest machine-guards the per-block write-key contract (a transplanted block may rebind only the value-binding key, and that key MUST stay the gcStateEditing-selected variable, never a literal 'default'). Composer test 155/155, composer stan [OK] 0 at level 9, node --check clean. CPT-editor visual verify is the follow-on once the deploy run lands.

· directive-driven phase-plan consolidation

Phase 7b merged into Phase 11; Video embed Pro block also lands in Phase 11

Phase 7b (the sidebar transplant, paused 2026-05-15 with 5 of 15 shipped blocks transplanted) is no longer a standalone phase. The 10 remaining transplants (three primitives: Container, Columns, Repeater, plus seven library blocks, Affiliate Disclosure, Author Bio, Discount Banner, Hero, Pricing Pro, Table of Contents, Trust Badges) fold into Phase 11’s library-upgrade scope. The Video embed Pro block (idea graduated the same day from the Block-ideas backlog) also lands in Phase 11: a Conditional-Content-leveraging YouTube / Vimeo / Twitch / generic-iframe wrapper whose commercial-distribution use cases: geo-licensed video, premium-content gating, A/B-by-device, fit the Pro tier.

Phase 11 becomes a single library-completeness pass with three folded scopes: (a) the 10 remaining transplants onto the locked sidebar pattern from the 2026-05-15 Default block-builder sidebar decision, retiring the four transplant padlocks and the src/Modules/Scratch/ substrate when the last block ships; (b) the Video embed Pro block (full block shape brief deferred to /impeccable shape Block video-embed when Phase 11 starts); (c) the six state+interaction-driven blocks on top of Phase 8/9 primitives: Accordion, Tabs, Modal, Carousel, Counter, Reveal-on-scroll. The 2026-05-15 Phase 7b pause-decision is marked superseded; the 8/9/10 rotation that note also codified stays load-bearing. Phase 9 (the interaction system) is the next active beat.

· commits 5a710cb (plan), 2becd68 (CLAUDE.md), ffdacf5 (this site), fc73d6a (readme)

Phase 7b paused; the back half reordered (8 / 9 / 10)

Phase 7b (sidebar transplant) was paused by directive at 5 of 16 blocks, and the back half was resequenced: state-driven controls moved to Phase 8, the interaction system to Phase 9, and import / export to Phase 10. Phase 7 (the Repeater primitive) had already shipped on 2026-05-14 and keeps its number ahead of the renumbered tail. The roadmap sections, table of contents, status pill, and every cross-reference on this site were retargeted to match; the plan, CLAUDE.md, and the readme changelog were corrected in step.

· commits ae0b0e97e9662b (the Phase 7b transplant + Section Divider shadow chain, v2.2.42 → v2.2.65)

Phase 7b: the locked sidebar pattern propagates to the library

Every shipped block is migrating onto one universal Styles-tab shape (Typography, Dimensions, Border, Opacity, Color, an optional block-specific panel, Shadow, Advanced) with a role-based Color panel that carries named brand roles plus a Custom background / text / link mode. The pattern was locked against a throwaway scratch substrate over ~30 commits, then propagated block by block, each its own shippable version.

  • Five of sixteen blocks transplanted. Badge and Section Divider proved the pattern handles real bespoke complexity; order #3 (FAQ, Testimonial, Pricing Table) followed as the moderate-content trio. Each ships native Typography / Dimensions / Border, the Cairnstone Opacity and Color panels, and per-block placement of its own controls (Testimonial’s Layout in Settings between Quote and Attribution; Pricing Table’s variant in Settings as identity, its highlight accent as its own Styles panel).
  • Section Divider shadow rebuilt per-geometry. A long-running shadow defect was root-caused: CSS filter is a chain, so stacking identical drop-shadow() passes recursively smeared the source (sparse-glyph saturation, a wave-path browser freeze). Fixed by choosing the shadow primitive per source geometry: box-shadow for the hairline, a non-recursive text-shadow list for glyph and label text, a bounded drop-shadow() for the SVG wave. Captured as a reusable contract.
  • Four transplant invariants are now build-enforced. Regression tests fail composer test if a transplanted block’s manifest typography drifts from the anchor, a Styles panel loses its marker class, a colour-opted-out block is missing from the duplicate-control suppression list, or no styles panel is mounted unconditionally to anchor the slot order. Live review caught what contract checks missed; the guards now catch it first.

Phase 7b was paused on 2026-05-15 at 5 of 16 blocks (orders #1–#3 done). When it resumes it closes only when the last block ships and the scratch substrate is deleted; order #4 (Hero, Discount Banner, Author Bio, Pricing Pro) is the resume entry point. The universal-sidebar buttons / CTR-styleability pass stays sequenced after the phase closes.

· release bump 1d9668a, plus the surrounding craft and polish commit chain

v2.2 release line: Badge block, Pricing Pro craft, universal CTA polish, inserter split

Five clusters landed across the release line.

  • Badge, the thirteenth library block (ec9b479 + polish chain through v2.2.14). A small label block (free tier) with seven silhouettes (rectangular, rounded, pill, hexagon, ribbon, octagon, parallelogram), a curated 24-icon Cairnstone set (relocated from Pricing Pro into Shared\IconSet for cross-block reuse), label-text that accepts spaces, optional shadow, optional radius slider, and a clip-path emission that survives the universal Style panel’s drop-shadow. Opts out of the universal Style panel for the outer wrapper so the silhouette stays clipped; gcStyle inline-style emission moved after the sprite emission to fix a shadow + clip-path render-ordering bug. Twelve free + one Pro becomes thirteen free + one Pro across the library.
  • Pricing Pro craft + a11y (b1832610232791d6759b6). Per-tier icon picker with the 24-glyph Cairnstone curated set (the same set Badge now re-uses via Shared\IconSet); per-feature glyph override popover so a feature can carry an icon distinct from its tier’s default; toggle a11y upgrade to radiogroup + aria-checked + arrow-key navigation; sticky-header offset for the WP admin bar (the sticky-top header was sliding under the 32px / 46px admin-bar strip); editor preview parity (the editor canvas was rendering a stale variant after a billing-mode flip); seed-reset paths and brightening-reversal fixes.
  • Universal library polish (acb4ae4, 0be6a2b, ae6e95f, d6078a4). 44px tap target + distinct focus-visible on every CTA across the library (WCAG 2.2 AA Target Size Minimum + accessible focus indication, applied uniformly so no per-block follow-ups need to repeat the rule). Compact ColorDropdown for all per-instance colour pickers: dense swatch surface that fits more colours per row in the property panel without scrolling. Swatch polish: dropped the placeholder ‘/’ glyph on empty swatches; always-on Cairnstone Ink hairline border so the swatch reads as a control even when empty.
  • Affiliate Disclosure site-wide overrides (e179418 + bee9e1d + 3799e61). New Options key + typed getter for FTC-compliance template overrides at the site level (so a site that uses “Amazon Associates” as their default does not need to re-pick on every block insert). New Settings tab surfaces the overrides; render path threads them through both the editor and the frontend so the default cascades from Settings → block-level pick → free-text custom.
  • Inserter category split: Cairnstone Free + Cairnstone Pro (f61f8fe). The inserter now shows two Cairnstone categories: “Cairnstone Free” (twelve blocks, alphabetical) and “Cairnstone Pro” (one block, Pricing Pro). The “My blocks” category for CPT-saved blocks sits between them. Server-side category registration order coordinates with client-side block registration order so authors see a coherent free-then-pro grouping.

Plus a CairnstoneBlock module hardening session on 2026-05-14 (c9f3f75 + 64e5386 + 1a6c3f2 + 091fd83): CPT capability tightening to manage_options via the primitive-cap override map (singular meta-caps deliberately omitted to avoid user_has_cap recursion), _wp_old_slug write after the auto-slug repair so future alias-registration surfaces have the history WP-core would write on a normal wp_update_post path, and BreakingChangeForker::resolve() decoupled from the request layer (takes $new_slug as an explicit argument, callable from WP-CLI / REST / cron). No behaviour changes; the public-facing breaking-change resolution flow (Cancel / Migrate / Publish as a new block) is unchanged.

· commits 49c9c24 (PricingTable polish), ca0dcb4 (Affiliate Disclosure), 39cc947 (Pro-locked placeholder + LicenseStrings), 0cb023b (Discount Banner), b008b22 (Pricing Pro)

Phase 6 Batch D: first mixed-tier batch closes

Four blocks shipped, eleven free + one Pro across the full library. Together with the PricingTable polish chain that prefaced the batch, this commit chain takes Phase 6 to twelve shipped library blocks.

  • PricingTable polish (49c9c24). Closed the five P0/P1/P2 findings from the 2026-05-13 critique that scored the block 20/40: the checkmark accent leaked to non-highlighted tiers (One-Voice Rule violation, ~30% of card surface accented), per-tier panel had no progressive disclosure (~16 controls visible per tier, 45+ on a three-tier table), seed copy was the generic-SaaS "Plans that grow with you" template fingerprint, CTA silently disappeared when label was set but URL was empty, and per-CTA opacity + colours were over-flexibility. Each tier now renders inside its own PanelBody with initialOpen: index === 0; seed copy rewritten Cairnstone-voice ("The library, on every site." not "Plans that grow with you"); inline Notice surfaces in the editor when a CTA needs a link; per-CTA colour + opacity removed (the universal Cairnstone Style panel handles block-wide CTA styling, the highlighted tier already inherits the accent). Mobile comparison-grid additionally gained the accessible-responsive-table pattern: data-tier-name + data-not-included-label attributes surface tier context via CSS attr() on stacked cells.
  • Affiliate Disclosure (ca0dcb4). FTC-compliance copy block, always-visible when placed. Three attributes: template (Amazon Associates / general / sponsored / commission / custom), disclosure (the rendered body), showIcon (info-circle SVG, default on). Template-picker UX: selecting a named template overwrites the Textarea with the template body (with a confirm() if the author had unsaved edits, FTC copy is the kind of thing nobody wants to accidentally lose); editing the Textarea away from the chosen template's default auto-flips the picker to "Custom" so the relationship stays visible. wp_kses allows ONE inline <a href> for "see how this works" links; rejects everything else. US-FTC templates ship in v1; EU localisation is a .po extension when international expansion lands.
  • Pro-locked placeholder + Shared\LicenseStrings (39cc947). A Cairnstone Pro discovery block. Renders an upgrade card in the editor canvas for free-tier authors (lock icon + headline + body + "Unlock Pro" external CTA + "What's in Pro" internal admin CTA); frontend render returns empty string (no public-facing payload). New Shared\LicenseStrings class is the single source of truth for Pro-discovery copy across all surfaces: methods tier_label() / tier_body() / primary_cta_label() / secondary_cta_label() / purchase_url() / admin_overview_url(). Purchase URL filterable via new Hooks::FILTER_PRO_PURCHASE_URL for reseller channels + A/B-tested landing pages. Today registers unconditionally because Licensing::has_tier('pro') returns true under the dev stub; Phase 18 will add the inverse gate (skip registration when Pro is active) so the discovery block disappears for Pro-licensed sites.
  • Discount Banner (0cb023b). Time-windowed promotional block with three layout variants picked at insert: bar (full-width strip), card (in-flow contained block), floating-corner (position: fixed with a four-corner enum). Coupon-pill copy-to-clipboard via navigator.clipboard.writeText (textarea + execCommand fallback for older browsers, selectable-text fallback when JS missing); 2-second "Copied!" feedback. Countdown is static text re-rendered each page load: thresholds at 2 days ("Ends in N days"), 4 hours ("Ends today"), 1 hour ("Last hours"), under an hour ("Ending soon"). PHP render-time auto-hide: if NOW > endDate, render returns empty string (cache-friendly; the whole block disappears once the page cache expires). Frontend JS enqueued only when the page contains the block (has_block() gate on wp_enqueue_scripts). On mobile, all four floating-corner positions collapse to the same full-width sticky-bottom toast.
  • Pricing Pro (b008b22). The first Pro-tier library block. Inherits Pricing Table's CSS scaffold (.gc-library-pricing__*) and layers three Pro additions: (1) Block-level Monthly/Annual toggle with auto-computed savings badge (computed as 100 * (1 - annual / (12 * monthly)) averaged across all dual-priced tiers, rounded; suppressed when ≤0%); both price blocks render in the DOM, the wrapper's billing-mode class hides one via CSS, toggle JS is a class-swap, not a DOM rewrite. (2) Block-level recommendedTier: number | null as a SelectControl picker, replaces the free block's per-tier highlighted boolean (a pricing table has at most one recommended tier; the boolean shape allowed the always-wrong "two recommended" state). (3) Sticky comparison header (position: sticky; top: 0) pins the tier header row to the viewport top while the visitor scrolls a long feature list; dropped on mobile (the layout stacks anyway). Reorder + remove operations on tiers track the recommendedTier index correctly. Frontend toggle persists the visitor's preference to localStorage. Per-tier icon picker + custom feature-glyph override + multi-currency model deferred to a follow-up commit (open questions in the brief).

Cross-batch craft notes: all four blocks ship at the same fidelity bar as Hero + Pricing Table (PHPStan 0, composer test green, Chrome MCP smoke on TEST, accessible markup, theme-neutral rendering, edge cases handled). Color strategy locked Restrained across all four, the named failure mode "WP-cheery surface chrome" (gradients, rounded-everything reflex, emoji on pricing badges, animated checkmarks) explicitly anti-goaled in the brief. The Pro-locked placeholder is the architecturally-novel piece: it sets the pattern future Pro blocks will follow (single placeholder per block, free-tier-visible inserter discovery card, frontend renders empty, filtered out when Pro is active).

· commits 2ab9d52 (Hero), c257caf (CC category move), e45167e (Hero CTA invisibility bug), 0c89fa6 (per-CTA colour + opacity), b99237c (Pricing Table), 7e068b7 (Language dropdown + Hero CTA collapsibles)

Phase 6 Batch C: Hero and Pricing Table with the variation-picker pattern

Two attribute-driven library blocks shipped, both exercising Gutenberg's BlockVariationPicker on insert. Each variation pre-seeds copy + structural defaults; post-insert the variant is editable from the sidebar without losing content. The pattern lifted from Columns in Batch A, now standard for any library block with meaningfully different layouts.

  • Hero. Six variants: split-right, split-left, centered, left-aligned, overlay-bg, minimal. Heading + subheading + heading level (H1–H6). Two CTAs (primary + secondary) with per-CTA background / text / colour + 0–100% opacity. Image + focal point picker (split + overlay-bg only). Overlay darken slider (overlay-bg only). Silent degradation: overlay-bg without image falls back to centered; minimal suppresses subheading + secondary CTA. Sidebar split into four collapsible PanelBody sections (Hero, Primary action, Secondary action, Image) so the rail stays compact; CTA panels closed by default since variations seed sensible defaults.
  • Pricing Table. Five variants: two-tier, three-tier (highlighted middle, the SaaS default), four-tier, comparison grid, single-offer. Tier repeater carries name + description + currency / amount / period (amount stored as string so "Free" / "Custom" / "Contact us" round-trips alongside numeric prices) + features list + highlight toggle + badge label + CTA with per-tier colour + opacity. The comparison variant auto-aligns features across tiers by string match in first-appearance order, no separate "comparison features" data shape; same per-tier list drives every variant. Card surface uses color-mix(in oklab, currentColor 4%) so it adapts to dark / light themes without inline colours; the accent colour for highlighted tiers + per-CTA overrides are the only inline-style emissions.

Two debugging waypoints worth pinning from the Hero ship chain:

  • Primary CTA was invisible (e45167e). The first CSS had background: currentColor paired with color: var(--wp--preset--color--background) on the same selector. currentColor resolves to THIS element's color property, which was just set to the background preset, so bg and text both computed to the same value. Fix: two independent token reads, background: var(--wp--preset--color--foreground, oklch(22% 0.02 250)) + color: var(--wp--preset--color--background, oklch(98% 0.005 250)). Pattern worth remembering: currentColor + a color-property override on the same rule is a circular dependency.
  • Visibility panel Language signal was a text input asking for BCP-47 tags (7e068b7). Violation of CLAUDE.md's "Build for humans, not developers" rule: nobody remembers ISO codes. Converted to a curated ComboboxControl + chip-list multi-picker mirroring the Country pattern: 22 languages with human labels ("Norwegian Bokmål", "Chinese (any)") storing the BCP-47 tag under the hood. Parentheses for regional disambiguation, em-dashes banned per the language convention.

Wiring: two new Options keys, two ModuleRegistry entries, two boot() calls in Plugin::register_hooks(), two rows in BlockRuntimeAssets::manifest_driven_modules, two new block-name entries on the wp_enqueue_block_style list, ~480 lines of theme-neutral CSS in gc-blocks-frontend.css. 61 tests green, PHPStan 0 errors. Chrome MCP smoke on the CPT editor confirmed both blocks register, both variation pickers surface their schematic icons, and the Pricing Table comparison grid auto-aligns 8 distinct features across 3 tiers with 12 checkmarks (3+4+5 raw → 8 deduped union; --gc-pricing-cols: 3 set on the wrapper).

· commits 6697def, a500987, fced4ae, 0e5a06e, d033fef, 23e232a, 2cd25e7, 81cb06b, e237161

Section Divider polish + cross-cutting block-attribute hygiene

The Section Divider block from Batch B got a focused polish pass after the user surfaced a chain of regressions. Each commit closed a specific bug and several surfaced architectural foot-guns worth fixing preemptively elsewhere:

  • Defaults + per-variant gating + Styles-tab placement (6697def). Default thickness 1 → 3 (the line was nearly invisible out of the box). Width slider gated to line/wave/label (made no sense on dots/asterisks). Thickness control surfaced for all variants with variant-aware help text. Both panel bodies moved from Settings to Styles tab. Force-default-tools-panel-items filter extended to supports.color so Background+Text both render without a + click.
  • Saved-block slug repair for title-less publishes (a500987). Title-less gc_cairnstone_block publishes got an auto-slug equal to the post ID (e.g. "115"), a valid WP slug but NOT a valid Gutenberg block-name leaf (must start with a letter). Block never reached the inserter. Three-part fix: SavePostHandler::is_valid_block_slug() regex check; save_post repair that writes cairnstone-block-<ID> via $wpdb->update when the slug fails the regex; SavedBlockRegistry safety-net fallback for legacy rows.
  • Supports parity + border-opacity row layout (fced4ae). Section Divider's supports declared a thin slice (margin + text-color); other library blocks declared the broader matrix. Expanded to padding + background + border-color/radius/style/width. Plus: rebuilt Border Opacity row as a flex layout [input on left] [full-width slider on right] matching the native Border + Radius rows it shares the panel with.
  • Attribute rename stylevariant (0e5a06e). The first foot-gun: Gutenberg's __experimentalBorder support writes to attributes.style as an object. Section Divider's manifest declared its variant picker on the same key as a string. Every border-slider drag clobbered style: "dots" with style: { border: {...} }, editor read “not a known variant”, snapped to default Line. Renamed to variant. Backward compat: PHP render reads raw $block->parsed_block['attrs'] first (WP's schema filter strips unknown keys from $attributes but parsed_block carries them); editor adds mount-time migration. Plus: font-size GapFiller now gated by variant (only visible when variant === 'label') since dots/asterisks/wave/line have no text to size.
  • Shadow color persistence + wave-shaped drop-shadow (d033fef). DropShadowRow's commit heuristic was dropping the whole dropShadow attribute when all geometry was zero, killing the author's chosen colour the moment they dragged blur back to 0 (and preventing colour-first authoring). Fix: drop only when geometry AND colour both match defaults. Plus: rebuilt wave-variant drop-shadow as filter: drop-shadow() on the SVG path with the author's actual values (via a --gc-divider-shadow CSS custom property + --has-wave-shadow wrapper class), suppressing the rectangular box-shadow that would otherwise paint a bar behind the wave. Previous selector-driven fix ([style*="box-shadow"]) matched on the frontend but not in the editor; class-driven works in both contexts.
  • Preemptive Testimonial rename layoutlayoutVariant (23e232a). Same foot-gun, applied before it bites: Gutenberg's supports.layout auto-declares attributes.layout as an object. Testimonial declared its "stacked" / "side-by-side" variant on the same key. Testimonial doesn't declare supports.layout today, but a future enablement would silently clobber the variant. Renamed proactively. Same three-step shape (manifest + PHP parsed_block fallback + editor mount-time migration).
  • Saved Cairnstone block deletability (2cd25e7). Inserting a saved block, then trying to delete it via Backspace did nothing; after save+reload the block was essentially unreachable without the code editor. Two root causes: the editor return wrapped in a plain <div> without useBlockProps (so apiVersion 3 couldn't attach selection chrome), and useBlockProps drops merged onKeyDown props (same Phase 5 finding the CC block hit). Fix: useBlockProps on the wrapper + native keydown listener on [data-block] that catches Backspace/Delete when the parent is the selected block.
  • Documentation: reserved-name rule (81cb06b). Added to CLAUDE.md's “Things NOT to do”: don't name a custom block attribute after a Gutenberg-reserved key (style, layout, className, align, anchor, backgroundColor, textColor, linkColor, gradient, fontSize, fontFamily, lock, metadata). Pinned references to the divider + testimonial rename commits so future maintainers can lift the working pattern verbatim.
  • Documentation: browser-MCP speed routine (e237161). Added to CLAUDE.md's “Live browser + server testing” section: six defaults that cut typical browser-driving from ~7 round-trips per verification to ~2 (state changes via wp.data.dispatch, state inspection via javascript_tool, aggressive browser_batch, drop sub-2s waits, prefer javascript_tool over find for known surfaces, never standalone screenshots). Pinned to the wave-shadow verification from d033fef as worked example.
  • Deploy filter fix (separate, untracked in git: deploy.txt). The auto-deploy WinSCP script used -criteria=size, which silently skipped byte-equivalent edits (a 13 default change at the same string position). Manifest renames AND version bumps both hit this. Changed to -criteria=either so modification-time triggers sync when size is identical. Verified with a 2.1.0 → 2.1.1 round-trip bump on TEST: file synced both directions, identical size, different mtime.

Two patterns elevated to project lore: any custom block attribute named after a Gutenberg-reserved key is a latent bug (apply the divider+testimonial three-step fix preemptively), and Gutenberg's render_callback receives schema-filtered $attributes but the raw payload survives on $block->parsed_block['attrs'] (essential for legacy-attribute fallbacks). Both now in CLAUDE.md.

· commit df10fe1

Phase 6 Batch B: Trust Badges, Testimonial, Section Divider, FAQ, Table of Contents

Five attribute-driven library blocks shipped, all following the v2.1 Author Bio pattern: no InnerBlocks, sidebar-only authoring, theme-neutral rendering, manifest-driven registration.

  • Trust Badges. Repeater of badge images (Media Library + alt + URL), heading toggle, four-mode alignment (left/center/right/spread-evenly), badge-height slider (24–96 px), grayscale-by-default with hover-restore.
  • Testimonial. Quote + attribution (name + role + photo) + optional 1–5 star rating (Unicode ★ / ☆, no icon font). Two layouts: stacked (default, photo next to attribution) and side-by-side (feature photo on the left, collapses to stacked under 600 px viewport).
  • Section Divider. Five styles: line, dots, asterisks, wave (inline SVG), labelled (centered text with rules either side). Width + thickness sliders. currentColor everywhere so the host theme paints the mark. (Subsequently polished in the 2026-05-13 chain above.)
  • FAQ. Repeater of question/answer pairs rendered as native HTML <details>/<summary> accordions, zero JS. Any-open + single-open modes (modern HTML name= attribute, gracefully degrades to any-open on older browsers). Emits FAQPage JSON-LD schema (toggleable) so search engines and AI assistants can read the questions (AEO; Google deprecated FAQ rich results in 2023).
  • Table of Contents. DOMDocument-parsed nested heading list from the live post body, auto-injecting anchor IDs into headings that lack them via a the_content filter. Configurable depth (H2–H6), numbered toggle, mobile-collapsible toggle. Returns empty (no markup) when the post has no qualifying headings.

Wiring: six new Options keys, five ModuleRegistry entries, five boot() calls in Plugin::register_hooks(), five rows in BlockRuntimeAssets::manifest_driven_modules, five new block-name entries on the wp_enqueue_block_style list, ~330 lines of theme-neutral structural CSS in gc-blocks-frontend.css. 61 tests green, PHPStan 0 errors, server-side introspection confirms all five register and render via do_blocks().

Phase 6 Batch A: library scaffold + Author Bio Card v2.1 + Social Profiles

The structural scaffold for library blocks: src/Library/<Slug>/ directory pattern with each block as an AbstractModule subclass + manifest-driven runtime. Second inserter category gillish-cairnstone-custom (“Cairnstone, My blocks”) split from the shipped library bucket so authors see their CPT-saved blocks separately from the shipped ones.

Author Bio Card v2.1 is the first library block, attribute-driven (no InnerBlocks template). Sidebar-only controls: photo picker, photo-size slider 48–320 px, name, title, bio, social link toggles + icon-size radio (small/medium/large). Theme-neutral rendering: no opinionated colours, no inline fonts, the theme paints via theme.json tokens and Gutenberg's has-* classes. The v1 InnerBlocks experiment (5f4473e) was rebuilt as v2 attribute-driven after the user flagged that mixing inner-block authoring (image toolbar in the canvas) with sidebar authoring (everything else) confused authors.

Social Profiles settings. New Settings → Social profiles tab. Single source of truth for ten platforms (Twitter/X, LinkedIn, GitHub, YouTube, Instagram, Facebook, Mastodon, Bluesky, TikTok, Personal website). Library blocks that surface social links (Author Bio today, more in later batches) read from these as the default; per-block override available in the block sidebar. Closed-enum SocialProfiles::PLATFORMS with hand-curated inline SVG icons (16×16, currentColor).

Permanent Preview button shipped alongside: PluginPreviewMenuItem entry “Preview Cairnstone block ↑ ” in the editor header's Preview dropdown. Always visible regardless of which sidebar tab is open. Disabled with “(publish first)” label when the post is an auto-draft.

· commits 678ed1ee271ec2 (seven Phase 5 commits in the chain)

Phase 5 shipped: save = registered block type, end-to-end

The first phase that closes the chain from designer composes a block in the CPT editor to that block appears in the inserter on every post and page. Seven commits across the foundation chain, editor-side bridge, Preview-live route, document-panel additions, templateLock for author-side instances, two editor JS gotchas surfaced as CLAUDE.md docs, the breaking-change detection + sticky inline notice, and the three resolution paths (Cancel + Migrate + Publish-as-new-block) all wired and verified.

  • Foundation chain (commit 678ed1e). save_post_gc_cairnstone_block listener parses post_content via parse_blocks into _gc_block_tree meta with schemaVersion = 1. On init priority 12, SavedBlockRegistry::register_all() iterates published CPT posts and calls register_block_type( "gillish-cairnstone/<slug>", … ) for each. Render assembles inner blocks via do_blocks; per-instance content overrides the CPT template when present.
  • Editor-side bridge (commit 6afe37b). SavedBlockRegistry::enqueue_editor_bootstrap() localises window.gcSavedBlocks with each published block’s serialized markup. gc-saved-blocks-bootstrap.js reads the payload + calls wp.blocks.registerBlockType client-side. First insert populates innerBlocks from the saved markup via replaceInnerBlocks; existing instances keep their own content.
  • Preview-live route (commit 7fe5b19). Dedicated query var ?gc_cairnstone_block_preview={ID}&_wpnonce={NONCE} with five layers of leak prevention: CPT’s public:false, dedicated query var (not the CPT slug), edit_post cap + valid nonce required (failure → 404, not 401), X-Robots-Tag header + <meta> robots, and explicit sitemap-exclusion filters for WP-core, Yoast, Rank Math, AIOSEO.
  • Document-panel additions (commit aec6f73). Three Phase 5 surface elements in Gutenberg’s document sidebar via PluginPostStatusInfo: Preview-live button, read-only block-name strip showing the slug authors will see in the inserter, and a first-publish success snackbar. The snackbar uses wp.data.subscribe + a sessionStorage bridge to survive Gutenberg’s post-new → post.php navigation.
  • templateLock for author-side instances (commit 378a9a9, contextual fix in daada6b). Inserting a saved Cairnstone block on a regular post locks the inner structure as contentOnly, authors edit text + media but can’t reorder or delete blocks. Inserting the same block inside another gc_cairnstone_block CPT post unlocks fully, the designer composes freely, including nesting saved blocks inside each other.
  • Breaking-change detection + notice (commit 433a2a2, polished in 84bf44c + 023b3e1 + b2f73ad). Tree-shape diff between snapshot and new tree; structural changes surface a sticky inline admin notice with three resolution radios. Cancel default-checked (safe path is the default). Reverse-diff suppressed via a re-entrant $skip_detection_depth counter so the Cancel-triggered wp_update_post doesn’t re-surface a fresh notice.
  • Migrate + Publish-as-new-block (commit e271ec2). Migrate walks every affected post and rewrites instances’ innerBlocks to match the new template (deep-copied via JSON round-trip, innerContent reset to [null × N]). Lossy-diff helper inspects top_removed + attrs_removed; a JS confirm() dialog with PHP-templated count surfaces before submit when the diff loses fields. Publish-as-new-block creates a fresh CPT under a typed slug + reuses resolve_cancel() to restore the original post; both block-types end up registered, designer continues editing the new one.

Deliberately deferred to Phase 5.x: snapshot rollback at render time (the snapshot meta exists; the render-time fallback doesn’t), pre-publish dry-run validation (the resolution UX already covers the failure mode after the fact), and CPT list-view enhancements (WP’s default works; bespoke columns are polish). None of these block Phase 6, all are non-trivial enough that batching them into a single Phase 5.x sweep is cleaner than threading them through other work.

· commit 051efb9

Columns overhaul: variation picker, 1–5 columns, drag-to-resize

The free-tier gillish-cairnstone/columns primitive got a fundamental rewrite. The previous version was “two narrow columns hugging the left edge with no asymmetric ratios”, absolutely mediocre. The new version matches the WP-native columns block’s UX while exceeding it on column count (5 vs. 4) and intentional defaults (full alignment, drag handles).

  • Variation picker: Gutenberg’s BlockVariationPicker fires on insert with eight presets: 100 · 50/50 · 33/66 · 66/33 · 33/33/33 · 25/50/25 · 25/25/25/25 · 20/20/20/20/20. Each variation pre-seeds inner column blocks with their width attribute set as a percentage; align: 'full' on the parent so the row spans the page by default. Inline-SVG icons render the column proportions visually, matching core/columns’s picker.
  • Per-column widths: new width attribute on gillish-cairnstone/column (string CSS length). Server-side render_callback emits the value onto a --gc-col-width custom property + flex-basis inline style; the stylesheet does flex layout off those values. Validation regex rejects anything that isn’t a CSS length.
  • Drag-to-resize: custom pointer-event handle on the right edge of every non-final column (ResizableBox was a false start: it can’t be a proper flex child). 16px hit area with a 4px visible chevron, fading to blue on hover/select. Drag math: pixel-delta against the .gc-columns flex track’s offsetWidth, converted to percentage, added to this column’s width + subtracted from the next sibling’s. Clamped 5%–95% so neither column collapses.
  • “Add new” admin shortcut (separate commit c3d83e2): direct submenu entry under Cairnstone → Add new pointing at post-new.php?post_type=gc_cairnstone_block. Designers no longer have to navigate via “Blocks → All Cairnstone blocks → Add new”.

Browser-verified end-to-end on TEST: picker rendered with all 8 variations, 50/50 produced two 527px columns spanning the row, simulated +100px drag on the handle updated widths from 50/50 → 59.25/40.75 (math: 100px ÷ 1080px = 9.26%, matches the actual delta). Rules of Hooks violation crash that surfaced mid-test was traced to two useBlockProps calls in the same render + conditional useInnerBlocksProps, fixed by hoisting all hooks above branching.

· commits 4d9008e, d3e5f31, af56141, 5b73ad9, d7bc330

Conditional Content audit and REST namespace migration

Cairnstone's launch block (Conditional Content) got a full audit pass. Five findings closed across security, plumbing, and doc accuracy:

  • REST endpoint for client-mode rendering moved from gillish/node/v1/cc/country (the pre-fork slug, kept by mistake when Cairnstone became standalone in v0.2.0) to gillish/cairnstone/v1/cc/country. wp.org plugin-prefix discipline restored. Old URL stays live for one release as a backward-compat alias and carries X-Deprecated-Endpoint + X-Deprecated-Replacement response headers so monitoring can spot stale clients. Verified end-to-end on the staging env.
  • EventStore insert exceptions now surface to error_log when WP_DEBUG is on. Production stays silent (analytics is opportunistic, not load-bearing). Helps diagnose schema drift during local development without changing public behaviour.
  • Pre-pivot phase labels swept from every Conditional Content and VisitorContext doc-block: file headlines + inline comments + cross-references. Phase numbers in code now exclusively refer to the current FOUNDATION plan, not the pre-2026-05-11 numbering that had Phase 5 meaning "client-side render mode" instead of "save = register block type".
  • Stale Node references cleaned. The Cairnstone → Node runtime dependency surface is now exactly one filter: gillish_node_is_bot for crawler classification when Node is active. Country resolution lives entirely inside Cairnstone.
  • PreviewFixtureStore::save_from_post() doc-block now states that the caller must verify nonce + capability. Library-shaped sanitisation helpers should be defensive about the contract they assume.

· commit 194a31f

Phase 5 shape brief confirmed

The implementation brief for Phase 5 (save = register block type) is locked in plans/CAIRNSTONE-PHASE-5-SHAPE.md alongside the prior phase briefs (Phase 2 / 3 / 4 / 4.1). Three surfaces, three lifecycle safeguards, one new PluginPostStatusInfo slot:

  • Surfaces: (a) CPT editor save flow with a Cairnstone publish-success toast + read-only block-name strip + Preview-live button; (b) frontend-author insertion with templateLock="contentOnly"; (c) wp-admin CPT list view (5 columns, DataViews on WP 7.0+, WP_List_Table on 6.9).
  • Lifecycle safeguards: breaking-change detection on save with a three-radio inline admin notice (migrate / new-slug / cancel) on next load, snapshot rollback when an instance's latest template fails, dry-run validation of every existing instance pre-publish.
  • Design law: save is never blocked by Cairnstone. The breaking-change UX is a sticky inline notice, not a modal interrupt. Modal only for the lossy-migrate sub-step, because that action is irreversible and the user deserves an explicit confirm.

Ready for /impeccable craft.

In one sentence#

Cairnstone is a block plugin where you compose new blocks in the standard Gutenberg editor (on our own custom post type), with universal Conditional Content and style controls injected into the Gutenberg Block Inspector, the existing right-side panel that already holds Colour, Typography, and Dimensions. Not a separate Plugin Sidebar, not a separate composer surface. Saving registers the composition as a real block type that appears in the Gutenberg inserter everywhere.

Phasing#

Phase numbers are stable across the document. Phase 1 (demolition + CPT bootstrap) is the only one that tidies; the rest build. Each phase is independent enough that stopping between them leaves the product in a coherent state. License plumbing is always the last phase by convention, when a new phase is added, it goes before license plumbing, never after.

01

Demolition + CPT bootstrap + PHP / WP baseline bump

Shipped 2026-05-11 · commit 6fbc0a8

(a) Delete Sandbox/SandboxApp.php, Sandbox/BridgeMessages.php, Sandbox/SandboxRoutes.php, Sandbox/NodeProvider/, assets/js/gc-sandbox-app.js, assets/js/gc-sandbox-frame.js, composer-specific CSS. (b) Lift the control components (BoxModelControl, ColorControl, OpacityControl, NumericControl, CollapsibleSection) into assets/js/gc-cairnstone-sidebar.js and its CSS, so the code exists as a component library even while it isn’t yet mounted. (c) Delete the Composer admin-menu entry. (d) Register the CPT gc_cairnstone_block as a backend-only workspace:

(e) Add an admin-menu entry “Blocks” that points to the CPT list. The list view uses WP 7.0’s wordpress/dataviews package when available; falls back to native WP_List_Table on WP 6.6. (f) Bump baseline: gillish-cairnstone.php header Requires PHP: 8.18.2; phpstan.neon.dist phpVersion: 80200. (g) composer.json require.php bumped to ^8.2.

End state. Plugin loads without composer remnants. User sees a “Blocks” menu, can create a gc_cairnstone_block post, types a title + composes with default blocks. Save works. Visiting the post’s would-be URL on the frontend returns 404. No Cairnstone inspector panels yet (Phase 2). No block-type registration yet (Phase 5). Tests green on PHP 8.2+.

02

Cairnstone inspector shell: summary control hub + manifest-driven accordion sections

Shipped 2026-05-11 · commit e2f02ba

Inject Cairnstone’s UI into Gutenberg’s existing Block Inspector via the editor.BlockEdit higher-order-component filter, using InspectorControls slots from @wordpress/block-editor. The shell consists of:

  1. Summary row at the top that is a control hub, not a label. Each line carries: a section name with a task-oriented subtitle (“Style, What this block looks like”, “Visibility, Who sees this block, and when”, “Schema, How search engines understand this block”); a status string in plain language when the section has changes (“2 changes”, “Only on mobile”); an eye-icon quick toggle on the Visibility line (Schema does NOT get a quick toggle, multi-step decision); a chevron that jumps to and opens the relevant section. Subtitles replaced by badge count once changes exist.
  2. Up to three accordion sections (Style, Visibility, Schema), one open at a time by default. Section visibility is manifest-driven and wholesale: when a block’s manifest declares a section is irrelevant, that entire accordion AND its summary-row line are hidden (not stubbed with “Schema: None”).
  3. Role-based default-open section: identical layout everywhere, but the default-open section switches by editor surface. CPT editor (designer-context) opens Style; regular post / page (author-context) opens Visibility.
  4. A footer-tools row with “Reset Cairnstone for this block” + “Copy Cairnstone settings”.

Sections are empty shells at this stage, the inspector architecture, summary-as-control-hub behaviour, manifest-driven section visibility, and role-based default-open must work, but the controls inside each section land in phases 3 / 4 / 13.

Verification. Open any post → select a Heading → right sidebar shows Gutenberg’s native panels followed by Cairnstone’s summary row + two accordion sections (Style + Visibility) + footer-tools; no Schema accordion because Heading’s manifest opts out; Visibility is default-open. Click the eye-icon on the Visibility summary line → toggles “Show everyone / Show conditionally” without opening the section. Click the on the Style line → Style opens, Visibility closes. Switch to the CPT editor → Style is default-open.

03

Style panel: universal style controls (gap-filling, not duplicating)

Shipped 2026-05-11 · commit 2dfb6d2

The Style panel adds Cairnstone’s controls only where Gutenberg native is missing or inconsistent, never as a duplicate. The check: wp.blocks.hasBlockSupport(name, feature, false) is read per block; for each Cairnstone control the panel either renders the control or shows a small note pointing to Gutenberg’s equivalent (“For background colour, see the Colour panel above”).

Universal-by-Cairnstone controls (Opacity, drop-shadow geometry, Gutenberg doesn’t ship these) are always rendered. Gutenberg-also-handles controls (Background colour, Text colour, Padding, Margin, Font size) are rendered only when the selected block lacks native support. Controls write to a gcStyle attribute injected via blocks.registerBlockType on the JS side and register_block_type_args on the PHP side. The data shape is nested with a state key at the top ({ default: { opacity, dropShadow: { x, y, blur, spread, color }, ...gap-fillers } }) so Phase 8 slots hover / active / focus / disabled as sibling keys without a migration. The render_block filter (priority 20) translates gcStyle.default to an inline style="..." declaration merged into the rendered block’s root element (per-instance, no separate stylesheet emitted). Editor-side preview wired via an editor.BlockListBlock HOC so slider drag fades the block in real time.

UX. Live summary-row status string (“Opacity 80%” on one change, “2 changes” on two or more, “No customisations” on default). An [Advanced ▾] panel held the original Phase 8 placeholder; the 2026-05-15 Model D decision moves Phase 8 to a top-of-Style state switcher with no new panel, retiring this slot when Phase 8 craft lands. A right-aligned Reset Style ghost button appears only when gcStyle has non-default values.

Verification. Insert core/heading in the CPT editor → Cairnstone Style auto-opens (designer-context default from the Phase 2 role rule) → Opacity slider drag fades the heading in real time and the summary reads “Opacity 50%”. Heading’s hasBlockSupport('color.background') returns false so Cairnstone renders the Background colour control; color, spacing.padding, spacing.margin, typography.fontSize all return true so Cairnstone yields with plain-language notes. Frontend-render parity: do_blocks() against wp:heading with a full gcStyle payload returned <h2 class="wp-block-heading" style="opacity:0.5;box-shadow:0px 4px 12px 0px oklch(...);background-color:oklch(...);padding:16px 24px 16px 24px">...</h2>. The launch CC block opts out (variant-aware render path is incompatible with universal gcStyle).

04

Visibility panel: recipe-first Conditional Content with intent grouping

Shipped 2026-05-11 · commit ed8964d

The Visibility panel opens with a plain-language toggle: “Show this block to everyone” (default) / “Only show under certain conditions”. When the second option is selected, a recipe list grouped by intent appears first, not the rule builder, not a flat 18-item list. Five intent groups as flat subsections inside the Visibility accordion (NOT nested accordions):

Each recipe maps to the same conditions attribute shape the rule builder produces; the engine underneath is unchanged. Below the grouped recipes, a [Build custom rule ▾] disclosure expands the full rule builder for power users (Phase 4.1, see below). A separate [Advanced ▾] disclosure holds Render mode + Sticky cookie.

Free vs Pro: all 18 recipes are Free; Pro unlocks the custom rule builder + all 14 signals.

Render-mode default = client since most modern WordPress hosts cache pages aggressively and server-mode + page cache = silent cross-visitor pollution. The default flipped from server to client in this phase. Cache-plugin detection (CachePluginDetector: 10 named plugins + Cloudflare proxy probe) drives an amber inline warning beneath the render-mode picker when server-mode is selected on a cache-enabled site.

Universal CC injection. The conditions / match / renderMode / stickyCookie attributes are now injected on every block at registration time via register_block_type_args, not just on manifest-opted-in blocks. The launch CC block (gillish-cairnstone/conditional-content) opts out by name because its variant-aware render path uses a different attribute shape.

Verification. Mode toggle flipped via ToggleGroupControl → 5 intent groups expand with all 18 recipe rows. Click “Only on mobile” → recipe row collapses to a focus-mode chip with [ Change] button, summary row reads “Only on mobile”. Pick “Only in [Country]” → ComboboxControl typeahead with 249 ISO 3166-1 alpha-2 codes, labels via Intl.DisplayNames in the user’s editor locale. Frontend gate via do_blocks(): NO visitor sees the heading, US visitor gets empty string; iPhone UA sees the mobile heading, Windows UA does not.

04.1

Custom rule builder: recursive AND / OR tree with nested groups

Shipped 2026-05-11 · commit ed8964d (sub-phase of Phase 4)

The [Build custom rule ▾] disclosure from Phase 4 now hosts the actual builder, replacing the “coming next” stub. Pro-tier gated: free-tier users see an upgrade landing card instead of the builder; their existing rules created on a lapsed Pro license stay readable but uneditable. Pro-tier users see the full editor.

Recursive UI. The builder is a recursive tree of rule rows and group boxes. Each rule row carries a signal picker (14 plain-language labels: “Country”, “Device type”, “Visit count”, etc.), an operator dropdown (filtered to the valid operators for the chosen signal’s type), and a value editor that swaps per signal type (country combobox single + multi-chip, enum SelectControl, yes/no toggle, role picker, number input, hour-range pair, text input). Each group box has its own Match all / Match any SegmentedControl, its own rule list, and a remove button. Nesting depth is soft-capped at 3 levels for readability.

Sticky customMode attribute. Once the user enters the builder (via the “Switch from recipes” banner or by clicking + Add rule), a customMode: true attribute is persisted on the block. Reselecting the block or reloading the editor opens the builder directly, even if the rule happens to match a recipe shape. Reset Visibility clears customMode back to false. This was a real bug-fix mid-craft: without the flag, the first + Add rule click wrote a default {device.type: mobile} rule, which matched the “Only on mobile” recipe, which bounced the UI back to recipe view, which re-rendered the builder, which re-seeded... an infinite render loop. The flag commits to one mental model.

Recursive evaluator. RuleEvaluator::evaluate_node dispatches between leaf rules (presence of signal) and groups (presence of conditions), recursing through arbitrary nesting depth. ConditionalRender::gate_block’s type guard relaxed to accept both shapes. The data schema stays backward-compatible, flat-rule arrays from Phase 4 keep working unchanged.

Verification. Canonical Phase 4.1 example: “Norway-or-Sweden customers on mobile, who have visited before”, 3 top-level rules (logged_in, device.type, visit_count) plus a nested OR group (country in [NO, SE]), all wrapped in top-level match: all. Four visitors tested via do_blocks():

04.X

CC visitor-state primitive: cross-block key-value store on top of the existing visitor-context

Shape brief locked 2026-05-20 (sub-phase of Phase 4) · cross-cutting WHERE decision locked the same day · ready for /impeccable craft Phase 4.X CC visitor-state primitive

An extension of Cairnstone’s existing visitor-context primitive (visit count + sticky cookies + local hour) into an arbitrary key-value store keyed by namespace + sub-key. Phase 9 (set-visitor-state action), Phase 11 (visitor/<namespace>.<key> binding source), Phase 4 (a new CC visitor.state matches X recipe family), and Phase 16 (Chapter Router’s per-visitor bookmark and playhead-resume features) all consume this primitive; none of them owns it. Free tier on wp.org, cross-device persistence via Node-authenticated identity is a future Pro upgrade per the better-together strategy.

Storage hybrid. Anonymous visitors get sticky-cookie storage (a new gc_visitor_<namespace> cookie scheme, additive to the existing gc_cc_<blockId> CC stickiness which keeps shipping unchanged). Logged-in WP users get WP user-meta storage (cross-device, persists across browsers). On the wp_login hook, cookie state migrates into user meta with a 10-minute conflict window (cookie wins for state set within the last 10 minutes; user meta wins for older state).

Write hybrid. Anonymous writes go client-side via data-wp-on-* directives directly to document.cookie: no auth, no server roundtrip. Logged-in writes go through a REST endpoint (POST /wp-json/gillish/cairnstone/v1/visitor-state) with wp_rest nonce + read capability check + 60-writes-per-minute rate limit + 4 KB payload cap + 100-keys-per-namespace cap.

API surface. Shared\VisitorState::get( namespace, key, default ) / set / has / delete / all / register_namespace / migrate_cookie_to_user_meta. The JS client mirrors the same API on window.gcCairnstoneVisitorState and adds a subscribe( namespace, key, callback ) method that fires on cross-tab cookie changes via the storage event.

Cross-phase contracts. Phase 9’s set-visitor-state action writes through VisitorState::set(). Phase 11 registers visitor/ as a source family resolving against VisitorState::get() with per-namespace permission policy (default visitor-self-readable; user_only: true namespaces require login). Phase 4 gains a visitor.state matches X CC recipe joining the existing country / device / login / visit-count vocabulary. Phase 16 Chapter Router reads / writes per-visitor playhead-resume through the same surface.

Shipping order. Phase 4.X primitive shipped 2026-05-20, ahead of any consumer; the four downstream surfaces (Phase 9 set-visitor-state action, Phase 11 visitor/ binding source, Phase 16 Chapter Router visitor-saved bookmarks, Phase 4 CC visitor.state matches X recipe family) all wire live against the existing primitive when each lands.

05

Save = register block type + live preview (first end-to-end milestone)

Shipped 2026-05-12 · commits 678ed1ee271ec2 (foundation chain → resolution paths)

On save_post for gc_cairnstone_block (priority 10): parse post_content into a block tree, validate against the manifest schema, save the tree + panelConfig + schemaVersion (= 1) in CPT meta. On init priority 12: SavedBlockRegistry::register_all() iterates published posts and calls register_block_type( "gillish-cairnstone/<slug>", [ apiVersion => 3, render_callback => …, attributes => […, schemaVersion] ] ) for each. The render builds HTML from the saved block tree via do_blocks( serialize_blocks( … ) ), with gcStyle + conditions evaluated. Inner blocks carry as <InnerBlocks> with templateLock="contentOnly".

Live preview from the CPT editor ships with this phase: a “Preview live” button opens a frontend-styled render of the block via a dedicated query-var route (?gc_cairnstone_block_preview={ID}&_wpnonce={NONCE}). Five layers of defence prevent any leak to the public:

  1. CPT’s public: false + publicly_queryable: false already returns 404 for the would-be permalink.
  2. Dedicated query-var, not the CPT’s slug, so a missing query var means normal 404.
  3. Handler requires both current_user_can( 'edit_post', $id ) AND valid wp_verify_nonce (failure → 404, not 401, so the URL gives zero signal it might exist).
  4. Preview response carries X-Robots-Tag: noindex, nofollow, noarchive, nosnippet, noimageindex HTTP header + matching <meta> in HTML.
  5. Explicit filter hooks block gc_cairnstone_block from Yoast / Rank Math / AIOSEO / WordPress-core sitemaps.

Three lifecycle safeguards. Breaking-change detection landed end-to-end; the other two are queued behind Phase 6 as Phase 5.x follow-ups (they harden the chain but don’t block it):

  • Breaking-change detection on save (shipped). Tree-shape diff between snapshot and new tree; structural changes surface a sticky inline admin notice on the CPT edit screen with three resolution radios. Cancel restores the snapshot in place (reverse-diff loop suppressed via a re-entrant counter). Migrate walks every affected post and replaces each gillish-cairnstone/<slug> instance’s innerBlocks with the new template; a JS confirm() dialog warns the designer when the diff is lossy (top-level field removed or attr key dropped from a same-position block). Publish as a new block creates a fresh CPT under a typed slug + restores the current post to its snapshot, so the old slug keeps serving the previous template untouched.
  • Snapshot rollback at render (deferred to Phase 5.x). The snapshot meta already lives on every save; what’s missing is the render-time guard that falls back to it when the latest template throws. Author-side notice surfaces the fallback so the regression is visible, not silent.
  • Dry-run validation pre-publish (deferred to Phase 5.x). Existing instances would be dry-rendered server-side before persisting; failures surface as inline warnings on the CPT edit screen. Deferred because the resolution UX already covers the failure mode after the fact, and pre-publish dry-runs add latency to every save.

Plus one Phase 5 deliverable still deferred: CPT list-view enhancements (5 columns, DataViews on WP 7.0+, WP_List_Table on 6.9). WP’s default list works today; the bespoke columns + bulk actions are a polish pass.

Verified end-to-end on TEST. Set up a CPT post with two paragraphs, two regular posts using it as gillish-cairnstone/<slug>. Saved the CPT with a different structure (1 heading) → notice rendered with affected count, lossy flag detected, no “Coming next” badges. Migrate path: confirm() template surfaced “Migrating will rewrite 2 posts…”, both author posts’ inner content was rewritten to the new heading template, finding + snapshot meta cleared. Publish-as-new-block path: typed a new slug, both block-types ended up registered, old CPT restored to its snapshot, flash cross-linked to the previous slug. Preview-live: query-var URL renders with full theme styles + X-Robots-Tag: noindex,nofollow; without the nonce returns 404; as logged-out user returns 404.

06

Library: first batch (shipped: A + B + C + D)

All four batches shipped 2026-05-12 → 2026-05-13 · Batch A (library scaffold + Author Bio + Social Profiles), df10fe1 Batch B (Trust Badges + Testimonial + Section Divider + FAQ + Table of Contents), 2ab9d52 + b99237c Batch C (Hero + Pricing Table with the variation-picker pattern), ca0dcb4 + 39cc947 + 0cb023b + b008b22 Batch D (Affiliate Disclosure + Pro-locked placeholder + Discount Banner + Pricing Pro). Twelve library blocks live in the inserter, eleven free + one Pro.

8–12 ready-made blocks shipped as code in src/Library/<slug>/. Each is an AbstractModule subclass with a manifest (tier: 'free' or tier: 'pro') + render callback. Boot-time registered via Plugin::register_hooks() (priority < user-CPT blocks so the catalog loads first). Inserter categories: “Cairnstone, Layout”, “Cairnstone, Content”, “Cairnstone, Conversion”, “Cairnstone, Interactive”.

First-batch composition is mixed-tier from day one so the free/pro structure is real, not retrofitted: free-tier blocks land first across Batch A + B + C; Batch D introduces the first Pro-tier blocks to exercise the gating path. State + interaction land in later phases, so library blocks that need those (Accordion, Tabs, Modal) ship with static structure only until phase 10/11.

Byte-budget audit: each block has a measured HTML + CSS + JS budget at ship. Target: HTML ≤ 1 KB per instance, CSS ≤ 2 KB per block file (only enqueued when block is on the page), JS 0 KB. A block that exceeds budget either shrinks or doesn’t ship.

07

Repeater + parent/child rules

Shipped 2026-05-14 · commits 6280421b3837ab (v2.2.39 → v2.2.40) · Repeater + RepeaterRow block types, manifest repeater / parentRules contracts. The sidebar-transplant work is a separate sub-track (Phase 7b, below), paused at 5 of 16 blocks. The 2026-05-15 reorder placed state-driven controls at Phase 8 and interaction at Phase 9 (those positions held through the 2026-05-20 renumbering); import / export sat at Phase 10 at the 2026-05-15 reorder but moved to Phase 12 on 2026-05-20 when the sidebar-infra phases were pulled forward.

A new primitive: gillish-cairnstone/repeater with “one row template, N instances”. Parent/child rules extend Gutenberg’s parent field to a “can contain / can only live inside / minimum N / maximum N” model. Inspector UI for setting parent restrictions per CPT block. Frontend: <InnerBlocks allowedBlocks={...}> with the restrictions, validated server-side in render_callback. Prerequisite for Accordion / Tabs / Pricing grid to function as truly reusable blocks.

07b

Sidebar transplant: folded into Phase 16

Folded into the library-upgrade phase on 2026-05-20 · 5 of 15 blocks transplanted before the merge · commits ae0b0e97e9662b (v2.2.42 → v2.2.65) · library upgrade was Phase 11 at the time of the merge; renumbered to Phase 16 later the same day

A later-added sub-track, not in the original eighteen-phase plan: propagate the locked default block-builder sidebar (one universal Styles tab: Typography, Dimensions, Border, Opacity, Color, an optional block-specific panel, Shadow, Advanced, with a role-based Color panel) onto every shipped block (three primitives + twelve library blocks). Each block ships as its own version.

Shipped (orders #1–#3, 5 of 15): Badge and Section Divider proved the pattern against real bespoke complexity; FAQ, Testimonial, and Pricing Table followed as the moderate-content trio. Four transplant invariants are build-enforced (manifest typography, Styles-panel marker class, duplicate-control suppression list, unconditional styles anchor) and stay green guarding the five transplanted blocks until Phase 16 closes.

Folded into Phase 16 on 2026-05-20 by directive (Phase 16 was Phase 11 at the time of the directive; renumbered to 16 later the same day). All 15 transplants now land in Phase 16: the 5 already-shipped get a sidebar-alignment pass against the post-infrastructure sidebar (they stay live through Phases 10–15, no UX regression); the 10 remaining (three primitives: Container, Columns, Repeater, plus seven library blocks, Affiliate Disclosure, Author Bio, Discount Banner, Hero, Pricing Pro, Table of Contents, Trust Badges) do the full transplant onto the same post-infrastructure pattern. Phase 16 closes when all 15 are aligned, the scratch substrate is deleted, the Video embed Pro block ships, and the six state+interaction-driven blocks ship. The universal-button / CTR-styleability pass remains deferred unless explicitly resequenced.

08

State-driven controls: active / hover / focus / disabled

Shipped 2026-05-20 · commits 45ba07c5283bda (v2.2.82, Pass 6 closes the phase) · Model D: a five-segment compact state switcher at the top of the Styles tab re-scopes the Opacity / Color / Shadow panels AND the universal StyleBody gap-fillers; the server emits a Phase 9-safe dual-selector pair (:hover, .gc-hover; :focus-visible, .gc-focus; :active, .gc-active; :disabled, [disabled], .gc-disabled) merged over default. Switcher and gated controls verified on the CPT editor. StateBindingDisciplineTest pins the value-binding write-key contract.

Each Cairnstone Style control gets a “states” selector. Slider UI unchanged; storage becomes nested gcStyle: { default: {...}, hover: {...}, focus: {...}, active: {...}, disabled: {...} }. Frontend generates scoped CSS for each non-default state variant on the server. Classes: .gc-hover, .gc-focus, .gc-active, .gc-disabled, toggled by Phase 9 and falling back to their native pseudos by default. Prerequisite for the interaction system.

09

Interaction system: runtime triggers and actions across four trigger axes

Shipped 2026-05-21 · nine ship-passes + one hotfix (669238e2efece9) · audit landed at 20/20 (Excellent band) · 307 PHPUnit / 798 assertions green · PHPStan L9 0 errors · zero open closure-debt

“When X happens on block A, do Y on block B.” The locked shape brief expands the trigger taxonomy from the original five interaction-driven names to ~12-13 names across four axes:

  • Interaction: click, hover, focus, keyboard-enter.
  • Time: media-time, fires when a parent media element’s currentTime crosses a configured timestamp. Registered no-op until Phase 16 Block video-embed ships the parent-child media discovery model.
  • Index: slide-change / step-change / tab-change, fire when a parent slideshow / steps / tabs block changes its current item. Registered no-op until Phase 16 ships those blocks.
  • Scroll: scroll-progress continuous, plus the existing scroll-into-view / scroll-out-of-view discrete crossings.

Action vocabulary lands in four families:

  • State: toggle-state-<state> against the Phase 8 dual-selector classes (.gc-hover / .gc-focus / .gc-active / .gc-disabled).
  • Class: add-class / remove-class / toggle-class for author-named CSS hooks.
  • Media: seek-media-to / play / pause against HTMLMediaElement targets.
  • Property: set-property against Phase 11 Block Bindings sources (registered no-op until Phase 11 ships the source registry); set-visitor-state (live as of 2026-05-20, Phase 4.X CC shipped the visitor-state primitive end-to-end, see Phase 4.X below).

Builds on the WP Interactivity API (data-wp-on--click etc.). The visual builder is a per-block Interactions PanelBody in the default Settings tab as a sibling to Visibility + Schema. Plain-language “When → Then” two-section structure inside per-interaction popovers, click-to-target with hover-highlight + dropdown fallback for cross-block targets, Setup → Configure → Test lifecycle per interaction. Free tier on wp.org, Pro differentiation lives at Node-integration / Phase 11 binding-source level per the better-together strategy. Prerequisite: state-driven controls (phase 8).

10

Schema panel: guided JSON-LD assistant in Overview / Details / Advanced task-mode

Shipped 2026-05-21 · audit-closed at 20 / 20 (top of Excellent band) · commit 2c62a27 (v2.2.136) · extended through v2.2.137 (voice polish) and v2.2.138 (Rich Results Test footer link) · supersedes the shape brief, see closure recent-change below

Each block can declare a Schema.org @type and map its own attributes to schema properties. Frontend emits aggregated JSON-LD in <head>. Universal attribute injection via register_block_type_args: a single gcSchema attribute (shape { enabled, type, mapping, rawJsonLd }) is added to every block whose manifest sets globalAttributes.schemaEmission: true. Runtime: Shared/SchemaCatalogue.php (closed enum of six supported types: Article, Product, FAQPage, HowTo, Event, Recipe; extensible via the public gillish_cairnstone_schema_catalog filter for third-party block plugins) + Shared/SchemaEmitter.php (wp_head listener, parse_blocks() walk, dedup on @type, one <script type="application/ld+json"> per type or a combined @graph) + Shared/SchemaAttribute.php (the gcSchema injection + the $auto_maps + $attribute_labels registries).

Manifest-driven five-seam pattern. Library blocks declare per-block schema behaviour via three manifest fields, each routed through the same five seams (manifest field, ManifestSchema validator, Shared registry, InspectorPanelsModule payload publisher, editor-JS read):

  • schemaDefault: { enabled, type } seeds the block with a chosen @type on first-open. Pricing → Product, FAQ block → FAQPage, Recipe library block → Recipe, Hero → Article (pre-baked across the substrate).
  • schemaAutoMap: { <property>: <attribute> } wires schema properties to block attributes so authors don’t fill fields the block already carries. Hero declares headline → heading, description → subheading, image → imageUrl; datePublished / dateModified lean on Phase 11’s core/post-data binding rather than custom Cairnstone callbacks.
  • attributeLabels: { <attribute>: <label> } carries plain-language names so the editor’s “Auto-filled from Heading” tag reads in English, not in raw attribute keys.

Inspector UI is a guided assistant in three-step task-mode (Overview → Details → Advanced) with a horizontal step indicator at the top of the section body. Schema is the one Cairnstone section that uses task-mode (Style is a flat field-list, Visibility is a recipe-picker, neither benefits from being step-gated):

  • Overview is the first visible state. Yes/no toggle + vertical type picker if yes. Plain-language type list with one descriptor per row (no em-dash separator). For library blocks with a schemaDefault, this step shows “Using Product schema. Confirm?” and the designer is done in one click.
  • Details is revealed when the chosen type needs author input. Three-state per-field rendering surfaces only the fields that need attention: Filled (manual value present), Auto-filled from <block attribute> (auto-map source has a value; neutral muted-blue tag, no warn border, dropped from the missing-field count so the footer reads truthfully), and Required (manual empty AND no usable auto-map source; warn-color border + status badge + footer aggregate “N required fields missing. See Details.”).
  • Advanced surfaces optional schema properties beyond required, plus a collapsed raw JSON-LD pass-through textarea for properties Cairnstone does not surface as named fields. Top-level keys merge into the structured output; invalid JSON shows an inline warn state and is ignored at emission (server-side falls back to the structured-field emission, no published-side regression).

Repeating fields (Pass 9). FAQPage’s mainEntity renders as a structured card-list editor inside Details: one card per question/answer pair, with + Add row / × Remove row per card and Snackbar Undo on remove. The RepeatingFieldEditor primitive lives at window.gcCairnstone.SchemaPanel.RepeatingFieldEditor for the 2.x release line; Phase 11 graduates it to window.gcCairnstone.Controls.RepeatingFieldEditor with a backward-compat alias.

Role-based behaviour. In the CPT editor (designer-context), the designer sees the full Overview → Details → Advanced flow. In a regular post (author-context), the schema the designer set in the CPT shows as read-only (“Using Article schema, set in Cairnstone blocks editor”) with an inline Override values button beside the inheritance pill. Overriding expands the same Details + Advanced controls inline beneath; a Back to inheriting from Cairnstone blocks link reverts to the inherited default.

Footer status reads in present tense: Emits Product JSON-LD when active, Not emitting when toggled off, Pick a schema type to start emitting. when on but no type chosen, N required fields missing. See Details. when required-fields are empty. The actively-emitting state carries an inline Test in Rich Results ↗ link that opens Google’s Rich Results Test in a new tab, pre-populated with the current post’s preview URL (falls back to the public permalink, falls back to the bare validator URL on unsaved drafts).

Accessibility. WCAG 2.2 AA across the board: 44×44px touch targets on every interactive element via the pseudo-element overlay pattern (six elements use it across the Phase 10 surface), aria-live="polite" announcements on repeating-row add/remove, aria-required + aria-describedby wiring on required inputs, full keyboard nav on the step indicator (Arrow / Home / End) + roving tabindex semantics.

Closure trajectory. Nine craft-passes (Pass 1 substrate → Pass 9 repeating fields) plus a cross-pass craft polish, then a /impeccable auditharden + optimize + adapt + polish coordinated landing → extended adapt-pass → re-audit cycle that lifted the score 16 → 20. v2.2.137 voice polish dropped the “JSON-LD” acronym from the toggle-off help text and shifted footer copy to present tense. v2.2.138 added the Rich Results Test link. Two P3-er parked (F-8 read_block_type_default per-request caching; F-10 dynamic-content aria-live polish on warn footer + Auto-filled tag transitions); neither blocks wp.org submission. See the closure recent-change for the full process-lesson list.

11

Block Bindings integration: Cairnstone data sources for core blocks shipped

Shipped 2026-05-27 across v2.3.13v2.3.28 (sixteen commits + DESIGN.md alignment) · three /impeccable critique cycles green · shape brief retired into plans/DONE-WORK.md on closure · see the three Phase 11 recent-change entries for the full chronological trajectory

Phase 11 makes a Cairnstone-built block declare where its content comes from, not just what it looks like. A designer-builder configures a Paragraph, Heading, Image, or Button on a Cairnstone-built block to draw its content from one of two source families. The frontend’s render_block pulls the live value via the WordPress Block Bindings API; library blocks opt into bindable attributes via WP 6.9’s block_bindings_supported_attributes filter.

Two source families:

  • WP-core built-in sources (no Cairnstone registration needed, 6.9-native): core/post-data for post-level fields (date, modified, link), core/term-data for term-level fields. Hero’s Article-schema datePublished + dateModified resolve through core/post-data rather than Cairnstone-custom callbacks (the “use what core provides, don’t reinvent” doctrine applied at the binding-source layer).
  • Cairnstone-registered sources (Shared/BlockBindings.php registers via register_block_bindings_source()): post meta key (e.g. gc_price), taxonomy term meta, another Cairnstone block attribute, Node-managed-link metadata when Node is active. Each Cairnstone-registered source ships with an orphan-label callback contract: get_value_callback always returns a meaningful default string, never null or empty, so a bound block never renders empty content or orphaned labels.

The picker is WP-core’s native Connect UI (6.9 native). Cairnstone registers binding sources via register_block_bindings_source with a get_fields_list callback; WP-core renders the picker inside the block inspector’s standard Connect UI. No Cairnstone-custom combobox, no Cairnstone-managed keyboard nav. The decision retired ~200-400 lines of editor JS from the original PM-1 brief.

Cairnstone data inspector tab peer to Style, Visibility, and Schema. Header carries a quiet status pill reading BOUND: PRICE when a binding is active, with the plain-language label pulled from Phase 10 Pass 8’s attributeLabels registry. The pill coexists with WP-core’s own binding indicator (the chain glyph inside the Connect UI attribute control): WP-core’s glyph is the “is bound” state-marker; Cairnstone’s pill is the human-readable label of what it’s bound to. Below the picker, a chip-row in the bound state reads Bound to: gc_price (post meta) with a small × to clear, plus a fallback line carrying the typed-in content that renders when the source returns empty.

Editor-cairnstone marker pill sits in the block toolbar of every bound block. Reads BOUND: PRICE in label typography (the same vocabulary as the inspector pill, one mental model across the two surfaces; the v1 brief’s FIELD: prefix was retired as form-builder jargon without earned context). Hover tooltip exposes the full source qualifier (“From post meta gc_price. Click to unbind.”). Click confirms via Snackbar Undo, matching the Phase 9 Pass 12a unbind pattern.

Author-context Block content form in the document sidebar via PluginPostStatusInfo (subject to a pre-craft audit; see below). One row per bound field, plain-language labels from attributeLabels, native inputs matched to the source type. Array-shaped bindings (FAQ items, pricing tiers, testimonial lists) render via the Phase 10 Pass 9 RepeatingFieldEditor primitive, which graduates from window.gcCairnstone.SchemaPanel.* to window.gcCairnstone.Controls.* in Phase 11 craft with a backward-compat alias kept through the 2.x line and dropped in 3.0.

Manifest-driven defaultBindings mirrors the Phase 10 Pass 5-8 five-seam pattern: manifest field (defaultBindings: { <attribute>: { source, key, fallback } }), ManifestSchema::validate_default_bindings validator, Shared\BlockBindings::$default_bindings registry, InspectorPanelsModule::publish_block_bindings_payload publisher, editor-JS read on first-open. On first-open of a library block, Cairnstone seeds each declared attribute into WP-core’s standard metadata.bindings shape only if the author hasn’t configured a binding or typed in custom content. Mirrors the Pass 5 first-save semantics for schemaDefault; no parallel attribute namespace.

Phase 9 set-property picker is unconditionally active on the WP 6.9 floor (v2.3.23 un-dim landed). Shared/BlockBindings.php exposes both get_value( $source ) (read) and set_value( $source, $value ) (write, capability-gated on edit_others_posts); the runtime bridge in gc-interactions-runtime.js POSTs to POST /wp-json/gillish/cairnstone/v1/set-value with X-WP-Nonce for cross-page writes. A click can write back to the same bound source the block reads from; one registry, one set of source types, one mental model. The picker shape is a 3-field Cairnstone form (source → key → value), locked as v1 canonical (WP-core’s BlockBindingsAttribute Connect UI is read-side only and isn’t exposed as a standalone primitive).

AI-readability win: core blocks with bindings are transparent to Phase 13’s Abilities API. An AI agent introspects “this Paragraph displays content from gc_price post meta” and writes changes via meta updates rather than parsing or rewriting HTML. Binding attribute shape { source, key, fallback } on the block, ready for Phase 13’s cairnstone/get-block ability to surface.

Pre-craft audits ran on TEST (WP 6.9.4 + PHP 8.3.31 + StifLi Flex MCP) before craft opened, with three outcomes folded into the shipped surface: (1) WP-core’s author-context binding overview was confirmed to coexist with Cairnstone’s Block content form rather than collide; (2) the marker-pill toolbar position was confirmed to clear WP-core’s in-block treatment; (3) edit_others_posts was picked over manage_options + a future edit_cairnstone_blocks for the set-property write side (rationale: edit_others_posts is the WP-canonical cap for write-on-behalf-of-author surfaces, and gates on author capability rather than admin-only).

Post-close evolution (v2.3.74–v2.3.79): the author-facing surface described above later shifted from whole-attribute binding to inline data tokens for text fields. An author types a {Title} tag straight into a field and may mix several ({Title} by {Author}); an “Insert a field” dropdown drops the tag at the caret, and a stored field is typed by hand as {price}. The BOUND: panel-title pill and the chip-row × described above were retired for text fields, the panel moved out of the branded Cairnstone group to sit under the block’s own settings, image fields kept a slim whole-value pick (featured image / stored image field / another block), and a button link resolves a Gillish Node [node id] short link through Node’s gillish_node_resolve_link bridge (Gillish Node 2.91.13). The Block Bindings substrate, the Cairnstone-owned frontend resolver, and the source families are unchanged; only the surface evolved. The current model lives in the documentation page and the product brief.

12

Import / export of built blocks shipped

Shipped 2026-05-27 across v2.3.29v2.3.52 (twenty-four versions: Pass A wire format, Pass B REST, Pass C CLI, Pass D-1 sidebar slot, Pass D-2 list-view chrome + bulk zip, Pass D-3 Import modal, plus harden + polish + reload-after-commit fix) · three /impeccable critique cycles green · shape brief retired into plans/DONE-WORK.md on closure · see the Phase 12 closure recent-change entry for the full trajectory

Phase 12 made Cairnstone blocks travel between sites as plain .gcblock.json files. A single block is one file; multiple blocks pack into a single .zip. The wire format carries six top-level fields and is diff-stable across re-exports: exporting the same block twice produces byte-identical output, so a designer can version-control their library in git if they want to.

Service class Modules/CanvasBlock/ImportExportService handles validation, formatVersion migration, slug collision via wp_unique_post_slug, persistence as draft Cairnstone Block CPT posts. The collision-resolver passes 'publish' (not 'draft') to wp_unique_post_slug so the SQL lookup actually runs against the published-slug namespace; the v2.3.48 RENAMED-vs-NEW fix closed the wp.org-submission blocker that the early-return path had been hiding.

Five user-facing surfaces make the round-trip easy: (1) Block export sidebar slot on the CPT editor via a PluginPostStatusInfo slot between Phase 11’s “Block content” and the Permalink panel; (2) per-row Export link in the WP-admin row-actions cluster on the Cairnstone blocks list table; (3) Export selected bulk action that zips the checked rows; (4) top-row Export all button next to Import that zips the whole library in one click; (5) Import modal with file-picker + drop-target overlay on the list table, per-file preview rows with status pills (NEW / RENAMED / NEWER FORMAT / INVALID / MIGRATING per brief §8), modal-internal drop-target that appends additional files to the open preview, and a Snackbar confirmation on commit reading “Imported N blocks as drafts.” with the list table auto-reloading 1500ms later so the new rows appear.

REST: four routes under /wp-json/gillish/cairnstone/v1/blocks/*: GET /<slug>/export (single-block download with pretty-printed JSON via rest_pre_serve_request), POST /preview (multi-file validation, no DB writes, returns per-file status rows), POST /import (commit step, partial-imports-land contract), POST /export-bulk (zip stream). All four cap-gated on manage_options per brief §10 item 10. WP-CLI: wp gillish-cairnstone list-blocks (formats: table / json / csv / yaml / count / ids), export-block <slug> (canonical JSON to stdout, composable with | jq / >), import-block [--from=<file>] (from --from=<path> or stdin), wipe-blocks --yes (trash-recoverable bulk reset).

Security hardened for wp.org submission: pre-extract zip-bomb count via ZipArchive::numFiles + per-entry size check before unzip_file writes anything (caps at 100 entries, 5 MB per entry); two-layer path-traversal defence (pre-flight slash + backslash check, post-extraction realpath boundary that closes Windows zip-slip via validate_file); sanitize_file_name on both Content-Disposition filename constructions (single + bulk); slug regex accepts digit-leading per wp_unique_post_slug’s real output ([a-z0-9][a-z0-9-]*); pretty-print on the wire for the single-export route via the rest_pre_serve_request filter pattern. v3 critique deferred-12 (P2/P3 hygiene) closed in v2.3.50: trash-list row-action skip, dual style handle consolidation, helper-text WCAG AA contrast, HTML-fatal-as-SyntaxError diagnostic, error-state retry button, UNKNOWN pill enum exposure, INVALID pill aria-controls, long-slug row-head flex-wrap: nowrap, drop-overlay blur / Escape / dragend reset, bulk-action busy state, dead-code triple-background declarations.

13

AI integration: Abilities API base surface shipped

Shipped 2026-05-30 · 13a v2.3.90, 13b.1 v2.3.91, 13b.3 v2.3.92, 13c v2.3.93 (13b.2 RateLimiter dropped)

Cairnstone registers WordPress Abilities API capabilities under the cairnstone/* namespace so any MCP-speaking AI assistant can introspect and build blocks. Shipped in three slices. 13a (discovery): five read-only abilities (cairnstone/list-blocks, get-block, list-library-blocks, list-primitives, get-schema-types), plus cairnstone/list-node-links when Gillish Node is active. 13b.1 (mutation): cairnstone/create-block, update-block, delete-block, import-block, all draft-only and behind an opt-in gate. Each ability carries a JSON-schema input + output spec and a permission_callback.

No rate limit, no quota (13b.2 dropped). The original plan had a Free monthly mutation quota with a Pro unlock plus a per-hour rate limit. That was recognised as wp.org trialware (a counter that disables included functionality), and on a second look the limiter had no real abuse vector to close: the calls run against the site owner’s own AI client, and the surface is already gated by authentication + manage_options + draft-only + the admin opt-in toggle. So the rate limiter and quota were dropped entirely; there is no 402 or 429 on the AI surface. Pro AI abilities will live in a separate add-on plugin hosted outside wp.org, not behind a counter in the free plugin.

Five security mitigations, all shipped: admin opt-in gate (mutation stays dormant until enabled in Settings); capability gates (manage_options); draft-only enforcement (AI-created blocks land as draft; a publish attempt is refused); output sanitisation (the same wp_kses_post + schema-validated-attribute wash the import path uses, stripping <script>, javascript: URLs, event handlers); soft-delete only (trash, never hard-delete). AI-friendly error shapes carry a suggestion line so the assistant can self-correct.

13b.3 (Settings): an “AI assistants” tab in Cairnstone Settings flips the mutation opt-in, off by default. 13c (activity + provenance): a Cairnstone → AI activity admin page renders the last 30 days of every cairnstone/* call (timestamp, user, ability, result, an inline request / response drill, CSV export) from the gillish_cairnstone_ai_events audit table, and AI-created draft blocks carry a native “AI draft” marker in the blocks list until a human publishes them.

Post-close runtime-audit hardening (v2.3.99–v2.3.101): a consolidated Chrome-MCP run of the whole surface surfaced four defects that PHPUnit could not reach, all fixed. AI-created blocks were invisible in the editor (create-block stored the composition in post-meta but left post_content empty, so a reviewer opened a blank canvas and a save there wiped the tree); the fix serialises the tree into post_content too, the same path import uses. GC_NODE_ACTIVE was defined at entry-file load with a class_exists check that froze false in WordPress’s alphabetical plugin-load order (gillish-cairnstone before gillish-node), so list-node-links never registered and the dashboard showed a false “install Node” notice to sites that already had it; the define now runs on plugins_loaded. The import-block payload contract was under-documented and a publish-refusal returned a generic schema error instead of the AI-friendly draft_only hint; both corrected.

14

AI integration: richer introspection shipped

Shipped 2026-05-30 across v2.3.102v2.3.108 (semantic tags, the validate-composition dry-run validator A1–A3, the style catalogue, the pre-save validation gate, and human-readable refusal messages) · the whole twelve-ability surface re-run end-to-end against the floor install and pinned as a runtime baseline

Semantic tags. Each of the twelve library blocks carries a stable, kebab-case semanticType tag for the design pattern it implements (hero, pricing-table, faq-accordion, testimonial, trust-signals, …), surfaced through cairnstone/list-library-blocks, so an agent matches a user request to the right block by pattern instead of guessing from the display label. Two blocks may share a tag (Pricing Table and Pricing Pro are both pricing-table); tier and description differentiate.

The dry-run validator. New read-only ability cairnstone/validate-composition runs a proposed block tree through the same checks the save path applies and returns a structured { valid, findings } report, so an agent self-corrects before it mutates anything. Three layers, each with a precise path into the tree: A1 manifest-attribute (an unregistered block, or a setting the block’s schema rejects); A2 nesting (a block placed where its Gutenberg parent / ancestor rule forbids); A3 Conditional-Content (a malformed visibility rule, an unknown operator, a match that is not all / any), plus a content_stripped warning when a node’s HTML would be cleaned on save. Errors fail the tree; warnings inform without failing.

The style catalogue, the honest form of per-block introspection. The roadmap planned a per-block list of “which gcStyle keys are meaningful for this block.” The architecture answered honestly: gcStyle is universal, every block type carries the same controls, so a per-block relevance model would describe a distinction that does not exist. Instead, new ability cairnstone/list-style-controls returns one block-agnostic catalogue, the eight universal controls (opacity, font size, background + text colour, padding, margin, drop shadow, border opacity) with their types and ranges, plus the five states they apply to (default / hover / focus / active / disabled; border opacity is default-only). The catalogue lives next to the render-time translator so the two stay in sync.

The save gate + human refusals. Two follow-on hardenings closed the loop. create-block, update-block, and import-block now run the validator on the raw tree before writing, so a block that cannot work is refused before it reaches the draft list, the support-headache of a second person inserting a broken block and silently getting nothing. The refusal names the block the way a designer sees it, e.g. The “Column” block (gillish-cairnstone/column) can only be placed directly inside the “Columns” block (gillish-cairnstone/columns), with the technical id kept alongside for precision and an id-only fallback when a block has no title. With the phase closed, all twelve cairnstone/* abilities were re-run end-to-end against the floor install (discovery + the full mutation lifecycle) and pinned as a runtime baseline.

15

AI integration: preview rendering + batch shipped

Shipped 2026-05-31 across v2.3.110v2.3.111 (server-side preview-as, dry-run on every mutation, the cairnstone/batch multi-op ability, plus a contract-parity harden) · every ability runtime-verified over the wire against the floor install

Preview-as, server-side. New read-only ability cairnstone/preview-block renders a saved block’s real frontend HTML as a chosen visitor profile would see it, plus a per-block show/hide trace, so an agent can confirm what a Conditional Content rule actually does before it commits. It adds a request-scoped preview-as override to VisitorContext (the server render path had none, so it always used the requesting admin’s own signals), maps a fixture to a full visitor-context dict, and evaluates each block’s rule against the same context the render gated against, so a trace row can never disagree with the render. The trace names the deciding signal (“it checks country”) and, for a request-only signal a fixture cannot set (referrer, UTM, visit count, hour-local, crawler), says so rather than reading as a real mismatch.

Dry-run on every mutation. create-block, update-block, and import-block take dry_run: true: every check the real save runs, then the resolved artifact returned marked committed: false without writing (update returns a before/after diff, import the resolved slug with a rename flag on a collision). A schema fix made it reachable over the wire, the three input schemas declared no dry_run property under additionalProperties: false, so the REST controller rejected the flag before the handler ran; it is now declared on all three with a schema-contract regression test guarding it.

The batch ability. One mutating ability cairnstone/batch applies an ordered list of mixed create / update / delete / import in a single call, partial-success by default (each item saved or failed on its own, the rest still go through) with an atomic: true all-or-nothing mode (a validate-all-dry first pass means a bad item writes nothing; a rare post-validation write failure triggers a compensating rollback, newest first). A soft max_items cap of 100 guards runaways, not abuse. A follow-on harden brought it to contract parity with the single ops: dry_run previews a whole batch, the result always carries committed, an atomic failure surfaces failed_indexes, and an empty batch is a clean rejection. Every ability was runtime-verified end-to-end over the wire against the floor install through the Abilities REST endpoint. With this Phase 15 closes, and the AI-integration arc (Phases 13–15) is complete.

16

Library upgrade pass + sidebar-alignment on all 15 transplants

A single library-completeness pass once Phases 8 (state-driven controls), 9 (interaction system), 10 (Schema panel), 11 (Block Bindings), 12 (import/export), and 13–15 (AI integration) have all landed, with three folded scopes. (a) Align all 15 shipped blocks against the post-infrastructure sidebar pattern: the 5 already-transplanted in the May 2026 Phase 7b run (Badge, Section Divider, FAQ, Testimonial, Pricing Table) get an alignment pass against whatever sidebar shape locks after Phases 10–15 ship, expanded into Phase 16 scope 2026-05-20 by directive; they stay live through the infrastructure phases (no UX regression) and Phase 16 only touches what the new infrastructure actually changed for them. The 10 remaining ones (three primitives: Container, Columns, Repeater, plus seven library blocks: Affiliate Disclosure, Author Bio, Discount Banner, Hero, Pricing Pro, Table of Contents, Trust Badges) do the full transplant onto the same post-infrastructure pattern, retiring the four transplant padlocks and the src/Modules/Scratch/ substrate once the last block ships. (b) Add the Video embed Pro block (idea graduated 2026-05-20): wraps YouTube / Vimeo / Twitch / generic-iframe sources so the embed inherits Conditional Content’s geo / device / referrer / login-state gating for free. (c) Build state+interaction-driven blocks on top of the Phase 8/9 primitives: Accordion, Tabs, Modal, Carousel, Counter, Reveal-on-scroll, demonstrating that the primitive + system model delivers what the 50-hardcoded-blocks model would deliver. Phase closes when all 15 transplants are aligned, the scratch substrate is gone, Video embed ships, and the six interactive blocks ship. In progress (v2.3.150 → v2.3.171): the How-to block, the shared image engine (Shared\Image + render_set + Lightbox + ImageSizes), the new Image block (the fourteenth library block), and the migration of How-to / Hero / Trust Badges / Testimonial / Author Bio onto the engine have all shipped; the six interaction blocks, Video embed Pro, and the remaining sidebar transplants are still open.

17

Third-party blocks: verification verified 2026-06-07

Stackable / Kadence / GenerateBlocks blocks are tested with the Cairnstone inspector panels (Style + Visibility). Expected to just work: we inject gcStyle + CC attrs via the blocks.registerBlockType filter on every block, third-party included; the editor.BlockEdit HOC injects the Cairnstone panels into every block’s inspector. Any incompatibilities are documented + addressed. Third-party blocks with their own save() (not render_callback) may have edge cases around markup freezing.

Result (verified 2026-06-07 on the floor): confirmed against Kadence Blocks 3.7.5, GenerateBlocks 2.2.1, and Stackable 3.19.9. The Visibility + Interactions + Schema panels attach to every third-party block (the HOC hooks at the registration layer); render_block Conditional Content gating removes a hidden instance from the page; and gcStyle merges inline into each block’s root element (Kadence <h2>, GenerateBlocks .gb-text, Stackable .stk-block-card). The static-save() edge case did not bite: the universal render_block filters act on the saved markup regardless of how the block stores it. No Cairnstone-caused console errors.

18

License plumbing: make Pro real (always the last phase)

Replace the Shared\Licensing dev-mode stub (which currently returns true unconditionally) with a real key-validated implementation. Decision on backend (Lemon Squeezy, Freemius, EDD Software Licensing, Paddle, or other) is made at phase-start. The contract surface stays fixed regardless of choice: has_tier( string $tier ): bool, license_key(): string, license_status(): array, refresh_now(): void.

Deliverables (provider-agnostic):

  • Admin “License” tab in Settings with key input + activate / deactivate buttons + status display (tier, expiry, sites used vs allowed, last validation timestamp).
  • Activation flow: on key entry, REST call to the chosen backend’s validation endpoint, payload signed (HMAC); result cached in wp_option with 14-day grace window.
  • Weekly cron re-validation idempotent on init; failure within grace window keeps the cached status, failure beyond grace window downgrades to free.
  • Phone-home pirate detection: weekly cron reports site URL + key hash; server detects same key on many sites and revokes (privacy-compliant opt-out in Settings, defaulting to ON).
  • Pro-build separate from wp.org zip (or single-zip-with-runtime-gating, depending on chosen backend).

Cross-cutting requirements (every phase)#

Discipline requirements that apply each time a phase touches UI, REST, or plugin activation. Listed here so they don’t get repeated in every phase row.

  • i18n. Every visible string goes through __() / esc_html__() / esc_attr__() with the 'gillish-cairnstone' text-domain.
  • REST permission discipline. Every new REST endpoint has an explicit permission_callback (never __return_true) + nonce verification.
  • Multisite defensive guard. register_activation_hook blocks network-activate with a clear error message.
  • Hook registry. Every add_action / add_filter lives in Plugin::register_hooks().
  • Option / hook constants. Every option key and hook name exists as a constant in Shared/Options.php and Shared/Hooks.php.
  • Cron self-heal. Cron events are registered idempotently on init.
  • Plain-language UX. Every visible string must be readable without a developer glossary. “Country”, not “Country code”. An acceptance criterion in every phase that touches UI.
  • Lean frontend output. Every phase that produces frontend HTML/CSS/JS has a byte-cost review step before merge. Lighthouse Performance ≥ 95, TTFB ≤ 200 ms, LCP ≤ 2.5 s, ≤ 1 KB HTML per block average, 0 frontend JS by default, no new font loads. render_block filter benchmark: total gate_block cost ≤ 5 ms for 195 no-condition blocks, ≤ 10 ms for 5 conditioned blocks.
  • Tier classification at merge. Every new block, every new feature flag, every new ability gets a clear 'free' or 'pro' classification when it lands. No “we’ll figure out monetisation later”.
  • Progressive UI, never exhaustive. Each Cairnstone-injected panel must follow the contextual-not-complete model: summary row as control hub, manifest-driven section visibility (wholesale), role-based default-open section, task-oriented subtitles, one section open at a time, badge counts, advanced behind [Advanced ▾], per-section reset, no duplication of Gutenberg native controls, task-mode reserved for Schema only.
  • PHP 8.3 minimum / WordPress 6.9 minimum. (Floor lifted from PHP 8.2 / WP 6.6 on 2026-05-21; that is the current floor.) WP 7.0 features (DataViews, native AI client) are used opportunistically with graceful degradation; Cairnstone works on 6.9 and 7.0 alike. Block Bindings is a WP 6.9 baseline feature (Phase 11 shipped on block_bindings_supported_attributes), not a 7.0-only one.
  • DataViews for admin lists. WP 7.0+ when available; falls back to native WP_List_Table on 6.9 with feature parity at the essentials.
  • License plumbing is always the last phase. Convention for plan maintenance: when a new phase is added, insert it before the license-plumbing phase (currently phase 18).

Deferred (in-scope, parked beyond launch)#

Inspector-side UX ideas that are Cairnstone-scope and concrete enough to leave room for in the launch architecture, but are deliberately deferred because the launch experience does not need them and shipping them now would expand surface area before the core inspector pattern has stabilised.

  • Cairnstone presets, saved Style + Visibility combinations, apply with one click. Per-user presets in user-meta, site presets in options. Mechanism is small (serialise attributes.gcStyle + attributes.conditions into a named slot, expose “Apply preset ▾” from the inspector summary row); UX of “what’s in this preset, what happens to existing values” is the work. Deferred to post-Phase 16 (the library phase: presets only matter once there are enough custom blocks around to need them).
  • Cairnstone lens, document-level Cairnstone view. A document-scope Plugin Sidebar (separate from the block-level Inspector) that lists every Cairnstone-configured block on the current post. Audit / overview tool, not an editing tool. Deferred to the post-launch tier (after Phase 18 ships). Likely Pro; not committed.

Non-goals#

Explicitly OUT of this plan:

  • A custom composer / fullscreen composition surface. That direction is parked.
  • Whole pages via Cairnstone (Bricks / Elementor competition). We build blocks, not pages.
  • Custom payment processor / store for Pro sales. Phase 18 wires Shared\Licensing to an external provider; we don’t build our own billing infrastructure.
  • Multi-site / network features. Explicitly blocked at activation.
  • Edit-in-Sandbox or any other instance-bound editing mode. The author owns the content, the designer owns the structure.