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) ran from v2.3.150 and was closed 2026-08-31 at v2.4.24 by decision, never having been defined by a fixed block list (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). The month that followed (v2.3.335 → v2.3.756, 2026-06-19 to 2026-07-19) closed no numbered phase but reshaped a great deal of the product: the pricing reshape replaced three legacy blocks with two and took the library from 18 to 15, the scoped-styling engine made any part of a block individually styleable, all six Phase 16 interaction blocks shipped, four further blocks landed (Button, Table, Code Snippet, and a twice-rebuilt Modal), a block-by-block review pass went through eleven existing blocks, and the front-end stylesheet was split from one 380 KB monolith into one file per block. The cards below cover it arc by arc.
Every phase is closed, including the one that was never started, and none of that means the product is finished
The phase structure is retired. Phases existed so the owner could watch the product being built. That was genuinely useful while it was being built, and it had stopped telling him anything he did not already know. So all of them close – including the last one, the licence plumbing, which closes without ever having been started. That is not an oversight being papered over: the work behind it is understood to be undone, and closing the phase simply stops pretending a planning label was tracking it.
Closing the roadmap does not move the version to 3.0.0. This is the part worth stating plainly, because the opposite reading is the natural one: a finished roadmap sounds like a finished product, and a finished product reaches for the big round number. It is reserved for the public release, and the public release is still a long way off. The version line carries on exactly as before.
Retired is not the same as finished. There are still blocks to make. There were always going to be, none of them is needed before launch, and they continue as ordinary planned work – the same conclusion the library phase reached when it closed a week earlier. What changed is only that no planning label is holding them any more.
Everything on this page is now a record rather than a status board. The phase tables stay, because they are a good account of what was built and in what order, and reading old wording like “still open” or “next” means reading the state at the date beside it. The page said so about itself for a while without it being true: one phase was described as outstanding for nearly three months after it had been verified, another as unbuilt after it had shipped. A status board that has to keep correcting itself is reporting a structure nobody is reading.
WordPress’s own controls are actually gone from the designer now, sticky became a Cairnstone control, and the one native panel left was kept on purpose
The fix that was reported done two cards down is done now. The reason it had never worked on the current WordPress release turned out to be a plain one, found by measuring rather than guessing: the server was sending the right instruction the whole time, and the newer editor simply rebuilds its list of available controls from the site’s global styles after the page has loaded, throwing away what the server said. So the designer now hands the editor its own decision and re-applies it whenever the editor drifts. Checked before anyone believed it: the oldest supported version with Cairnstone alone, the oldest version with every Gillish plugin, and the current version with every Gillish plugin – the WordPress version was the only thing that moved. On the current release the designer went from six native panels carrying sixteen live controls to none of them; the ordinary post editor was not touched; the oldest release was unchanged.
The first attempt at that fix broke the editor, and the boundary it found is written down. Carrying the same decision into the theme’s own settings file removed the panels correctly and stripped the theme’s fonts and demoted the post title from a heading to a paragraph while doing it, because those settings declare what the site actually looks like, not merely which controls are on offer. Rolled back within minutes, never shipped, and the rule that a fix of this kind stays on the editor’s side of that line now sits in the code where the next attempt would start.
One native control became a Cairnstone one instead of disappearing. The WordPress panel that came back on 7.1 offered a single thing: keep a block pinned to the top of the window while the page scrolls past it. Under the standing rule that a capability WordPress gains belongs in Cairnstone’s own sidebar rather than being switched off and forgotten, Stick to the top now lives in Size & spacing, next to the two controls it is most like, and it gets along with the stacking-order control that would otherwise have silently un-stuck it. Proven on a published page, not in the editor: a long scroll left the block sitting exactly at the top.
And one panel that looked like the same problem was not, and stays. Container’s Layout panel – a switch for whether the blocks inside keep to the theme’s reading width or run edge to edge – is WordPress’s own, but it has always been there, in a different tab on the older release, and it decides structure rather than look. Asked whether an author ever needs the edge-to-edge option, the owner answered that nobody can know what a user will want, which settles it: the capability stays, and re-drawing the same switch in Cairnstone’s sidebar would give an author nothing new. It is the designer’s one named exception, recorded so it is not re-opened from a screenshot. Three wrong claims about that panel were corrected on the way there, all from the same cause: a comment in the code read as if it were the code.
Cairnstone will ship as two plugins, and which features sit on which side is still open on purpose
There will be a free Cairnstone and a paid one, and the paid code will not be inside the free one. Not locked, not dormant, not shipped-but-switched-off: absent. The free plugin is a whole working thing on its own, and everything in it works for everybody who installs it, permanently. Anything paid arrives as a second plugin you install alongside it, or it does not arrive at all.
What is not decided is which features go where, and that is the deliberate part rather than an oversight. Drawing that line now would mean drawing it a long way ahead of the split, with worse information than will be available when the split actually happens, and then defending a line nobody has tested. So it stays open, and this page will say which side things landed on when they land, not before.
One consequence for anyone reading the source. The plugin has carried internal “free” and “pro” marks on its modules since early development, and those marks are older than this decision. They are a leftover, not the answer, and reading them as a preview of what you will have to pay for would be reading them wrong. The code now says so out loud: the three places a person would land while wondering – the licence gate, the module list, and the one module carrying the most confusing mark – each carry a note naming where the decision actually gets made, stating that the marks are not it, and asking the reader not to tidy the difference away.
Two old promises in that same code were removed on the way, because they had quietly become false: a comment saying that when the licensing layer arrived only one class would need a real implementation, and a to-do item promising that real check. Neither will happen. The paid code leaves rather than being locked, so there is no lock to build.
Nothing a visitor sees changed, and nothing a site owner has to do changed. This is a decision about how the work will be packaged, published here because the packaging shapes what the free plugin is allowed to be – and the answer to that is: complete.
A phase that had no way of ending was ended by decision, and the three checks it told us to delete were kept instead
Phase 16 is closed. It was the library upgrade pass, and it had a problem no amount of work could fix: it was never defined by a list of blocks, so nothing that got built could ever complete it. It could have stayed open forever. It is now closed by decision instead – many blocks remain to be made, none of them is needed before launch, and they carry on as ordinary planned work rather than holding a phase open behind them. Phase 18, the licence plumbing, is the only phase left.
The one real decision in closing it was to refuse an instruction it carried. Written into the phase, years of context ago, was a line saying that when the last block shipped, three particular automated checks should be deleted. Read at closing time, that line would have removed them. They stay. None of them was scaffolding for the phase: each one watches for a mistake that produces no error at all – a block that still loads, still saves, and has quietly stopped doing one of its jobs. One catches a block losing the ability to be styled differently when hovered or focused. One catches a panel drifting out of alignment with the panels above it. One guards two faults that have each already shipped once. Every block still to be made is copied from the same template these three watch, so deleting them would have removed the protection exactly when the copying starts again. The refusal is written into the checks themselves, so that nobody reading the old instruction acts on it later.
And part of what the phase was waiting for had already been overtaken. The phase wanted ten blocks moved one by one onto a styling sidebar of their own. Counted properly rather than assumed: seven of thirty-one blocks have a panel of their own, and the other twenty-four are not missing anything, because the sidebar became shared along the way. One piece of the plugin now provides it for every block, and a block only adds its own panel when it genuinely needs something the shared one cannot express. The work was not left undone; it stopped being the work. The same thing had already happened once inside this phase, when the pricing rebuild replaced the blocks the plan had listed for upgrading.
Nothing was lost in the closing. The three blocks the phase still named – a video embed, a before-and-after image slider, and a container for media effects – were already written up in full elsewhere, in better detail than the phase itself held.
WordPress’s own controls had crept back into the block designer, and an edit made in the ordinary editor had never actually applied
Cairnstone’s designer hides WordPress’s native styling controls so its own panel is the only place a look gets decided. That hiding worked from a list of names: switch off this setting, and this one, and this one. A list of things to switch off fails open – every setting WordPress adds in a later release simply arrives switched on, and nothing anywhere says so. WordPress 7.1 added five, so native controls quietly reappeared in the designer on the twenty-four blocks that carry text. No error, no failing test. It was spotted by looking at the screen.
The list is now inverted. Cairnstone reads WordPress’s own register of settings and names what stays on, so anything a future release adds is off by default rather than leaking through. A new check reads the real WordPress files from three installs at once – the oldest version still supported, the current one, and an unreleased development build – and fails the moment WordPress declares a setting Cairnstone has never made a decision about. It reports that as work to do, not as something to bury: a capability WordPress gains is a capability Cairnstone should offer through its own control. The development build means the next release announces itself before it ships.
Correction, 2026-09-01: this fix was reported as done and it is not done. On the current WordPress release the native controls are still there in the designer, and they always were – this is not something that broke afterwards, because that test site has not been updated since. The claim above was made on the strength of two things that between them did not check it. The hands-on walk was done on one site only, running the oldest WordPress version Cairnstone supports, where the fix does work. And the automated check, which genuinely does read the real WordPress files from three versions, only compares lists: it confirms that every setting WordPress declares is one Cairnstone has made a decision about. It never opens an editor, so it cannot notice when the switching-off does not actually happen. Three green ticks across three versions, one version actually tried. To be plain about where the fault sits: WordPress is not doing anything wrong here. Adding settings is WordPress working as intended; failing to account for them is ours alone. Fixed the same day, in 2.4.25 – the card above this one has the cause and the fix. This paragraph stays so the sequence stays visible.
Sizes are a complete set again. The designer had a minimum height and nothing else. Height, width and minimum width join it in the same group. They behave identically on every supported WordPress version, including the oldest, which has never heard of some of them – because the controls are Cairnstone’s own and produce ordinary styling. WordPress adding them was the signal that they matter, not the machinery underneath.
Where a phone ends and a tablet begins is now WordPress’s decision, not Cairnstone’s. The plugin carried its own two widths from before WordPress had any. Version 7.1 introduced its own, and they disagreed – so one page had two answers to the same question, and WordPress won wherever they met, which left Cairnstone’s number showing in the editor and nowhere else. The setting itself stays, because it was added for a good reason: no single fixed width fits every theme. That reason is exactly why it now reads WordPress’s value, which a theme can set for itself. What changed is only the starting point. A site that has already saved its own widths keeps them.
And the one that had been wrong the longest: an author’s change in the ordinary editor was accepted, stored, and then ignored. A designer builds a block and sets a size. An author drops that block into a page and adjusts the size with WordPress’s own control, which is exactly what they are supposed to use there. Both values ended up on the page, the designer’s written last, and the last one wins – so the author’s number sat visibly in the page’s code doing nothing at all. The control said yes and the page said no. Now the designer’s value steps aside for precisely the settings the author changed, and for nothing else: the block design is still the starting point, and the person working on the page can still adjust it. Measured on all three WordPress versions, before and after.
All thirty-eight blocks now carry a full accessibility record, and building the last ten found four real faults
Eleven days ago four blocks had been measured. All thirty-eight now do, each against seventeen questions at two screen widths, on a specimen built the way a real one is built. The interesting part is not the number: it is that the last stretch of the work kept finding faults that no automatic check had ever asked about, and each one was a fault on the default path – something an author would hit by doing nothing wrong.
A grid set to three columns kept all three on a phone. At the narrowest supported screen the three columns squeezed to sixty-nine pixels each, a button inside one of them could not fit its own text, and the whole page scrolled sideways – which is exactly what the standard for narrow screens forbids. The block already ships a layout that cannot overflow, and choosing a column count quietly threw it away on the one width that needed it. The sibling columns block has always done the safe thing here, so the two agreed to disagree about what a phone means for a year. They no longer do: a phone gets as many columns as actually fit, unless the author says otherwise in as many words.
A form field’s hint was a placeholder, so it was never read out and it vanished when you typed. Four fields, four hints, and not one of them reached a screen reader – the field announced its name and nothing else. A sighted visitor lost the guidance the moment they started typing, which is precisely when guidance is for. The block’s own source already made the argument against this one paragraph above the bug: it keeps the field’s NAME out of the placeholder for exactly that reason, and then put the guidance in one. The hint is now real text beside the field, and the field points at it.
A link that opens a new tab said nothing about it, in four of the five blocks that can make one. A sighted visitor sees the tab open; a screen-reader user is simply somewhere else, and Back does not come home. One block had been fixed for this in isolation; checking the shared code behind the other four found them all silent. The wording now lives in that shared piece, so it cannot go missing from one block again, and it is keyed to whether the link really opens a tab rather than to what the author asked for – a link that turns out to lead nowhere cannot promise a tab it will never open.
Four records described the test page instead of the block. Four blocks ship a control the author has to switch on – a linked logo, a row of social links, a link inside a notice, a call to action – and every one of them was left off on the page they were measured on. So the record said “this block has no controls”, which was true of that page and worthless about the block. The pages were rebuilt with the controls on and everything re-measured; the new-tab fault above is what fell out of it.
The audit then finished the hard way: a full night with a real screen reader and real key presses, and zero open findings at the end. Every control in the library was operated with actual keys and every announcement listened to. That last pass caught what no earlier layer could: the discount banner’s copy and close buttons were completely dead on pages where the banner arrives through a reusable block or personalised content – the code behind them either never loaded there or never listened; the animated counter could read the wrong number to a screen reader; copying said nothing out loud; and the layout starter transformed in silence. All of it is fixed, each fix re-verified by ear the same night, and the record now stands at thirty-eight of thirty-eight blocks audited, zero open findings – with the accessibility walk built into how every future block gets made, enforced by the plugin’s own test suite rather than by anyone’s memory.
One block was recorded as impossible to measure, on purpose. The layout starter shows a gallery of arrangements and then replaces itself with whichever one you pick, so it never reaches a visitor at all. Its record says that, with the reason, rather than sitting empty – an empty file and a finished one look identical, and across thirty-eight blocks that is the difference between a number and a guess. What it owes instead is a question about the editor, and that is written down where it belongs.
Four blocks measured properly, and four defects that no test had been asking about
Accessibility had a procedure and a record, and until now almost nothing in it. Four blocks were walked in full this round – the container, the image, the accordion and the tabs – each against seventeen questions at two screen widths, on a specimen built the way a real one is built and taken from real products rather than invented. Four of thirty-eight blocks now carry a completed record, with no open finding. Two more, the carousel and the modal, were checked against a single question and still read as unaudited, because one answered question is not a walk.
Two blocks could only be reordered by dragging. The comparison table and the pricing cards let an author move a column or a row by dragging its handle, and offered no other way. That fails twice over: dragging cannot be done from a keyboard at all, and the standard that asks for an alternative asks for one that works with a single pointer, which a keyboard is not. Both now carry buttons in the block toolbar, which closes both at once – a click is a pointer action, and the same button takes focus and Enter. The announcements they make were rewritten too: “position 2 of 3” tells a screen-reader user nothing they can act on, so they now name the thing that moved.
The accordion’s questions were not headings. They were set larger and bolder than the text around them, which is how a reader is told “this is the heading of a section”, and the markup said nothing of the kind. The consequence is narrow and real: somebody using a screen reader could not jump from question to question and had to walk through every control or read the whole section. The fix marks each question as a heading at a level the author chooses, and the claim was afterwards measured with a screen reader rather than inferred – all six questions reachable by the heading key, and each still announcing that it is a button and whether it is open.
A tab row that folds on a phone looked like a list of questions and said nothing about opening. On a wide screen a tab is underlined and reads as a tab; folded, it becomes a stack of rows, and the accordion has always drawn a small mark to say those rows open. The tab fold had none. It does now, as a setting with the same three choices, and it appears only when folded – a mark on a wide tab row would promise opening from a control that switches. The same block also turned out to be the one stylesheet of fourteen whose animation could not be switched off by a visitor who has asked for less motion.
The image block’s case was decided the other way, deliberately. The only sign that a picture opens larger is the pointer turning into a magnifier, and a touch screen has no pointer, so on a phone nothing says the picture opens. Marking images is not what the web does, so the default stays bare and a setting now exists for authors who want the mark. The record says accepted rather than fixed, and carries the condition under which it would be reopened; a decision recorded as a decision is worth more than one dressed up as a fix.
The checklist itself grew, because a real defect had passed all sixteen questions. The folded tab row had correct roles, correct keyboard handling, correct contrast and correct behaviour at 320 pixels; not one question asked whether a person can see that a control does what it does. That is now the seventeenth, and adding it correctly marked every already-finished block as incomplete until each was answered – which is how a check that is data rather than prose is supposed to behave. Applied to the two blocks already signed off, it found one more.
Wiring the strings was half the job; nothing produced the list
The change before this one connected the plugin’s text to WordPress’s translation system. It left an obvious hole that was easy not to notice: nothing in the project produced the file a translator opens. No template file, no step to build one. The plumbing was live and the tap unreachable until the plugin directory generated a language pack at release, which is far too late for anyone who wants to translate it now.
Two sets of text could not have been collected even by a proper tool. The image viewer passed its four control labels through a small helper, so the words sat in the file looking perfectly ordinary while no collector could ever see them. Those four are the ones a screen reader reads to a visitor, which made them the only text in the plugin whose untranslatability reached past the editor. Elsewhere a panel passed the plugin’s own name through a variable instead of writing it out, costing another ten. Both were repaired in the source rather than papered over in the tooling – the rule being that text a collector cannot read is a fault in the code, not an exception to be listed.
The collector is written into the project, beside the scripts that already build the documentation and the readme. It reads PHP through the language’s own parser rather than by pattern-matching, so a piece of text mentioned inside a comment can never be mistaken for a real one; its first run against real code found six faults in itself, which is exactly why it was pointed at the real thing instead of a rehearsal. It now reports 2306 pieces of text and nothing it could not read, and it writes them in a fixed order with no timestamp, so running it twice on unchanged code produces an identical file. That last detail is what allows an automatic check to assert the list is current rather than merely well-formed, and that check went off correctly the first time a change shifted the line numbers underneath it.
An adversarial review of the collector, run the same day, found it shipping notes attached to the wrong sentence. The list itself was right where it mattered: an independent second scan confirmed not one of the plugin’s 3119 pieces of text was missing. What was wrong sat in the layer nobody checks – the short explanations that tell a translator what a number or a name in a sentence stands for. Half of them never made it into the file at all, and six were attached to a different sentence than the one they described, which is worse than missing: a translator follows them. Both causes were fixed, and the numbers moved from 56 of 103 explanations captured with six misattached, to 91 captured with none. The plugin’s own name and description, which live in a header rather than in the code and so were invisible to any collector, went in at the same time.
Worth recording as a habit rather than a fix: five more faults were found that were not firing, each one keystroke away from doing so – among them a sorting rule that could reorder the file with no text having changed, which would have broken the very check that guarantees the list is current. The review is also the reason an explanation in the previous release’s notes has been corrected: the conclusion and the live measurement stood, the reasoning given for them did not, and it was rewritten where it was written rather than quietly dropped.
A correction, recorded rather than quietly dropped. The reason first given for writing the collector by hand was that the standard command-line tool was not installed. It is, and it is active on every test install; the check that said otherwise looked in one place and treated an empty answer as proof. That question has since been settled the way it should be, by running both. The standard tool is installed – it is reached through the Studio command rather than as a program of its own, which is why looking for a program of its own found nothing. Run against the same code it produced an identical list, 2366 entries against 2366, once three details from the plugin’s own header were added to ours. Nothing is missing in either direction. Ours stays, for two reasons the comparison made concrete rather than assumed: the standard tool stamps every file it writes with the time it ran, so the check that guarantees the list is current would report a change on every run, and ours reports every piece of text it could not read, which is what uncovered both defects above. What any replacement would have to keep is the part that earned its place: this collector reports every piece of text it could not read, and that report is what uncovered both of the defects above.
Then the same question asked of a different surface found 103 more. The styling sidebar does not use the normal mechanism at all: it reads its labels from a list the server sends, and falls back to English written into the script when a label is missing from that list. 103 of the 324 labels it asks for were missing, so the fallback was all anyone could ever see – the whole move-and-rotate section, the labels for each state a block can be in, the alignment and image-fit choices, and a screen-reader label among them. Every check written this week was blind to it by being correct: the script contains no ordinary translatable text to audit, so nothing had anything to complain about. Only comparing the two sides finds it. All 103 are published now, two of them rewritten from sentence fragments into whole sentences first, and a new check compares the two sides on every run. Proven with a real translation file on a running site: three of the newly published labels came back translated, three deliberately left out stayed English.
The one thing the standard tool did better is now built into ours, and it found eleven faults rather than the seven it reported. It checks that a piece of text carrying a number or a name also carries a short note saying what that value is, since a bare number tells a translator nothing about what is being counted. Two pieces of text carried contradictory notes, which is worse than none: a translator follows them and cannot tell which is wrong. Fixing those surfaced four more of the half-sentence-glued-to-a-value shape, in the screen-reader announcements for reordering questions and steps – so the check gained a rule the standard tool does not have, and that rule immediately found two more nobody had gone looking for, both of them labels a screen reader reads aloud. Eleven fixed. Both tools were then re-run on the corrected code and both report nothing.
Two things from that round are worth keeping as habits rather than as fixes. The notes added to the first five were silently not picked up, because the collector demanded that nothing but a narrow set of characters sit between a note and the text it describes – the same mistake, in the other language, as one corrected days earlier. The rule is now about meaning rather than punctuation: a note belongs to a piece of text unless another piece of text sits between them. And a change that passed the syntax check, the audit and the whole test suite would still have broken a panel on screen, because it used a helper that file had never defined; syntax checks do not see that, and only looking for it did.
One risk was worth reading the WordPress source over before touching: pointing the plugin at its own translations folder could have made the system look there and stop, quietly undoing the previous change. It does not – and rather than leave that resting on a reading, it was measured on a running site afterwards, including on the published page where the image viewer opened with two labels translated and two, deliberately left out of the test file, still in English.
One reported gap turned out to be four percent of the real one
The task was small and already written down: the Conditional Content editor’s 152 translatable lines had been correctly labelled in v2.3.943 but never connected, so a translation could not reach them. Connecting them is one line. Instead of writing that line, the same defect shape was swept for across the whole plugin – and the reported gap turned out to be a twenty-fifth of what was actually broken.
Every block’s editing panel was in the same state. All thirty-seven per-block editor scripts, 1487 translatable calls between them, from the comparison table’s 176 down to a reference block’s three. So were the shared controls every block draws for picking an image, reordering rows and choosing an icon. Nothing in the codebase knew: the English rendered perfectly, no check went red, and the only symptom was that a Norwegian or German site would have stayed in English forever no matter what language pack it installed. All three surfaces are editor-side, so a visitor downloads nothing new.
The thirty-seven took one line, not thirty-seven. Every block registers its editor through a single loop, so the connection goes next to that loop – and a block added next year is connected the moment it joins the list, with nothing for anyone to remember.
Two habits are worth recording, because both were nearly missed. The first: the fix was proven by putting a real translation file on a running site before changing anything, watching the strings stay English, then changing the code and watching them turn over – while a control string deliberately left out of that file stayed English, proving the lookup was per-string and not a blanket override. The second: the automated check written to keep this from recurring was itself, in its first version, looking in only one folder. It reported everything healthy while the thirty-seven sat one directory away. It now walks both, and it fails if it ever stops reaching either – because a check that quietly narrows its scope reports a clean bill of health for the part it still looks at, which reads exactly like a clean bill of health for the whole.
Settings takes over the site-wide choices, and the switches stop waiting to be submitted
The working-unit choice moved out of the style panel. It shipped there one evening and left the next: a choice that governs the whole site belongs with the other site-wide defaults, not inside the column you use to style one block. It is now on Settings, and it is one setting for the site rather than one per person – which also means it no longer needs a per-user store to live in.
Then a sweep took the Norwegian shape out of the product. The trigger was small: an author noticed two example values on a test site that were written for Norway. Those turned out to be site settings rather than shipped defaults, but the question behind them was right, and four real leaks were hiding in code that does ship. The currency examples used a Scandinavian symbol as the archetype of “a currency”. The one-click region scaffold seeded a “Nordic” zone spanning Norway, Sweden and Denmark under a single shared symbol – three different currencies wearing one mark, so two of the three countries got wrong prices, which made it a pricing bug and not only a locale one. The built-in preview visitors were the Americas, Europe and Norway, seen unprompted on every install. The language pickers put Nordic ahead of every other language, and the comment beside the list said why out loud. And the plugin and author links, which the plugin directory displays, pointed at a domain that does not resolve at all.
One finding rode along that had nothing to do with locale: the Conditional Content editor ships 152 translatable lines carrying a sibling plugin’s name, so a translation of this plugin could never have reached them. Corrected – though worth saying plainly that they are now correctly labelled, not yet wired.
The Settings screen then learned to save properly. It had two differently named buttons doing exactly the same thing, one above the other. Removing the second one, the obvious fix, would have left four of five tabs with no save button at all – so instead every label became the same word and each tab got one within reach of the settings it saves. The rule that decided it: a button far below the fold teaches people the page saves itself, and then they navigate away having lost the change they thought they made.
Where the page really does save itself, it now says so and shows no button. The three tabs holding only on/off switches write each flip the instant it happens. That was already true of the block switches; it is now true of country detection, the image lightbox, the Cairnstone data panel and the AI drafting switch. Two of those drive text that changes with their state – and one of them also depends on whether Gillish Node is running, which the browser cannot know – so the server sends the finished sentence and the page places it. The AI switch saves instantly like the rest but behind a stricter door, because it is the single check keeping the AI write surface closed: it additionally requires full administrator rights, not just the plugin’s own settings permission.
The unit arc: sizes carry their unit as data, and typing 35 means 35
CSS has more than one way to say a size. Pixels are fixed; rem grows when a reader raises their browser’s text-size setting, which is the most ordinary accessibility act there is. Cairnstone had already committed to the reader-friendly form internally – but the author paid for it at the keyboard: a heading an author thinks of as 35 showed up as a field saying 2.1875, and the only way to change units was a built-in dropdown so small the product’s own designer worked beside it for weeks without seeing it. The owner’s ruling, backed by how the established page builders all do it: every size control gets its own visible unit choice.
Underneath, every stored size now carries its unit as data. A size used to be a bare number whose meaning depended on which code read it; it is now a value and a unit, stored together, and the render path does no arithmetic at all – it checks the pair is sane and writes it out, identically in PHP and in the editor’s JavaScript twin. A pair that fails the check is dropped, never silently replaced with a guess. This is what makes a design portable: the block means the same thing on every site because the meaning travels with it.
On the surface, every length control gained a small blue unit button – em, rem, px or % – always visible, one tap to switch. Typing got the same honesty: type 35 and see 35 (the reader-friendly form is stored behind the scenes, with a hint saying so), type “35px” to switch unit and value in one stroke, use a decimal comma, or paste a CSS size straight in; anything unreadable is refused with a visible message instead of being quietly replaced. A “Work in” choice on Settings → Site defaults sets the working unit once for the whole site – defaulting to rem – and never converts anything already set. It began as a row at the top of the panel and moved the same evening: a site-wide design default belongs with the other site-wide defaults, not inside the styling column.
The editor also stopped assuming every site draws text at 16 pixels. It now reads the canvas’s real base size and says so at the top of the panel when it differs, and text set to scale with screen width was reworked so it lands on exactly the same sizes as fixed text at every base setting – measured, and it did not before. The old dropdown is retired everywhere, the last leftover pixel-conversion code went to the visible archive, and the whole day’s work is pinned by the standing test battery.
The Contrast colour choice stops being a fixed pair of colours and starts reading the page
Among the ready-made colour choices for a badge, a section divider and an FAQ box sits one called Contrast. Its whole job is to stand out wherever it is placed. Until now it was a near-black fill with near-white text, decided once and stored – correct on a light page, and on a dark page the one choice whose entire purpose is to be seen was the one that vanished into the background.
The choice no longer ships fixed colours; it works them out on the page it lands on. At the moment the page draws, the fill takes the colour of the surrounding text – the page’s own ink, whatever the theme made it – and the label measures how light that fill is and picks white or black, whichever side of the legibility line it falls on. A light page gets a dark mark with light text; a dark page gets the reverse; a theme change re-answers the question by itself. The badge got this first, then the section divider and the FAQ box.
Getting there took four wrong constructions, each caught by measuring rather than reasoning. The first fill read its own text colour instead of the page’s, producing white-on-white at a measured 1.03 to 1 – invisible. The second used a blending trick that collapsed exactly on a mid-grey page, the case between the easy ones. Then a grey rectangle appeared behind the shield-shaped badge – found by looking at the dark page after every number had come back green, because the shield draws its shape on an inner layer and the new fill had painted the outer box too. And one theme paints a bare divider line mid-grey on its own account, which poisoned the calculation and drew a white mark on a white page. Each fix went in with its measurement attached.
The final numbers are read off the rendered pages, light and dark. The divider mark measures 21 to 1 on both – on the dark page the old mark was invisible. The FAQ box reads 18.88 to 1 on light and 7.37 to 1 on dark. A test now pins every load-bearing detail of the construction, proven the only way a test can be: each detail was removed in turn, the test failed, and the files were restored to the byte.
An untouched control now shows the block’s real value, in grey, instead of a zero
The owner put a badge on a page, opened its styling panel, and every spacing field said zero – while the badge on the canvas plainly had padding. The design was real; the panel denied it. And the zero was not just uninformative, it was loaded: one press of the stepper wrote 1 pixel over the block’s actual fourteen, replacing the design with the number the panel had invented. The height slider was worse – a resting control showed 31, and a single nudge made the block seven hundred pixels tall.
An untouched field now shows the value the block is actually drawn with, measured live off the canvas, in grey. It stores nothing. The small dot on the panel header – the one signal that a hand has chosen something – stays off, so scanning the panels for what has been decided works exactly as before. Touch the field and the stepper steps from the real value, not from a lie: nudge a badge’s fourteen and you get fifteen, not one.
The same honesty went in everywhere a number or a colour rests. The spacing boxes, the corner radius, the typography numbers, the minimum height, and the colour swatches – a swatch with a dashed ring shows the colour the block inherits from the page, a striped one means truly empty. In every case the grey value is the measured truth, and choosing something makes it yours, dot and all.
Proven across the whole library, not the one block in front of us. An automated run walked the styling panels of all thirty-six blocks: thirty-six green. Two blocks first reported as having no panel at all turned out to be the measuring window degrading, not the product – disproven by running them again in a clean harness.
Code that leaves a file is moved to an archive, not deleted, and the rule caught something on its first run
The rule, set by the project owner: nothing is deleted. Code that leaves a source file is moved to an archive folder inside the project and kept switched off there, with a note saying where it came from, what it was, and what was measured before it left. The reason is not sentiment about old code – version control already stores it – it is that a wrong removal should be findable by looking in an obvious place, rather than by knowing which command to run against the project’s history. That distinction stopped being theoretical earlier the same day, when an editing mistake destroyed several hundred lines of a working document and the only thing that recovered it was a three-week-old copy on another drive.
The panel from the card below went first. All of it, out of the source file and into the archive, with the source dropping by around five hundred lines and keeping no commented-out remains. The test suite reported the identical count before and after, which is what makes the move provable rather than hopeful.
Then the last piece of that panel’s wiring came out, and the procedure earned its place immediately. The switch that used to control the panel was hardcoded off. Taking it out revealed a third thing nobody was looking for: a setting passed into the component that only the removed code ever read. That kind of leftover is invisible to the automatic checks – nothing would ever have reported it – so it would have sat there looking alive to the next person. It was found by the archive procedure’s own step for exactly this, not by the toolchain.
The replacement was proved by enumeration rather than by trying it. With the switch permanently off, the old branching logic could only ever produce one outcome, for every possible input, in both editors. That outcome is now written out as a single line. Confirmed afterwards on a running site: the same section opens as before, and the styling tab is untouched and complete.
A related check found four wrong statements in the project’s own design document. It described a section as appearing only when a block asks for it; the code does the opposite and shows it unless a block opts out, which means seventeen blocks display a sidebar section nobody ever decided they should have. It also presented a per-block setting as the way to switch styling off, when in fact no block uses that setting and the real mechanism is a hardcoded list. Both are corrected, along with two smaller claims about a panel that no longer exists. The seventeen-block question is written down as future work rather than answered in passing.
An old panel is switched off in place before it is removed, and the act of switching it off found a test and a page that were both wrong
An earlier version of the Look panel has been unreachable for some time: the flag that would show it is set in exactly one place, and set to off, because Look moved to its own tab. It is 509 lines. Rather than delete it outright, it is switched off where it stands, so the removal can be confirmed on a running site before the code is taken out. Nothing else goes with it: every helper it uses is shared with the panel that replaced it, which was checked rather than assumed.
Its own note claimed it was still in use, and the note was wrong. A comment above the old panel said it still served the ordinary post editor. A Cairnstone block selected in an ordinary post now shows nothing from Cairnstone in the sidebar at all, and the builder’s own Look tab is complete and unchanged. When a comment and the code disagree, the code is the one telling the truth – a lesson this week has now taught three times.
Switching it off immediately broke a test written two days ago, which is the useful part. That test reads the source as plain text, and text inside a switched-off block still reads as text. Its number of checks jumped by two-thirds overnight without a single working control being added: it had started inspecting a panel nobody can see. Harmless this time. The next time, it would fail on code that cannot run and send someone hunting a fault that does not exist – which is precisely what that test exists to prevent one level up. It now reads only lines that run, confirmed by running it against a copy where the code really is deleted and getting the identical result.
Then the pre-publication checks failed on a page that had never been checked with anything on it. The AI activity log had always been inspected while empty. Filling it exposed two readability faults that had been there all along. A small code beside each result was dimmed with transparency, which spent exactly the contrast its colour had been chosen to provide, leaving small text at 2.9 against a needed 4.5. The dimming is gone; the quieter reading now comes from a lighter weight, which costs no contrast at all.
The second fault is a good illustration of why measuring beats reasoning. In dark mode the column-sorting links kept a blue that barely separated from the background. The existing rule for that was correct but only covered the page heading – on an empty page, that was every link there was. Widening it to the whole page made things worse: 1.84 where the original fault was 2.18, because the export control and the filter chips are links dressed as buttons and keep their pale button surface even in dark mode, so they got pale-blue text on pale grey. The final rule was settled by reading every link’s actual colour against its actual backdrop, one at a time.
The request that tells an AI assistant what it may style answered every call with an error
The card below added eight styling controls to the list Cairnstone publishes for AI assistants. Being on a list is not the same as being usable, so the next step was to check that an assistant could actually reach them. It could not reach the list at all. Every call came back refused, with a complaint about the shape of the input - and three other read-only requests behaved the same way.
The cause is a single missing line, and WordPress’s own equivalent has it. When a request arrives with nothing attached, the input is checked against what the request expects before anything runs. Cairnstone’s requests never said “nothing is a valid input here”, so nothing failed the check. For a request that takes no input at all this is a dead end rather than an inconvenience: there is no wrong value to correct, because there is no value to send. WordPress core’s own site-information request answered the identical empty call perfectly, because its definition carries that one line. Five Cairnstone requests now carry it too, and all five answer: 46 styling controls, 7 primitives, 31 library blocks, 6 schema types, 13 saved blocks. The requests that genuinely need something - fetch this block, preview that one, create, update, delete - are untouched, because there an empty call really is the caller’s mistake.
Then the eight controls were proven rather than assumed. A block was created through the AI route with all eight set at once, read back to confirm nothing was silently dropped, and rendered so each could be measured on the page: the link took the exact colour asked for, the elevation drew the shadow its preset defines, the drop cap floated at four lines of height as specified, the text rotation resolved to exactly eight degrees, and the setting deliberately left off drew nothing.
Fluid text needed a second attempt, and the first attempt was wrong in an instructive way. It appeared to work: the text visibly grew and shrank with the window. It was the theme’s own fluid typography doing that, and Cairnstone had emitted nothing at all - because fluid scaling needs a size to scale down from, and the test had set no size. Re-run with a size, two different minimums produced two different floors, which is the only result that proves the setting is being read rather than merely accepted.
Two of the eight come with a condition, recorded so it is not mistaken for a fault later. The drop cap styles the first letter of a paragraph inside the block, so it needs a block that wraps a paragraph rather than a block that is one. Text rotation turns a specific inner layer that only some blocks draw. In both cases the instruction was written correctly with the chosen value inside it; it simply needs something to act on.
Two entries in the changelog were also corrected. Both described a status line in the sidebar changing its wording - a line that, as the card below records, never appeared on screen. The claims are marked as wrong where they were made rather than quietly edited away.
A setting the engine never reads now fails a test, and writing that test found eight controls nobody could see
The fault in the card below - a control that saved to one place and read from another - was closed with a check. That check only reached ten of the forty-one controls in the panel: the ten that name their setting as a plain piece of text a scanner can find. The other thirty-one are written by hand, and they read a value that was worked out from the block twenty or fifty lines earlier, sometimes in two steps. A scanner that simply looks for the name near the read finds four faults in those, and all four of them are the scanner being wrong rather than the code.
So the check was widened by asking a different question, one that has a definite answer. Not “does this control read the same place it writes” - that needs to follow code around - but “does the place it writes to exist at all”. Cairnstone keeps a single list of every styling control, and a control saving to something not on that list is saving into nowhere: the field accepts what you type, it looks accepted, and the page never moves. The same question is asked of the editor preview, which removes certain settings before drawing. Neither needs a list of old names to watch for, so the next time something is renamed, both checks follow it by themselves.
The first thing it found had been broken since the units changed, in a place no test could reach. The Badge is drawn as a shape that hugs its text, sitting inside an invisible full-width strip. The preview has to take spacing and borders off that strip, or a border meant to trace a shield ends up drawing a rectangle across the whole page. Two of those removals still used the old spacing names, so they had been removing nothing for two days. Every test stayed green throughout, for the same reason as last time: none of them go in that way.
The second thing it found is bigger than a bug. Cairnstone publishes a list of what it can style, and that list is what an AI assistant reads to learn what it is allowed to change. Eight real, working, everyday controls were absent from it: link colour, drop cap and how many lines it spans, vertical text, text rotation, the two fluid-text settings, and the ready-made elevation depths. Every one of them works in the panel and draws on the page. They were missing for a single reason - the list had been assembled by copying from one part of the drawing engine, and these eight are drawn by other parts. Nobody was ever going to notice by reading the panel, because the panel was right. The published list now names forty-six controls instead of thirty-eight.
And a line of the sidebar that never appeared has been removed. At the top of the Cairnstone box sits a short summary, one line per section. The Look line was switched off when Look moved to its own tab, and switched off in a way that could not be switched back on by accident - so the text it would have shown was worked out afresh every time you changed anything, and thrown away. It was left in place once, on the reasoning that it was one flag away from working again. That reasoning cost twice: the wording inside it was investigated as a real fault, and it was described to the owner as something on his screen, which it was not. Thirteen pieces of translated text went with it. The Visibility and Schema lines are untouched, because those draw.
Both new checks were proven the only way a check can be: by putting each fault back and watching the suite go red, then restoring the files to the byte.
The spacing box stops lying, then stops speaking a language nobody types in
The unit work above moved padding and margin onto the unit that follows the reader. It moved where the control saves to. It did not move where the control reads from, and the two had to match. So the box wrote to the new place and displayed the old one, which nothing had written to since the change landed.
That is not a dead control, it is a lying one, and the lie cost work. A new value never came back, so the field showed zero whatever you picked. Worse, a block built before the change still carried its old value, so the field showed 14 while the page was drawing the same fourteen pixels expressed the new way - a number sixteen times too large, presented as the current setting. And because saving takes the number the field is showing, nudging that 14 to 15 took the padding from fourteen pixels to two hundred and forty, on all four sides at once. Every design built before this week was one touch of that control away from it.
Nothing in the automated checks could have caught it. The engine that draws the page was correct the whole time, and every test of it stayed green, because none of them goes in through the control. The same day, every block that uses the engine had been re-walked and the drawing found sound - which it was. The fault was found by someone using the panel. A control is a third way into the data, and it needed a check of its own; it has one now, and the check was proven by putting the fault back and watching it fail.
Then the fix exposed the real problem, because an honest control showed what the unit actually looks like. The field now read 0.0625. That is one pixel, correctly stored, and completely meaningless to look at. Storing a size that grows with the reader is right. Asking a person to type it is not.
Two smaller things closed the same arc. The conversion line was only drawn once a side carried a value, so the field GREW as you typed and pushed the controls below it down while your hand was still on the stepper; it now holds its own space and appears in it, measured at a jump of zero. And the preset menu of eleven named spacings that the Frame gap used to offer had been replaced by a plain number field days earlier and left behind as a fallback that could never run - it was described to the operator as if it existed, he replied that it did not, and he was right. Deleted. The sizes themselves stay: a frame you have not typed a number into still uses one, which is exactly why such a frame shows a value in the reader-relative unit rather than pixels.
So the box asks for pixels and stores the other thing, and shows you both. You type whole numbers - 1, 16, minus 2 - with px printed beside the field, and underneath sits the conversion the page will actually use: 16px = 1rem. Nothing is hidden and nothing is assumed; the panel does the division and shows its working. The little arrows step one pixel at a time, which they did not before: a single press used to jump a resting box straight to sixteen pixels. Open an older design and it reads in both units at once, so what you see and what the page draws are finally the same number.
Sizes start following the reader, and the fix has to start where the number is typed
Some people set a larger default text size in their browser. It is the most ordinary accessibility setting there is, it is one click in the browser’s own preferences, and until this week Cairnstone quietly worked against it. You typed 21 into the size box, the box said “px”, and a reader at a 22-point default got 28.9. The number was never wrong. The unit printed next to it was.
There is a rule now, and it decides each control rather than each argument. Pixels are for the things that must never scale: a border, a shadow, a corner radius. Anything that belongs to one piece of text scales with that text: the space between letters, a first-line indent, the weight of an underline and its distance from the words. Everything structural scales with the page: text size itself, minimum heights, the width of a reading column. Applied to every size control in the panel, the rule answered all of them without a single judgement call.
The first attempt was built at the wrong end, and that is the useful part. It converted the engine that turns the number into a page, and left the control saying “px”. Everything rendered correctly. Every test passed. The lie simply moved one step further from where anyone could see it. Starting again at the control deleted an entire mechanism that the first attempt had needed: a way to work out what a hover state’s letter-spacing should be measured against, threaded through two separate places in the code and pinned by its own tests. Correct, tested, and completely unnecessary the moment the control stored the right unit. That whole apparatus is gone in the same change that introduced the real fix.
A test written days earlier caught the fix breaking something. Boxes now grow with the reader’s text, and a badge placed by hand on top of a card did not: at a doubled text size it measured 322 wide, sitting 74 in from the card’s edge, inside a card only 336 wide. It ran 59 pixels off the end. That fault had been there all along and was invisible while widths were frozen. It is closed in a way that leaves a deliberate overhang alone: if you hang a badge over the edge on purpose, nothing touches it. Only a box you placed inside is stopped from wandering out.
Two things were caught by looking at the screen rather than at the change. After the units moved, the help text under several controls still read “in pixels”, because those sentences come from a different half of the code that quietly wins. And the spacing menu in the layout box offered choices labelled 4, 8 and 12 that were never pixel values at all: they had always stored something reader-relative, and the bare numbers had been reading as pixels for as long as the menu existed. Same fault as the size box, one control away, and it was only found because every size control got swept rather than the one where the last fault sat.
Nothing you have already built changes. Every old value is converted once, when the block is read, using exactly the division the engine used to do on every page view. A card that was 620 pixels wide is 38.75 of the new unit, which is the same 620 pixels for a reader who has changed nothing, and a wider card for one who has. Measured on a real card: at the standard setting it draws 460 wide, at a doubled text size 920, with nothing cut off and nothing hanging out.
The last piece was spacing, and the answer there was to build less. Padding and margin are shared by every block, and the proposal was that the one control should mean two different things depending on where you stand: inside a section it saves one way, inside a button another. That would have needed the panel to know what kind of block every one of the thirty-nine is. So they were all read, from what their own code does rather than from what their names suggest: does this block hold other blocks, does it show its own words, how is its box sized, and has its own stylesheet already chosen to scale with the text.
The list came back too ragged to carry a rule. Twenty-seven behaved like frames, four like content, and ten refused to sit on either side. A second pass, whose only job was to argue against the first, overturned one classification in five. And the clearest example of all, the button, turned out to hold a decision already made in the opposite direction: its spacing had been tied to its text size once, that made the text size the only way to change the button’s size, it was reported as unintuitive, and it was deliberately untied. Re-tying it would have walked straight back into a complaint that had already been answered.
So the shared control saves the same way for every block. The proportional spacing the rule was reaching for already exists, in the three or four blocks that genuinely need it, written into their own styling where an author never has to think about it. What the sidebar sets is the space around a block, and that is the same kind of thing wherever you are standing. No table of blocks to maintain, no control that changes meaning when you click something else.
One last fault, found by a check running in a real browser rather than in isolation. There are two ways into the styling engine, and a handful of blocks use the second one. The conversion for older content had only been fitted to the first. Both test suites were green because each goes in through a different door: through one door an existing design kept its spacing, through the other it silently lost it. Fixed at both entrances. Worth recording because no amount of testing in isolation would have shown it.
The layout work gets a ruler, and it earns it by failing three times before it works
Everything in this arc is justified the same way: a number moved where we said it would. The measurements that produced those numbers had only ever been taken by hand, once, in a single sitting. So the next change would have been graded on whether it looked right, which is exactly how the earlier faults in this arc survived long enough to be found by eye.
There is now a ruler. It builds its own test page, opens it once, and reads five things at fifteen different screen widths, on all three surfaces a design is actually seen on: the published page, the block preview, and the workspace you build in. It depends on no hand-made content and cleans up after itself.
A test nobody has watched fail is a guess, and this one failed three times before it counted. The first deliberate break sailed straight through, because the test page had been tidied up in a way that removed the one piece of geometry where the broken rule could be seen at all. The second break was applied to a comment that quotes the rule rather than to the rule, so nothing changed and the test was honestly green. And one set of readings had to be thrown away because the corrected code was never copied out to the test site, so the measurements were taken against the broken version. All three are now written down where the next session reads them, because “the break survived” means three different things and only one of them is a weak test.
The theme's text column was reaching inside a frame. A box 740 wide held a container measured at 676, correct, which held a row of columns measured at 645. The missing 31 pixels were the site theme's text width applying to something two levels inside a box that had already been given its own width. The real cost was not the pixels: below about 800 pixels of screen the theme value stopped applying and the two evened out, so the relationship between a box and its own contents changed depending on the browser window. Nothing inside a frame could be reasoned about while a number from outside was still in the chain. The theme's column now stops at the frame's edge. The frame's own alignment still reads that same theme value, which is why the fix is applied to the contents rather than to the frame: applied to the frame it would have silently switched alignment off, one control quietly overruling another, which is the fault this file has already recorded twice.
The block preview was showing a narrower design than the page. Not by a fixed amount, which is why it had been hard to pin: nothing at wide screens, 32 pixels at 800, 52 from 600 down. At two screen sizes it was enough to make a row of columns stack in the preview while the real page kept them side by side, so an author reviewing their own work saw something broken that no visitor would ever see, and corrected a design that was fine. The cause was arithmetic. The preview card was charging its own 24 pixels of outer padding plus 32 of inner padding to the content, 56 a side, against a theme that charges 30. It now spends exactly what the theme spends, so the two agree because they count the same way rather than by coincidence.
One measurement is recorded and deliberately left alone. In the workspace where you build, a box set to 740 wide reads 740 at every window size, including when the workspace itself is only 397 wide. On a narrow window the page draws 337 and the workspace draws 740. That is a 403 pixel difference between what an author designs against and what a visitor gets. It is now measured and watched, but not changed: what the workspace ought to show on a narrow window is a decision about the product, not a fault with an obvious answer.
Four relationships are now watched permanently, independently of any particular number: contents fill the box that holds them, the preview draws what the page draws, the preview stacks when the page stacks, and a freely placed element keeps its position as a share of the frame. That last one holds at 22.0 per cent at every width from 1920 down to 320. Its size as a share of the frame goes from 22 per cent to 65.7 over the same range, which is the open half of that problem, now measured rather than suspected.
Controls stop overruling each other, and a name you cannot see stops deciding which panel opens
Nineteen releases over two days, found the same way as the last batch: by building real designs by hand and measuring whatever looked wrong. Almost every one of them turned out to be two rules disagreeing, with the loser being the one the author had actually set.
Four screens were imposing a reading column the published page does not have. A panel set to 1040 pixels wide drew at 645 in the preview and in the ordinary post editor, while the block workspace and the real page drew it correctly. The same rule reached inside dialogs, containers and columns, all of which had already been given a width of their own, and squeezed everything they held. The rule that decides “is this box at the top of the page” had been written backwards: it listed the places it should not apply, which is a promise to remember every future box that holds other boxes, and that promise had already been broken twice. It now names the five places that really are the top of a page. A box that holds other boxes needs no entry at all, which is the whole point. Measured on the way: a box set to fill a full-width section was filling half of it, 645 pixels inside a section of 1226.
Switching a box to “placed freely” now leaves it exactly where it was standing. It used to jump to the top right corner, shrink to the width of its text, and often end up hidden behind the box below, leaving almost nothing to grab. Every design tool converts to free placement by keeping the box and letting you drag from there, and the pieces to do that were already in the code and simply never called. It pins the top and left edges rather than the nearest corner, because the box around it changes size the moment its child leaves the flow, and only those two edges stand still. A freely placed box is also drawn above the boxes it left, which sounds obvious and was not: every box is positioned, so the neighbour underneath was winning on nothing more than being later in the file.
A button and the panel it opens are paired by where they sit, not by a name they share. The name is stored inside the block, so every copy of a block carries the same one. Put the same saved panel on a page twice and you have two buttons and two panels all saying the same word: match on the word and every button binds to every panel. Measured with eight copies of one saved block, nothing edited by hand: one panel could be opened and seven were unreachable, with all eight buttons opening the same one. What survives copying is the arrangement, since the button is always inserted immediately before its own panel. That is what decides now, and a button you added yourself somewhere else on the page still opens the panel whose name you paste into it. Repeated names are also made unique where the page is built rather than in the editor, so a page saved months ago and never opened since is correct on its next view, with nothing for anyone to do.
The height you set stopped being overruled by the alignment setting. A box set to 118 pixels tall next to a taller neighbour came out 214. The height was being applied correctly the whole time and then stretched past, by a rule about left, centre and right that had no business touching height at all. Which direction “alignment” stretches depends on whether the boxes are stacked or side by side, and only one of those two cases was ever meant. A number you type is not a suggestion for another control to overrule. Boxes set to match the tallest neighbour still do, and alignment works exactly as before.
Buttons have a size setting. Until now the only way to make a button smaller was to make its text smaller, because the space around the label was tied to the font: one control doing two jobs, and not the two you wanted. Small, Normal and Large are their own choice now, and text size is set separately, so a small button with large text is possible and so is the reverse. The first version shipped the three steps only six pixels apart, which is not three steps; they are roughly 30, 42 and 58 pixels tall at a normal text size now. And full width on a button did nothing inside a layout box, in fact rather less than nothing: a button set to full width came out narrower than the same button set to its own width.
The greyed-out text inside an empty field can be written once. It is worded the same way on nearly every email box on a site, so there is now one setting per kind of field, and a field with nothing written on it uses that. Anything written on a single field still wins. The canvas shows the inherited words rather than an empty box, because editing against a box that renders text on the page is the confusion the setting was meant to remove.
And a saved block stopped building its contents twice on every page view. WordPress was rendering the blocks inside it, and Cairnstone was then rendering the same thing again and throwing the first result away. Found by two counters coming out one round too high rather than by profiling. It was proposed as the day’s clear speed win and, measured, it is not one: the same page takes the same time either way, because a page view is dominated by everything else. The duplicated work is gone and the counters are right, which is worth having, and the claim it was going to make is withdrawn rather than quietly kept.
The layout block becomes usable: four sides, placement that survives a narrow screen, and a place in the menu
Every change below came from using the block rather than reading the code for it. A design was built by hand, something looked wrong, it was measured, and the measurement said what to fix. Thirteen of them in a day.
A box can now be adjusted from all four sides, and a height you set is a floor rather than a ceiling. Only one edge had a handle before, and the block had no height at all. Both are answered, and the choice about height matters more than it sounds: content that grows past the height you set makes the box taller instead of being cut off. A reader who scales the text up cannot break the layout, by the way the rule is built rather than by anyone remembering to be careful.
The block is in the block menu now. It carried a flag meaning “not finished, keep it away from customers”, and this is a studio of one. The only person the flag ever hid it from was the person building it. Removed rather than softened. The two internal style benches stay hidden, by a different gate that stops them existing at all outside development.
The editor was lying about width, and everything placed by hand inherited the lie. A box set to 722 pixels drew 722 on the published page and 645 while you built it. Since a freely placed thing is pinned to a corner of the box around it, a box 77 pixels narrower in the editor throws every corner-pinned child 77 pixels off, and the design lands somewhere else than where it was built. Two rules were fighting with exactly equal weight, so the winner was whichever the browser happened to read last. That is a coin toss, not a rule. It is settled by weight now, on both surfaces.
A freely placed thing now holds its place as the screen narrows. Its position was stored in pixels while everything around it was fluid, so a badge sitting just inside its parent’s edge on a desktop had drifted to a tenth of the way across on a phone: the design did not shrink, it came apart. Sideways position is now a share of the box it sits in, and vertical position follows the text size. The two are deliberately different, and four real measurements decided it rather than taste. Nothing already placed moved.
Dragging an edge now moves that edge with your hand. Three separate reports turned out to be one fault with three faces, all on the same page: a centred box grew both ways, so its edge ran at half the speed of the pointer and drifted away from it; a nested box happened to behave correctly; and a corner-pinned box grew away from the hand entirely, so pulling the bottom edge down moved the top edge up and left the bottom where it was. The size now comes from the pointer’s distance to whatever is actually holding still.
Where a box sits across the page is a setting now, not a consequence of how deep it is nested. The same box was centred at the top level and pinned left inside another, and nothing in the sidebar said so. It is Left, Centre or Right, and it means the same thing at any depth. Right-to-left languages needed no work, because the stored values were never Left and Right underneath; only the words in the picker are. The first version of this then sent a left-aligned box off the edge of the page, because the margin it removed was not decoration: it was what builds the theme’s reading column. Left now means the left edge of the column you read in.
Two more faults from the same day, both reported with a screenshot. Every freely placed box drew a phantom spacing line across its parent, and each one you added drew another, because the spacing markers were counted from the boxes inside without asking which of them were actually in the flow. And “send backward” was sending boxes out of existence: they were present, full size, on screen, and painted underneath their own parent’s background. A thing sent behind its siblings should not go behind the wall. Fixed, and scoped so no page anyone has already built re-decides what paints over what.
Finally, the handles across the whole editor agreed on one order. Each block had picked its own layer in isolation, so the winner was whoever picked the bigger number: a column’s width handle was covering a frame’s move grip, and a selected box could not be grabbed. The rule is about intent rather than numbers now: a handle belonging to the block you have selected always beats a handle belonging to one you have not.
Two claims written here in the last few days were wrong and are corrected in the record. The un-grabbable box was described as the same fault as the backward stacking; it was a guess, written down without being measured, and it was two faults with one symptom. And a change to how the block tree is walked was written, mirrored to the test sites, and then taken back out unshipped when the measurement that motivated it turned out to be a broken probe reading the wrong page.
A real layout box arrives, and everything about it is dragged rather than typed
Until now a design was assembled out of blocks that each had opinions about spacing, and the way to line two of them up was to type numbers into both and re-type them whenever the words changed. The new box has one job: hold things, and decide how they sit. Stack or side by side, one spacing value, and a size.
Rounded corners nest correctly without arithmetic. A box with rounded corners works out what radius the boxes inside it should have, from its own radius and its own padding, and hands that down. Put a picture in a rounded card and it takes the right inner curve by itself. It keeps working at any depth, including through a middle box that has no radius of its own, which took a second pass to get right: the first version handed a grandchild its grandparent’s value undiminished.
Three ways to be a size. Fill the space, be as wide as the content, or be exactly this wide. Size belongs to the box rather than to what is in it: a paragraph that should stretch is a paragraph in a stretching box, which is the same answer spacing already had. A fixed width can never force the page to scroll sideways.
A box can be pinned to a corner and hang over the edge. That is the badge on the corner of a card, the one shape a stack of blocks cannot make. It is dragged, not typed: the original build shipped a corner dropdown and two number sliders, which is exactly the control shape the product brief names as the competitors’ failure, where you hold the geometry in your head and guess at numbers. Both were removed. You grab it and move it, and the numbers are what comes out.
Then spacing and width became drags too. A selected box draws a handle on each of its own spacing gaps and one on its edge; dragging sets the value, and dragging the edge is itself the declaration that the width is fixed, so there is no sidebar step first. Spacing snaps to the rhythm scale, because a crooked step there is a mistake. Width does not, because 437 pixels can be exactly right. Everything is reachable from the keyboard as well.
The proof of the whole thing was a real composition at ten screen widths, and it found three faults a passing test would not have. A box set to 520 drew at 584, because a width should name the outside of the box and not the inside. A fixed box refused to shrink on a phone and pushed the page 386 pixels sideways, because the theme’s own column rule outranked ours. And a row of rigid boxes overflowed rather than wrapping. All three fixed, all three now guarded, and the badge keeps its full overhang down to a 320-pixel screen with no sideways scroll anywhere.
Three things went wrong during the build and are worth recording. A review of the design, run before the next feature could copy them, found two faults in code that had already shipped: values were leaking from a box into the boxes inside it, so a nested box with a broken value silently adopted its parent’s; and a shared alignment control would have collapsed a side-by-side row into a stack with no error anywhere. The handles then turned out to be unreachable in ordinary use, which every scripted proof had missed: they appeared only while the box itself was selected, and the first thing anyone does is click the text to start writing. They now stay while you work inside the box. And a Frame briefly lost its entire toolbar, which is why moving anything out of one felt impossible; the checks had all been run in one of the editor’s two toolbar modes, and a green result on one of two modes is a green result on half the product.
The editor’s own labels stop getting in the way
Several blocks draw a small name tag in the corner while you edit, so you can click the box itself rather than the paragraph inside it. Three faults in that tag, all found by measuring the editor against the page.
The container’s tag was sitting on the author’s own text. It needs 22 pixels of clear space and nothing guaranteed it had any: with a paragraph long enough to reach the right-hand edge, the tag covered the words outright. A box with generous padding was never affected, which is why it survived this long. Parking the tag outside the box was tried first and rejected on measurement: containers nest, and a nested container flush against its parent put the two tags on exactly the same rectangle, which is a bug the insert button had already been cured of once. The tag now draws only on an empty box, where there is nothing to cover and nothing else on screen naming it. A box with content already has its name in three places the editor provides.
The same tag was buying editor height that the page does not have. It was seated in a text line sized by the surrounding text rather than by the tag, so three blocks were each about 13 to 19 pixels taller while you built them than they would ever be when published. Measured by removing the tag and watching the block shrink. This is the same family as the container that drew 120 pixels for a 45-pixel box: the editor quietly reporting a size the page does not have.
And a decision about when the tag should show at all, settled by measuring rather than by preference. It stays at rest, because it is the only way to click the box from the canvas: a first click on the content selects the content, and neither a carousel’s arrows nor a Read more button select anything. The moment you select the box or anything inside it, the tag goes away. So the editor measures exactly what the page ships at the precise moment you are styling the box and comparing it against what you meant. A hover-only version was measured too, and rejected: it covers the first line of your text and hides the only way in behind a pointer stroke.
The authoring walk that tested the new rhythm found something else entirely. A selected Read more or Reveal block had never offered its insert button, and had not since the insert button was rebuilt: those two have a page structure the rebuild’s list did not name, so the button stood in the page with the hide rule still on it. Pressing Enter at the end of the last paragraph still worked, which is why nobody had hit it hard enough to notice. All four structures are now named and guarded.
The canvas starts telling the truth, and the library gets its first real field
The day began with a test panel that looked right on the page and barely recognisable while you built it. Measured on the same design: a bordered box 45 pixels tall on the page was drawn 120 in the editor, and the box holding two of them was 505 against 288. That is not a rounding difference. It is a different picture.
The cause was one line. An empty box needs a minimum height or there is nothing to click into, and that minimum was being applied to full boxes as well, so any box an author had deliberately made short was drawn tall. It now applies only when the box really is empty. The same composition measures 292 against 288 afterwards; the gap went from 217 pixels to four.
Two things were blamed first and cleared by measuring. The insert button was accused of taking up the space: it is already out of the flow and contributes nothing, which was proved by pushing it further out and watching the height not move. Then the border and padding were assumed not to render in the editor at all: they do, and always did. The box was never missing. It was the right box at two and a half times the height, which is why it read as an empty container rather than the input it is on the page.
The insert button did have a real problem, just a different one. There were four treatments of it across the library, and only one was actually a competing shape: Repeater draws a full-width row bar so the action keeps one shape from zero rows to many, and Carousel draws its own because the standard one lands outside a clipped strip. Both keep theirs, for those reasons. The rest now share one definition, in one place, at 28 pixels, comfortably above the 24 that guidelines ask of a click target and small enough to sit in a short box without covering the words in it. Its size was never a choice this plugin had made; it was the editor’s own default, which is worth knowing before deciding it has to be that big.
It also stacked. In a nested design every level offered its own, and where a box nearly filled the one around it they landed on top of each other, two on exactly the same spot. A box that already holds something now offers its button only while it is the box you have selected, so there is never a second one to overlap. An empty box keeps its own at all times, because that is the way in.
An image now takes its height from the box, instead of the box taking its height from the image. A panel with a picture beside a column of text could only be lined up by matching a shape against a padding value on the other side, and re-matching both every time a word changed. The picture now fills what it is given and crops rather than stretching. The first attempt did not work and the page said so: with the wrappers set to full height but the picture still in the layout, a tall file made the row follow the picture again. Taking the picture out of the layout entirely is what actually reverses the direction, because something contributing no height cannot outvote its neighbour.
A dialog also stopped opening in the wrong place. Every panel sat near the top edge with no way to move it. The browser centres a dialog by itself, and WordPress’s own layout rules were overwriting that; specificity alone could not win the argument, and a first attempt pushed the panel to the bottom instead. There is now a plain choice of where a panel opens: the middle of the screen, near the top, or near the bottom. Middle is the new default, which is what the browser intended all along.
And the library gained its first input, as the 29th block. Until now there was no field of any kind, so a form could only be drawn: a bordered box with a line of text in it. It photographs well and is not a field. The hint never goes away, nobody can type, and a screen reader announces body text. The new block renders a real one, and it was proved with a real keyboard rather than a passing test: focus lands, typed characters arrive, clicking the label moves the cursor into the field, an empty required field is refused by the browser itself, and the browser offered to autofill it, which it only does for a control it recognises. It sends nothing on its own, deliberately. Who runs the sending is a separate question, and bundling half an answer into a field block would settle it by accident.
The dark theme then caught two faults in that field which reasoning had missed. Its border came out invisible there, because a theme styling inputs by type outranks a single class, and once that was fixed the border was still too faint to see against black: measured at 1.55 to one where guidelines ask three. Both are fixed and measured on all four test themes, and the three-to-one floor is now a test that renders the field on a black background and on a white one, rather than a number written in a comment. A guard nobody has watched fail is not yet a guard.
Table is a table again: free layout is removed, to be rebuilt on its own
The Table block held two things behind a single setting: a real data table, with header scopes, a caption, merged cells and everything a screen reader needs to read a grid of numbers correctly; and a free composition canvas, a grid of cells you drop any block into, with no table semantics at all. They shared a name and nothing else. Free layout is now removed from Table and will be rebuilt as a block of its own.
The reason is not that either half was bad. It is that they were one block. Every change to either had to be reasoned about twice, the canvas needed authoring the table has no use for (dragging a cell edge, spanning rows, a per-cell width for phones), and the block’s own documentation had to keep explaining which of its two selves each rule belonged to. The day before the removal, free layout had been rebuilt for the third time in twenty-four hours; the rebuild was sound and is recorded, but the third rebuild made it obvious that the thing being rebuilt did not belong where it lived.
What went: the mode setting, the cell block and its six settings, the whole canvas drag mechanism, and the grid’s stylesheet section. The Table block’s stylesheet fell from 388 lines to 141 and its editor from 753 to 360. What stayed is untouched: header row and header column with real scopes, caption as a named region, merge and split that render correctly while you edit, striping that switches itself off when a merge would make it lie, sticky headers, per-cell styling, and the focusable scroll region that keeps a wide table whole on a phone instead of stacking it into cards.
This breaks existing free-layout blocks, which was accepted before the change was made: a block saved in that mode no longer has a mode to read and its cells are no longer a registered kind of block. No automatic conversion is provided, because the place that content should convert TO does not exist yet. A guard now fails the build if any part of free layout reappears in the Table block, on the manifest, the render, the editor and the stylesheet at once; it was deliberately broken three separate ways to confirm it can fail before it was trusted.
One thing worth keeping from the rebuild that is now removed, because the next block will want it. The design that finally worked stopped writing sizes as instructions on each cell and wrote them as plain numbers instead, which a single rule in the stylesheet then read. That one distinction removed every forced override from the file, let a cell keep its own width on a phone rather than the whole grid opting out together, and left the parent grid with no styling of its own to go stale.
A modal that can be any shape, and a fix that had to be undone first
Put a grid of columns inside a modal set to fit its content and the panel came out 120 pixels wide, on a phone and on a wide desktop alike. It looked like a fault in the grid. It was not. The panel was asking the grid how wide it wanted to be, and the grid had been told to fold itself according to its own width, so it had to know that width before it could answer. Each was waiting for the other. The rule that created the standoff had been added for a good reason, to let the grid react to the space it was given rather than to the size of the screen, and it was the wrong tool for the job. It is gone, and the sliver cannot happen any more.
The obvious replacement was to put the folding into the column definition itself, so the grid steps from three columns to two to one according to its own width with no such rule. That was built, and measured working in Chrome, Safari and Firefox, in both text directions. It is not what shipped. A cell that spans several columns is written by the cell itself, which cannot see how many columns the grid currently has, so a folding count let a span outlive the columns it was sized against: the grid then grew wider than the space it had and pushed the page sideways on a phone. That is an accessibility regression the previous version did not have. An independent review of the change caught it, and the finding was reproduced before it was accepted.
So the column count stays fixed, and the folding happens at one screen-width threshold that drops the count and every cell span in the same instant. Both halves are needed; either alone reopens the fault. The honest cost is that this follows the screen rather than the block, so a three-column grid dropped into a narrow sidebar on a wide display keeps its three columns. It does not spill or clip there, it is simply narrow. Folding by the block’s own width needs the very rule that caused the sliver.
With the standoff gone, the grid’s own width setting had nothing left to decide and was removed: it exists to report the width its content needs and to fill the space it is given, and it now does both instead of asking you to choose. A block saved with the old “only as wide as its content” setting now fills its space like any other.
The modal gained the shapes it was missing. It can be full screen. It can take a width you set with the height following the content, which is the sensible default for most dialogs. The exact-rectangle and fit-the-content settings are unchanged. All of them stay inside the screen, so a modal is naturally wider on a desktop than on a phone with no second number to keep in sync. Fit-the-content is still right for one image or one message, and the editor now says plainly what it costs: a grid of columns inside it comes out as a single narrow column, for the same reason as before.
Two process notes worth recording. The first conclusion written here was that no combination of settings could give both behaviours, and that was wrong: it was true only of the one approach that had been tried. The claim was challenged rather than accepted, checked against an outside review and six independent attempts to break it, and every proposed solution was re-tested in a real browser before being believed; two did not survive that. The second: every new guard added with this change was deliberately broken to confirm it fails. One of them could not, because a character in it had been mangled when it was written, so it demanded something that can never appear and would have passed forever. A test never seen to fail is not yet a test.
Search data gets a name, and a quiet text-mangling bug is closed
A page can send several pieces of data to search engines at once: one for each product you review, one for a price table, one for a set of questions. Until now none of them said which thing it was about. On a page reviewing five fishing rods that meant five nameless products, floating free, with nothing tying them to the page or to each other. Each piece now carries an address of its own, built from the page it sits on and its place on that page.
That address is worked out fresh every time a page is shown, and never saved. The reason matters if you reuse your own blocks: a block you design once and place on many pages is a pointer back to the design, not a copy of it, so an address saved into the design would be the same address everywhere you used it. Two things sharing one address are treated as one thing. We measured what that costs: two products sharing an address are rejected by Google outright, whether their contents differ or match, and the same pages pass cleanly once each has its own. Worth knowing for its own sake: neither Google’s checker nor Schema.org’s could tell a well-formed page from a muddled one otherwise, so the design rests on the data being true rather than on a tool’s verdict.
The Review Box also gained an optional customer rating, kept separate from your own score because they are two different claims about the same product: your verdict, and what a body of buyers said. Both the average and the number of ratings are needed, since one without the other is not sent at all, and the sidebar says so. One thing to know before using it: Google’s rules say not to reuse ratings collected on another website, so a figure copied from a shop breaks their guidelines even when the number is true. The field is there and the choice is yours.
Underneath all that sat a fault worth describing plainly, because it touched far more than search data. Blocks created by an import file or written by an AI were storing your text wrong: any name, heading or sentence containing a quote mark, an apostrophe or an ampersand came out mangled, so a product called Erlend’s “Best Buy” rod & reel was saved as gibberish and displayed that way. Blocks you write and save yourself in the editor were never affected, which is exactly why it went unnoticed. Two relatives of the same fault turned up beside it: what you paste into the advanced search-data box was being quietly thrown away instead of sent, and a code snippet arriving from a file or an AI lost its line breaks, its indentation and any HTML in the sample. All three are fixed, all three now refuse bad input plainly instead of silently, and the same fault was found and fixed in Gillish Node, where a tool that rewrites links inside published articles had been damaging them a little more on every run.
Two smaller repairs came out of the same pass. Deleting the picture from a Review Box used to be permanent, with no way to add one back short of throwing away the whole box; there is now an add button inside the block, and it brings back any part you removed. And the Pros and Cons block ignored your choice of layout while editing, always showing one list above the other, because its switch-to-one-column threshold grew with the theme’s text size while the editor’s width did not. Published pages were never affected by that one; only the editor was misleading you.
Ratings become crisp pairs
A score drawn as symbols has two states, earned and not earned, and the difference has to be unmistakable. It was not. The Score Box asked for two separate icon choices, one for the earned symbols and one for the empty ones, which allowed a heart next to a mountain; and because every icon in the shared set is drawn as an outline, the “empty” state could only be the same outline faded. On a dark page the faded copy nearly vanished, so a four-out-of-five read as four-out-of-four. Two rulings settled it: the symbol is one choice, never two, and a rating never fades anything.
The fix was to give each rating symbol a genuine pair in the icon set: a solid shape for the earned points and the hollow outline of the same shape for the rest, the classic filled and hollow star. Six pairs ship, star, heart, thumbs up, circle, smile and gem; two are Cairnstone’s own and four come from Font Awesome’s free set, credited in the plugin. Picking one is now the whole job: the typed-character fields are gone, the picker offers exactly the six pairs, and the star is the default. The faded look is removed from the plugin entirely, held out by a test that fails if it ever comes back. The Testimonial’s star row, previously plain text characters whose weight depended on the theme’s font, now draws the same pair, identical in the editor and on the page.
The Comparison Table joined last, and went further. A cell there has always been a check, a cross, or typed text; it can now also be a star rating: a third toggle in the editor turns the cell’s field into a number, and the actual stars draw beside it as you type, half stars included. Typed text like “4/5” stays text; only the toggle makes stars, because the block never guesses at what a value means. And when the columns are named products, a rating row is also published as review data search engines read, one review per named product, the kind of data that can put star ratings under a page in search results. It uses the Review Box’s safety rules unchanged: only a named product gets a review, and your own page is never the subject.
Bugs that passed every test
The through-line of the week was one kind of bug: a block or a control that looks right, saves what you set, passes the whole test suite, and is broken only on a surface nobody happened to look at. It is the most expensive kind, because a green check reads as proof and is not. Several turned up in a row, and each one is now held down by a test that fails loudly rather than shipping quietly.
Two were blocks leaning on a neighbour. The icons you can pick for a Score Box were invisible while editing, because the block never placed the shared icon sheet on the page itself; it looked fine on every test page that also held a Button, which places that sheet for its own sake, and broke only on a page with no Button. Separately, an icon chosen by typing a Dashicon name was invisible to a logged-out visitor, because that icon font is loaded only inside the admin, and looked correct to the logged-in author whose toolbar drags it in. Both were fixed at the single place an icon is drawn, so every block that draws one is covered at once. The lesson, now written into the checks: a block must bring what it needs, never borrow it from whatever else is on the page.
The others were about a block being present but not really there. A new block can sit in every internal list and still never appear, because one hand-written line that starts it up is missing; the maps agree with each other while the block registers nowhere. And an editor-only tag that lets you select a container block had vanished from Reveal, dropped because it was tucked inside the element that holds the block’s content, which the editor rebuilds from its own list. Both are fixed, and both now have a guard: one that ties every block on disk back to the registry and refuses a block that is never started, and one that refuses a select-tag placed where the editor will eat it. Each guard was deliberately shown to fail on the broken code before being trusted, so it is a real fence and not a decoration.
The HTML anchor reaches the page
Every block in WordPress has an “HTML anchor” field: give a block a name and you can link straight to it with #that-name. On Cairnstone’s blocks the field did nothing. The reason is structural: WordPress writes that name into blocks it saves as fixed HTML, and every Cairnstone block is built fresh on each page view instead, so the name stayed in the block’s stored settings and never reached the page. A link to a Cairnstone block went nowhere, on thirty-three blocks at once.
The half that mattered more was invisible. When you set one block to react to another, say a button that opens a panel elsewhere on the page, the anchor is how the two find each other. With no anchor on the page, the target could not be found, and the reaction fell back to acting on the block you clicked instead of the one you aimed at. A correctly set up interaction did the right thing to the wrong element, silently.
Both are fixed from one place: the anchor is now written onto the block as it renders, so links land and reactions find their target, while any block that already sets its own identifier keeps it. And a reaction that genuinely cannot find its target now does nothing, rather than something wrong, because doing the wrong thing to the wrong block is harder to notice, and to debug, than nothing happening at all. Verified on a real page in both directions: the reaction lands on the block it was pointed at, and when the target is removed, nothing on the page picks it up.
Settings that saved, and changed nothing
A control that does nothing is worse than a missing one. It stores what you set, survives a reload, and looks like a rendering fault rather than a control that was never wired up. Two separate families of that turned up when every setting a block offers was checked against what WordPress actually emits for it, read out of WordPress itself rather than recalled.
The first was a spacing control on three blocks. Declaring that a block supports “Block spacing” does not, on its own, make the setting do anything: WordPress emits that value only for blocks that also declare a layout, and none of the three did. Each needed a different answer, which is why copying the fix from one to the next would have been wrong twice. Reveal takes the layout, because the blocks inside it sit directly in its wrapper, and its editing view lost a nesting level it had that the page never had, so the canvas previews the structure it actually renders. Read More cannot take it: the blocks inside it sit one level deeper, so the rule would have landed on the Read more BUTTON and quietly redefined “Block spacing” as the distance to it. Its control is removed instead, because there was nothing there to space. Pros and Cons is a two-column grid, where the rule WordPress emits pushes the drawbacks column out of line with the good one rather than opening the gap between the lists, so the block reads the value and applies it itself.
Pros and Cons also turned out to have a setting named after a word Gutenberg reserves for its own use, and the obvious fix for its spacing control was, literally, to switch on the feature that would overwrite it. Renamed. Two things about that are worth recording, because both were learned the hard way in the same hour. There is no way to migrate such a value inside the editor: the editor discards any setting a block no longer declares before any code of ours can see it, so a migration was written, looked correct, and could never once have run. And the migration that does live on the page was written the obvious way first, “if the new name is empty, read the old one”, which passed its test and was wrong on the real page, because WordPress fills in a setting’s default before asking a block to render. The new name is therefore never empty. It was found by instrumenting the live render, not by reasoning, and the test now recreates that default so it is able to fail.
The second family was worse, and one of its cases had been introduced hours earlier by the fix above. When a block draws something itself and the author also sets a colour or a spacing, WordPress joins the two instructions with a space and no separator. Seven blocks were not closing their own instruction properly, so the two fused and BOTH were discarded. A Hero lost its background image and the shading that keeps its headline readable. A Grid collapsed to one column. An author photo lost its size. What made it survive this long is that the fused result is not broken enough to notice: it is technically valid, so nothing errors, nothing is flagged, and the browser inspector shows a plausible-looking line. It shows up only as a setting that does not take. One shared rule now closes every one of them, and the check that holds it was deliberately shown to fail before being trusted.
Finishing the review trio, mostly by looking at it
Splitting the review block into three left a question the split itself could not answer: three blocks a reader sees as one box have to agree about how they look, and nothing was making them. Designed separately, they had picked four different text sizes and five different spacings for the same small object, and it read exactly like that. There is now one scale for the set. Everything is body size and separates by weight, with the single step up spent on the score itself, because a label that is both smaller and bolder than its neighbour is two signals doing one job.
One of those sizes could not have been found by reading the code. The heading above each list is a real heading, so the list can be reached by anyone navigating a page by its headings, and a real heading takes the theme’s heading look. Nothing in Cairnstone said so; the size simply arrived from outside. The same page also showed the parts sitting flush against each other with no space at all, for a reason worth writing down: a theme’s vertical rhythm reaches the blocks sitting directly in the article and nothing nested one level inside another block. A composed block has to supply that itself.
Then the same page was opened on a themeless dark site, and all three fixes came apart in different ways. The two list headings rendered in a different size, a different typeface and a different colour from the score beside them, because a classic theme’s own heading rule is more specific than a block’s. The spacing fix produced nothing whatsoever, because WordPress writes that rule only for themes that declare the feature, which a classic theme does not. And the unfilled rating symbol, dimmed to sit quietly behind the filled ones, was invisible on black: a score of four and a half out of five showed four symbols and nothing after them, which reads as four out of four. Fading ink toward transparent loses far more contrast over black than over white, a lesson this project has now learned twice on the same block. Every value is measured on the real rendered page in both themes, and each one is written down beside the rule with the number that produced it.
The score gained three things while this was going on. It can lead the symbols or follow them, since both orders are legitimate and the choice belongs to whoever is writing. Its symbols can be icons chosen from the same set the buttons use, rather than only typed characters: two characters are a matched pair only by luck, and their weights come from whatever font the theme happens to load. And the percentage option now asks for a percentage. It used to ask for “twelve and a half out of twenty” and then print “63%”, which is two units for one number. What it does not do is change what is stored, so a trip through percent and back returns the same rating: a display choice that rewrites the underlying value is not a display choice.
Two fixes in the same window belong here for a different reason. A block set up to show a value stored on a post could be pointed at one of WordPress’s own internal values and print it publicly, or read one from a post still in draft, private or password-protected. Both are closed, and the guard is written so a protected value is indistinguishable from one that does not exist, which is the only version of it a test can actually prove. Separately, on a page written right to left, a score of four out of five was PAINTED as five out of four, because the algorithm that lays out mixed-direction text treats the slash as neutral and reorders the run. Nothing in the page’s text was wrong; only the painting was. Reading the page’s content could never have caught it, and a screenshot of an Arabic render caught it at once.
Three blocks instead of one, a day later
The Review Score Box shipped in the morning and was taken apart in the afternoon, at the owner’s direction and for a good reason: a score has uses well beyond a review, and so does a list of what is good against what is not. They are now two blocks of their own, Score Box and Pros and Cons, each usable anywhere, and Review Box composes them. A block one day old with no installed base is the cheapest thing in the world to reshape, so it was reshaped rather than defended.
Review Box is still a real block, not an empty frame. It arrives already composed, with a score, a pros-and-cons pair and a button inside, and every one of those is an ordinary block that can be reordered, restyled, removed, or joined by a heading or a paragraph. It keeps its own settings, because which product a review is ABOUT is the one thing none of the parts can know. The call to action inside it is now the library’s own Button block rather than a button welded into the review: welding one in had produced a lesser copy of a control that already exists, so the composed version carries every look, every state and every action a Button can perform.
The sharper correction was to the editor itself. The first version put a form field in the middle of the rendered text to edit the score, which solved an implementation problem and gave the author a strange canvas: a page that does not look like a page. The score is a setting, so it belongs in the settings panel, and after the split the block contains no input fields at all. What the canvas draws is now character-for-character what the page draws.
That was possible because of a second change. The original avoided ordinary text editing because the render stripped formatting, so a bold word would have vanished on save; the input field was a way around a self-inflicted problem. Each point in a list is now edited as ordinary text with the full formatting toolbar, and the render keeps what that toolbar produces. Plain words are taken only where markup would be noise, which is the structured data a search engine reads. And the score in that structured data is read from whichever Score Box sits inside the review, rather than copied onto the frame, so the number a reader sees and the number a search engine reads cannot drift apart.
A verdict block, and the blocker that was not there
The Review Score Box is the companion to the comparison table: the table ranks many products, this one argues about a single product. A name, a score on a scale you choose, a list of what is good, a list of what is not, a bottom line, and a link. It ships free and runs without a line of JavaScript.
The score is the interesting part, because a rating has to be two things at once. Readers want symbols; machines want a number. So the symbols are marked as decoration and hidden from screen readers, and the number beside them is real text. That way a screen reader, a search engine, find-in-page and a translation tool all read the same value, and an author who prefers to score out of ten, or in pine cones, changes nothing about how it is understood. The usual way to draw a half symbol is to paint a full row and crop it to a percentage. It renders any fraction and it fails badly: with the stylesheet missing, every rating on the site reads as full marks. Here the whole symbols are real repeated characters and only the single fractional one is cropped, so the worst a broken stylesheet can do is overstate a score by less than one symbol, right next to the exact number.
The block had been recorded as blocked since early June, waiting on an expansion of the panel where structured data is filled in by hand. Reading the code rather than the note showed the wait was unnecessary: nothing in the block library reads that panel’s catalogue, and every block that actually publishes structured data builds its own, the FAQ and How-to blocks included. The new block does the same, through a small shared file modelled directly on the one the two pricing blocks already share. It describes the product being reviewed and refuses to publish anything at all when no product has been named, rather than falling back to the page or site title, which is exactly how a site ends up publishing a review of itself. Google withdrew rich results for self-reviews in 2019 and treats them as a manual penalty, so the safe answer is silence.
Two pieces became shared on the way in. The star row Testimonial has drawn since it shipped is now one definition used by both blocks, and Testimonial came out of that unchanged to the pixel while gaining a proper text equivalent in place of a label only screen readers could reach. And a contrast check on the dark theme moved one value: the muted mark beside a drawback measured comfortably on a light background and too faintly on a black one, because fading grey ink toward transparent over black loses far more than fading near-black ink over white. The reason is recorded beside the value so the next person does not quietly undo it.
Three ways the same seam was leaking
Picking one part of a block to style is a designer’s tool, and the panel it drives exists only in the Cairnstone block designer. In an ordinary post that panel is absent, so the small handle that picks a part has nothing to open. Two blocks, the comparison table and the pricing cards, were still drawing that handle in ordinary posts: seven of them on one block and twenty-four on the other, reachable by keyboard, and clicking one quietly wrote to the saved block and marked the post as edited while nothing visible happened. The four blocks that had gained the same feature more recently all had the check; the two that had it before the shared code existed had simply never been given one. The handle is now shown only where it works.
That is also why the second change matters. Those two blocks each carried their own copy of the code that previews a styled part, written before the shared version existed and drifting quietly ever since, which is precisely how the first fault arose. Both now read the single shared definition. Moving them there fixed a real fault as a side effect: the preview keyed off a note kept in memory rather than the choice saved with the block, and that note starts empty, so reopening a saved block showed a part’s resting appearance while the style panel was editing its hover state. The change was on the published page and invisible while you worked on it.
Reviewing that change before it shipped caught it about to introduce a new leak of the same kind. Pointing the preview at the saved choice would also have pointed the selection outline there, and the outline had no designer-only check of its own, so a block built in the designer and pasted into an ordinary post would have drawn a blue selection ring nobody could clear, over a part whose own shadow it covered. The outline is now checked alongside the handle, and the case is held by a test.
The last of the three was a disagreement inside the designer: reopening a saved block drew the ring on the right part and let you style it, while the settings panel still said to hover a part and click the handle. The saved choice is a single identifier and cannot be read backwards into its pieces, because the identifiers inside it contain the same separator, so the panel now walks the real plans and rows, rebuilds each candidate, and matches. It can only ever answer with something that exists, and when the saved choice names a plan that has since been deleted it says so rather than guessing.
Every control gets its own address
The accordion was the hard one, and it took three attempts. Its headers are drawn by the item blocks inside it while their styling belongs to the accordion itself, so the obvious build has each item watch its parent for the moment a designer picks the headers. That does not work: an item is never redrawn when its parent changes. What ships instead has the items watch nothing at all. Each contributes a hook and a plain button that reads the parent only at the instant it is clicked, while the accordion writes a single style rule of its own that reaches down into its items, which is exactly how the published page already does it.
The two earlier reverts are worth recording, because neither was a flaw in that design. The rule stopped being written because of the loading-order fault fixed in the previous card: the accordion had read the “is this the designer editor” flag once at load, frozen it wrong, and skipped the branch entirely. That fix had already shipped, but the accordion had been withdrawn by then and was never re-tested against it. The last apparent failure was not a failure at all, but a measurement error this project has now met four times: a colour read from a background browser tab reports its value from before the change. The tell had been written down twice and read correctly only the third time. A style rule that matches its element, has no competitor, and is still ignored cannot be losing a contest of priority, so priority is not what is wrong.
The carousel dots then followed the arrows in a few minutes, with their own separate address, so a designer can make the dots square and blue while the arrows stay round and red on the same carousel. That closes the run that began with the Read More button: the Read More toggle, the tab strip, the accordion headers, the carousel arrows, the carousel dots, and the two pricing blocks’ buttons and columns can each be styled on their own, without repainting the block around them.
Showing the designer the real control, not a stand-in
The carousel was the awkward case. Its editor navigation is a numbered slide-picker, chrome that exists so every slide is reachable while writing, and it looks nothing like the arrows the page actually draws. Hooking those arrows up for styling would have meant a designer adjusting one shape and a visitor seeing another, so the block waited. The canvas now draws the real arrows too, the same control the page emits, so their fill, size, rounding and colour are exactly what ships. They are a preview: clicking one picks the pair for styling, and the numbered strip above still moves between slides. Their POSITION in the editor is not the page’s, because the page places arrows through a layout the canvas does not run, and that is the deliberate trade: placement is a setting of its own, appearance is what this panel changes.
Two defects surfaced on the way, both invisible and both the kind that erode trust in a preview. A rounding written as a percentage was read as pixels, so a circle defined as “half the width” became a fixed number, which on a larger control would have squared its corners the moment a designer selected it. And a flag that decides whether the editor is the designer surface was read once when the page loaded rather than when a control is drawn: load the files in the other order and every designer-only control disappeared, with no error, coming back on the next reload. Both are fixed and both are now held by tests that were watched failing first.
The accordion headers did NOT ship, twice. Their styling belongs to the accordion while the headers live in its child items, and the children never notice the parent being selected. A second approach, where the parent writes its own rule and reaches down, got as far as drawing the selection outline correctly before failing on a specificity detail, and the attempt to match how the server solves that made the rule stop being written at all. Four explanations were each ruled out by measuring rather than reasoning. Rather than ship a control that highlights nothing, it was reverted and the one untested explanation was written down for the next attempt.
Making a part’s panel tell the truth
The card above gave one control its own styling address. Using it in earnest exposed something wider: a part’s geometry defaults live in the stylesheet, never in the stored styling, so the panel read the store, found nothing, and displayed zero next to a button that was visibly a rounded pill with a thin outline. Typing the zero already shown wrote nothing, because the value had not changed. The panel now MEASURES the element it is about to style and fills in what that element actually draws, so no block declares its defaults twice and the reading is honest by construction. It fills in only what the designer has not already set, and only when the measurement is not zero, so a genuinely square part keeps its truthful zero. The outline became removable the same way. The comparison table gained the reading for free: its buttons are rounded by the look and read as zero too, found while re-checking that block after the engine change.
One measurement error is worth recording, because it was invisible and nearly shipped. Browsers snap an outline to whole screen pixels, so on a 125 percent-scaled display a one-pixel outline reports as 0.8 and a three-pixel one as 2.4. Reading that at face value would have filled in two for a three-pixel outline and quietly thinned it the moment a designer selected the part, which is the one thing selecting must never do. Outlines now undo the display scale; corner rounding deliberately does not, because it is not snapped the same way, and the same screen that reports a one-pixel outline as 0.8 reports a 999-pixel pill as 999. Both halves are now held by tests.
Two things followed. The tab strip joined the list of controls a designer can style on its own, through a “Style the tabs” control beside the existing move and remove buttons; one address for every tab, because tabs are a set. And a Reset this part button returns a part to whatever its block’s own stylesheet says, which gives back the “no opinion” that making zero mean sharp had cost. Not everything landed: the accordion headers live in child blocks while their styling belongs to the parent, and the child would not notice the parent’s selection through three attempts, so the change was reverted rather than shipped as a control that highlights nothing. The carousel is waiting on a preview of its real arrows, because its editor navigation is a numbered slide-picker rather than the arrows the page draws, and styling against a stand-in would mislead. The modal needs nothing: it is a bare panel whose close is an ordinary Button block, already styleable in full.
Styling the button, not the box around it
Read More shipped with one styling address: the block itself. So a designer who wanted a black button with white text got a black fold, because the only thing the panel could reach was the whole box. The toggle now carries its own address, is drawn in the editor canvas, and is picked by clicking it there; the style panel then says it is styling the button, and every control writes to the button alone. Clicking it again hands styling back to the block. The canvas previews the button’s real look, including whichever state is being edited, so a hover or focus design is visible without saving first. In the ordinary post editor the button is drawn but not selectable, because the design panel does not exist there and a highlight with nothing behind it is a lie.
Two gaps in the shared styling engine came out with it, and both reach every block with individually styleable parts. A part’s corner radius was written into a cascade layer that the block’s own stylesheet outranked, so a radius set by hand never reached the page. And dragging that radius to zero deleted the value as “no opinion”, which handed the rounding straight back to the block: square corners were not merely hard, they were unreachable. A part has no house default to fall back on, so on a part zero now means sharp, and it stays. The last piece was honesty rather than plumbing: with no radius stored, the control displayed zero next to a visibly rounded button, so picking the button now fills in the rounding it actually draws. Measured on the finished page with animation stopped first: the button paints black with a white label at 18.88:1 and square corners, while the box around it stays exactly as it was.
Read More: a fold that search engines and a JavaScript-off reader still see whole
Long-form and buying-guide pages needed a way to tame length without hiding content. Read More wraps any blocks and clips them to a height, with a real button that folds the rest into view. The content is always in the page, clipped with a max-height rather than removed with display:none, so search engines and find-in-page read all of it. With JavaScript off it shows in full and the button stays hidden rather than sitting there dead: the clip and the button are both gated on @media (scripting: enabled), while the collapsed state ships in the server markup, so a JavaScript-on visitor is clipped from the first paint with no flash and no layout shift.
The soft fade at the cut is a CSS mask that fades the preview into the real page background beneath it, so it is correct on any theme with no hard-coded colour; a hard-cut option turns it off, and a reader who prefers reduced motion gets an instant open. An optional setting remembers, per reader and on their own device, that they opened it. Verified live on a light and a dark theme (the button wash stays legible on both), and put through a three-lens adversarial review that added a print rule so a Save-to-PDF keeps the folded text, scoped the toggle so a Read More nested inside another controls only itself, and hardened the open and close against a fast double-click. It sits beside its neighbours without overlapping them: the Accordion is a list of titled sections, Reveal eases content in on scroll, and this is one inline expand of a single stretch.
Closing a Callout: a Button with a job, not an operating-system X
A dismissible notice needs a close control, but a small × pinned to the corner reads as application chrome rather than as part of the page, so there is no built-in one. A Callout is made closeable by dropping the ordinary Button block inside it and setting the Button’s action to “close a callout”, a new verb alongside the links and interaction actions the Button already carries. The Button renders a plain marker; one small delegated script closes the nearest enclosing callout on click and moves focus somewhere sensible, and it loads only on a page that actually holds such a Button, so a page of static notices still ships no JavaScript.
An optional setting remembers the close per reader. With it on and a stable id set, closing writes to the shared visitor-state rail the previous card repaired, and a return visit hides that one callout before the first paint rather than flashing it and pulling it away. It is off by default, is per-device by design, and makes Callout the third block to lean on that rail. Nothing is hidden from search or find-in-page by it: a remembered close only removes an advisory the reader has already dismissed.
Callout: the advisory box, and the icon picker it forced out of two blocks into one
The most-used documentation primitive had no block. Callout ships five kinds, each with its own colour and icon; the body is ordinary inner blocks, so a callout can hold a paragraph, a list, or a code snippet. The colour is reinforcement, never the message: the wording carries the meaning, the icon is decorative, and every surface is a translucent wash of one hue variable over currentColor text, so the box composites over any theme rather than pasting a fixed fill on it. Measured on the rendered page: 17:1 on text against a light theme, 7:1 against a black one, with the five icon lightnesses solved so each clears 4.5:1 against both white and black.
Wiring an icon picker onto Callout meant a third copy of a control that already lived, privately, in Button and Badge, and the two copies had already drifted apart. They were extracted to the shared block runtime as one control and both locals deleted, with two accessibility lies fixed once rather than propagated. The same work surfaced three more defects that were fixed rather than filed: a fresh Callout showed two add-block buttons; four other blocks (Accordion item, Reveal, Carousel slide, Tab panel) showed none once their body held a block, so a second block could not be added at all; and Badge’s editor hid an icon its front end still rendered. Three inserter-icon collisions between block pairs were separated in the same pass.
Visitor state that finally survives a page load, and a review that caught the fix mid-flight
The logged-in read filtered stored values through an in-memory namespace registry that nothing ever populated: the method that fills it had no caller, its shipping left to a “Pass 4” that never landed. So the boot payload the front end hydrates from came back empty for every logged-in visitor, whatever was stored, while writes and single-key reads worked, which is why it stayed invisible. The write is the only place that knows the namespace an author typed, so the write now records it in a persisted index the read consults. Three coupled defects fell with it: booleans stored as the string “1” now round-trip with their type, logging in no longer discards cookie state it could not migrate, and migrated data is now visible rather than orphaned.
An adversarial review of the fix caught a collision before it shipped: the first value envelope would have mis-read any pre-existing value that was itself a JSON object. It is now a version-marked prefix that only unwraps a value that also parses as JSON. Conditional Content was proven, by code rather than assumption, to be untouched: it never enters this primitive, gates server-side, and reserves its own namespace. The one shipped writer is a write-only sink with no reader yet, so nothing rendered wrong for a visitor; the foundation is simply correct now, before anything loads it.
The 27 submission blockers two disarmed lint rules were hiding
WordPress Plugin Check is the gate a new plugin must pass to be listed, and it reported 27 blocking errors: missing translator comments, unordered placeholders, and direct filesystem calls. The cause was exact and self-inflicted: two lint rules that catch precisely these had been excluded wholesale from the project’s own config, each on a “noise over signal” rationale written before the noise was measured. A check told to look away is a check that stopped running. The excludes were removed and the findings closed, most as real fixes and a few as per-call-site exemptions that name why the WordPress wrapper is the wrong tool there.
Removing the excludes was not enough to make the pre-push gate able to fail on them, since the sniff shipped its findings as warnings the gate ignores; it was raised to error, and a behavioural guard now runs the real config against a deliberately-violating file so the rule cannot be silently switched off again. A live Plugin Check run then surfaced two more classes the local config never governed: three direct-access guards buried past the line where the checker stops looking, and mixed line endings a scripted edit had introduced. Both were fixed and pinned. Plugin Check on the floor install now reports zero errors and zero warnings, other than the deliberate pre-launch stable-tag difference.
One panel per block: a scattering sidebar fixed by rule rather than by patch
Two blocks that open with a layout-variation picker (Hero, Discount Banner) lost their own sidebar panels on a fresh insert. The cause was not a general Gutenberg ordering quirk: the picker was returned before the sidebar was built, so the block contributed nothing to the inspector at mount, and every panel it added later was appended below the shared Cairnstone panels. Discount Banner carried the same early return above useBlockProps, which cost it the block wrapper entirely, so a freshly inserted banner could not even be selected. That had been fixed on Hero at v2.3.63 and never carried across.
The first fix pinned only the first panel and was rejected by its own measurement; the second was caught incomplete by review. What closed it was the standing rule set at v2.3.508 during the comparison-table merge: a block contributes exactly one PanelBody, with what used to be separate panels folded into sub-headings inside it. Sections inside one panel are children of that panel, not of the shared slot, so the ordering hazard cannot occur by construction and the earlier anchor patch was deleted. The library-wide sweep also corrected the count: the previous release claimed seven blocks still stacked panels, and the real number was four. Three further blocks each carried a private copy of the sub-heading helper (Modal’s visibly different from the rest); it now lives once on the block runtime and is read by nine.
The block stylesheet split: 380 KB on every page becomes ~13 KB on the pages that need it
gc-blocks-frontend.css was a single 380 KB, 982-rule stylesheet loaded on every front-end request whether or not the page contained a Cairnstone block. It is gone. In its place: gc-base.css (shared tokens, resets, the @layer cascade order, and the accessibility and CTA rules used by more than one block) plus one gc-<block>.css per block, each shipped through wp_enqueue_block_style() so it downloads only on pages where its block is actually present. A page with one block now ships roughly 13 KB; a block-free page ships none. The editor-chrome stylesheet got the same treatment, and a separate fix stopped its ~23 KB reaching visitors at all: it had been scoped by CSS selector rather than by request type, so the dead bytes downloaded anyway.
The split was preceded by a six-phase safety investigation recorded in docs/css-split/, using a deterministic clone-and-prune splitter proven to conserve every rule with none missing and none added. Rule conservation turned out to be necessary and not sufficient: an adversarial review caught a bare @layer declaration that had lost its semicolon, which a CSS concatenator would have turned into a cascade bomb. The standing law is now written down: one CSS file per block, deletable with the block, and anything shared by two or more blocks lives in exactly one base file rather than being copied into each consumer, because there is no build step that would keep the copies in sync.
Code Snippet: a real code panel that still works with JavaScript off
Paste code, pick one of 35 languages and one of 15 editor colour themes (Monokai, Dracula, Nord, Solarized and the rest), and the block renders a proper code panel with a copy button, a filename header, line numbers, per-line highlighting and a wrap toggle. It is built as progressive enhancement: the server always renders plain escaped text, so the snippet is readable with scripting off, indexable by search engines, and copies cleanly. A self-hosted, bundled PrismJS colours it in the browser, and only on pages that contain the block.
The follow-up releases were all legibility and speed on the same block. The editor preview had been showing something the page never shipped, silently omitting the header and buttons and leaving “line numbers” and “highlight lines” as dead switches with no visible effect. Colours now render live as you type, the line-number gutter derives its colour per theme (several themes rendered line numbers close to invisible, Nord at 1.69:1), the copy and wrap chrome was contrast-fixed across all 15 themes, and typing in a long snippet got roughly three times faster.
Modal, rebuilt twice: a decoupled dialog, then an empty one on purpose
The first Modal shipped with the trigger button welded to the panel and the styling attached to an invisible wrapper, so colour, spacing and border never reached the surface the visitor actually sees. The rebuild separated them: the trigger and the panel are two independent blocks auto-paired on insert, the native <dialog> element is itself the styled block root (which buys focus trapping, top-layer stacking, Escape and focus return from the browser), and the Button block gained a “dialog” action so any Button can open one. A full open and close system landed alongside: on load, after a delay, at a scroll depth, on exit intent, with frequency capping, auto-close, dismiss links, Escape and backdrop click. One shipping bug is worth recording because the shape recurs: a saved modal would not open at all, because its id lived in the anchor attribute, which does not serialize for a dynamically rendered block. The id moved to a declared attribute.
Then the whole presentation system built on top of it was deleted. Nine views, six semantic tones and roughly 458 lines of preset CSS went, on an explicit brief: an empty surface whose size you set in pixels, with no built-in close button, no default padding and no default corner radius. The reasoning is a composition argument rather than a taste one. The styled modal was a small padded content strip surrounded by dead space, which is not a surface you can compose inside; a layout grid belongs in a separate block you drop in, never baked into the dialog. Because the bare modal ships no close chrome, the Button needed a “close a dialog” action, and that generalised into a full vocabulary of ten actions with an optional second action riding the same click: link, open dialog, close dialog, download, copy, print, share, scroll to an anchor, media play/pause/mute, and toggle another element’s visibility with aria-expanded kept in sync.
Table: merged cells that survive editing, and a grid any block can be dropped into
A new Table block, built and redesigned inside two days. It began as a data grid and was refactored mid-flight from several blocks into one, because merging cells cannot be shown honestly any other way: the editor owns the block wrapper, so colspan and rowspan set on it never render. A mergeable table therefore has to be a single block whose parent renders the <table>, which is how the core table block does it too. On that footing it gained rectangular multi-cell merge and split, a sticky header row and first column, and per-cell styling through the shared style engine.
It then grew a second mode. In layout mode the same grid becomes a free composition canvas: any block can be dropped into any cell, and each cell is addressed by a universal cell id resolved identically by a JavaScript and a PHP twin. That mode is the composition substrate the bare Modal above was stripped down to hold. The block ships visible in the inserter.
The two-editor rule: a saved block behaves like a native block wherever it is placed
A permanent separation was settled and then closed leak by leak. Inside the gc_cairnstone_block designer, a Cairnstone block is styled with the Cairnstone Look sidebar and the native WordPress styling panels are suppressed. In an ordinary post or page, the reverse: the block shows only native WordPress controls and the Look sidebar does not appear at all. Getting there took several corrective passes, because an earlier session had stripped native typography outright rather than suppressing it in one place, then restored it under the wrong support keys, and the client-side block registration did not mirror the server-side fix, which silently hid native Typography for ten blocks in the regular editor.
The second half of the rule is what a placed block can do. A saved Cairnstone block dropped into a normal post is now fully editable there, content and settings: a Modal’s size, a Hero’s layout, a Carousel’s slides. An intermediate attempt marked only text fields editable and was corrected within the day on the grounds that settings are not styles; the lock was dropped entirely instead. Only the shared Cairnstone Style panel stays exclusive to the designer. One finding along the way turned out to be a false alarm worth naming: “inner blocks do not re-render in a placed instance” came from reading a cached DOM node after React had already replaced the subtree, and the workarounds built on top of it were removed.
The six interaction blocks, and the case that primitives beat a hardcoded library
All six state-and-interaction blocks in Phase 16 scope (c) shipped. Each server-renders its real content first and layers a small deferred script on top, so each one degrades to something usable with no JavaScript at all. Accordion is built on native <details>/<summary> and needs no script whatsoever, with six colour schemes and six structural looks. Tabs renders every panel server-side with correct ARIA and hydrates into a keyboard-correct tab strip, offering four looks, a vertical direction, deep links, per-tab disabling, and two mobile behaviours including folding into a real accordion with the ARIA roles swapped. Counter counts up on scroll, with a PHP and JavaScript number formatter matched byte for byte so the animated end state equals the server value. Reveal is free of flash-of-unstyled-content by construction, since its hidden state only exists behind a scripting-enabled media query. Carousel is a CSS scroll-snap track that degrades to a native side-scroller. Modal uses the native <dialog> element.
Two things came out of building them that outlived the blocks themselves. First, Carousel shipped almost unauthorable: there was no way to add a slide except Gutenberg’s default appender, which landed off-screen behind the clipped track, and no way to reach slide two at all. It now has a real “Add slide” button and an editor-only navigation strip. Second, a Tabs-specific complaint that the in-canvas move and remove buttons were invisible turned out to be library-wide: WordPress’ dashicon font is not loaded inside the editor canvas iframe, and blue-on-transparent buttons disappear on any dark block background an author sets. Both were fixed once, centrally, for every block that paints its own tools in the canvas, and pinned by a contract test.
When a control reads one state and writes another
One report, that the Filter sliders froze after using “copy styles from default”, turned out to be a single instance of a class. A style control can read the state slice you are looking at (Hover, say) while writing its value to a different one (Default), so the control appears dead. A second variant: a control whose neutral value emits no CSS at all, which means a non-default state can never switch off something set on Default. The sweep fixed the reported bug, found the same read/write mismatch in six more block-wide controls, then closed eight separate cases of the second variant one property at a time: opacity, outline, transform, text shadow, drop shadow, border width, corner radius and filter.
The durable part is the guard. Rather than a test per control, one generic test parses every style control in the inspector source and fails if any control’s read slice and write slice ever disagree again. The rule is enforced by structure rather than by review.
The block review pass: eleven blocks taken through the same checklist
A named, repeatable pass was run block by block against one checklist: dead or duplicate controls, focus rings, 44 px tap targets, right-to-left correctness, in-canvas authoring, and panel consolidation. The outcomes were not uniform, which is the point of running it rather than assuming. Section Divider lost its Shadow and Border controls outright, because both broke the divider’s geometry on every variant. FAQ’s icon rail turned out to be genuinely invisible, from dashicon font names that never load inside the editor canvas, and was rebuilt in inline SVG. Image was substantially reworked, with five distinct sizes, a border that frames the picture rather than the caption box, and a real alignment control. Badge, Button, Hero and How-to took smaller corrections.
Three blocks came out changed rather than corrected. Testimonial was redesigned from real product references into six selectable layouts (Stacked, Side-by-side, Split-hero, Editorial, Centered card, Spotlight), with every hardcoded colour replaced by theme-derived values. Trust Badges was renamed Logo Row across code, CSS classes, namespace, copy and icon: the block holds an author’s own uploaded logo strip, not a set of ready-made trust seals, and the old name promised the wrong thing. It also gained a per-logo “keep in colour” override so payment marks stay full colour when the rest of the strip is greyed. Table of Contents grew into a full navigation block: hierarchical and roman numbering, a heading-level window, per-entry hide and rename, smooth scroll with sticky-header offset, scrollspy highlighting, and an optional floating panel with a no-script-safe fallback. Its first real bug was two different anchor-id generators that had drifted, so the table of contents could link to an anchor the page had never used.
The shared Look sidebar grows up, and text tokens stop being per-block
Every block draws its styling from one shared sidebar, so a control added there arrives everywhere at once. Over this stretch that sidebar gained text alignment, vertical (top-to-bottom) text, fluid text that scales with the viewport, vertical content position, first-line indent, drop caps, minimum height, an inner-content width with a paired wide width, aspect ratio and overflow, text balancing, block-level z-index and isolation, advanced text decoration and line clamping, and five waves of effects: text shadow, CSS filters, outline, inner shadow, and a media-fit control for replaced elements. Text size moved from pixels to rem with a rem-aware fluid clamp. Border and outline were then unified into one segmented picker. The panel itself had to be reorganised twice to stay navigable as it grew, with shadows grouped, outline folded into the border panel, and “Spacing” renamed “Size & spacing”.
Two supporting changes belong with it. A dev-only reference block, God, was built as the media counterpart to the existing Jesus text bench, hosting real uploaded images, self-hosted video and third-party embeds so replaced-element styling could be built against something real rather than a placeholder; the media-fit control came directly out of it. Separately, Gillish Core’s Dynamic Tags stopped being a per-block feature: every author-typed text field across all 15 rendering blocks now resolves tokens such as [today] and [sitename] at render, in the correct order relative to each field’s escaping, backed by a cross-block guard test. The one deliberate exception is the Discount Banner’s coupon code, where resolving a token would corrupt a pasted promo code.
The pricing reshape: two blocks that do what three did badly
The plugin had three overlapping pricing blocks. It now has two purpose-built ones, and the three legacy blocks were deleted at v2.3.503 rather than deprecated, taking the library from 18 blocks to 15. Comparison table renders a real semantic <table> with proper column and row headers and category groups, not a styled grid of divs, and is authored entirely in the canvas: add and remove plans and features, drag to reorder, edit ticks and crosses in place, add category bands. Pricing cards is the card-based alternative, with independent plan cards aligned by subgrid, a Pro billing toggle with dual prices and per-plan annual periods, and a mobile mode that shows one card at a time.
Both carry named looks (Boxed, Lined, Bold and Editorial, later reworked with a “Brutal” look and a “Soft” default), an author-chosen recommended-plan marker with a choice of pennant, corner or none, cell-background patterns, a grid-lines setting because forced cell borders read as amateur, sticky scrolling headers, and a single block-level accent colour that drives border, CTA, marker and ticks with automatically readable text on top. Both also emit per-plan Product and Offer JSON-LD with an AggregateOffer summary and an author opt-out. One episode is worth keeping: a mid-arc treatment of the recommended column was rejected outright for burying the zebra striping, and the block was stripped back to what had actually been asked for, so the diff alone will not explain the reversal.
Scoped styling: the engine that lets an author style a part of a block
Until this point a style control applied to a whole block. This engine lets an author select a part inside a block and style that part alone: a single pricing column, a table row, one cell, a CTA. It has three pieces, built in order: an additive data shape that records which part carries which style, an emitter that writes the CSS using cascade layers so a per-part rule never needs !important, and byte-identical PHP and JavaScript rounding for line-height and letter-spacing, which closed a class of bug where the editor preview and the published page disagreed on a decimal. Per-column addressing came next, and the editor learned to preview per-part hover, focus and active states live, which it could previously only do for the block root, so an author styling one card’s hover saw nothing happen while the front end rendered it correctly.
The hard part was not the engine but the cascade around it. Twice in this stretch a per-part colour rendered correctly in the editor and lost to the active theme’s own unlayered CSS on the published page, first for link and CTA text and then for non-link elements and theme-palette variables. That failure mode is worth stating plainly, because it is what makes editor-only verification untrustworthy: the inspector can show one value while the page paints another, so a colour is only proven on the rendered, themed page. Both fixes were made in the shared engine rather than in the block where they were caught.
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.
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.
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.
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.
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.
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.
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).
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.
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-data → core.editPost; gillish-cairnstone/post-meta → core.editPost({ meta: ... }); gillish-cairnstone/term-meta → core.editEntityRecord; gillish-cairnstone/block-attribute → core/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 (source → key → value) 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.
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_bindings → Shared\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.gcCanvasBlocks → window.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-linecap → strokeLinecap 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 before every git push origin main as a required gate).
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.
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.
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.
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.
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-progresscontinuous 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 includingset-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 (withwp_restnonce +readcapability check + 60-writes-per-minute rate limit + 4 KB payload cap + 100-keys-per-namespace cap). Anonymous → logged-in migration onwp_loginwith a 10-minute cookie-wins / older-state-from-user-meta-wins conflict window. Additive cookie naming (the existinggc_cc_<blockId>CC stickiness contract preserved 100%). Unblocks the three Phase 9 no-op actions:set-visitor-statedirectly,set-propertyvia 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.
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.
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.
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.
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
filteris a chain, so stacking identicaldrop-shadow()passes recursively smeared the source (sparse-glyph saturation, a wave-path browser freeze). Fixed by choosing the shadow primitive per source geometry:box-shadowfor the hairline, a non-recursivetext-shadowlist for glyph and label text, a boundeddrop-shadow()for the SVG wave. Captured as a reusable contract. - Four transplant invariants are now build-enforced. Regression tests fail
composer testif 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.
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 intoShared\IconSetfor cross-block reuse), label-text that accepts spaces, optional shadow, optional radius slider, and aclip-pathemission 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;gcStyleinline-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 (
b183261→0232791→d6759b6). Per-tier icon picker with the 24-glyph Cairnstone curated set (the same set Badge now re-uses viaShared\IconSet); per-feature glyph override popover so a feature can carry an icon distinct from its tier’s default; toggle a11y upgrade toradiogroup+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). CompactColorDropdownfor 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.
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 ownPanelBodywithinitialOpen: 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-labelattributes surface tier context via CSSattr()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 aconfirm()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_ksesallows ONE inline<a href>for "see how this works" links; rejects everything else. US-FTC templates ship in v1; EU localisation is a.poextension 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). NewShared\LicenseStringsclass is the single source of truth for Pro-discovery copy across all surfaces: methodstier_label()/tier_body()/primary_cta_label()/secondary_cta_label()/purchase_url()/admin_overview_url(). Purchase URL filterable via newHooks::FILTER_PRO_PURCHASE_URLfor reseller channels + A/B-tested landing pages. Today registers unconditionally becauseLicensing::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: fixedwith a four-corner enum). Coupon-pill copy-to-clipboard vianavigator.clipboard.writeText(textarea +execCommandfallback 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: ifNOW > 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 onwp_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 as100 * (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-levelrecommendedTier: number | nullas a SelectControl picker, replaces the free block's per-tierhighlightedboolean (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 therecommendedTierindex correctly. Frontend toggle persists the visitor's preference tolocalStorage. 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).
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
PanelBodysections (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 hadbackground: currentColorpaired withcolor: var(--wp--preset--color--background)on the same selector.currentColorresolves to THIS element'scolorproperty, 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 curatedComboboxControl+ 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).
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 tosupports.colorso Background+Text both render without a+click. - Saved-block slug repair for title-less publishes (
a500987). Title-lessgc_cairnstone_blockpublishes 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_postrepair that writescairnstone-block-<ID>via$wpdb->updatewhen the slug fails the regex;SavedBlockRegistrysafety-net fallback for legacy rows. - Supports parity + border-opacity row layout (
fced4ae). Section Divider'ssupportsdeclared 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
style→variant(0e5a06e). The first foot-gun: Gutenberg's__experimentalBordersupport writes toattributes.styleas an object. Section Divider's manifest declared its variant picker on the same key as a string. Every border-slider drag clobberedstyle: "dots"withstyle: { border: {...} }, editor read “not a known variant”, snapped to default Line. Renamed tovariant. Backward compat: PHP render reads raw$block->parsed_block['attrs']first (WP's schema filter strips unknown keys from$attributesbut parsed_block carries them); editor adds mount-time migration. Plus: font-size GapFiller now gated by variant (only visible whenvariant === '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 asfilter: drop-shadow()on the SVG path with the author's actual values (via a--gc-divider-shadowCSS custom property +--has-wave-shadowwrapper 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
layout→layoutVariant(23e232a). Same foot-gun, applied before it bites: Gutenberg'ssupports.layoutauto-declaresattributes.layoutas an object. Testimonial declared its"stacked" / "side-by-side"variant on the same key. Testimonial doesn't declaresupports.layouttoday, but a future enablement would silently clobber the variant. Renamed proactively. Same three-step shape (manifest + PHPparsed_blockfallback + 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>withoutuseBlockProps(so apiVersion 3 couldn't attach selection chrome), anduseBlockPropsdrops mergedonKeyDownprops (same Phase 5 finding the CC block hit). Fix:useBlockPropson the wrapper + nativekeydownlistener 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 viawp.data.dispatch, state inspection viajavascript_tool, aggressivebrowser_batch, drop sub-2s waits, preferjavascript_tooloverfindfor known surfaces, never standalone screenshots). Pinned to the wave-shadow verification fromd033fefas 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 (a1→3default change at the same string position). Manifest renames AND version bumps both hit this. Changed to-criteria=eitherso 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.
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.
currentColoreverywhere 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 HTMLname=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_contentfilter. 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.
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_blocklistener parsespost_contentviaparse_blocksinto_gc_block_treemeta withschemaVersion = 1. Oninitpriority 12,SavedBlockRegistry::register_all()iterates published CPT posts and callsregister_block_type( "gillish-cairnstone/<slug>", … )for each. Render assembles inner blocks viado_blocks; per-instance content overrides the CPT template when present. - Editor-side bridge (commit
6afe37b).SavedBlockRegistry::enqueue_editor_bootstrap()localiseswindow.gcSavedBlockswith each published block’s serialized markup.gc-saved-blocks-bootstrap.jsreads the payload + callswp.blocks.registerBlockTypeclient-side. First insert populates innerBlocks from the saved markup viareplaceInnerBlocks; 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’spublic:false, dedicated query var (not the CPT slug),edit_postcap + valid nonce required (failure → 404, not 401),X-Robots-Tagheader +<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 viaPluginPostStatusInfo: 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 useswp.data.subscribe+ a sessionStorage bridge to survive Gutenberg’s post-new → post.php navigation. - templateLock for author-side instances (commit
378a9a9, contextual fix indaada6b). Inserting a saved Cairnstone block on a regular post locks the inner structure ascontentOnly, authors edit text + media but can’t reorder or delete blocks. Inserting the same block inside anothergc_cairnstone_blockCPT post unlocks fully, the designer composes freely, including nesting saved blocks inside each other. - Breaking-change detection + notice (commit
433a2a2, polished in84bf44c+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_depthcounter so the Cancel-triggeredwp_update_postdoesn’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 inspectstop_removed+attrs_removed; a JSconfirm()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 + reusesresolve_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.
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
BlockVariationPickerfires 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 theirwidthattribute 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
widthattribute ongillish-cairnstone/column(string CSS length). Server-side render_callback emits the value onto a--gc-col-widthcustom property +flex-basisinline 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-columnsflex 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 atpost-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.
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) togillish/cairnstone/v1/cc/country. wp.org plugin-prefix discipline restored. Old URL stays live for one release as a backward-compat alias and carriesX-Deprecated-Endpoint+X-Deprecated-Replacementresponse headers so monitoring can spot stale clients. Verified end-to-end on the staging env. EventStoreinsert exceptions now surface toerror_logwhenWP_DEBUGis 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
VisitorContextdoc-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_botfor 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.
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_Tableon 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.
Demolition + CPT bootstrap + PHP / WP baseline bump
(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 was PLANNED as WP 7.0’s wordpress/dataviews with a WP_List_Table fallback, and neither branch was ever built: the list renders as WordPress’s standard CPT list table (re-verified 2026-08-21; this sentence asserted the split as shipped until then). (f) Bump baseline: gillish-cairnstone.php header Requires PHP: 8.1 → 8.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+.
Cairnstone inspector shell: summary control hub + manifest-driven accordion sections
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:
- 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. - 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”).
- 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.
- 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.
Style panel: universal style controls (gap-filling, not duplicating)
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).
Visibility panel: recipe-first Conditional Content with intent grouping
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.
Custom rule builder: recursive AND / OR tree with nested groups
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():
CC visitor-state primitive: cross-block key-value store on top of the existing visitor-context
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.
Save = register block type + live preview (first end-to-end milestone)
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 carried as <InnerBlocks> with templateLock="contentOnly" on author-side instances; that lock was later dropped (it also hid the per-block settings an author needs), so a placed instance now ships templateLock={false} and is fully editable in every editor.
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:
- CPT’s
public: false+publicly_queryable: falsealready returns 404 for the would-be permalink. - Dedicated query-var, not the CPT’s slug, so a missing query var means normal 404.
- Handler requires both
current_user_can( 'edit_post', $id )AND validwp_verify_nonce(failure → 404, not 401, so the URL gives zero signal it might exist). - Preview response carries
X-Robots-Tag: noindex, nofollow, noarchive, nosnippet, noimageindexHTTP header + matching<meta>in HTML. - Explicit filter hooks block
gc_cairnstone_blockfrom 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; the old plan named DataViews on WP 7.0+ with a WP_List_Table fallback, and neither was ever built: a repo-wide grep for dataview returns nothing at 2.4.16). 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.
Library: first batch (shipped: A + B + C + D)
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.
Repeater + parent/child rules
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.
Sidebar transplant: folded into Phase 16
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, Logo Row) 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.
State-driven controls: active / hover / focus / disabled
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.
Interaction system: runtime triggers and actions across four trigger axes
“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-progresscontinuous, plus the existingscroll-into-view/scroll-out-of-viewdiscrete 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-classfor author-named CSS hooks. - Media:
seek-media-to/play/pauseagainst HTMLMediaElement targets. - Property:
set-propertyagainst 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).
Schema panel: guided JSON-LD assistant in Overview / Details / Advanced task-mode
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@typeon 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 declaresheadline → heading,description → subheading,image → imageUrl;datePublished/dateModifiedlean on Phase 11’score/post-databinding 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 audit → harden + 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.
Block Bindings integration: Cairnstone data sources for core blocks shipped
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-datafor post-level fields (date,modified,link),core/term-datafor term-level fields. Hero’s Article-schemadatePublished+dateModifiedresolve throughcore/post-datarather than Cairnstone-custom callbacks (the “use what core provides, don’t reinvent” doctrine applied at the binding-source layer). - Cairnstone-registered sources (
Shared/BlockBindings.phpregisters viaregister_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 anorphan-label callback contract:get_value_callbackalways returns a meaningful default string, nevernullor 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.
Import / export of built blocks shipped
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.
AI integration: Abilities API base surface shipped
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.
AI integration: richer introspection shipped
Semantic tags. Each of the twenty-nine library blocks carries a stable, kebab-case semanticType tag for the design pattern it implements (hero, comparison-table, faq-accordion, testimonial, logo-cloud, …), 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.
AI integration: preview rendering + batch shipped
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.
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, Logo Row) do the full transplant onto the same post-infrastructure pattern, retiring the transplant padlocks and the src/Modules/Scratch/ substrate once the last block ships. [Corrected and refused at phase close, 2026-08-31.] There were three padlocks, never four (StateBindingDisciplineTest, StylesPanelMarkerClassTest, TransplantInvariantsTest), and none of them was retired. Each guards a silent-failure mode in the editor template every library block uses, including every block still to be built, so retiring them would have removed the protection precisely when the copying resumes. The substrate had already gone. (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.904). Shipped so far: the shared image engine (Shared\Image + render_set + Lightbox + ImageSizes) with How-to / Hero / Logo Row / Testimonial / Author Bio migrated onto it; the How-to and Image blocks; the in-canvas text arc; the layout primitives; the scoped-styling engine; the pricing reshape (three legacy pricing blocks deleted at v2.3.503, the library down to 15); the block review pass over eleven existing blocks, including the Trust Badges → Logo Row rename; four further new blocks (Button, Table, Code Snippet, Modal); and all six state+interaction blocks in scope (c), complete at v2.3.684 (Accordion, Tabs, Counter, Reveal, Carousel, Modal). Since then the library has grown past the original scope with Callout, Read More, Score Box, Pros and Cons, Review Box and Field (the library’s first real input, and its 29th block), and a new layout primitive, Frame, shipped v2.3.854 → v2.3.885: a box that holds things and decides how they sit, with spacing, width, height and free placement all set by dragging rather than typing. A follow-up run to v2.3.904 made switching a box to free placement leave it where it stood, stopped four editing screens imposing a reading column the published page does not have, stopped the alignment setting overruling the height setting, gave buttons a size of their own, and paired a button with its dialog by arrangement rather than by a shared name. CLOSED 2026-08-31 at v2.4.24, by decision rather than by completing this list. The phase was never defined by a fixed set of blocks, so the closing condition stated above could not fire; leaving it open indefinitely described the project less accurately than closing it. Video embed Pro (scope b) and the two image-effect blocks (scope d) remain unbuilt and continue as ordinary planned work, fully written up in the block pipeline rather than here. The sidebar transplants of scope (a) were overtaken rather than completed: measured at close, 7 of 31 library blocks carry a styling panel of their own, and the other 24 need none, because the shared inspector now provides the Styles tab for every block centrally. That is the same thing that happened to this phase’s pricing sub-scope when the pricing rebuild removed the blocks it listed. The src/Modules/Scratch/ substrate had already been retired.
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.
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_optionwith 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: no activation guard exists.
register_activation_hookruns only the table migration + capability seeding, andis_multisiteappears nowhere undersrc/(re-measured 2026-08-21). A network activation is not prevented today; this bullet claimed a blocking guard until 2026-08-21. - Hook registry. Every
add_action/add_filterlives inPlugin::register_hooks(). - Option / hook constants. Every option key and hook name exists as a constant in
Shared/Options.phpandShared/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_blockfilter benchmark: totalgate_blockcost ≤ 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), task-oriented subtitles, one section open at a time, 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) were planned for opportunistic use; none is used today (re-verified 2026-08-21), so Cairnstone simply 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: a convention, NOT yet implemented. Whenever a new admin list ships, it uses the
wordpress/dataviewspackage when available (WP 7.0+), falling back to nativeWP_List_Tableon the declared floor. Status at 2.4.16: unbuilt; a repo-wide grep fordataviewreturns zero hits, and the shipped lists are WordPress’s default table plus one hand-written PHP table on the AI activity page (templates/admin/ai-activity.php). - 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.conditionsinto 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\Licensingto 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.