skills/hyva-alpine-component/SKILL.md
Write CSP-compatible Alpine.js components for Hyvä themes in Magento 2. This skill should be used when the user wants to create Alpine components, add interactivity to Hyvä templates, write JavaScript for Hyvä themes, or needs help with Alpine.js patterns that work with Content Security Policy. Trigger phrases include "create alpine component", "add interactivity", "alpine for hyva", "x-data component", "csp compatibility", "csp compliant javascript".
npx skillsauth add hyva-themes/hyva-ai-tools hyva-alpine-componentInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
This skill provides guidance for writing CSP-compatible Alpine.js components in Hyvä themes. Alpine CSP is a specialized Alpine.js build that operates without the unsafe-eval CSP directive, which is required for PCI-DSS 4.0 compliance on payment-related pages (mandatory from April 1, 2025).
Key principle: CSP-compatible code functions in both standard and Alpine CSP builds. Write all Alpine code using CSP patterns for future-proofing.
| Capability | Standard Alpine | Alpine CSP |
|------------|-----------------|------------|
| Property reads | x-show="open" | Same |
| Negation | x-show="!open" | Method: x-show="isNotOpen" |
| Mutations | @click="open = false" | Method: @click="close" |
| Method args | @click="setTab('info')" | Dataset: @click="setTab" data-tab="info" |
| x-model | Available | Not supported - use :value + @input |
| Range iteration | x-for="i in 10" | Not supported |
Every Alpine component in Hyvä follows this structure:
<div x-data="initComponentName">
<!-- Template content -->
</div>
<script>
function initComponentName() {
return {
// Properties
propertyName: initialValue,
// Lifecycle
init() {
// Called when component initializes
},
// Methods for state access
isPropertyTrue() {
return this.propertyName === true;
},
// Methods for mutations
setPropertyValue() {
this.propertyName = this.$event.target.value;
}
}
}
window.addEventListener('alpine:init', () => Alpine.data('initComponentName', initComponentName), {once: true})
</script>
<?php $hyvaCsp->registerInlineScript() ?>
Critical requirements:
Alpine.data() inside alpine:init event listener{once: true} to prevent duplicate registrations$hyvaCsp->registerInlineScript() after every <script> block$escaper->escapeJs() for PHP values in JavaScript strings$escaper->escapeHtmlAttr() for data attributes (not escapeJs)function initMyComponent() {
return {
open: false
}
}
window.addEventListener('alpine:init', () => Alpine.data('initMyComponent', initMyComponent), {once: true})
Why named global functions? Constructor functions are declared as named functions in global scope (not inlined in the Alpine.data() callback) so they can be proxied and extended in other templates. This is an extensibility feature of Hyvä Themes - other modules or child themes can wrap or override these functions before they are registered with Alpine.
When combining objects (e.g., with hyva.modal), use spread syntax inside the constructor:
function initMyModal() {
return {
...hyva.modal.call(this),
...hyva.formValidation(this.$el),
customProperty: '',
customMethod() {
// Custom logic
}
};
}
Use .call(this) to pass Alpine context to composed functions.
return {
item: {
is_visible: true,
title: 'Product'
}
}
<span x-show="item.is_visible" x-text="item.title"></span>
CSP does not allow inline transformations. Create methods instead:
Wrong (CSP incompatible):
<span x-show="!item.deleted"></span>
<span x-text="item.title || item.value"></span>
Correct:
<span x-show="isItemNotDeleted"></span>
<span x-text="itemLabel"></span>
return {
item: { deleted: false, title: '', value: '' },
isItemNotDeleted() {
return !this.item.deleted;
},
itemLabel() {
return this.item.title || this.item.value;
}
}
For simple boolean negation, use bracket notation:
return {
deleted: false,
['!deleted']() {
return !this.deleted;
}
}
<template x-if="!deleted">
<div>The item is present</div>
</template>
Wrong (CSP incompatible):
<button @click="open = !open">Toggle</button>
Correct:
<button @click="toggle">Toggle</button>
return {
open: false,
toggle() {
this.open = !this.open;
}
}
Wrong (CSP incompatible):
<button @click="selectItem(123)">Select</button>
Correct:
<button @click="selectItem" data-item-id="<?= $escaper->escapeHtmlAttr($itemId) ?>">Select</button>
return {
selected: null,
selectItem() {
this.selected = this.$el.dataset.itemId;
}
}
Important: Use escapeHtmlAttr for data attributes, not escapeJs.
Methods can access Alpine's special properties:
return {
onInput() {
// Access event
const value = this.$event.target.value;
this.inputValue = value;
},
getItemUrl() {
// Access x-for loop variable
return `${BASE_URL}/product/id/${this.item.id}`;
}
}
x-model is not available in Alpine CSP. Use two-way binding patterns instead.
<input type="text"
:value="username"
@input="setUsername">
return {
username: '',
setUsername() {
this.username = this.$event.target.value;
}
}
Use hyva.safeParseNumber() for numeric values:
return {
quantity: 1,
setQuantity() {
this.quantity = hyva.safeParseNumber(this.$event.target.value);
}
}
<textarea @input="setComment" x-text="comment"></textarea>
return {
comment: '',
setComment() {
this.comment = this.$event.target.value;
}
}
<input type="checkbox"
:checked="isSubscribed"
@change="toggleSubscribed">
return {
isSubscribed: false,
toggleSubscribed() {
this.isSubscribed = this.$event.target.checked;
}
}
<template x-for="option in options" :key="option.id">
<input type="checkbox"
:value="option.id"
:checked="isOptionSelected"
@change="toggleOption"
:data-option-id="option.id">
</template>
return {
selectedOptions: [],
isOptionSelected() {
return this.selectedOptions.includes(this.option.id);
},
toggleOption() {
const optionId = this.$el.dataset.optionId;
const index = this.selectedOptions.indexOf(optionId);
if (index === -1) {
this.selectedOptions.push(optionId);
} else {
this.selectedOptions.splice(index, 1);
}
}
}
<select @change="setCountry">
<template x-for="country in countries" :key="country.code">
<option :value="country.code"
:selected="isCountrySelected"
x-text="country.name"></option>
</template>
</select>
return {
selectedCountry: '',
isCountrySelected() {
return this.selectedCountry === this.country.code;
},
setCountry() {
this.selectedCountry = this.$event.target.value;
}
}
<template x-for="(product, index) in products" :key="index">
<div x-text="product.name"></div>
</template>
Loop variables (product, index) are accessible in methods:
<template x-for="(product, index) in products" :key="index">
<span :class="getItemClasses" @click="goToProduct" x-text="product.name"></span>
</template>
return {
products: [],
getItemClasses() {
return {
'font-bold': this.index === 0,
'text-gray-500': this.product.disabled
};
},
goToProduct() {
window.location.href = `${BASE_URL}/product/${this.product.url_key}`;
}
}
The value provider can be a method (called without parentheses):
<template x-for="(item, index) in getFilteredItems" :key="index">
<div x-text="item.name"></div>
</template>
return {
items: [],
filter: '',
getFilteredItems() {
return this.items.filter(item => item.name.includes(this.filter));
}
}
Note: Range iteration (x-for="i in 10") is not supported in Alpine CSP.
The global hyva object provides these utilities:
hyva.getFormKey() - Get/generate form key for POST requestshyva.getUenc() - Base64 encode current URL for redirectshyva.postForm({action, data, skipUenc}) - Submit a POST form programmaticallyhyva.getCookie(name) - Get cookie value (respects consent)hyva.setCookie(name, value, days, skipSetDomain) - Set cookiehyva.setSessionCookie(name, value, skipSetDomain) - Set session cookiehyva.formatPrice(value, showSign, options) - Format currencyhyva.str(template, ...args) - String interpolation with %1, %2 placeholdershyva.strf(template, ...args) - Zero-based string interpolation (%0, %1)hyva.safeParseNumber(rawValue) - Parse number safely (for x-model.number replacement)hyva.replaceDomElement(selector, content) - Replace DOM element with HTML contenthyva.trapFocus(rootElement) - Trap focus within element (for modals)hyva.releaseFocus(rootElement) - Release focus traphyva.getBrowserStorage() - Get localStorage/sessionStorage safelyFor toggle components, use hyva.createBooleanObject:
function initToggle() {
return {
...hyva.createBooleanObject('open', false),
// Additional methods
};
}
This generates: open(), notOpen(), toggleOpen(), setOpenTrue(), setOpenFalse()
hyva.alpineInitialized(fn) // Run callback after Alpine initializes
<div x-data="initMyComponent"
@private-content-loaded.window="onPrivateContentLoaded"
@update-gallery.window="onGalleryUpdate">
return {
onPrivateContentLoaded() {
const data = this.$event.detail.data;
// Handle customer data
},
onGalleryUpdate() {
const images = this.$event.detail;
this.images = images;
}
}
return {
updateQuantity() {
this.qty = newValue;
this.$dispatch('update-qty-' + this.productId, this.qty);
}
}
private-content-loaded - Customer section data loadedreload-customer-section-data - Request customer data refreshupdate-gallery - Product gallery images changedreset-gallery - Reset gallery to initial stateFor multiple window/document event listeners, use the x-bind pattern:
<div x-data="initGallery" x-bind="eventListeners">
return {
eventListeners: {
['@keydown.window.escape']() {
if (!this.fullscreen) return;
this.closeFullScreen();
},
['@update-gallery.window'](event) {
this.receiveImages(event.detail);
},
['@keyup.arrow-right.window']() {
if (!this.fullscreen) return;
this.nextItem();
}
}
}
Return class objects from methods:
<div :class="containerClasses">
return {
fullscreen: false,
containerClasses() {
return {
'w-full h-full fixed top-0 left-0 bg-white z-50': this.fullscreen,
'relative': !this.fullscreen
};
}
}
<div x-data="initProductList"
data-products="<?= $escaper->escapeHtmlAttr(json_encode($products)) ?>"
data-config="<?= $escaper->escapeHtmlAttr(json_encode($config)) ?>">
return {
products: [],
config: {},
init() {
this.products = JSON.parse(this.$root.dataset.products || '[]');
this.config = JSON.parse(this.$root.dataset.config || '{}');
}
}
function initComponent() {
return {
productId: '<?= (int) $product->getId() ?>',
productName: '<?= $escaper->escapeJs($product->getName()) ?>',
config: <?= /* @noEscape */ json_encode($config) ?>
}
}
<?php
declare(strict_types=1);
use Hyva\Theme\ViewModel\HyvaCsp;
use Magento\Framework\Escaper;
/** @var Escaper $escaper */
/** @var HyvaCsp $hyvaCsp */
$productId = (int) $product->getId();
$minQty = 1;
$maxQty = 100;
$defaultQty = 1;
?>
<div x-data="initQtySelector">
<label for="qty-<?= $productId ?>" class="sr-only">
<?= $escaper->escapeHtml(__('Quantity')) ?>
</label>
<div class="flex items-center">
<button type="button"
class="btn"
@click="decrement"
:disabled="isMinQty"
:class="decrementClasses">
-
</button>
<input type="number"
id="qty-<?= $productId ?>"
name="qty"
:value="qty"
@input="onInput"
min="<?= $minQty ?>"
max="<?= $maxQty ?>"
class="form-input w-16 text-center">
<button type="button"
class="btn"
@click="increment"
:disabled="isMaxQty"
:class="incrementClasses">
+
</button>
</div>
</div>
<script>
function initQtySelector() {
return {
qty: <?= (int) $defaultQty ?>,
minQty: <?= (int) $minQty ?>,
maxQty: <?= (int) $maxQty ?>,
productId: '<?= $productId ?>',
onInput() {
let value = hyva.safeParseNumber(this.$event.target.value);
if (value < this.minQty) value = this.minQty;
if (value > this.maxQty) value = this.maxQty;
this.qty = value;
this.$dispatch('update-qty-' + this.productId, this.qty);
},
increment() {
if (this.qty < this.maxQty) {
this.qty++;
this.$dispatch('update-qty-' + this.productId, this.qty);
}
},
decrement() {
if (this.qty > this.minQty) {
this.qty--;
this.$dispatch('update-qty-' + this.productId, this.qty);
}
},
isMinQty() {
return this.qty <= this.minQty;
},
isMaxQty() {
return this.qty >= this.maxQty;
},
decrementClasses() {
return { 'opacity-50 cursor-not-allowed': this.isMinQty() };
},
incrementClasses() {
return { 'opacity-50 cursor-not-allowed': this.isMaxQty() };
}
}
}
window.addEventListener('alpine:init', () => Alpine.data('initQtySelector', initQtySelector), {once: true})
</script>
<?php $hyvaCsp->registerInlineScript() ?>
vendor/hyva-themes/magento2-default-theme-csp/vendor/hyva-themes/magento2-theme-module/src/view/frontend/templates/page/js/hyva.phtmldocumentation
Apply Hyva UI template-based components to a Hyvä theme. This skill should be used when the user wants to add, install, or apply a Hyva UI component (such as header, footer, gallery, menu, minicart, etc.) to their Hyvä theme. It lists available non-CMS components and their variants, displays component README instructions, and copies component files to the theme directory.
data-ai
List all Hyvä theme paths in a Magento 2 project. This skill should be used when the user wants to find Hyvä themes, list available themes, discover theme paths, or when other skills need to locate Hyvä themes. Trigger phrases include "list hyva themes", "find themes", "show themes", "available themes", "theme paths".
development
Generate responsive image code for Hyvä Theme templates using the Media view model. This skill should be used when the user wants to render images in a Hyvä template, create responsive picture elements, add hero images, product images, or any image that needs responsive breakpoints. Trigger phrases include "render image", "add image to template", "responsive image", "picture element", "hero image", "responsive banner", "image for mobile and desktop", or "banner image".
development
Write Playwright tests for Hyvä themes with Alpine.js components. This skill should be used when writing e2e tests, creating page objects, or debugging selector issues in Playwright tests for Hyvä Magento storefronts. Trigger phrases include "write playwright test", "playwright alpine", "test hyva page", "e2e test", "playwright selector".