Magento 2 Hyvä Developer
Hyvä is a modern Magento 2 frontend framework with dramatically simplified JavaScript and CSS. This skill covers Hyvä-specific patterns.
Related Skills
REQUIRED BACKGROUND: Load magento2-dev-core first — it defines the PHP/backend patterns (DI, escaping, repositories) this skill assumes for any ViewModel or backend code behind a Hyvä template.
Hyvä and Luma (magento2-frontend-dev) are mutually exclusive theme stacks — check the theme's theme.xml parent (Hyva/default/Hyva/reset vs Magento/blank) and composer.json for hyva-themes/* packages before assuming either applies. Hybrid projects (e.g. dual-stack) keep both: app/design/frontend/<Vendor>/hyva_* → this skill, app/design/frontend/<Vendor>/luma_child → magento2-frontend-dev. Pair with govard-magento for the container/CLI side.
Detect the project's actual setup first
On DSH: call
hyva_theme_inspect {classes:["<class-from-diff>"]}— returns{themes[],tailwind:{major,...},hyvaPackages[]}grounded from theme.xml + package.json + tailwind.config/hyva.config + composer.lock. If any field null, check notes and downgrade to a question. Otherwise: read theme.xml parent, /web/tailwind/package.json, tailwind.config.js or tailwind-source.css+hyva.config.json, and composer.lock — verify, don't memorize table (verified 2026-08-26).
Hyvä/Tailwind conventions vary a lot by project age — check before applying a pattern:
- Tailwind version: v4 uses CSS-based config and
hyva.config.jsondesign tokens; v2/v3 usetailwind.config.js. Checkweb/tailwind/package.json. - CSP build:
Hyva/default-cspvs the plainHyva/default/Hyva/resetparent intheme.xml. Applying CSP-only nonce patterns to a non-CSP theme (or vice versa) wastes effort. - Parent theme:
Hyva/reset(built from scratch) vsHyva/default(full starter) changes how much markup/CSS already exists to extend rather than rewrite.
Hyvä vs Luma Comparison
| Aspect | Luma | Hyvä |
|---|---|---|
| JavaScript | ~200 resources (RequireJS/Knockout) | 2 resources (Alpine.js) |
| CSS | LESS-based | Tailwind CSS |
| Bundle Size | 500KB+ | <50KB |
| Core Web Vitals | Challenging | Optimized |
| Learning Curve | Steep | Gentle |
| Maintenance | Complex | Simple |
Theme Structure
Creating a Child Theme
Always copy web/ from the parent theme rather than creating it from scratch — it carries the Tailwind config and build tooling the theme needs:
mkdir -p app/design/frontend/<Vendor>/<Theme>/web
cp -r vendor/hyva-themes/magento2-default-theme/web/* app/design/frontend/<Vendor>/<Theme>/web/
# For a CSP theme, copy from magento2-default-theme-csp instead
Then add registration.php, theme.xml (parent: Hyva/default, Hyva/reset, or Hyva/default-csp), and composer.json, install Tailwind deps and build, then bin/magento setup:upgrade && bin/magento cache:flush to pick up the new theme.
app/design/frontend/Vendor/Theme/
├── registration.php
├── theme.xml
├── composer.json
├── package.json
├── tailwind.config.js
├── package.json
├── web/
│ ├── tailwind/
│ │ ├── base/ # Preflight, resets
│ │ ├── components/ # Reusable components
│ │ │ ├── buttons.css
│ │ │ ├── forms.css
│ │ │ └── messages.css
│ │ ├── utilities/ # Custom utilities
│ │ └── theme/ # Page-specific
│ └── js/
│ └── alpinejs/ # Alpine components
├── layout/
│ └── default.xml
└── templates/
└── ...
CSP (Content Security Policy) Compliance
Critical: PCI-DSS 4.0 (Required since April 2025)
Payment pages MUST NOT use:
unsafe-evalCSP directiveunsafe-inlineCSP directive
CSP Nonce Registration
Every inline script MUST register with CSP:
<?php
/** @var Hyva\Theme\ViewModel\HyvaCsp $hyvaCsp */
use Hyva\Theme\ViewModel\HyvaCsp;
?>
<!-- Register script before using nonce -->
<?php $hyvaCsp->registerInlineScript() ?>
<script nonce="<?= $cspNonce ?>">
// CSP-compliant code
</script>
$hyvaCsp is ambient — never demand an assignment. The Hyvä theme provides the
$hyvaCspview model to every template: a/** @var */docblock (as above) is the complete setup, no$viewModels->require(HyvaCsp::class)needed.isset($hyvaCsp)guards are the correct pattern for templates that must also render without Hyvä/CSP — never flag them as dead code, and never claimisset()is always false from template text alone. If CSP registration genuinely looks unwired, check layout XML / theme wiring first (a missing call to a mechanism the project doesn't have is "not applicable", not a finding).
CSP-Compatible Alpine.js Patterns
WRONG (CSP violations):
<!-- These patterns break CSP -->
<button @click="count++">Add</button>
<span x-show="!loading">Ready</span>
<input :value="name" @input="name = $event.target.value">
CORRECT (CSP-compliant):
<!-- Use methods for mutations -->
<button @click="increment">Add</button>
<span x-show="isNotLoading">Ready</span>
<input :value="name" @input="updateName">
function initComponent() {
return {
count: 0,
loading: true,
name: '',
increment() {
this.count++;
},
isNotLoading() {
return !this.loading;
},
updateName(event) {
this.name = event.target.value;
}
}
}
window.addEventListener('alpine:init', () => {
Alpine.data('initComponent', initComponent);
}, {once: true})
Registering Alpine Components
<?php
/** @var Hyva\Theme\ViewModel\HyvaCsp $hyvaCsp */
?>
<script>
function initProductSlider() {
return {
products: [],
currentIndex: 0,
init() {
// Initialization
},
next() {
this.currentIndex = (this.currentIndex + 1) % this.products.length;
},
prev() {
this.currentIndex = (this.currentIndex - 1 + this.products.length) % this.products.length;
}
}
}
window.addEventListener('alpine:init', () => Alpine.data('initProductSlider', initProductSlider), {once: true})
</script>
<?php $hyvaCsp->registerInlineScript() ?>
Alpine.js Component Structure
Basic Component
// web/js/alpinejs/Example.js
function initExample() {
return {
// Observable state
isOpen: false,
items: [],
selectedId: null,
// Computed (reactive)
get hasItems() {
return this.items.length > 0;
},
// Methods
toggle() {
this.isOpen = !this.isOpen;
},
select(id) {
this.selectedId = id;
},
// Lifecycle
init() {
// Called when component initializes
this.loadData();
},
loadData() {
fetch('/api/data')
.then(res => res.json())
.then(data => this.items = data);
}
}
}
window.addEventListener('alpine:init', () => Alpine.data('initExample', initExample), {once: true})
Template Usage
<div x-data="initExample">
<button @click="toggle">Toggle</button>
<div x-show="isOpen">
<template x-for="item in items" :key="item.id">
<div @click="select(item.id)" :class="{ 'selected': selectedId === item.id }">
<span x-text="item.name"></span>
</div>
</template>
</div>
</div>
<script>
function initExample() {
// ... component logic
}
window.addEventListener('alpine:init', () => Alpine.data('initExample', initExample), {once: true})
</script>
<?php $hyvaCsp->registerInlineScript() ?>
Passing Data from PHP
<?php
/** @var \Magento\Framework\Escaper $escaper */
/** @var \Hyva\Theme\ViewModel\HyvaCsp $hyvaCsp */
$productsJson = json_encode($products, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
?>
<div x-data="initProductList"
data-products="<?= $escaper->escapeHtmlAttr($productsJson) ?>">
</div>
<script>
function initProductList() {
return {
products: [],
init() {
this.products = JSON.parse(this.$root.dataset.products || '[]');
}
}
}
window.addEventListener('alpine:init', () => Alpine.data('initProductList', initProductList), {once: true})
</script>
<?php $hyvaCsp->registerInlineScript() ?>
Hyvä Utilities
Hyvä provides global utilities via the hyva object:
Form Handling
// Get form key
hyva.getFormKey()
// Submit form via POST
hyva.postForm({
action: '/checkout',
data: { product_id: 123, qty: 1 }
})
// Alternative with fetch
async function submitForm(url, data) {
const formKey = hyva.getFormKey();
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ ...data, form_key: formKey })
});
return response.json();
}
Cookies
hyva.getCookie('customer_segment')
hyva.setCookie('recent_viewed', productId, 30)
Formatting
hyva.formatPrice(price, showSign)
hyva.str('Hello {0}', name)
hyva.safeParseNumber(value)
DOM Manipulation
hyva.replaceDomElement('#target', '<span>New content</span>')
hyva.trapFocus(modalElement)
Events
// After Alpine initialization
hyva.alpineInitialized(function() {
console.log('Alpine ready');
})
View Models
Prefer view models (Hyva\Theme\Model\ViewModelInterface / Magento's ArgumentInterface) over blocks for passing data to templates — they keep PHP logic out of the theme directory (which should hold only templates, layout, i18n, and web/ assets) and in a proper app/code module where Magento's DI can autoload the class.
// app/code/Vendor/Module/ViewModel/ProductInfo.php
declare(strict_types=1);
namespace Vendor\Module\ViewModel;
use Hyva\Theme\Model\ViewModelInterface;
use Magento\Framework\View\LayoutInterface;
class ProductInfo implements ViewModelInterface
{
public function __construct(
private readonly LayoutInterface $layout
) {}
public function isInStock(): bool
{
$product = $this->layout->getBlock('product.info')->getProduct();
return $product && $product->isInStock();
}
}
<!-- layout/default.xml -->
<referenceBlock name="product.info">
<arguments>
<argument name="product_info_view_model" xsi:type="object">Vendor\Module\ViewModel\ProductInfo</argument>
</arguments>
</referenceBlock>
<?php /** @var \Vendor\Module\ViewModel\ProductInfo $productInfoViewModel */ ?>
<?php if ($productInfoViewModel->isInStock()): ?>
<button class="btn-cart">Add to Cart</button>
<?php endif; ?>
Tailwind CSS
Tailwind v4 (CSS-based config)
Newer Hyvä themes use Tailwind v4, which drops tailwind.config.js for a CSS-based config plus a hyva.config.json design-token file — check web/tailwind/package.json first, the two configs are not interchangeable.
/* web/tailwind/tailwind-source.css */
@import "tailwindcss";
@theme {
--color-primary: oklch(46% 0.2 265);
--spacing-xs: 0.5rem;
}
@layer components {
.btn-primary {
@apply bg-primary text-white px-4 py-2 rounded;
}
}
// hyva.config.json
{
"tokens": {
"src": "hyva.design.tokens.json",
"format": "default",
"cssSelector": "@theme"
}
}
Generate tokens/sources with npx hyva-sources / npx hyva-tokens rather than hand-rolling them. Alpine gating: Tailwind v4 sources come from CSS @source directives and hyva.config.json tailwind.include/exclude; editing hyva.config.json on a v3 setup is a silent no-op — verify web/tailwind/package.json tailwindcss major before touching either config.
Directory Structure
web/tailwind/
├── base/
│ └── _styles.pcss # Preflight, typography
├── components/
│ ├── _buttons.pcss
│ ├── _forms.pcss
│ └── _messages.pcss
├── utilities/
│ └── _custom-utilities.pcss
├── theme/
│ ├── _header.pcss
│ └── _footer.pcss
└── app.css # Main entry
Build Commands
# Development with watch
npm run watch
# Production build
npm run build
# PurgeCSS config (auto-included)
# Tailwind automatically removes unused classes
Common Classes
<!-- Buttons -->
<button class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
Add to Cart
</button>
<!-- Forms -->
<input type="text"
class="w-full border border-gray-300 rounded px-3 py-2 focus:border-blue-500 focus:outline-none">
<!-- Cards -->
<div class="bg-white rounded-lg shadow-md p-4">
<!-- Content -->
</div>
<!-- Grid -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Items -->
</div>
Responsive Design
<!-- Mobile first -->
<div class="w-full md:w-1/2 lg:w-1/3">
<!-- Shows 100% on mobile, 50% on tablet, 33% on desktop -->
</div>
Reviewing Tailwind/Hyvä Diffs — Verify Before Claiming
When reviewing a diff against a Hyvä theme, never assert that a utility class "is missing", "compiles to nothing", or "will be purged" without verifying against the actual project. Work through this gate:
1. Detect the stack first
Read <theme>/web/tailwind/package.json for the tailwindcss major version. Hyvä default theme 1.4.x/1.5.x ships Tailwind v4, 1.2.x/1.3.x v3, older v2. The version decides what "exists":
- Tailwind v3: a utility exists only if it is in the default scale OR
theme.extend.*OR produced by a plugin/safelist entry intailwind.config.js. - Tailwind v4: numeric spacing utilities are dynamic (
w-18,py-3.75work out of the box); sources come from CSS@sourcedirectives andhyva.config.json(tailwind.include/exclude). Editinghyva.config.jsonon a v3 setup is a silent no-op.
2. Check the real config before claiming absence
Grep/read the theme's actual tailwind.config.js for theme.extend.*, safelist, and the content globs before any existence claim. Three traps:
safelistentries survive purge scanning even when no scanned source uses them — "no usage found in markup" is NOT evidence that a class is missing.contentglobs may exclude whole trees (stock Hyvä child themes ship with theapp/code/**glob commented out); classes outside scan scope need safelist or arbitrary-value syntax.- CMS-content classes can live in
Hyva_CmsTailwindJitdatabase-stored CSS injected at render time — they never appear inweb/css/styles.css, so absence from built CSS is not proof of absence.
3. Built CSS: only if actually present
If a compiled stylesheet is available (committed, or built locally), grep it for the escaped selector as final confirmation. Review worktrees usually have NO built CSS and NO vendor/ — do not attempt to read either; infer composer dependencies from composer.json/composer.lock. If verification is impossible from where you stand, downgrade the finding to a question ("confirm X survives the build") instead of an assertion.
4. Dynamic class names
PHP-interpolated utilities (class="columns-<?= $n ?>") are invisible to the scanner. Suggest the fix ladder: CSS-variable binding via arbitrary-value syntax, or a co-located PHP comment listing every variant (Tailwind scans comments), or safelist / @source inline() reserved for DB-sourced values only.
5. Alpine under strict CSP
Directive values must be dot paths — no operators, literals, globals, or spaces. Violations fail silently (console.warn only), so static review is the primary gate. Treat vendor guidance like "x-model is unsupported" as recommended style: dot-path x-model works on shipped CSP builds, so warn rather than fail.
Idea credits: verification-gate patterns distilled from makotokimura96/hyva-skills (CC BY 4.0) and hyva-themes/hyva-ai-tools (OSL-3.0).
Layout XML
Hyvä-Specific Handles
<!-- Add Hyvä modal support -->
<update handle="hyva_modal"/>
<!-- Register custom Alpine component -->
<head>
<script src="Hyva_Theme::js/alpinejs/my-component.js"/>
</head>
Override Template
<referenceBlock name="product.info" template="MyCompany_MyTheme::product/view.phtml"/>
Migration from Luma
Step 1: Analyze Dependencies
# List jQuery dependencies
grep -r "require.*jquery" app/design/frontend/Vendor/Theme/web/js/
# Check Knockout bindings
grep -r "data-bind=" app/design/frontend/Vendor/Theme/templates/
Step 2: Replace JavaScript
// Luma Knockout
define(['ko'], function(ko) {
return {
items: ko.observableArray([]),
addItem: function(item) {
this.items.push(item);
}
};
});
// Hyvä Alpine
function initComponent() {
return {
items: [],
addItem(item) {
this.items.push(item);
}
}
}
Step 3: Replace LESS with Tailwind
// Luma LESS
.product-card {
.lib-card();
.lib-respond-to(@mobile, { width: 100%; });
}
// Hyvä Tailwind
<div class="bg-white rounded-lg shadow-md p-4 w-full md:w-1/2">
Step 4: Update Templates
// Luma (Knockout)
<span data-bind="text: product.name"></span>
// Hyvä (Alpine)
<span x-text="product.name"></span>
Third-Party Compatibility Modules
A Luma-built third-party extension needs a Hyvä compatibility module to override its templates and JS — check the vendor's GitHub for an existing one (many ship under hyva-themes/*) before writing your own.
To build one: create a module that requires the original module, copy only the .phtml templates you need to override, replace any jQuery/Knockout JS with CSP-compatible Alpine, and sequence it after both the original module and Hyva_Theme in module.xml. Then register it so Hyvä actually picks it up:
<!-- etc/frontend/di.xml -->
<type name="Hyva\CompatModuleFallback\Model\CompatModuleRegistry">
<arguments>
<argument name="compatModules" xsi:type="array">
<item name="hyva_vendor_module" xsi:type="array">
<item name="original_module" xsi:type="string">Vendor_Module</item>
<item name="compat_module" xsi:type="string">Vendor_ModuleHyva</item>
</item>
</argument>
</arguments>
</type>
Without this CompatModuleRegistry registration, Hyvä has no way to know the compat module should override the original's frontend output — the templates get copied but never actually take effect.
Hyvä UI & CMS Components
- UI components (
hyva-themes/hyva-ui): prebuilt, template-based components installed into a theme — copysrc/*into the theme, merge any layout XML, and add config toetc/view.xml. - CMS components: custom Hyvä CMS blocks live in a module depending on
Hyva_CmsBase, declared in acomponents.jsonschema. Key gotchas:childrenis a root-level property (not a field type), validation lives underattributes, and the default-value key isdefault_value, notdefault.
Responsive Images
Use Hyva\Theme\ViewModel\Media::getResponsivePictureHtml() to generate <picture> markup instead of hand-rolling srcset. Set loading="eager" fetchpriority="high" on the LCP image (hero/first product image) and loading="lazy" on everything below the fold — getting this backwards is a common, easy-to-miss LCP regression.
Testing with Playwright
Hyvä pages scatter hidden x-show elements around the DOM — always scope message assertions to #messages, never a bare .message.error selector, or the test will match a hidden element and give a false pass/fail. Prefer getByRole / getByLabel / scoped getByText over raw CSS selectors, and use web-first assertions with a longer timeout to account for Alpine's reactive re-render delay after form submits.
Official Hyvä AI Tools
Hyvä provides official AI skills for various assistants:
| Tool | Purpose | Install |
|---|---|---|
| hyva-alpine-component | CSP-compatible Alpine components | .opencode/skills/ |
| hyva-child-theme | Theme creation | .opencode/skills/ |
| hyva-cms-component | CMS blocks | .opencode/skills/ |
| hyva-ui-component | UI component installation | .opencode/skills/ |
# Install Hyvä AI tools
curl -fsSL https://raw.githubusercontent.com/hyva-themes/hyva-ai-tools/main/install.sh | sh -s opencode
Verification
# Build CSS
cd web/tailwind && npm run build
# Clear cache
bin/magento cache:clean layout block_html full_page
# Static content deploy
bin/magento setup:static-content:deploy -f
CSP Console Check
Chrome DevTools MCP (preferred, if available): navigate to the page,
then list_console_messages — filter for CSP violation reports
(Content-Security-Policy directive errors, "Refused to execute inline
script" / "Refused to load the stylesheet" messages). No manual browser
interaction needed; test with CSP headers enabled the same way.
Manual fallback (no MCP connected): open the page in a browser, check the DevTools console for CSP errors, and confirm again with CSP headers enabled.
Pitfalls recap
- Hyvä replaces Luma entirely: no RequireJS, Knockout, UI-component JS, jQuery, or LESS in Hyvä templates.
- Every inline
<script>needs$hyvaCsp->registerInlineScript(), or it silently fails under CSP. - Prefer the CSP-safe Alpine pattern (named
Alpine.data()functions, no inline expression logic like@click="count++") even on a non-CSP build — it's the cleaner default and avoids a rewrite if the project later enables CSP. - Rebuild Tailwind (
npm run build) after any style change, or the new classes won't be in the compiled CSS. - Always escape output in
.phtmlwith$escaper, same as any other Magento template.