The Block Design Contract: Tokens, Primitives, and the Same-Family Rule
BLOCK QUALITY FOUNDATION · 3-PART SERIES
Part 1: Anatomy & First Block · → Part 2: Design Contract · Part 3: Shipping & Audit
The block-quality foundation we use at Wbcom Designs is not a checklist. It is a contract. The mechanical part of building a block is straightforward once you have done it twice. The hard part is making one hundred blocks across two dozen products feel like one designer drew all of them.
That is what this post is about: the design contract that holds the portfolio together.
Part 1 walked the five-file anatomy and the first seven commands. If you have not read that, start there. This post assumes you can scaffold a block and you know what an attribute is. The link to Part 1 is at the bottom.
The Four Architectural Boundaries
Every Wbcom plugin, every theme, every block we ship sits inside four hard boundaries. These are not preferences. They are enforced by the wp-plugin-qa MCP. A pull request that violates any one fails the build before a human looks at it.
100% REST-ready. Every frontend feature reads and writes through /{prefix}/v1/* endpoints. No admin-ajax.php on the frontend. No PHP-rendered state that the page later patches via inline data. apiFetch from @wordpress/api-fetch is the canonical client. Plugins do not bundle their own fetch wrapper. REST controllers extend a shared base so every endpoint ships the same envelope shape and cursor-pagination contract.
No jQuery on the frontend. Frontend stack is the WordPress Interactivity API plus ES modules. jQuery is allowed only in legacy admin screens until they migrate. New admin work also avoids it. The frontend never enqueues jquery as a dependency. The reason is structural: jQuery on the frontend forces defer=false, fights with Interactivity hydration order, doubles bundle weight, and freezes us out of viewScriptModule block registration. None of those costs is worth jQuery’s nostalgia.
Tokens, never raw values. No hex. No px. Every CSS value in a block we ship is a token. The system is layered (covered in the next section). The audit catches every raw hex or px on every PR.
Same-family, same-shell. Every plugin uses the same six component primitives. Different prefixes, identical shapes. A user opening Settings then Dashboard then a CPT editor inside the same plugin must feel like one product, not three. A customer who installs Jetonomy and BuddyPress Polls must feel like the same designer drew both.
These four boundaries are the floor. Everything else in this post is how we operationalize them.
The 3-Layer Token Model
Tokens flow outside in. WordPress core defines a base layer. Each plugin extends it with its own semantic layer. Components consume the semantic layer and occasionally publish their own per-component locals. Three layers, predictable inheritance.
Layer 1: WordPress core CSS preset variables (the same vocabulary Claude Code and other AI tooling read when generating blocks). WordPress 6.0 onward exposes a typed token system through its core preset variables. --wp--preset--color--primary, --wp--preset--spacing--40, --wp--preset--font-size--medium. These are the host theme’s vocabulary. A well-built block does not redefine them. It reads them.
Layer 2: Plugin semantic tokens. Each plugin owns a prefix and exposes its own semantic layer. For Jetonomy that prefix is jt. For WP Vanguard it is wpv. For our hypothetical wbe plugin the tokens look like this:
--jt-accent: var(--brand, var(--wp--preset--color--primary, #3B82F6));
--jt-text: var(--wp--preset--color--contrast, #0f172a);
--jt-bg: var(--wp--preset--color--base, #ffffff);
--jt-space-md: var(--wp--preset--spacing--30, 12px);
--jt-radius-lg: 12px;
The pattern is fixed: each semantic token references Layer 1 with a hex fallback. Modern browsers see the host theme. Older browsers see the brand fallback. The audit catches plugins that skip the fallback (which is a real bug, not a style preference).
Layer 3: Per-component locals. When a primitive needs a tweak, the tweak lives on the primitive, not in the global namespace:
.jt-card {
--jt-card-padding: var(--jt-space-xl);
--jt-card-radius: var(--jt-radius-lg);
}
.jt-card__body { padding: var(--jt-card-padding); }
A component reads its own local. The local reads the semantic layer. The semantic layer reads WordPress core. No component reaches past its layer. No raw value anywhere.
This sounds bureaucratic until you watch a customer toggle their theme to dark mode and every Wbcom block follows automatically because the contract is honoured at every layer. Then it sounds essential.
Token Scales: One Vocabulary, Twenty Plugins
Tokens without scales become spaghetti. The scale is what makes a system. We use the same scale across the entire portfolio. Same step counts. Same naming. Different prefix only.
Spacing: eight steps. From xs (4px) to 4xl (48px). Card padding is xl (20px). Grid gap between cards is xl. Section gap within a card is lg (16px). Form field gap is md (12px). Inline element gap is sm (8px). The same steps exist in every plugin, so a contributor moving from Jetonomy to WP Vanguard finds the same spacing vocabulary.
Typography: eight sizes, five weights, four line heights. A combinations table maps each role to a specific (size, weight, line height) triple: page heading, section heading, card title, body text, secondary text, meta, badge label, button text, form label, stat number. The combinations are fixed. A block does not invent a new typography role.
Radius: six steps. none, sm (4px), md (8px), lg (12px), xl (16px), 2xl (20px), full (9999px). Cards use lg. Buttons use md. Avatars use full. The audit catches blocks that ship a custom radius.
Shadow: five steps. sm through 2xl, plus a focus-ring shadow. Cards at rest get sm. Cards on hover get md. Modals get lg. Popovers get xl.
Z-index: five layers. base (1), dropdown (10), sticky (100), modal (1000), toast (10000). Blocks never invent a z-index. They pick a layer.
Lucide icon sizes: six steps. xs (12), sm (14), md (16), lg (20), xl (24), 2xl (32). Stroke width is always 1.75 to match the WP admin look. Dashicons are forbidden in new code.
Transitions: three speeds. fast (120ms), normal (200ms), slow (300ms), all on cubic-bezier(0.4, 0, 0.2, 1). Hover transitions get fast. Panel slides get normal. Modal open and close get slow.
That is the complete vocabulary. Every block on every plugin in the portfolio reads from this list. The audit catches additions.
The Six Component Primitives
Layouts are built from primitives, not new components. A “settings card with two columns of fields” is a card with a grid plus token gap. Not a new .jt-two-col-settings-card. New primitives need explicit approval and get added to the canonical reference. One-off variants do not.
Six primitives. Every page composes from these.
Page shell (.{prefix}-page). The outer wrapper. Branded header with plugin name, version, and primary action. Main content area below. Same shape on Settings, Dashboard, every admin screen. No raw inside a Wbcom admin page.
Card (.{prefix}-card). Section container with __head (title plus description) and __body (controls). Variants: default, danger, success. Hover state changes border color. Focus-within draws a 2px outline. Card on a card never happens, that is a layout smell.
Button (.{prefix}-btn). Single ladder: primary, secondary, ghost, danger, in sizes sm (32px), md (40px), lg (48px). Twelve variants total. Never a one-off with raw styles. The audit flags any without a .{prefix}-btn class.
Input (.{prefix}-input). All form controls. Text, number, select, checkbox, radio, textarea. 40px default height. Focus-visible ring uses --{prefix}-accent. Number inputs never default to -1. A checkbox labelled “Unlimited” plus an enabled number input is the pattern for unbounded values. The audit catches with min="-1" or similar.
Badge (.{prefix}-badge). Status pill. Variants: neutral, info, success, warning, danger. Never a coloured with inline styles.
Empty state (.{prefix}-empty). Used for zero-state lists and search-no-results. Icon plus title plus body plus optional CTA. Never a bare “No items found.” string. Always discoverable so the audit can flag bare strings.
Six. That is the full set. A block needing something outside this set is a primitive that needs to be proposed, agreed, added to the canonical reference, and rolled out across every plugin. We do not freelance primitives.
The Twelve Non-Negotiables for Blocks Specifically
The four architectural boundaries and six primitives are the plugin-wide contract. Blocks have twelve additional non-negotiables that map onto our broader 16-rule UI contract. The audit gates all twelve.
1. apiVersion 3 and iframe-ready (Part 1 covered the why) 2. Three-breakpoint responsive system: desktop, tablet, mobile 3. Per-side spacing as a four-edge object, not a single value 4. hideOnDesktop / hideOnTablet / hideOnMobile toggles for responsive visibility 5. Hover state controls on every interactive element, declared as attributes 6. Box-shadow plus per-corner border radius on every block, both token-driven 7. Unique-ID scoping (.{prefix}-block-{uniqueId}) so two instances never fight 8. Design tokens (--{prefix}-*) instead of hardcoded hex and px 9. BEM class names, predictable for theme overrides 10. Accessibility: ARIA roles, keyboard nav, focus rings, prefers-reduced-motion 11. Theme isolation: never bare element selectors 12. No jQuery in viewScript; use viewScriptModule and the Interactivity API
The audit script enforces these twelve plus the sixteen broader UI rules in roughly three seconds across a 500-file plugin. The wp-plugin-qa MCP enforces them in roughly 121 milliseconds against a PR diff. Part 3 covers the audit pipeline.
The Same-Family, Same-Shell Rule
This is the one rule that ties everything else together. Worth restating because it is the most-violated rule in custom WordPress work.
Every page in a plugin is the same shell. Every section is the same card. Every button is the same ladder. Every input is the same 40px default. Every badge is the same five variants. Every empty state is the same primitive.
A user opening Settings, then Dashboard, then a CPT editor inside the same plugin must feel like one product, not three. A customer who installs Jetonomy and BuddyPress Polls must feel like the same designer drew both, even though the two products evolved in different years by different engineers.
The mechanism is one CSS file per plugin: assets/css/{prefix}-ui.css contains the entire UI vocabulary. Page shell plus card plus button plus input plus badge plus empty plus tokens. Frontend templates and admin pages compose from these primitives. They never invent local variants.
The audit fails any plugin that ships multiple competing UI stylesheets. A plugin with settings.css plus dashboard.css plus cpt.css is a plugin that will drift inside six months. The plugin with one wbe-ui.css is the plugin that still feels coherent two years later.
Where AI Tooling Fits
When Claude Code or Cursor generates a block for us, the output goes through the same gate as human work. The audit catches the mistakes AI tooling makes most: missing useBlockProps, raw hex in CSS, attributes without defaults, save() output drift. The tool catches them in roughly 121 milliseconds, which is faster than a human reviewer can read the PR title.
This is also where the four boundaries earn their keep against AI-assisted development. An AI tool that generates a block without REST contract will fail the audit. A block that uses jQuery in viewScript will fail. A block with raw hex will fail. The contract does not care who wrote the code. It cares whether the code honours the rules.
The result is consistent quality regardless of how the block got built. Junior developer, senior developer, contractor, Claude Code, Cursor. The output crosses the same line before it ships.
What Comes in Part 3
Part 3 is the shipping and maintenance half:
- Deprecations done right and the migrate() function pattern
- The five drift patterns we catch most in audits across the portfolio
- `ux-audit.sh` as the local gate and the wp-plugin-qa MCP as the CI gate
- The 22-item implementation checklist organized into four phases
- Going deeper: Interactivity API stores, block context, transforms, apiVersion 2 to 3 migration playbook, and the difference between patterns, variations, and styles
If you have read Part 1 and this, you understand the foundation. Part 3 is how we keep the foundation honest at portfolio scale.
Cross-References
Part 1: The Block-Quality Foundation: How We Ship Gutenberg Blocks That Last
Part 3: Shipping and Maintaining Gutenberg Blocks at Portfolio Scale
If you run an agency, a plugin team, or an in-house WordPress group that wants to adopt a similar foundation, Wbcom Designs takes on these engagements directly. Block-quality audits, custom integrations, full-stack plugin engineering. Details on wbcomdesigns.com.
A Real Walkthrough: How Tokens Flow Through One Wbcom Plugin
Abstractions are easy to nod along to. The walkthrough that follows traces the token vocabulary through Jetonomy, our community-spaces plugin, from the moment a developer scaffolds a new block to the moment a customer sees the result on their site.
A Jetonomy developer needs to add a “Member Spotlight” block that highlights one BuddyPress member with a configurable accent color, avatar size, and surrounding card padding. Standard requirement, the kind of block that builds half a community theme.
The developer starts in assets/css/jt-tokens.css. This is Layer 2: the plugin’s semantic vocabulary, already defined for every other Jetonomy block. The relevant tokens for this block:
--jt-accent: var(--brand, var(--wp--preset--color--primary, #3B82F6));
--jt-space-card: var(--jt-space-xl);
--jt-radius-card: var(--jt-radius-lg);
--jt-text-strong: var(--wp--preset--color--contrast, #0f172a);
--jt-shadow-card: 0 1px 3px rgba(15, 23, 42, 0.08);
The developer does not change any of these. The vocabulary is shared. The developer reads.
The block’s style.scss consumes Layer 2:
.jt-member-spotlight {
background: var(--jt-bg);
border-radius: var(--jt-radius-card);
padding: var(--jt-space-card);
box-shadow: var(--jt-shadow-card);
color: var(--jt-text-strong);
}
.jt-member-spotlight__name {
color: var(--jt-accent);
font-weight: var(--jt-font-semibold);
font-size: var(--jt-text-lg);
}
Six tokens consumed. Zero raw values introduced. The block inherits the visual contract.
Now the developer adds the customizable attribute: the accent color. The block.json declares it as a string with a token-based default:
"accentColor": {
"type": "string",
"default": "var(--jt-accent)"
}
And the render.php applies the override only when the customer changes the default:
$accent = isset($attributes['accentColor']) && $attributes['accentColor'] !== 'var(--jt-accent)'
? esc_attr($attributes['accentColor'])
: 'var(--jt-accent)';
echo "";
When the customer leaves the accent at default, the block reads from Layer 2, which reads from Layer 1 (their theme’s primary color), which means the block matches their theme automatically. When the customer changes the accent, the per-instance override fires and the rest of the page stays as-is. No conflict.
Layer 3 enters when the same block is reused inside a larger composition. A “Featured Members” grid might want each card slightly tighter than the default. The grid block declares a Layer 3 local:
.jt-featured-members {
--jt-card-padding: var(--jt-space-lg);
}
The grid container sets a local that overrides the card padding for descendants. The card primitive reads --jt-card-padding which now resolves to --jt-space-lg instead of --jt-space-xl. The grid composes from the same primitive; the cascade respects the layer order.
This walkthrough sounds heavy on paper. In practice, a developer who has done it twice writes the block in under an hour. The vocabulary lives in the editor’s autocomplete (we ship a custom snippet pack for VS Code). The cascade is predictable because the layers never overlap. The customer’s customization options are richer than what most custom blocks provide, because the block reads tokens that the theme already exposes.
Portfolio Governance: How We Maintain Token Discipline Across 20+ Plugins
The 3-layer model works for one plugin. The challenge is keeping the model honest across the whole portfolio when twenty plugins ship in parallel and developers move between them.
Three governance mechanisms hold the line.
A canonical token reference, versioned with the foundation. The full set of semantic tokens lives in a markdown reference inside our shared skill. When a new token gets added (the bar is high, the proposal must reference at least two plugins that need it), it goes into the reference first, then propagates to plugins on their next release. The reference is the contract; the plugin implementations are the artifacts.
Cross-plugin audits. Every quarter we run the audit script across the entire portfolio and look for divergences. If three plugins are using --{prefix}-space-xl for card padding and one is using a custom --{prefix}-card-pad, the audit flags the outlier. Either the outlier becomes a canonical token (proposal goes through the foundation reference), or the outlier converges back to the standard. We do not allow per-plugin token sprawl to accumulate.
Token migration windows. When the foundation itself evolves (a new token gets added or an old one gets renamed), every plugin gets a quarter to migrate. The audit runs in warning mode for the first month, error mode after. We have done four foundation migrations in the last three years. Each one moved every plugin to the new vocabulary within the quarter.
The governance is what makes the foundation real, not just documentation. Without governance, the rules drift into “what the lead developer prefers.” With governance, the rules stay the rules even when team composition changes.
Why This Matters For Junior Developers
A senior developer can hold a portfolio of style decisions in their head. A junior developer cannot, and should not have to. The 3-layer token model is genuinely easier to learn than the alternative of “look at how the senior dev did it last time and try to match.” It is fewer rules, more structure, predictable composition.
A junior developer hitting this foundation for the first time picks up Layer 2 tokens in a day. Layer 3 in a week. The cross-plugin invariants in a month. After three months they are writing blocks indistinguishable from senior work, because the rules are doing most of the design thinking for them. The senior developer’s leverage is in proposing new tokens when the foundation needs to evolve, not in re-deciding the same visual decisions every PR.
That is the real argument for a shared design contract. It scales the senior developer’s design judgment across the team. Every block junior or senior ships sits inside the same decisions. The foundation is the design memory of the team.
A short closing note. This is Part 2 of three. Part 1 covers the anatomy of a single block. Part 3 covers the shipping and maintenance pipeline that keeps the contract honest year after year. Each post stands alone but the value compounds when you read all three. Save the links. Work through them at your own pace. If you have a question this post did not answer, find me on X at @vapvarun.