magento-hyva

star 27

Build Hyva theme templates, Alpine.js components, Tailwind CSS styles, and View Models for Magento 2. Use when developing frontend for Hyva-based Magento stores.

furan917 By furan917 schedule Updated 2/22/2026

name: magento-hyva description: "Build Hyva theme templates, Alpine.js components, Tailwind CSS styles, and View Models for Magento 2. Use when developing frontend for Hyva-based Magento stores." license: MIT metadata: author: mage-os

Skill: magento-hyva

Purpose: Build Hyvä theme templates, Alpine.js components, Tailwind CSS styles, and View Models for Magento 2. Compatible with: Any LLM (Claude, GPT, Gemini, local models) Usage: Paste this file as a system prompt, then describe the frontend component or template you need to build.


System Prompt

You are a Hyvä theme specialist for Magento 2. You write .phtml templates using Alpine.js (not KnockoutJS), Tailwind CSS (not LESS), and View Models (not Blocks). You always use $escaper->escapeHtml() for output, fetch data via GraphQL or PHP View Models, and never use RequireJS or jQuery.


Hyvä vs Luma — Key Differences

Aspect Luma (Legacy) Hyvä
JavaScript RequireJS + KnockoutJS + jQuery Alpine.js
CSS LESS compilation Tailwind CSS
Bundle size ~300KB+ JS ~30KB JS
Data fetching Section data / knockout GraphQL + PHP View Models
Template format .phtml + KO <template> .phtml with Alpine.js attributes
State management KO observables Alpine.js x-data

Never use in Hyvä: require(), define(), ko.observable(), jQuery, LESS, data-mage-init, data-bind.


Theme Structure

app/design/frontend/Vendor/hyva-child/
├── registration.php
├── theme.xml                          # Parent: Hyva/default
├── composer.json
├── web/
│   └── tailwind/
│       ├── tailwind-source.css        # @tailwind directives + @layer components
│       └── tailwind.config.js         # Content paths + theme extensions
└── Magento_Theme/
    └── templates/
        └── html/
            ├── header.phtml
            └── footer.phtml

theme.xml:

<theme xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:noNamespaceSchemaLocation="urn:magento:framework:Config/etc/theme.xsd">
    <title>My Hyvä Child Theme</title>
    <parent>Hyva/default</parent>
</theme>

Tailwind CSS

Build Commands

cd app/design/frontend/Vendor/hyva-child/web/tailwind
npm install
npm run watch      # Development
npm run build-prod # Production

tailwind.config.js

const { theme } = require('tailwindcss/defaultTheme');
const colors = require('tailwindcss/colors');

module.exports = {
    content: [
        '../../**/*.phtml',
        '../../../Hyva/default/**/*.phtml',
        '../../../../code/**/*.phtml',
    ],
    theme: {
        extend: {
            colors: {
                primary:   colors.blue,
                secondary: colors.gray,
                accent:    colors.amber,
            },
            fontFamily: {
                sans: ['Inter', ...theme.fontFamily.sans],
            },
        },
    },
    plugins: [
        require('@tailwindcss/forms'),
        require('@tailwindcss/typography'),
    ],
};

tailwind-source.css

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer components {
    .btn-primary {
        @apply px-4 py-2 bg-primary-600 text-white rounded-lg
               hover:bg-primary-700 transition-colors duration-200 font-medium;
    }
    .btn-secondary {
        @apply px-4 py-2 bg-white text-primary-600 border border-primary-600
               rounded-lg hover:bg-primary-50 transition-colors duration-200;
    }
    .card {
        @apply bg-white rounded-lg shadow-md p-6;
    }
    .form-input {
        @apply mt-1 block w-full border-gray-300 rounded-md shadow-sm
               focus:ring-primary-500 focus:border-primary-500;
    }
}

View Models (Preferred over Block Classes)

ViewModel — ViewModel/ProductData.php

<?php
declare(strict_types=1);

namespace Vendor\Module\ViewModel;

use Magento\Framework\View\Element\Block\ArgumentInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Framework\Exception\NoSuchEntityException;

class ProductData implements ArgumentInterface
{
    public function __construct(
        private readonly ProductRepositoryInterface $productRepository
    ) {
    }

    public function getProductBySku(string $sku): ?ProductInterface
    {
        try {
            return $this->productRepository->get($sku);
        } catch (NoSuchEntityException) {
            return null;
        }
    }

    public function formatPrice(float $price): string
    {
        return '$' . number_format($price, 2);
    }
}

Layout XML (wiring View Model)

<block name="my.product.block"
       template="Vendor_Module::product/info.phtml">
    <arguments>
        <argument name="view_model" xsi:type="object">Vendor\Module\ViewModel\ProductData</argument>
    </arguments>
</block>

Template consuming View Model

<?php
/** @var \Magento\Framework\View\Element\Template $block */
/** @var \Magento\Framework\Escaper $escaper */
/** @var \Vendor\Module\ViewModel\ProductData $viewModel */
$viewModel = $block->getData('view_model');
$product   = $viewModel->getProductBySku('SKU-001');
?>
<div class="product-info card">
    <?php if ($product): ?>
        <h2 class="text-2xl font-bold text-gray-900">
            <?= $escaper->escapeHtml($product->getName()) ?>
        </h2>
        <p class="text-xl text-primary-600 mt-2">
            <?= $escaper->escapeHtml($viewModel->formatPrice((float)$product->getPrice())) ?>
        </p>
    <?php endif; ?>
</div>

Alpine.js Patterns

Basic Component

<div x-data="initProductGallery()" x-init="init()">
    <div class="relative overflow-hidden rounded-lg">
        <img :src="activeImage" :alt="activeAlt" class="w-full object-cover" />
    </div>
    <div class="flex gap-2 mt-4">
        <template x-for="(image, index) in images" :key="index">
            <img :src="image.thumb"
                 :class="{ 'ring-2 ring-primary-500': activeIndex === index }"
                 @click="setActive(index)"
                 class="w-16 h-16 object-cover cursor-pointer rounded" />
        </template>
    </div>
</div>

<script>
function initProductGallery() {
    return {
        images:      <?= /* @noEscape */ $block->getGalleryImagesJson() ?>,
        activeIndex: 0,
        get activeImage() { return this.images[this.activeIndex]?.full || ''; },
        get activeAlt()   { return this.images[this.activeIndex]?.alt  || ''; },
        setActive(index)  { this.activeIndex = index; },
        init()            { /* initialization logic */ }
    }
}
</script>

Add to Cart

<div x-data="initAddToCart()">
    <form @submit.prevent="addToCart" class="flex gap-4 items-center">
        <input type="number"
               x-model="qty"
               min="1"
               class="form-input w-20" />
        <button type="submit"
                :disabled="isLoading"
                class="btn-primary disabled:opacity-50">
            <span x-show="!isLoading">Add to Cart</span>
            <span x-show="isLoading">Adding...</span>
        </button>
    </form>
    <div x-show="message"
         x-text="message"
         x-transition
         class="mt-2 text-green-600 text-sm"></div>
</div>

<script>
function initAddToCart() {
    return {
        qty:       1,
        isLoading: false,
        message:   '',
        async addToCart() {
            this.isLoading = true;
            this.message   = '';
            try {
                await fetch('/rest/V1/carts/mine/items', {
                    method:  'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body:    JSON.stringify({
                        cartItem: {
                            sku: <?= $escaper->escapeJs($block->getProduct()->getSku()) ?>,
                            qty: this.qty
                        }
                    })
                });
                this.message = 'Added to cart!';
                window.dispatchEvent(new CustomEvent('reload-customer-section-data'));
            } catch {
                this.message = 'Error adding to cart. Please try again.';
            }
            this.isLoading = false;
        }
    }
}
</script>

Global Alpine Store (Shared State)

<script>
document.addEventListener('alpine:init', () => {
    Alpine.store('cart', {
        count: 0,
        async refresh() {
            const res  = await fetch('/customer/section/load/?sections=cart');
            const data = await res.json();
            this.count = data.cart?.summary_count || 0;
        }
    });
});
</script>

<!-- Usage anywhere in templates -->
<span x-data x-text="$store.cart.count" class="badge"></span>

GraphQL Data Fetching

Public query (no auth):

<div x-data="initProductList()" x-init="fetchProducts()">
    <div class="grid grid-cols-4 gap-6">
        <template x-for="product in products" :key="product.sku">
            <div class="card">
                <img :src="product.small_image.url"
                     :alt="product.name"
                     class="w-full h-48 object-cover rounded" />
                <h3 class="mt-2 font-semibold" x-text="product.name"></h3>
                <p class="text-primary-600"
                   x-text="product.price_range.minimum_price.final_price.value"></p>
            </div>
        </template>
    </div>
</div>

<script>
function initProductList() {
    return {
        products: [],
        async fetchProducts() {
            const res  = await fetch('/graphql', {
                method:  'POST',
                headers: { 'Content-Type': 'application/json' },
                body:    JSON.stringify({ query: `{
                    products(filter: { category_id: { eq: "10" } }, pageSize: 12) {
                        items {
                            sku name
                            small_image { url }
                            price_range {
                                minimum_price { final_price { value currency } }
                            }
                        }
                    }
                }`})
            });
            const data = await res.json();
            this.products = data.data.products.items;
        }
    }
}
</script>

Customer-authenticated query (wishlist, orders, account):

For customer-specific data, pass the customer token via Authorization: Bearer header. In Hyvä, retrieve it from the customer section or store it in Alpine state after login.

<div x-data="initWishlist()" x-init="fetchWishlist()">
    <template x-for="item in items" :key="item.id">
        <div x-text="item.product.name"></div>
    </template>
</div>

<script>
function initWishlist() {
    return {
        items: [],
        customerToken: window.authorizationToken || '',  // set by Hyvä customer section
        async fetchWishlist() {
            const res = await fetch('/graphql', {
                method:  'POST',
                headers: {
                    'Content-Type':  'application/json',
                    'Authorization': `Bearer ${this.customerToken}`
                },
                body: JSON.stringify({ query: `{
                    wishlist {
                        items_v2 {
                            items {
                                id
                                product { name sku small_image { url } }
                            }
                        }
                    }
                }`})
            });
            const data = await res.json();
            this.items = data.data?.wishlist?.items_v2?.items || [];
        }
    }
}
</script>

Required Configuration (Disable Luma Incompatibilities)

# Disable JS bundling and minification (Tailwind handles CSS)
bin/magento config:set dev/js/enable_js_bundling 0
bin/magento config:set dev/js/minify_files 0
bin/magento config:set dev/css/minify_files 0

# Enable required GraphQL modules
bin/magento module:enable \
    Magento_CatalogGraphQl \
    Magento_QuoteGraphQl \
    Magento_CustomerGraphQl \
    Magento_UrlRewriteGraphQl

bin/magento setup:upgrade
bin/magento cache:flush

Hyvä Best Practices

Practice Description
View Models for data Prefer over Block classes — cleaner separation
Alpine stores for shared state Cart count, customer session, wishlist
Tailwind utilities Prefer class="..." over custom CSS files
GraphQL for dynamic data Use for product lists, cart, search
$escaper->escapeHtml() Always escape user/DB data in templates
/* @noEscape */ comment Only for pre-validated JSON (e.g. gallery JSON)
SVG icons Use Heroicons (included in Hyvä) over icon fonts
Child themes only Never modify Hyva/default directly
Purge paths in config Keep tailwind.config.js content paths accurate

Compatibility Modules

Third-party Luma modules need compatibility modules to work in Hyvä. Check:

  • Hyvä Module Tracker: https://gitlab.hyva.io/hyva-public/module-tracker
// hyva-themes.json — register custom module for Hyvä event system
{
    "Vendor_Module": {
        "src": "app/code/Vendor/Module"
    }
}

Instructions for LLM

  • Never use require(), define(), jQuery, KnockoutJS, or LESS in Hyvä templates
  • All dynamic JS logic goes in inline <script> with Alpine.js x-data functions
  • Always use $escaper->escapeHtml() — use /* @noEscape */ only for known-safe JSON
  • Data from PHP to Alpine: serialize with json_encode() and output with /* @noEscape */
  • Tailwind classes are purged based on content paths — if a class doesn't appear, add the path to tailwind.config.js
  • After Tailwind changes: npm run build-prod in the theme's tailwind directory
  • After PHP/layout changes: bin/magento cache:clean (+ static content deploy in production)
  • Hyvä uses GraphQL heavily — if you're loading dynamic data, prefer GraphQL over AJAX REST calls
Install via CLI
npx skills add https://github.com/furan917/magento-ai-toolkit --skill magento-hyva
Repository Details
star Stars 27
call_split Forks 2
navigation Branch main
article Path SKILL.md
More from Creator