Skip to main content

Fixing 4 CSS Conflicts Between WooCommerce and FSE Block Themes

· 5 min read

While developing a WordPress FSE Block Theme with WooCommerce integration, I encountered 4 hidden traps where WooCommerce silently overrides theme styles. Each one was difficult to diagnose. Here are the root causes and solutions to help other FSE + WooCommerce theme developers.

TL;DR

  1. Font size preset override: WooCommerce registers small/medium/large/x-large presets that override theme.json. Use custom slugs like h-1~h-6 to avoid conflicts.
  2. Font-size class hyphenation: has-h-1-font-size(48px) vs has-h1-font-size(20px). Hand-written HTML must use the hyphenated version.
  3. Contrast color inversion: --wp--preset--color--contrast is set to a light color (#f8fafc) by WooCommerce, making white text invisible.
  4. ul.products pseudo-elements: WooCommerce injects ::before/::after that break CSS Grid layout. Fix with display:none.

Trap 1: Font Size Presets Overridden

Symptom

Defined font size presets in theme.json:

{
"settings": {
"typography": {
"fontSizes": [
{ "slug": "small", "size": "0.875rem" },
{ "slug": "large", "size": "1.125rem" }
]
}
}
}

After installing WooCommerce, paragraphs using has-large-font-size render at 36px instead of the expected 18px. H1 headings may also shrink from 48px to 20px.

Root Cause

WooCommerce registers its own font size presets via WP_Theme_JSON_API:

slugtheme.json originalWooCommerce override
small0.875rem (14px)13px
medium1rem (16px)20px
large1.125rem (18px)36px
x-large1.25rem (20px)42px

Presets with the same slug get overridden. Custom slugs (like h-1~h-6, base) are unaffected.

Solution

Use custom slugs in theme.json to avoid WooCommerce conflicts:

{
"fontSizes": [
{ "slug": "h-1", "size": "3rem" },
{ "slug": "h-2", "size": "2.25rem" },
{ "slug": "h-3", "size": "1.75rem" },
{ "slug": "base", "size": "16px" }
]
}

Use has-h-5-font-size instead of has-large-font-size in templates.

Trap 2: Font-Size Class Hyphenation

Symptom

When hand-writing FSE template HTML, H1 headings render at 20px instead of the theme.json value of 48px:

<!-- Wrong: renders at 20px -->
<h1 class="has-h1-font-size">Heading</h1>

<!-- Correct: renders at 48px -->
<h1 class="has-h-1-font-size">Heading</h1>

Two classes differ by one hyphen, but the rendered size differs by 2.4x.

Root Cause

Gutenberg renders fontSize: "h1" slug as has-h-1-font-size (hyphen added before the number). This is correct behavior.

But when WooCommerce overrides the font size system, has-h1-font-size (no hyphen) matches WooCommerce's override value (20px), while has-h-1-font-size (with hyphen) matches the theme.json original (48px).

Verify in browser console:

const el = document.createElement('div');
document.body.appendChild(el);

el.className = 'has-h1-font-size';
console.log('No hyphen:', getComputedStyle(el).fontSize); // 20px (wrong)

el.className = 'has-h-1-font-size';
console.log('With hyphen:', getComputedStyle(el).fontSize); // 48px (correct)

document.body.removeChild(el);

Solution

Always use the hyphenated format in hand-written FSE template HTML:

<!-- wp:heading {"fontSize":"h1"} -->
<h1 class="has-h-1-font-size">Heading</h1>
<!-- /wp:heading -->

<!-- wp:heading {"fontSize":"h3"} -->
<h2 class="has-h-3-font-size">Section Title</h2>
<!-- /wp:heading -->

Rule: has-h-{N}-font-size (with hyphen), applies to all h-* presets.

Trap 3: Contrast Color Inversion

Symptom

White text on a contrast background is invisible:

<div class="has-contrast-background-color">
<h2 class="has-base-color">White text on dark background</h2>
</div>

Expected contrast to be dark, but it renders as a very light background.

Root Cause

WooCommerce registers --wp--preset--color--contrast as #f8fafc (very light gray). This is WooCommerce's design intent (their default theme uses contrast as a light background section), but conflicts with most FSE themes where contrast means dark.

Solution

Use primary (dark) as text color on contrast backgrounds:

<div class="has-contrast-background-color">
<h2 class="has-primary-color has-text-color">Dark text on light background</h2>
</div>

Or define your own dark preset in theme.json (e.g. surface) instead of relying on contrast:

{
"settings": {
"color": {
"palette": [
{ "slug": "surface", "color": "#0f172a", "name": "Surface" }
]
}
}
}

Trap 4: ul.products Pseudo-Elements Break CSS Grid

Symptom

WooCommerce product listings using CSS Grid show misaligned cards with empty gaps:

ul.products {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}

Expected 3 equal columns, but empty slots appear and cards wrap to the next row.

Root Cause

WooCommerce injects ::before and ::after pseudo-elements on ul.products:

ul.products::before,
ul.products::after {
display: table;
content: '';
}

ul.products::after {
clear: both;
}

These pseudo-elements are designed as clearfix for traditional float layouts. But CSS Grid treats them as grid items, occupying two implicit grid cells.

Solution

Hide pseudo-elements for Grid layouts on ul.products:

ul.products {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}

ul.products::before,
ul.products::after {
display: none;
}

Important Notes

  • Each trap relates to WooCommerce's WP_Theme_JSON_API registration or CSS injection. Disabling WooCommerce removes these overrides.
  • Traps 1 and 2 are linked: after WooCommerce overrides font size presets, hyphenated and non-hyphenated classes map to different values.
  • Trap 3's contrast value may change across WooCommerce versions. Use a custom slug (e.g. surface) for stability.
  • Before hand-writing FSE templates, inspect Gutenberg's auto-rendered class names in DevTools. Don't guess class name formats.

Deploying a WooCommerce Site?

Recommended cloud hosting providers


Save 70% of Your AI Coding Plan Quota by Replacing ZhiPu Search with Tavily MCP

· 3 min read

When using ZhiPu GLM Coding Plan with Claude Code, I noticed my monthly quota draining faster than expected. The culprit? Built-in MCP tools — web-search-prime and web-reader — were consuming coding conversation quota for every web search and page fetch.

TL;DR

Replace ZhiPu's built-in web-search-prime and web-reader MCP services with Tavily MCP. Result: free 1000 searches/month with zero impact on coding quota.

The Problem: Every Search Costs Quota

ZhiPu GLM Coding Plan includes several built-in MCP services:

web-search-prime   → web search (consumes quota)
web-reader → web page reading (consumes quota)
zread → GitHub repo reading (consumes quota)

In my case, searching WooCommerce documentation, reading API pages, and checking marketplace standards during theme development consumed roughly 30% of my monthly MCP quota in just a few days.

MCP usage over one week showing search and reader consuming most calls

The 16% / 30% usage shown above was reached in less than a week, with web search being the primary consumer.

Solution: Tavily MCP

Tavily provides a free tier with 1000 API calls/month, completely independent of your coding plan quota.

Step 1: Get Tavily API Key

Register at tavily.com, free tier includes 1000 calls/month.

Step 2: Add Tavily MCP to Claude Code

claude mcp add tavily-search -s user -- npx -y tavily-mcp@latest

Step 3: Configure API Key

Edit ~/.claude.json, find the tavily-search entry and add the environment variable:

{
"mcpServers": {
"tavily-search": {
"type": "stdio",
"command": "npx",
"args": ["-y", "tavily-mcp@latest"],
"env": {
"TAVILY_API_KEY": "tvly-your-key-here"
}
}
}
}

Step 4: Remove ZhiPu's search services

claude mcp remove web-search-prime -s user
claude mcp remove web-reader -s user

Also clean up settings.json permissions — remove mcp__web-search-prime__* and mcp__web-reader__* from the allow list, add mcp__tavily-search__* instead.

Step 5: Verify

claude mcp list
# Should show: tavily-search: npx -y tavily-mcp@latest - Connected
# Should NOT show: web-search-prime, web-reader

Comparison

FeatureZhiPu Built-inTavily MCP
CostConsumes coding plan quotaFree 1000 calls/month
Search qualityOptimized for ChineseBalanced for EN/CN
Page extractionSeparate toolBuilt-in extract
ConfigurationPlatform-injected, can't disableOne claude mcp add command
After free tierSqueezes coding conversation budgetPay-as-you-go continues

Notes

Important Notes

  1. ZhiPu services are platform-injected — they may reappear after restart even after removal. Add them to settings.json deny list to block permanently.
  2. Keep zread (GitHub repo reading) — low consumption and unique functionality.
  3. Keep doubao-vision (image analysis) — Tavily doesn't cover this scenario.

Recommendation

Also using ZhiPu GLM Coding Plan?

Learn More About ZhiPu GLM Coding Plan


Fix FSE Block Theme Style Preview Single Color Block and Front Page Blank Canvas

· 4 min read

Encountered these two issues while developing a WordPress FSE Block Theme for a client. Here are the root causes and solutions.

TL;DR

  1. Style variation palette/gradients replace rather than merge -- declaring only 1 color drops all others. You must include the complete list and only change what differs.
  2. Hardcoding patterns in front-page.html causes Site Editor blank canvas and prevents users from editing the layout -- switch to content-driven architecture.

Pitfall 1: Style Variation Shows Only One Color Block

Problem

In Site Editor > Browse Styles, the default style shows 16 color blocks, but the second style variation (Amber) shows only 1. After switching to Amber, all elements referencing primary, base, contrast CSS variables lose their colors entirely.

Root Cause

In WordPress theme.json style variations, settings.color.palette and settings.color.gradients replace the parent theme's declarations entirely, rather than merging incrementally.

The original styles/amber.json:

{
"settings": {
"color": {
"palette": [
{ "slug": "accent", "color": "#b45309", "name": "Amber" }
]
}
}
}

The intent was "only change the accent color to amber", but the actual effect is "the entire palette now has only this one color". All colors from the parent theme.json -- primary, secondary, base, contrast, surface, neutral-50 through neutral-900 -- are gone.

The same applies to gradients: declaring 1 gradient replaces all 7 from the parent theme.

Solution

Include the complete palette and gradients list in the variation file, only modifying the values you want to change:

{
"$schema": "https://schemas.wp.org/trunk/theme.json",
"version": 3,
"title": "Amber",
"settings": {
"color": {
"palette": [
{ "slug": "primary", "color": "#0f172a", "name": "Primary" },
{ "slug": "secondary", "color": "#334155", "name": "Secondary" },
{ "slug": "accent", "color": "#b45309", "name": "Amber" },
{ "slug": "base", "color": "#ffffff", "name": "Base" },
{ "slug": "contrast", "color": "#f8fafc", "name": "Contrast" },
{ "slug": "surface", "color": "#0f172a", "name": "Surface" },
{ "slug": "neutral-50", "color": "#fafafa", "name": "Neutral 50" }
// ... remaining neutrals unchanged
],
"gradients": [
// include all 7 gradients, only modify the accent gradient target color
]
}
}
}

Key principle: Any array-type config in a variation (palette, gradients, fontSizes, spacingSizes, etc.) replaces entirely. Always include the complete list.

Pitfall 2: Site Editor Front Page Blank Canvas

Problem

Opening the front page via Site Editor > Pages > Home shows a completely blank canvas. However, the full layout is visible in Templates > Front Page.

Root Cause

These are two different editing entry points with different data sources:

EntryEditsData Source
Templates > Front Pagefront-page.html templateTheme files
Pages > Homepage_on_front page contentDatabase wp_posts.post_content

The original front-page.html hardcoded three patterns at the template level:

<!-- wp:pattern {"slug":"cclee-theme/hero-centered"} /-->
<!-- wp:pattern {"slug":"cclee-theme/features-grid"} /-->

<!-- wp:group {"tagName":"main","align":"full"} -->
<main class="wp-block-group alignfull">
<!-- wp:post-content /-->
</main>
<!-- /wp:group -->

<!-- wp:pattern {"slug":"cclee-theme/cta-banner"} /-->

The post-content block renders the page_on_front page's database content. That page content was empty, so Pages > Home shows a blank canvas -- this is expected WordPress behavior.

The real problem: hardcoding patterns in the template prevents users from adjusting the front page layout order and content in the page editor.

Solution

Switch the front page architecture from "template-hardcoded" to "content-driven".

1. Template keeps only the skeleton (front-page.html):

<!-- wp:template-part {"slug":"header-transparent","tagName":"header"} /-->

<!-- wp:group {"tagName":"main","align":"full","layout":{"type":"constrained"}} -->
<main class="wp-block-group alignfull">
<!-- wp:post-content {"layout":{"type":"constrained"}} /-->
</main>
<!-- /wp:group -->

<!-- wp:template-part {"slug":"footer-columns","tagName":"footer"} /-->

2. Inject default content on theme activation (functions.php or inc/setup.php):

add_action( 'after_switch_theme', function () {
$front_page_id = (int) get_option( 'page_on_front' );
if ( ! $front_page_id ) {
return;
}

$post = get_post( $front_page_id );
// Only inject when content is empty -- never overwrite user content
if ( ! $post || ! empty( trim( $post->post_content ) ) ) {
return;
}

$default_content = '<!-- wp:pattern {"slug":"cclee-theme/hero-centered"} /-->' . "\n"
. '<!-- wp:pattern {"slug":"cclee-theme/features-grid"} /-->' . "\n"
. '<!-- wp:pattern {"slug":"cclee-theme/cta-banner"} /-->';

wp_update_post( [
'ID' => $front_page_id,
'post_content' => $default_content,
] );
} );

Design decisions:

  • Template handles only the header / main (post-content) / footer skeleton
  • Front page layout is entirely content-driven -- users can add, remove, and reorder patterns in the page editor
  • after_switch_theme hook ensures default content on first activation
  • Empty content check guarantees no user customization is overwritten

Interested in similar solutions? Get in touch

Build a Custom MCP Toolkit with Python FastMCP to Connect Any AI Model

· 8 min read

While building AI Agent systems for clients, we found that different tasks require vastly different model capabilities and costs: vision models for image analysis, lightweight models for text completion, and local models for internal data queries. MCP (Model Context Protocol) turns each capability into an independent tool that AI clients invoke on demand.

TL;DR

Build a custom MCP Server in 30 minutes with Python FastMCP, connecting any OpenAI-compatible API based on scenario and cost. This article demonstrates the full workflow using the Doubao vision model, with extension templates for text generation, image generation, TTS, and more.

What Problem Does MCP Solve?

MCP (Model Context Protocol) is an open protocol proposed by Anthropic that standardizes communication between AI applications and external tools. Think of it as USB-C: regardless of the device, plug it in and it works.

Traditionally, integrating each AI capability requires writing custom integration code. MCP standardizes this process:

AI Client (Claude Code / Cursor / Cherry Studio)
↓ MCP Protocol (stdio / SSE)
MCP Server (your toolkit)
↓ HTTP API
Various AI Models / Internal APIs

One MCP Server can expose multiple tools, each backed by a different model or API.

Why Python over Node.js?

The MCP official SDK supports both Python and TypeScript. Both are functionally equivalent, so the choice depends on your context:

DimensionPython (FastMCP)Node.js (@modelcontextprotocol/sdk)
AI Ecosystemhttpx/OpenAI SDK native supportAdditional dependencies needed
Code VolumeOne-line decorator to define a toolManual schema registration required
Binary Handlingbase64/PIL is conciseBuffer API slightly more verbose
Data Sciencepandas/numpy readily availableNeeds separate toolchain
Deploymentvenv isolation, no Node version issuesNode version compatibility is a common pain point

Bottom line: MCP Server's job is "call APIs + process data." Python is more concise and intuitive for HTTP calls, image/file handling, and data transformations, with fewer dependencies. Node.js is a better fit for teams already invested in TypeScript.

30-Minute Build: Image Analysis Tool

Setup

mkdir -p ~/.claude/mcp-servers/doubao-vision
cd ~/.claude/mcp-servers/doubao-vision
python3 -m venv venv
source venv/bin/activate
pip install mcp httpx

Server Code

"""Doubao Vision MCP Server - OpenAI-compatible image analysis via Volcengine Ark."""

import base64
import os
from pathlib import Path

import httpx
from mcp.server.fastmcp import FastMCP

# Config - inject via environment variables, never hardcode
BASE_URL = os.getenv("DOUBAO_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3")
API_KEY = os.getenv("DOUBAO_API_KEY", "")
MODEL = os.getenv("DOUBAO_MODEL", "doubao-1-5-vision-pro-32k-250115")

mcp = FastMCP("doubao-vision")


def _build_image_content(image_source: str) -> dict:
"""Build image content part from URL or local file path."""
if image_source.startswith(("http://", "https://")):
return {"type": "image_url", "image_url": {"url": image_source}}
# Local file: encode to base64 data URI
path = Path(image_source)
if not path.exists():
raise FileNotFoundError(f"Image not found: {image_source}")
mime_map = {
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".png": "image/png", ".gif": "image/gif",
".webp": "image/webp",
}
mime = mime_map.get(path.suffix.lower(), "image/png")
data = base64.b64encode(path.read_bytes()).decode()
return {
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{data}"},
}


@mcp.tool()
def analyze_image(image_source: str, prompt: str) -> str:
"""Analyze an image using Doubao Vision Pro model.

Supports remote URLs and local file paths.

Args:
image_source: URL or local file path to the image.
prompt: What to analyze or describe about the image.
"""
try:
image_content = _build_image_content(image_source)
except FileNotFoundError as e:
return f"Error: {e}"

messages = [
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
image_content,
],
}
]

resp = httpx.post(
f"{BASE_URL}/chat/completions",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
json={
"model": MODEL,
"messages": messages,
"max_tokens": 4096,
},
timeout=60,
)

if resp.status_code != 200:
return f"API error {resp.status_code}: {resp.text}"

data = resp.json()
return data["choices"][0]["message"]["content"]


if __name__ == "__main__":
mcp.run(transport="stdio")

Key design decisions:

  • _build_image_content: Unified handling for remote URLs and local files (auto base64 encoding)
  • @mcp.tool() decorator: Function signatures and docstrings auto-generate MCP tool descriptions; AI clients discover parameters automatically
  • Environment variable config: API keys are never hardcoded; injected via .mcp.json env field

Register with Client

Add to ~/.claude/.mcp.json (global) or project-level .mcp.json:

{
"mcpServers": {
"doubao-vision": {
"command": "/path/to/venv/bin/python",
"args": ["/path/to/server.py"],
"env": {
"DOUBAO_API_KEY": "your-api-key",
"DOUBAO_MODEL": "doubao-1-5-vision-pro-32k-250115",
"DOUBAO_BASE_URL": "https://ark.cn-beijing.volces.com/api/v3"
}
}
}
}

Extend on Demand: Multi-Scenario Toolkit

One MCP Server can register multiple tools, each using a different model. Here are templates for various scenarios:

Text Generation / Completion

@mcp.tool()
def generate_text(prompt: str, model: str = "deepseek-chat") -> str:
"""Generate text using specified model. Supports deepseek-chat, qwen-turbo, etc."""
providers = {
"deepseek-chat": {"base": "https://api.deepseek.com/v1", "key": os.getenv("DEEPSEEK_API_KEY")},
"qwen-turbo": {"base": "https://dashscope.aliyuncs.com/compatible-mode/v1", "key": os.getenv("QWEN_API_KEY")},
}
cfg = providers.get(model)
if not cfg:
return f"Unknown model: {model}"
# Call OpenAI-compatible endpoint...

Image Generation

@mcp.tool()
def generate_image(prompt: str, size: str = "1024x1024") -> str:
"""Generate image from text prompt using Stable Diffusion WebUI API."""
resp = httpx.post(
"http://localhost:7860/sdapi/v1/txt2img",
json={"prompt": prompt, "width": 1024, "height": 1024},
timeout=120,
)
data = resp.json()
img_bytes = base64.b64decode(data["images"][0])
path = f"/tmp/mcp-gen-{hash(prompt)}.png"
Path(path).write_bytes(img_bytes)
return f"Image saved to: {path}"

Text-to-Speech (TTS)

@mcp.tool()
def text_to_speech(text: str, voice: str = "default") -> str:
"""Convert text to speech audio file."""
resp = httpx.post(
"https://openspeech.bytedance.com/api/v1/tts",
headers={"Authorization": f"Bearer;{os.getenv('TTS_API_KEY')}"},
json={"text": text, "voice": voice, "format": "mp3"},
timeout=30,
)
path = f"/tmp/mcp-tts-{hash(text)}.mp3"
Path(path).write_bytes(resp.content)
return f"Audio saved to: {path}"

Internal API Wrapper

@mcp.tool()
def query_database(sql: str) -> str:
"""Execute read-only SQL query on internal database. SELECT only."""
if not sql.strip().upper().startswith("SELECT"):
return "Error: Only SELECT queries are allowed."
resp = httpx.post(
"http://internal-api.company.com/query",
json={"sql": sql},
headers={"Authorization": f"Bearer {os.getenv('INTERNAL_API_KEY')}"},
timeout=10,
)
return resp.json()["result"]

Cost Optimization: Multi-Model Routing

Using different cost-level models for different tasks is the core value of a custom toolkit:

Task TypeRecommended ModelCost Level
Simple classification/extractionQwen-Turbo / Ollama localVery low
Copywriting/translationDeepSeek-ChatLow
Image analysisDoubao Vision ProMedium
Complex reasoningClaude / GPT-4High

Implement routing in your MCP Server:

@mcp.tool()
def smart_query(task: str, content: str) -> str:
"""Route task to optimal model based on complexity."""
route = {
"classify": ("qwen-turbo", "https://dashscope.aliyuncs.com/compatible-mode/v1"),
"analyze_image": ("doubao-1-5-vision-pro-32k-250115", "https://ark.cn-beijing.volces.com/api/v3"),
"deep_reason": ("deepseek-reasoner", "https://api.deepseek.com/v1"),
}
model, base = route.get(task, route["classify"])
# Unified OpenAI-compatible call...

Best Practices

  1. API Key Security: Inject via environment variables; never hardcode in source or commit to git
  2. Timeout Control: Set 120s+ for slow tasks (image generation), 10-30s for simple text tasks
  3. Error Handling: Return clear error messages; AI clients display them to users
  4. Sandbox Limits: Restrict scope when wrapping internal APIs (e.g., SQL SELECT only)
  5. Concurrency: Synchronous httpx calls are fine; MCP protocol itself is serial

Extensibility: Not Just Tools, but AI Infrastructure

The examples above cover a single MCP Server. In production, extensibility goes much further:

Multi-Server Composition

Split different capabilities into independent servers, load on demand:

{
"mcpServers": {
"doubao-vision": { "command": "python", "args": ["vision.py"] },
"deepseek-text": { "command": "python", "args": ["text.py"] },
"internal-api": { "command": "python", "args": ["api.py"] }
}
}

Benefit: one server crashing doesn't affect others; different team members maintain different servers.

Shared Configuration

Multiple servers reuse the same provider config via a shared Python module:

# providers.py - centralized API config management
PROVIDERS = {
"doubao": {
"base_url": "https://ark.cn-beijing.volces.com/api/v3",
"api_key_env": "DOUBAO_API_KEY",
},
"deepseek": {
"base_url": "https://api.deepseek.com/v1",
"api_key_env": "DEEPSEEK_API_KEY",
},
}

MCP Resources and Prompts

MCP isn't just about Tools. It also supports Resources (static data) and Prompts (preset templates):

# Resources: let AI read your internal docs
@mcp.resource("docs://api-spec")
def get_api_spec() -> str:
return Path("openapi.yaml").read_text()

# Prompts: preset common analysis templates
@mcp.prompt()
def code_review_prompt(code: str) -> str:
return f"Review this code for security issues and performance:\n\n{code}"

From Local to Remote Deployment

stdio transport works for local development. For production, switch to SSE transport and deploy as an HTTP service:

# Local development
mcp.run(transport="stdio")

# Production deployment (remote AI clients access via HTTP)
mcp.run(transport="sse", host="0.0.0.0", port=8080)

Advanced Directions

DirectionDescription
Caching LayerCache identical requests to reduce API costs
Rate LimitingLimit call frequency per user or tool
Audit LoggingLog every tool call's input/output for debugging and compliance
StreamingUse SSE streaming for long text generation to reduce wait time
Tool PipelinesChain one tool's output as another's input

Interested in similar solutions? Get in touch

Fix Hover Selector Penetration in Nested FSE Group Blocks

· 3 min read

Encountered this issue while developing a WordPress FSE Block Theme for a client: blog listing cards show text shifting on hover while the card border stays still. Here's the root cause and solution.

TL;DR

Generic card hover selector .wp-block-columns .wp-block-column > .wp-block-group:hover matches the inner nested text group inside cards, causing text-only displacement. Fix: use .wp-block-post-template prefix to reset hover effects on inner groups.

Symptom

Blog listing uses card-style layout -- outer bordered group wrapping an image + text group. On hover:

  • Inner title and excerpt text shifts by translateY(-4px)
  • Outer card border and shadow remain unchanged
  • Looks like text "floats out" of the card

Root Cause

FSE card patterns use nested HTML structure:

<!-- Outer card group (has border) -->
<div class="wp-block-group has-border-color ...">
<img ... /> <!-- Featured image -->

<!-- Inner text group -->
<div class="wp-block-group" style="padding:...">
<h2>Post Title</h2>
<p>Excerpt text...</p>
</div>
</div>

The theme defines a generic hover effect:

.wp-block-columns .wp-block-column > .wp-block-group:hover {
transform: translateY(-4px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.1);
}

This selector is intended to lift the entire card. But in blog listing pages, the post-template block wraps cards inside wp-block-columns, so the selector also matches the inner text group (which is also .wp-block-column > .wp-block-group).

Since the inner group has no border or shadow, only text displacement is visible, while the outer card remains static.

Solution

Two-step approach: reset inner layer + add separate hover to outer layer.

1. Reset hover on inner groups

Use .wp-block-post-template prefix for higher specificity, zeroing out all hover effects on inner groups:

/* Reset hover for inner groups inside post template cards */
.wp-block-post-template .wp-block-columns .wp-block-column > .wp-block-group {
transition: none;
}
.wp-block-post-template .wp-block-columns .wp-block-column > .wp-block-group:hover {
transform: none;
box-shadow: none;
}

2. Add dedicated hover to outer card

Outer cards have .has-border-color class -- use it for precise targeting:

/* Blog card (outer bordered group) -- hover lift + shadow */
.wp-block-post-template .wp-block-group.has-border-color {
transition:
transform 0.3s ease-out,
box-shadow 0.3s ease-out;
}
.wp-block-post-template .wp-block-group.has-border-color:hover {
transform: translateY(-4px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.08);
}

Why not narrow the generic selector?

The generic selector is shared across multiple patterns (features-grid, pricing, testimonial, etc.) where groups are single-level -- no nesting penetration issue. Narrowing the generic selector would break hover effects on those patterns.

Resetting inner layers is more reliable because:

  • It doesn't affect other patterns using the generic hover
  • It precisely targets the problematic nesting scenario
  • Complete CSS-level isolation without any HTML/PHP changes

Takeaway

Before adding hover effects in FSE Block Themes:

  1. Inspect the actual HTML nesting structure of your patterns
  2. Verify selectors only target the intended element (outer container), not nested groups
  3. When multiple patterns share a selector, prefer "reset inner" over "restrict outer"

Interested in similar solutions? Get in touch

Fix CSS ::before Pseudo-element Decorative Patterns Covering Buttons

· 3 min read

TL;DR

When using ::before pseudo-elements for container background decorations (dots/grid), you must set opacity, pointer-events: none, and z-index: -1 together. Missing opacity causes 100% opaque patterns; missing z-index causes patterns to cover buttons and other child elements.

Problem

An FSE theme's CTA banner section used a ::before pseudo-element to render decorative dot patterns. The expected effect was a subtle background texture, but the actual result was fully opaque dots covering the button surface:

/* Broken code — pattern is fully opaque and covers children */
.has-dots-pattern::before {
content: "";
position: absolute;
inset: 0;
background-image: radial-gradient(circle, currentColor 1px, transparent 1px);
background-size: 20px 20px;
pointer-events: none;
/* Missing opacity — pattern renders at 100% opacity */
/* Missing z-index — pattern overlays child content */
}

Dense white dots appeared on button surfaces, and obvious grid lines covered the gradient background of the CTA section.

Root Cause

Three critical properties are required for ::before decorative patterns. Missing any one causes issues:

1. opacity — controls pattern transparency

radial-gradient generates solid dots. currentColor inherits the text color. On dark backgrounds, white solid dots are very prominent. Without opacity, the default value is 1, making the pattern fully opaque.

2. z-index: -1 — pushes the pattern behind child elements

::before is set to position: absolute. In the default stacking context, positioned elements paint after normal flow elements. When the container is position: relative, z-index: -1 pushes the pseudo-element behind child content while keeping it visible above the container's own background.

3. pointer-events: none — prevents click interception

This is the easiest to remember because it directly affects interaction. Without it, the pattern layer intercepts click events.

The same project had a correctly implemented reference using a standalone element approach:

/* Correct implementation — standalone element approach */
.cclee-dots-pattern {
position: absolute;
inset: 0;
background-image: radial-gradient(circle, currentColor 1px, transparent 1px);
background-size: 24px 24px;
opacity: 0.08; /* Present */
pointer-events: none;
/* Standalone element controlled by HTML order, no z-index needed */
}

Solution

Add opacity and z-index: -1 to the ::before pseudo-element:

/* Fixed */
.has-dots-pattern {
position: relative;
}
.has-dots-pattern::before {
content: "";
position: absolute;
inset: 0;
background-image: radial-gradient(circle, currentColor 1px, transparent 1px);
background-size: 20px 20px;
opacity: 0.08; /* Added: 8% opacity */
pointer-events: none;
z-index: -1; /* Added: behind child content */
}

Same fix for grid patterns:

.has-grid-pattern {
position: relative;
}
.has-grid-pattern::before {
content: "";
position: absolute;
inset: 0;
background-image:
linear-gradient(currentColor 1px, transparent 1px),
linear-gradient(90deg, currentColor 1px, transparent 1px);
background-size: 40px 40px;
opacity: 0.05; /* Grid is more subtle, 5% opacity */
pointer-events: none;
z-index: -1;
}

Checklist for decorative pattern pseudo-elements:

PropertyPurposeMissing consequence
opacity: 0.05~0.1Controls pattern transparencyPattern is fully opaque, overwhelming
z-index: -1Stacks behind child elementsPattern covers buttons and other children
pointer-events: nonePrevents mouse event interceptionClick-through fails

All three properties must be present together. This is the fixed pattern for ::before decorative overlays.


Interested in similar solutions? Get in touch

Fix WooCommerce FSE Cart Block Blank Page and Product Image Collapse

· 4 min read

Encountered these two issues while developing a WooCommerce FSE Block Theme for a client: Cart Block renders a blank page when empty, and product cards collapse when no featured image is set. Here's the root cause and solution.

TL;DR

  1. Cart Block requires explicit filled-cart-block and empty-cart-block inner blocks -- without them, empty cart renders nothing.
  2. FSE's post-featured-image block renders an empty string when no thumbnail exists, causing card height collapse. Fix with post_thumbnail_html filter to inject WooCommerce placeholder.

Issue 1: Cart Block Blank Page on Empty Cart

Symptom

The cart page works fine with products, but after clearing the cart the entire content area becomes blank -- no message, no "Continue Shopping" button, users are stuck.

Root Cause

WooCommerce Cart Block (wp:woocommerce/cart) requires developers to explicitly declare two inner blocks:

  • wp:woocommerce/filled-cart-block -- shown when cart has items
  • wp:woocommerce/empty-cart-block -- shown when cart is empty

If you only add the Cart Block without these inner blocks, WooCommerce doesn't know what to render for an empty cart and outputs nothing.

This issue doesn't occur in classic themes because their PHP templates (cart.php) have built-in empty cart handling. But FSE HTML templates are declarative -- you must declare all states.

Solution

Correct structure in cart.html:

<!-- wp:woocommerce/cart {"className":"cclee-cart"} -->
<div class="wp-block-woocommerce-cart alignwide is-loading">

<!-- wp:woocommerce/filled-cart-block -->
<div class="wp-block-woocommerce-filled-cart-block">
<!-- Full layout for items: product list + totals -->
<!-- wp:columns -->
<div class="wp-block-columns">
<!-- wp:column {"width":"65%"} -->
<div class="wp-block-column" style="flex-basis:65%">
<!-- wp:woocommerce/cart-items-block -->
<div class="wp-block-woocommerce-cart-items-block"></div>
<!-- /wp:woocommerce/cart-items-block -->
</div>
<!-- /wp:column -->
<!-- wp:column {"width":"35%"} -->
<div class="wp-block-column" style="flex-basis:35%">
<!-- wp:woocommerce/cart-totals-block -->
<div class="wp-block-woocommerce-cart-totals-block">
<!-- wp:woocommerce/cart-order-summary-block /-->
<!-- wp:woocommerce/proceed-to-checkout-block /-->
</div>
<!-- /wp:woocommerce/cart-totals-block -->
</div>
<!-- /wp:column -->
</div>
<!-- /wp:columns -->
</div>
<!-- /wp:woocommerce/filled-cart-block -->

<!-- wp:woocommerce/empty-cart-block -->
<div class="wp-block-woocommerce-empty-cart-block">
<!-- Empty cart message + continue shopping button -->
<!-- wp:paragraph {"align":"center","textColor":"neutral-500"} -->
<p class="has-text-align-center has-neutral-500-color has-text-color">Your cart is currently empty.</p>
<!-- /wp:paragraph -->
<!-- wp:buttons {"layout":{"type":"flex","justifyContent":"center"}} -->
<div class="wp-block-buttons">
<!-- wp:button {"backgroundColor":"accent","textColor":"base"} -->
<div class="wp-block-button"><a href="/shop/" class="wp-block-button__link has-base-color has-accent-background-color has-text-color has-background wp-element-button">Browse Products</a></div>
<!-- /wp:button -->
</div>
<!-- /wp:buttons -->
</div>
<!-- /wp:woocommerce/empty-cart-block -->

</div>
<!-- /wp:woocommerce/cart -->

Key point: filled-cart-block and empty-cart-block must be direct children of wp:woocommerce/cart. WooCommerce uses them for conditional rendering based on cart state.

Symptom

In product listing pages (archive-product), products without featured images only show the text area -- no image placeholder. Mixed with products that have images, the layout heights are inconsistent and visually broken.

Root Cause

FSE's wp:post-featured-image block renders an empty string when a post/product has no thumbnail. Classic themes handle this in PHP templates with has_post_thumbnail() checks, but FSE HTML templates cannot embed conditional logic.

Solution

Add a filter in your theme's functions.php or WooCommerce integration file:

/**
* Product placeholder image when no featured image is set.
*
* FSE post-featured-image block renders empty when no thumbnail,
* causing card height collapse. This filter injects the WooCommerce
* placeholder image for product post type.
*
* @param string $html The post thumbnail HTML.
* @param int $post_id The post ID.
* @return string
*/
add_filter( 'post_thumbnail_html', function ( $html, $post_id ) {
// Already has image or not a product -- skip.
if ( $html || get_post_type( $post_id ) !== 'product' ) {
return $html;
}

// Use WooCommerce's built-in placeholder.
$src = function_exists( 'wc_placeholder_img_src' )
? wc_placeholder_img_src()
: '';

if ( ! $src ) {
return '';
}

return sprintf(
'<img src="%s" alt="%s" loading="lazy" decoding="async" style="width:100%%;height:100%%;object-fit:cover;">',
esc_url( $src ),
esc_attr( get_the_title( $post_id ) )
);
}, 10, 2 );

Notes:

  • wc_placeholder_img_src() depends on WooCommerce being active -- use function_exists() as a guard
  • object-fit: cover ensures the placeholder matches the cropping behavior of regular featured images
  • Only applies to product post type, does not affect blog posts or other post types

Interested in similar solutions? Get in touch

WordPress FSE Block Validation Failed: The Hidden Cause of Missing JSON Quotes

· 4 min read

TL;DR

In WordPress FSE themes, if a JSON attribute in a Pattern/Template HTML comment has a missing closing quote ", the brace count remains balanced, but parse_blocks() silently sets the block's attrs to null. Gutenberg's save function then produces no inline styles, triggering Block validation failed. Validate JSON with json.loads() to catch this.

The Problem

Opening the wishlist template in WordPress Site Editor shows a console error:

Block validation: Block validation failed for `core/group`

Content generated by `save` function:
<div class="wp-block-group has-border-color has-neutral-200-border-color"></div>

Content retrieved from post body:
<div class="wp-block-group has-border-color has-neutral-200-border-color"
style="border-style:solid;border-width:1px;border-radius:var(--wp--custom--border--radius--lg);
padding-top:var(--wp--preset--spacing--40);...">

The save function outputs correct CSS classes but completely loses inline styles, and the content is empty — even though the file clearly contains style attributes and child blocks.

Root Cause

The issue is in the JSON attributes of a core/group block HTML comment:

<!-- Broken -->
<!-- wp:group {"style":{"spacing":{"padding":{"top":"var(--wp--preset--spacing--40)",
"right":"var(--wp--preset--spacing--40)",
"bottom":"var(--wp--preset--spacing--40)",
"left":"var(--wp--preset--spacing--40)"}}, <!-- missing closing quote -->
"border":{"radius":"var(--wp--custom--border--radius--lg)","width":"1px","style":"solid"}},
"borderColor":"neutral-200","layout":{"type":"constrained"}} -->

The "left" value "var(--wp--preset--spacing--40)" is missing its closing quote ". It's written as "var(--wp--preset--spacing--40).

Why brace counting misses this:

Correct: { "left": "value" }  → quotes paired, braces balanced
Broken: { "left": "value } → quotes unpaired, but braces still balanced

When the closing quote is missing, the } characters are treated as string content by the JSON parser (the quote never closed), so the brace count stays balanced.

parse_blocks() doesn't throw an error — it silently sets attrs to null:

// What parse_blocks returns
[
'blockName' => 'core/group',
'attrs' => null, // entire attribute object discarded
'innerHTML' => '<div ...>', // raw HTML still present
]

Gutenberg calls save() with null attrs, produces no inline styles, and the mismatch triggers Block validation failed.

Why this is hard to spot:

  • No white screen — the page still renders (falls back to innerHTML)
  • Braces are balanced, so visual inspection easily misses it
  • Site Editor shows a subtle "block needs recovery" notice
  • Audit scripts typically check brace balance and attribute correspondence, not JSON validity

Solution

1. Locate the Problem

Validate JSON with Python:

python3 -c "
import json
with open('templates/wishlist.html') as f:
content = f.read()
marker = 'wp:group {'
start = content.index(marker) + len(marker) - 1
end = content.index(' -->', start)
json_str = content[start:end]
try:
json.loads(json_str)
print('JSON OK')
except json.JSONDecodeError as e:
print(f'Error at position {e.pos}: {e.msg}')
print(f'Context: ...{json_str[max(0,e.pos-20):e.pos+20]}...')
"

Output pinpoints the exact error:

Error at position 196: Expecting ',' delimiter
Context: ...g--40)}},"border":{"...

2. Fix the JSON

Add the missing closing quote after the "left" value:

<!-- Fixed -->
<!-- wp:group {"style":{"spacing":{"padding":{"top":"var(--wp--preset--spacing--40)",
"right":"var(--wp--preset--spacing--40)",
"bottom":"var(--wp--preset--spacing--40)",
"left":"var(--wp--preset--spacing--40)"}}, <!-- closing quote added -->
"border":{"radius":"var(--wp--custom--border--radius--lg)","width":"1px","style":"solid"}},
"borderColor":"neutral-200","layout":{"type":"constrained"}} -->

3. Verify the Fix

# Verify parse_blocks correctly parses the block
docker exec wp_cli wp eval '
$blocks = parse_blocks(file_get_contents(get_stylesheet_directory() . "/templates/wishlist.html"));
echo $blocks[...]["attrs"]["style"]["border"]["radius"];
' --allow-root

4. Prevention

Add a JSON comment validity check to CI:

import json, re, sys

def check_block_json(filepath):
with open(filepath) as f:
content = f.read()
for m in re.finditer(r'<!-- wp:\w+ (\{.*?\}) -->', content):
try:
json.loads(m.group(1))
except json.JSONDecodeError as e:
print(f"{filepath}: JSON error at comment position {m.start()}: {e}")
sys.exit(1)

check_block_json(sys.argv[1])

Interested in similar solutions? Get in touch

Fix Gutenberg Gradient Class Naming Change Causing Block Validation Failure

· 2 min read

Encountered this issue while developing a WordPress FSE theme for a client. Here's the root cause and solution.

TL;DR

After a Gutenberg upgrade, the gradient CSS class naming convention changed from has-{slug}-gradient to has-{slug}-gradient-background. Hand-written Pattern HTML with old classes doesn't match Gutenberg's validation logic, causing Site Editor errors. The fix is to batch replace class names.

Problem

All legacy Patterns show an error in Site Editor:

Block contains unexpected or invalid content
  • New Patterns work fine
  • Frontend renders correctly
  • Clicking "Attempt Recovery" restores display

Root Cause

Check DevTools Console for Block validation failed logs, compare Expected vs Actual:

Expected (Gutenberg generated):

<div class="wp-block-group has-accent-gradient-background has-background">

Actual (hand-written in Pattern):

<div class="wp-block-group has-accent-gradient has-background">

After Gutenberg upgrade, gradient class naming changed:

Old NamingNew Naming
has-{slug}-gradienthas-{slug}-gradient-background

During Block validation, Gutenberg recalculates expected HTML based on JSON attributes in block comments (e.g., "gradient":"accent-gradient") and compares with actual HTML. Class mismatch triggers validation failure.

Solution

1. Identify Affected Files

# Find files using old class naming
grep -r "has-[a-z0-9-]*-gradient " patterns/ --include="*.php"

2. Batch Replace

# macOS/Linux compatible
sed -i '' 's/has-\([a-z0-9-]*\)-gradient /has-\1-gradient-background /g' patterns/*.php

# Linux (GNU sed)
sed -i 's/has-\([a-z0-9-]*\)-gradient /has-\1-gradient-background /g' patterns/*.php

Notes:

  • Only replace class attributes in HTML tags
  • Block comment declarations like "gradient":"accent-gradient" don't need changes
  • Regex ends with a space to avoid matching has-accent-gradient-background

3. Verify Fix

  1. Clear WordPress cache: wp cache flush
  2. Refresh Site Editor, confirm errors are gone
  3. Spot-check a few Patterns, verify frontend and editor display correctly

Prevention

  1. Prefer block comment attributes: Set styles via JSON attributes (e.g., "gradient":"accent-gradient") to let Gutenberg auto-generate classes, avoid hand-writing
  2. Watch Gutenberg changelog: Check Breaking Changes before upgrading
  3. Test in development first: After upgrade, test all Patterns in dev environment before deploying

Interested in similar solutions? Contact us

Fix FSE Group Block Layout Property Overriding Custom CSS

· 3 min read

TL;DR

WordPress FSE Group blocks with layout property automatically generate is-layout-* CSS classes that have higher specificity than custom CSS, causing size settings to be ignored. Solution: 1) Use "layout":{"type":"default"} in block annotation to avoid extra layout classes; 2) Use !important in CSS to force override; 3) Key: Add padding: 0 !important to clear the Group block's default padding.

Problem

Timeline component year dots should display as 80px circles, but appear as ellipses:

<!-- Size settings in block annotation -->
<!-- wp:group {"style":{"dimensions":{"width":"80px","height":"80px"}},"layout":{"type":"flex",...}} -->
/* Custom CSS */
.cclee-timeline-dot {
width: 80px;
height: 80px;
border-radius: 50%;
}

Neither adjusting CSS nor block attributes fixed the stretched dots.

Root Cause

WordPress FSE Group blocks automatically add layout-related CSS classes based on the layout property:

<div class="wp-block-group cclee-timeline-dot is-layout-flow">

These is-layout-* classes come from WordPress core stylesheets and override custom CSS. Additionally, Group blocks have default padding that increases element dimensions.

Key issues:

  1. layout: {"type": "flex"} generates is-layout-flex class, causing children to be stretched by flexbox
  2. style.dimensions in block annotation becomes inline style, but gets overridden by layout class styles
  3. Group block default padding adds to actual element size

Solution

1. Modify Block Annotation with Default Layout

<!-- wp:group {"className":"cclee-timeline-dot","style":{"border":{"radius":"50%"}},"backgroundColor":"accent","textColor":"base","layout":{"type":"default"}} -->
<div class="wp-block-group cclee-timeline-dot has-base-color has-accent-background-color has-text-color has-background" style="border-radius:50%">

Remove style.dimensions and complex flex layout, use "layout":{"type":"default"} instead.

2. CSS Override + Clear Default Padding

/* Timeline: Fixed circle dot */
.wp-block-group.cclee-timeline-dot {
width: 80px !important;
height: 80px !important;
min-width: 80px !important;
min-height: 80px !important;
flex-shrink: 0 !important;
aspect-ratio: unset !important;
border-radius: 50% !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
align-self: center !important;
box-sizing: border-box !important;
text-align: center !important;
padding: 0 !important; /* Key: clear default padding */
}

.wp-block-group.cclee-timeline-dot p {
margin: 0 !important;
white-space: nowrap !important;
line-height: 1 !important;
overflow: visible !important;
}

3. Use :has() to Control Parent Container

Prevent parent Column from being stretched by flexbox:

.wp-block-columns .wp-block-column:has(.cclee-timeline-dot) {
flex-shrink: 0 !important;
flex-basis: 100px !important;
width: 100px !important;
}

Key Finding

padding: 0 !important is the final solution. Group block's default padding stretches the element—even with width/height set, the actual rendered size exceeds expectations.


Interested in similar solutions? Contact us