Block Scaffolding — Custom Element Contract per Block
Scaffold a new ACF block with the full custom element architecture: scoped CSS, theme variations, optional JS lifecycle, selective enqueue, and documentation.
Announce at start: "I'm using the block-scaffolding skill to create the block."
When to use
- Auto-invoked by
/building: afterlando acorn acf:blockproduces a new stub - Standalone new block: a block outside of a plan
When NOT to use
- Existing block that needs evolution → use
/block-refactoringinstead. - Non-block UI component (Button, Card) → Blade component, not ACF block.
Input
$ARGUMENTS
Resolve to {ClassName} (PascalCase) and {slug} (kebab-case) before proceeding.
Quick Start
# Generate block stub (PascalCase name required)
bash skills/block-scaffolding/scripts/create-block.sh HeroBlock
# → app/Blocks/HeroBlock.php
# → resources/views/blocks/hero-block.blade.php
Hard Prerequisites
P1. Slug produces a valid custom element tag — block-{slug}. The block-
prefix guarantees the required hyphen.
P2. Design system foundation:
resources/css/design-tokens.css — must exist
resources/views/components/ui/ — must contain button + heading
P3. BaseCustomElement in theme:
resources/js/core/BaseCustomElement.js — must exist
Operation Modes
| Mode | When | $styles |
CSS variations |
|---|---|---|---|
| Full | Theme switching (light/neutral/dark) | 3 entries | 3 .is-style-* selectors |
| Minimal | Fixed appearance (footer, nav, ticker) | absent | base tokens only |
Phase 0 — Generate
lando acorn acf:block {ClassName} --localize
Produces:
app/Blocks/{ClassName}.php— controller stub (localization-ready)resources/views/blocks/{slug}.blade.php— view stub
Phase 0b — Shared component inventory
Before writing the view, build a local registry of available shared components:
- Glob
resources/views/components/*.blade.php— list all component files - For each component:
a. Read its
@propsdeclaration → note prop names + defaults b. Grep forvar(--)patterns → note CSS variable names consumed - Build local registry:
{ "section-header": { props: ["eyebrow","title","align"] }, "eyebrow": { consumes: ["--eyebrow-color","--decorator-color"] }, "button": { consumes: ["--btn-bg","--btn-text"] } }
This registry drives CSS generation in S1 — variable names come from the project, not from the skill's assumptions.
Rule: if the block needs eyebrow + heading markup, use <x-section-header> (or
the equivalent shared component) — do NOT emit <x-eyebrow> + <h2> inline.
If no suitable component exists, use inline markup and note it for future extraction.
Phase 0c — Form detection (conditional)
Detect whether the block embeds an HTML Forms form. Two signals — any match triggers:
- Plan/argument description mentions:
form,formulário,contact form,contato,html forms(case-insensitive) - The planned
fields()for this block includes anaddPostObjectwithpost_typecontaininghtml-form
If triggered, load the sage-forms skill and its references (blade-form-views.md, hf-validation.md, traps.md) before continuing. After Phase 1 S2 (controller written), run the three coordinated scaffolds below. If not triggered, skip this phase entirely.
0c.1 — Form Blade view (resources/views/forms/{form-slug}.blade.php)
Resolve {form-slug}:
- If the plan names a target
html-formpost (e.g. "contact form" →contact), use that slug. - Otherwise use
{slug}-formas placeholder and prepend{{-- TODO: rename file to match the html-form CPT post_name --}}at the top.
Write the form view using <x-html-forms> + x-form.* components, with one x-form.field per ACF field declared in S2. Submit button is <x-button type="submit">. Do not pass pattern attributes; do not use type="tel" (use type="text" inputmode="tel" for phone fields). See skills/sage-forms/references/blade-form-views.md for the full pattern and references/traps.md for the rationale.
0c.2 — Validation module (resources/js/modules/hf-validation.js)
Glob check: if resources/js/modules/hf-validation.js exists, skip — one module per project, reused across forms. If absent, write the scaffold from skills/sage-forms/references/hf-validation.md (the "Full Module Skeleton" section).
0c.3 — Block JS patch (resources/js/blocks/{slug}.js)
Add an import at the top:
import { initHfValidation } from '../modules/hf-validation';
Inside the block's init() method, add:
const form = this.querySelector('.hf-form');
if (form) {
initHfValidation(form, {
messages: {
// TODO: configure per form — see skills/sage-forms/references/hf-validation.md
},
validators: {
// TODO: configure per form
},
});
}
Phase 0c non-objective
Phase 0c does NOT write validator functions or localized messages — always produces // TODO: configure per form stubs. Validator content varies per project and per form; this is deliberate.
Phase 1 — Implement S1–S4
S1 — resources/css/blocks/{slug}.css
Before generating CSS, apply the background context decision table.
Read the component's design-guide.md (## Tokens → Colors section) if available:
| Token found in design-guide Colors | Background context | CSS action |
|---|---|---|
bg-depth, bg-primary, bg-dark, bg-inverse |
Dark | Override cascade vars with *-on-dark equivalents |
bg-identity, bg-sage, bg-accent |
Identity (brand color bg) | Override cascade vars with *-on-identity equivalents (e.g. var(--color-identity-fg)) |
bg-bg, bg-surface, bg-muted, absent |
Light (default) | No override — inherit :root defaults |
| Unrecognized token | Ambiguous | Generate with /* VERIFY: background context unknown */ |
Variable names for the cascade block come from the Phase 0b registry (what each
shared component actually consumes via var(--) references).
Full mode — light section (no override):
@reference "../app.css";
block-{slug} {
@apply block overflow-hidden;
/* cascade — inherited by child components */
--eyebrow-color: var(--color-identity);
--heading-color: var(--color-fg);
--body-color: var(--color-fg);
--decorator-color: var(--color-identity);
}
(variable names from Phase 0b registry; values from :root defaults)
Full mode — dark section (bg-depth detected in design-guide):
@reference "../app.css";
block-{slug} {
@apply block overflow-hidden;
/* cascade — dark section, override :root defaults; values may differ per component — adjust per design-guide */
--eyebrow-color: var(--color-depth-fg);
--heading-color: var(--color-depth-fg);
--body-color: var(--color-depth-fg);
--decorator-color: var(--color-depth-fg);
}
Full mode with $styles variations (background changes per variation):
@reference "../app.css";
block-{slug} {
@apply block overflow-hidden;
/* cascade — light default */
--eyebrow-color: var(--color-identity);
--heading-color: var(--color-fg);
--body-color: var(--color-fg);
--decorator-color: var(--color-identity);
}
.is-style-dark block-{slug} {
--eyebrow-color: var(--color-depth-fg);
--heading-color: var(--color-depth-fg);
--body-color: var(--color-depth-fg);
--decorator-color: var(--color-depth-fg);
}
Minimal mode: omit .is-style-* selectors. Include the token declarations
commented out so the developer has the vocabulary available:
@reference "../app.css";
block-{slug} {
@apply block overflow-hidden;
/* --eyebrow-color: var(--color-identity); */
/* --heading-color: var(--color-fg); */
/* --body-color: var(--color-fg); */
/* --decorator-color: var(--color-identity); */
}
CSS rules:
@applyfor all Tailwind utilities (block,overflow-hidden,flex, spacing, etc.)- CSS custom properties remain native CSS — no
@applyequivalent exists for cascade variables - No hardcoded color values — all values reference
@themetokens viavar(--) @referencenot@import— grants token access without duplicating the stylesheet.is-style-* block-{slug}single selector — works in editor and frontend
S2 — app/Blocks/{ClassName}.php
Use template: assets/block-atomic.php.tpl (leaf block) or assets/block-container.php.tpl (InnerBlocks).
Key rules:
- Always declare
$spacingand$supports— editor controls users expect $styles: Full mode only — omit entirely for Minimal blocks$stylesformat:['name' => 'dark']not['value' => 'dark'](WP 6.x)assets()must remain empty — enqueue viaThemeServiceProvider::boot()fields()declares all ACF fields — never use the ACF GUI
See references/acf-composer-registration.md for full class structure.
For InnerBlocks containers see references/inner-blocks.md.
For $styles and variant CSS see references/variants.md.
S3 — resources/js/blocks/{slug}.js
Always generate, even for static blocks:
import BaseCustomElement from '../core/BaseCustomElement.js';
export default class Block{PascalSlug} extends BaseCustomElement {
static tagName = 'block-{slug}';
init() {
// Block behavior. Empty is valid for static blocks.
}
}
BaseCustomElement.register(Block{PascalSlug});
Rules: class name Block{PascalSlug}, static tagName matches CSS selector,
init() empty = static block, BaseCustomElement.register() at bottom.
S4 — resources/views/blocks/{slug}.blade.php
Use template: assets/block-view.blade.php.tpl. Key points:
@unless ($block->preview)
<section {!! get_block_wrapper_attributes() !!}>
@endunless
<block-{slug} class="flex flex-col">
{{-- content --}}
</block-{slug}>
@unless ($block->preview)
</section>
@endunless
Rules:
@unless ($block->preview)wraps<section>— skipped in editor<block-{slug}>is the CSS and JS root- Structural utilities (flex, grid, gap, px) on
<block-{slug}>or children - No hardcoded colors, typography classes, or background utilities in the view
For editor vs frontend parity issues see references/edit-preview-parity.md.
For block.json and editor_script see references/block-json.md.
Phase 2 — Enqueue Guard (ThemeServiceProvider::boot())
Search for has_block in app/Providers/ThemeServiceProvider.php.
If pattern EXISTS: add '{slug}' => true, to the $blocks array.
If pattern DOES NOT EXIST: implement it:
add_action('wp_enqueue_scripts', function () {
$blocks = [
'{slug}' => true,
];
foreach (array_keys($blocks) as $slug) {
if (! has_block("acf/{$slug}")) continue;
$cssAsset = \Roots\asset("css/blocks/{$slug}.css");
if ($cssAsset->exists()) {
wp_enqueue_style("block-{$slug}", $cssAsset->uri(), [], $cssAsset->version());
}
$jsAsset = \Roots\asset("js/blocks/{$slug}.js");
if ($jsAsset->exists()) {
wp_enqueue_script("block-{$slug}", $jsAsset->uri(), [], $jsAsset->version(), true);
}
}
}, 20);
Phase 3 — Editor CSS
Add one @import per block to resources/css/editor.css:
@import './blocks/{slug}.css';
Phase 4 — Block README (docs/blocks/{slug}.md)
Document: custom element name, ACF fields table, theme variations table (Full mode), CSS tokens table, and file dependency list (controller, view, CSS, JS, enqueue, editor CSS).
If Phase 0c triggered, the README additionally documents:
- Form view path:
resources/views/forms/{form-slug}.blade.php - Validation module path:
resources/js/modules/hf-validation.js - DOM events handled:
hf-success,hf-error - Pointer to the
sage-formsskill for the integration pattern
Phase 5 — Verification
Build gates:
lando theme-build # exit 0; block-{slug}-*.css AND .js must appear in output
lando flush # clear Acorn/Blade/OPcache
Runtime checks:
- Screenshot at canonical width from
plan.md - Verify
<link href="*/block-{slug}-*.css">and<script>in DOM - Custom element upgraded:
document.querySelector('block-{slug}').constructor !== HTMLElement - Full mode: test each variation via DevTools adding
is-style-neutral/is-style-darkto the outer<section>and confirm cascade vars (e.g.--eyebrow-color,--heading-color) resolve correctly git commit -m "feat(blocks): scaffold {slug}" && git push
Critical Rules
- Always use
lando acorn acf:block— never create block stubs manually. The generator sets correct namespaces, registration hooks, and file paths. - View is Blade (
resources/views/blocks/{slug}.blade.php). Never plain PHP. - Fields via
fields()method — never the ACF GUI. GUI fields are lost on composer install. get_block_wrapper_attributes()on<section>— provides accessibility, spacing, alignment, and variation classes.assets()stays empty — CSS/JS enqueue belongs inThemeServiceProvider::boot().@referencenot@importin block CSS — avoids duplicating the full stylesheet.@apply block overflow-hiddenon the custom element — custom elements default toinline;overflow-hiddenprevents bleed.- Commit before declaring done — git state is part of the Definition of Done.
Anti-Drift Table
| Wrong | Correct |
|---|---|
<section class="b-{slug}"> + $attributes->merge() |
@unless preview <section {!! get_block_wrapper_attributes() !!}> + <block-{slug}> |
.b-{slug} { ... } |
block-{slug} { ... } tag selector |
&.is-style-neutral, .is-style-neutral & |
.is-style-neutral block-{slug} |
No @apply block on custom element |
@apply block overflow-hidden always — custom elements default to inline |
<hero> (no hyphen) |
<block-hero> |
assets() with enqueue |
assets() empty — enqueue in ThemeServiceProvider |
@import "../app.css" |
@reference "../app.css" |
['value' => 'dark'] in $styles |
['name' => 'dark'] |
Skip $spacing / $supports |
Always declare both |
acf:block without --localize |
lando acorn acf:block {Name} --localize |
| Skip JS for static block | Generate stub with empty init() |
| Done without git commit | git commit + git push required |
Verification
-
lando theme-buildexits 0 - Block CSS and JS files present in build output
-
<link>and<script>for block assets in frontend<head> - Custom element upgraded (not
HTMLElement) - Full mode: all three variations render correctly
- No ACF warnings in
lando wp --infooutput -
git commitmade withfeat(blocks): scaffold {slug}
Failure modes
Build fails — block CSS not emitted
Cause: Block CSS not imported in editor.css or Vite entrypoint.
Fix: Add @import './blocks/{slug}.css' to resources/css/editor.css.
Custom element not upgrading
Cause: JS not enqueued, or BaseCustomElement.register() missing.
Fix: Verify ThemeServiceProvider::boot() includes the slug and the JS file exists.
Variation colors wrong on frontend
Cause: <section {!! $attributes !!}> missing — is-style-* class not in DOM.
Fix: Restore @unless ($block->preview) wrapper with get_block_wrapper_attributes().
ACF fields not saving
Cause: Fields defined in fields() but block registered before ACF Composer boots.
Fix: Ensure the block class is auto-discovered by ACF Composer (check config/acf.php).
For all edit/preview parity issues see references/edit-preview-parity.md.