Primary Hero
+Homepage & main landing pages — sky blue tint, softer overlay
+diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0d47807..1e4a9ef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,34 @@ All notable changes to the MokoCassiopeia Joomla template are documented in this
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [03.09.02] - 2026-03-26
+
+### Added - Hero Variant System & Block Color System
+
+#### Hero Variants
+- **`.hero#primary`** and **`.hero#secondary`** CSS variant system for visually distinct hero treatments
+- Shared `.hero` base class with `background-size: cover`, `border-radius: .5rem`, and `overflow: hidden`
+- Six new CSS variables (`--hero-primary-bg-color`, `--hero-primary-overlay`, `--hero-primary-color`, and secondary equivalents)
+- Light and dark mode defaults in custom palette templates
+
+#### Block Color System
+- Automatic `:nth-child()` slot palette for `top-a`, `top-b`, `bottom-a`, `bottom-b` module positions
+- Four color slots (`--block-color-1` through `--block-color-4`) with matching text variables
+- Named per-module overrides: `#block-highlight`, `#block-cta`, `#block-alert`
+- ID specificity wins over `:nth-child()` — no `!important` needed
+
+#### Files Modified
+- `src/media/css/template.css` — hero variant rules, block color `:nth-child()` rules, named override rules
+- `src/templates/light.custom.css` — hero and block color variables (light mode)
+- `src/templates/dark.custom.css` — hero and block color variables (dark mode)
+- `docs/CSS_VARIABLES.md` — full variable reference for both systems
+- `CHANGELOG.md` — this entry
+
+#### Files Added
+- `src/templates/theme-test.html` — Bootstrap-style test page showing all CSS variables and new features
+
+---
+
## [03.08.03] - 2026-02-27
### Added - Main Menu Collapsible Dropdown Override
diff --git a/docs/CSS_VARIABLES.md b/docs/CSS_VARIABLES.md
index f4adec4..3a0017c 100644
--- a/docs/CSS_VARIABLES.md
+++ b/docs/CSS_VARIABLES.md
@@ -38,6 +38,8 @@ This document provides a complete reference of all CSS variables available in th
- [Responsive Tokens & Breakpoints](#responsive-tokens--breakpoints)
- [VirtueMart Variables](#virtuemart-variables)
- [Gable Variables](#gable-variables)
+- [Hero Variant Variables](#hero-variant-variables)
+- [Block Color Variables](#block-color-variables)
---
@@ -1357,6 +1359,81 @@ These ensure optimal readability for links within alert boxes.
---
+## Hero Variant Variables
+
+### `--hero-primary-bg-color`
+- **Description**: Fallback background color for the primary hero variant
+- **Light Mode Default**: `var(--color-primary)`
+- **Dark Mode Default**: `#0d1e3a`
+- **Usage**: `.hero#primary` background when no image loads
+
+### `--hero-primary-overlay`
+- **Description**: Gradient overlay tint for primary hero
+- **Light Mode Default**: `linear-gradient(rgba(163, 205, 226, .45), rgba(163, 205, 226, .45))`
+- **Dark Mode Default**: `linear-gradient(rgba(13, 30, 58, .65), rgba(13, 30, 58, .65))`
+- **Usage**: Semi-transparent color wash over hero background image
+
+### `--hero-primary-color`
+- **Description**: Text color for primary hero content
+- **Light Mode Default**: `var(--color-primary)`
+- **Dark Mode Default**: `#f1f5f9`
+- **Usage**: Headings and body text inside `.hero#primary`
+
+### `--hero-secondary-bg-color`
+- **Description**: Fallback background color for the secondary hero variant
+- **Light Mode Default**: `var(--color-primary)`
+- **Dark Mode Default**: `#080f1e`
+- **Usage**: `.hero#secondary` background when no image loads
+
+### `--hero-secondary-overlay`
+- **Description**: Gradient overlay tint for secondary hero
+- **Light Mode Default**: `linear-gradient(rgba(17, 40, 85, .75), rgba(17, 40, 85, .75))`
+- **Dark Mode Default**: `linear-gradient(rgba(8, 15, 30, .80), rgba(8, 15, 30, .80))`
+- **Usage**: Stronger overlay for inner-page heroes
+
+### `--hero-secondary-color`
+- **Description**: Text color for secondary hero content
+- **Light Mode Default**: `#f1f5f9`
+- **Dark Mode Default**: `#f1f5f9`
+- **Usage**: Headings and body text inside `.hero#secondary`
+
+---
+
+## Block Color Variables
+
+### Slot Palette (automatic by position order)
+
+| Variable | Purpose | Light Default | Dark Default |
+|---|---|---|---|
+| `--block-color-1` | Background for 1st module | `var(--color-primary)` | `var(--secondary-bg)` |
+| `--block-text-1` | Text for 1st module | `var(--body-color)` | `var(--body-color)` |
+| `--block-color-2` | Background for 2nd module | `var(--accent-color-primary)` | `var(--accent-color-primary)` |
+| `--block-text-2` | Text for 2nd module | `#fff` | `#fff` |
+| `--block-color-3` | Background for 3rd module | `var(--warning, #eec234)` | `rgba(238, 194, 52, .15)` |
+| `--block-text-3` | Text for 3rd module | `var(--body-color)` | `var(--body-color)` |
+| `--block-color-4` | Background for 4th module | `var(--success-bg-subtle, #eef7f0)` | `rgba(74, 166, 100, .15)` |
+| `--block-text-4` | Text for 4th module | `var(--body-color)` | `var(--body-color)` |
+
+### Named Per-Module Overrides
+
+| Variable | Purpose |
+|---|---|
+| `--block-highlight-bg` | Background for `#block-highlight` module |
+| `--block-highlight-text` | Text color for `#block-highlight` module |
+| `--block-cta-bg` | Background for `#block-cta` module |
+| `--block-cta-text` | Text color for `#block-cta` module |
+| `--block-alert-bg` | Background for `#block-alert` module |
+| `--block-alert-text` | Text color for `#block-alert` module |
+
+### Override Priority
+
+| Priority | Method | How applied |
+|---|---|---|
+| 1 (highest) | Named module ID (`#block-highlight`) | ID in module HTML, named variable in palette |
+| 2 | Slot color (`--block-color-1` etc.) | Automatic by `:nth-child()` order |
+
+---
+
## Metadata
* Document: docs/CSS_VARIABLES.md
@@ -1372,5 +1449,6 @@ These ensure optimal readability for links within alert boxes.
| Date | Change Summary | Author |
| ---------- | ----------------------------------------------------- | --------------- |
+| 2026-03-26 | Added hero variant and block color variable docs | Claude |
| 2026-02-07 | Added missing CSS variable documentation | GitHub Copilot |
| 2026-01-30 | Initial CSS variables reference documentation created | GitHub Copilot |
diff --git a/src/language/en-GB/tpl_mokocassiopeia.ini b/src/language/en-GB/tpl_mokocassiopeia.ini
index dbd4049..f747b77 100644
--- a/src/language/en-GB/tpl_mokocassiopeia.ini
+++ b/src/language/en-GB/tpl_mokocassiopeia.ini
@@ -238,6 +238,11 @@ TPL_MOKOCASSIOPEIA_CSS_VARS_VM_DESC="Surfaces & texttemplates/mokocassiopeia/templates/theme-test.html.
TPL_MOKOCASSIOPEIA_CSS_VARS_HERO_LABEL="Hero / Banner Overlay"
TPL_MOKOCASSIOPEIA_CSS_VARS_HERO_DESC="Applied to the .custom-hero / .banner-overlay layout. Set on :root[data-bs-theme] so light and dark values are independent.
--hero-height — Banner height (default: 70vh)
--hero-color — Base text color
--hero-bg-repeat — Background repeat (default: no-repeat)
--hero-bg-attachment — Background attachment (default: fixed)
--hero-bg-position — Background position (default: top center)
--hero-bg-size — Background size (default: cover)
--hero-border-bottom — Bottom border (default: solid var(--accent-color-secondary))
--hero-overlay-bg — Overlay tint color (light default: hsla(0,0%,0%,0.1) / dark default: hsla(0,0%,0%,0.3))
--hero-overlay-padding — Overlay inner padding (default: 1em)
--hero-overlay-text-align — Overlay text alignment (default: center)
--hero-overlay-text-color — Overlay text color"
+TPL_MOKOCASSIOPEIA_CSS_VARS_HERO_VARIANTS_LABEL="Hero Variants (.hero#primary / .hero#secondary)"
+TPL_MOKOCASSIOPEIA_CSS_VARS_HERO_VARIANTS_DESC="Two-variant hero system using .hero#primary and .hero#secondary. Each variant resolves its own CSS variable set per theme.
Primary variant — homepage, main landing pages (sky blue tint, softer overlay)
--hero-primary-bg-color — Fallback background color
--hero-primary-overlay — Gradient overlay tint
--hero-primary-color — Text color
Secondary variant — inner pages, events, about (navy overlay, lighter text)
--hero-secondary-bg-color — Fallback background color
--hero-secondary-overlay — Gradient overlay tint
--hero-secondary-color — Text color
HTML usage:
<div class="hero" id="primary" style="background-image:url(...)">"
+
+TPL_MOKOCASSIOPEIA_CSS_VARS_BLOCK_COLORS_LABEL="Block Color System (top-a / top-b / bottom-a / bottom-b)"
+TPL_MOKOCASSIOPEIA_CSS_VARS_BLOCK_COLORS_DESC="Automatic brand color palette for modules in top-a, top-b, bottom-a, and bottom-b positions. Colors assigned by :nth-child() order — no classes needed.
Slot palette
--block-color-1 / --block-text-1 — 1st module
--block-color-2 / --block-text-2 — 2nd module
--block-color-3 / --block-text-3 — 3rd module
--block-color-4 / --block-text-4 — 4th module
Named overrides (add an ID to the module HTML to bypass slot color)
--block-highlight-bg / --block-highlight-text — for #block-highlight
--block-cta-bg / --block-cta-text — for #block-cta
--block-alert-bg / --block-alert-text — for #block-alert
Priority: Named ID > Slot color. No !important needed — specificity handles it."
+
TPL_MOKOCASSIOPEIA_CSS_VARS_HEADER_LABEL="Header Background"
TPL_MOKOCASSIOPEIA_CSS_VARS_HEADER_DESC="Controls the background of the topbar/header area.
--header-background-image — CSS background-image value (default: built-in SVG pattern)
--header-background-attachment — fixed or scroll
--header-background-repeat — e.g. repeat, no-repeat
--header-background-size — e.g. auto, cover, contain"
@@ -232,6 +238,11 @@ TPL_MOKOCASSIOPEIA_CSS_VARS_VM_DESC="Surfaces & text
Live preview of all CSS variables, hero variants, block colors, and Bootstrap components rendered with your active theme. Use the Toggle Light / Dark button inside the preview to switch modes. This page is also available as a standalone file at templates/mokocassiopeia/templates/theme-test.html."
+TPL_MOKOCASSIOPEIA_THEME_PREVIEW_FRAME=""
+
; ===== Misc =====
MOD_BREADCRUMBS_HERE="YOU ARE HERE:"
diff --git a/src/script.php b/src/script.php
new file mode 100644
index 0000000..dcc3d46
--- /dev/null
+++ b/src/script.php
@@ -0,0 +1,221 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ *
+ * Template install/update/uninstall script.
+ *
+ * Joomla calls the methods in this class automatically during template
+ * install, update, and uninstall via the element in
+ * templateDetails.xml.
+ *
+ * Joomla 5 and 6 compatible — uses the InstallerScriptInterface when
+ * available, falls back to the legacy class-based approach otherwise.
+ */
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\Factory;
+use Joomla\CMS\Installer\InstallerAdapter;
+use Joomla\CMS\Log\Log;
+
+class Tpl_MokocassiopeiaInstallerScript
+{
+ /**
+ * Minimum PHP version required by this template.
+ */
+ private const MIN_PHP = '8.1.0';
+
+ /**
+ * Minimum Joomla version required by this template.
+ */
+ private const MIN_JOOMLA = '4.4.0';
+
+ /**
+ * Called before install/update/uninstall.
+ *
+ * @param string $type install, update, discover_install, or uninstall.
+ * @param InstallerAdapter $parent The adapter calling this method.
+ *
+ * @return bool True to proceed, false to abort.
+ */
+ public function preflight(string $type, InstallerAdapter $parent): bool
+ {
+ if (version_compare(PHP_VERSION, self::MIN_PHP, '<')) {
+ Factory::getApplication()->enqueueMessage(
+ sprintf(
+ 'MokoCassiopeia requires PHP %s or later. You are running PHP %s.',
+ self::MIN_PHP,
+ PHP_VERSION
+ ),
+ 'error'
+ );
+ return false;
+ }
+
+ if (version_compare(JVERSION, self::MIN_JOOMLA, '<')) {
+ Factory::getApplication()->enqueueMessage(
+ sprintf(
+ 'MokoCassiopeia requires Joomla %s or later. You are running Joomla %s.',
+ self::MIN_JOOMLA,
+ JVERSION
+ ),
+ 'error'
+ );
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Called after a successful install.
+ *
+ * @param InstallerAdapter $parent The adapter calling this method.
+ *
+ * @return bool
+ */
+ public function install(InstallerAdapter $parent): bool
+ {
+ $this->logMessage('MokoCassiopeia template installed.');
+ return true;
+ }
+
+ /**
+ * Called after a successful update.
+ *
+ * This is where the CSS variable sync runs — it detects variables that
+ * were added in the new version and injects them into the user's custom
+ * palette files without overwriting existing values.
+ *
+ * @param InstallerAdapter $parent The adapter calling this method.
+ *
+ * @return bool
+ */
+ public function update(InstallerAdapter $parent): bool
+ {
+ $this->logMessage('MokoCassiopeia template updated.');
+
+ // Run CSS variable sync to inject any new variables into user's custom palettes.
+ $synced = $this->syncCustomVariables($parent);
+
+ if ($synced > 0) {
+ Factory::getApplication()->enqueueMessage(
+ sprintf(
+ 'MokoCassiopeia: %d new CSS variable(s) were added to your custom palette files. '
+ . 'Review them in your light.custom.css and/or dark.custom.css to customise the new defaults.',
+ $synced
+ ),
+ 'notice'
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Called after a successful uninstall.
+ *
+ * @param InstallerAdapter $parent The adapter calling this method.
+ *
+ * @return bool
+ */
+ public function uninstall(InstallerAdapter $parent): bool
+ {
+ $this->logMessage('MokoCassiopeia template uninstalled.');
+ return true;
+ }
+
+ /**
+ * Called after install/update completes (regardless of type).
+ *
+ * @param string $type install, update, or discover_install.
+ * @param InstallerAdapter $parent The adapter calling this method.
+ *
+ * @return bool
+ */
+ public function postflight(string $type, InstallerAdapter $parent): bool
+ {
+ return true;
+ }
+
+ /**
+ * Run the CSS variable sync utility.
+ *
+ * Loads sync_custom_vars.php from the template directory and calls
+ * MokoCssVarSync::run() to detect and inject missing variables.
+ *
+ * @param InstallerAdapter $parent The adapter calling this method.
+ *
+ * @return int Number of variables added across all files.
+ */
+ private function syncCustomVariables(InstallerAdapter $parent): int
+ {
+ $templateDir = $parent->getParent()->getPath('source');
+
+ // The sync script lives alongside this script in the template root.
+ $syncScript = $templateDir . '/sync_custom_vars.php';
+
+ if (!is_file($syncScript)) {
+ $this->logMessage('CSS variable sync script not found at: ' . $syncScript, 'warning');
+ return 0;
+ }
+
+ require_once $syncScript;
+
+ if (!class_exists('MokoCssVarSync')) {
+ $this->logMessage('MokoCssVarSync class not found after loading script.', 'warning');
+ return 0;
+ }
+
+ try {
+ $joomlaRoot = JPATH_ROOT;
+ $results = MokoCssVarSync::run($joomlaRoot);
+
+ $totalAdded = 0;
+ foreach ($results as $filePath => $result) {
+ $totalAdded += count($result['added']);
+ if (!empty($result['added'])) {
+ $this->logMessage(
+ sprintf(
+ 'CSS sync: added %d variable(s) to %s',
+ count($result['added']),
+ basename($filePath)
+ )
+ );
+ }
+ }
+
+ return $totalAdded;
+ } catch (\Throwable $e) {
+ $this->logMessage('CSS variable sync failed: ' . $e->getMessage(), 'error');
+ return 0;
+ }
+ }
+
+ /**
+ * Log a message to Joomla's log system.
+ *
+ * @param string $message The log message.
+ * @param string $priority Log priority (info, warning, error).
+ */
+ private function logMessage(string $message, string $priority = 'info'): void
+ {
+ $priorities = [
+ 'info' => Log::INFO,
+ 'warning' => Log::WARNING,
+ 'error' => Log::ERROR,
+ ];
+
+ Log::addLogger(
+ ['text_file' => 'mokocassiopeia.log.php'],
+ Log::ALL,
+ ['mokocassiopeia']
+ );
+
+ Log::add($message, $priorities[$priority] ?? Log::INFO, 'mokocassiopeia');
+ }
+}
diff --git a/src/sync_custom_vars.php b/src/sync_custom_vars.php
new file mode 100644
index 0000000..a71672c
--- /dev/null
+++ b/src/sync_custom_vars.php
@@ -0,0 +1,406 @@
+
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ *
+ * CSS Variable Sync Utility
+ *
+ * Compares a user's custom palette file against the template starter file and
+ * injects any missing CSS variable declarations. Existing user values are
+ * never overwritten — only genuinely new variables are added.
+ *
+ * Usage (CLI):
+ * php sync_custom_vars.php
+ *
+ * Usage (from Joomla script.php or plugin):
+ * require_once __DIR__ . '/sync_custom_vars.php';
+ * MokoCssVarSync::run();
+ *
+ * The script auto-detects Joomla's root by walking up from __DIR__.
+ */
+
+defined('_JEXEC') or define('MOKO_CLI', true);
+
+final class MokoCssVarSync
+{
+ /**
+ * Template name used in Joomla's media path.
+ */
+ private const TPL = 'mokocassiopeia';
+
+ /**
+ * Palette pairs: [starter template path relative to this file, user file relative to Joomla root].
+ */
+ private const PALETTES = [
+ [
+ 'starter' => 'templates/light.custom.css',
+ 'user' => 'media/templates/site/%s/css/theme/light.custom.css',
+ ],
+ [
+ 'starter' => 'templates/dark.custom.css',
+ 'user' => 'media/templates/site/%s/css/theme/dark.custom.css',
+ ],
+ ];
+
+ /**
+ * Run the sync for all palette pairs.
+ *
+ * @param string|null $joomlaRoot Absolute path to Joomla root (auto-detected if null).
+ * @return array Results keyed by file path.
+ */
+ public static function run(?string $joomlaRoot = null): array
+ {
+ $tplDir = self::resolveTemplateDir();
+ $root = $joomlaRoot ?? self::detectJoomlaRoot();
+
+ $results = [];
+
+ foreach (self::PALETTES as $pair) {
+ $starterPath = $tplDir . '/' . $pair['starter'];
+ $userPath = $root . '/' . sprintf($pair['user'], self::TPL);
+
+ if (!is_file($starterPath)) {
+ self::log("SKIP starter not found: {$starterPath}");
+ continue;
+ }
+
+ if (!is_file($userPath)) {
+ self::log("SKIP user file not found (custom palette not deployed): {$userPath}");
+ continue;
+ }
+
+ $result = self::syncFile($starterPath, $userPath);
+ $results[$userPath] = $result;
+
+ $addedCount = count($result['added']);
+ if ($addedCount > 0) {
+ self::log("ADDED {$addedCount} variable(s) to {$userPath}");
+ foreach ($result['added'] as $var) {
+ self::log(" + {$var}");
+ }
+ } else {
+ self::log("OK {$userPath} — all variables present");
+ }
+ }
+
+ return $results;
+ }
+
+ /**
+ * Compare a starter file against a user file and inject missing variables.
+ *
+ * @param string $starterPath Absolute path to the starter template CSS.
+ * @param string $userPath Absolute path to the user's custom CSS.
+ * @return array{added: string[], skipped: string[]}
+ */
+ private static function syncFile(string $starterPath, string $userPath): array
+ {
+ $starterVars = self::extractVarsWithContext($starterPath);
+ $userVars = self::extractVarNames($userPath);
+
+ $missing = [];
+ foreach ($starterVars as $name => $declaration) {
+ if (!isset($userVars[$name])) {
+ $missing[$name] = $declaration;
+ }
+ }
+
+ if (empty($missing)) {
+ return ['added' => [], 'skipped' => []];
+ }
+
+ // Group missing variables by their section comment header.
+ $sections = self::groupBySection($missing, $starterPath);
+
+ // Build the injection block.
+ $injection = self::buildInjectionBlock($sections);
+
+ // Insert before the closing } of the :root rule.
+ $userCss = file_get_contents($userPath);
+ $userCss = self::injectBeforeRootClose($userCss, $injection);
+
+ // Write back (atomic: write to .tmp then rename).
+ $tmpPath = $userPath . '.tmp';
+ file_put_contents($tmpPath, $userCss);
+ rename($tmpPath, $userPath);
+
+ return ['added' => array_keys($missing), 'skipped' => []];
+ }
+
+ /**
+ * Extract CSS custom property declarations with their full text (name: value).
+ * Only extracts from the first :root block.
+ *
+ * @return array Variable name => full declaration line.
+ */
+ private static function extractVarsWithContext(string $filePath): array
+ {
+ $css = file_get_contents($filePath);
+ $vars = [];
+
+ // Match --variable-name: value (possibly spanning multiple lines until ;)
+ if (preg_match_all('/^\s*(--[\w-]+)\s*:\s*([^;]+);/m', $css, $matches, PREG_SET_ORDER)) {
+ foreach ($matches as $m) {
+ $name = trim($m[1]);
+ $value = trim($m[2]);
+ $vars[$name] = "{$name}: {$value};";
+ }
+ }
+
+ return $vars;
+ }
+
+ /**
+ * Extract just the variable names present in a CSS file.
+ *
+ * @return array
+ */
+ private static function extractVarNames(string $filePath): array
+ {
+ $css = file_get_contents($filePath);
+ $vars = [];
+
+ if (preg_match_all('/^\s*(--[\w-]+)\s*:/m', $css, $matches)) {
+ foreach ($matches[1] as $name) {
+ $vars[trim($name)] = true;
+ }
+ }
+
+ return $vars;
+ }
+
+ /**
+ * Group missing variables by the section comment they appear under in the starter file.
+ *
+ * @param array $missing Variable name => declaration.
+ * @param string $starterPath Path to starter file.
+ * @return array Section header => list of declarations.
+ */
+ private static function groupBySection(array $missing, string $starterPath): array
+ {
+ $lines = file($starterPath, FILE_IGNORE_NEW_LINES);
+ $section = 'Uncategorised';
+ $map = []; // variable name => section
+
+ foreach ($lines as $line) {
+ // Detect section comment headers like /* ===== HERO VARIANTS ===== */
+ if (preg_match('/\/\*\s*=+\s*(.+?)\s*=+\s*\*\//', $line, $m)) {
+ $section = trim($m[1]);
+ }
+ // Detect variable declaration
+ if (preg_match('/^\s*(--[\w-]+)\s*:/', $line, $m)) {
+ $name = trim($m[1]);
+ if (isset($missing[$name])) {
+ $map[$name] = $section;
+ }
+ }
+ }
+
+ // Group by section
+ $sections = [];
+ foreach ($missing as $name => $declaration) {
+ $sec = $map[$name] ?? 'Uncategorised';
+ $sections[$sec][] = $declaration;
+ }
+
+ return $sections;
+ }
+
+ /**
+ * Build a CSS block from grouped sections ready for injection.
+ */
+ private static function buildInjectionBlock(array $sections): string
+ {
+ $lines = [];
+ $lines[] = '';
+ $lines[] = '/* ===== VARIABLES ADDED BY SYNC (' . date('Y-m-d') . ') ===== */';
+
+ foreach ($sections as $sectionName => $declarations) {
+ $lines[] = '';
+ $lines[] = "/* -- {$sectionName} -- */";
+ foreach ($declarations as $decl) {
+ $lines[] = $decl;
+ }
+ }
+
+ $lines[] = '';
+
+ return implode("\n", $lines);
+ }
+
+ /**
+ * Inject a block of CSS just before the closing } of the :root[data-bs-theme] rule.
+ */
+ private static function injectBeforeRootClose(string $css, string $block): string
+ {
+ // Find the :root block's closing brace. The :root rule is the first major
+ // rule in the file; its closing } is on its own line.
+ // Strategy: find the LAST } that is preceded only by CSS variable content.
+ // More robustly: find the first } that appears on its own line (possibly
+ // with whitespace), which closes the :root block.
+
+ // Walk backwards from each } to see if it's inside the :root block.
+ // Simple approach: the :root closing } is the first bare } on its own line.
+ $pos = self::findRootClosingBrace($css);
+
+ if ($pos === false) {
+ // Fallback: append before last }
+ $pos = strrpos($css, '}');
+ }
+
+ if ($pos === false) {
+ // Last resort: append to end
+ return $css . $block;
+ }
+
+ return substr($css, 0, $pos) . $block . substr($css, $pos);
+ }
+
+ /**
+ * Find the byte position of the closing } for the :root rule.
+ */
+ private static function findRootClosingBrace(string $css): int|false
+ {
+ // Find where :root starts
+ $rootStart = preg_match('/:root\b/', $css, $m, PREG_OFFSET_CAPTURE);
+ if (!$rootStart) {
+ return false;
+ }
+
+ $offset = $m[0][1];
+ $depth = 0;
+ $len = strlen($css);
+
+ for ($i = $offset; $i < $len; $i++) {
+ if ($css[$i] === '{') {
+ $depth++;
+ } elseif ($css[$i] === '}') {
+ $depth--;
+ if ($depth === 0) {
+ return $i;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Resolve the template source directory (where this file lives).
+ */
+ private static function resolveTemplateDir(): string
+ {
+ return dirname(__FILE__);
+ }
+
+ /**
+ * Auto-detect Joomla root by walking up from template dir looking for
+ * configuration.php or the media/templates directory structure.
+ */
+ private static function detectJoomlaRoot(): string
+ {
+ $dir = dirname(__FILE__);
+
+ // Walk up max 10 levels
+ for ($i = 0; $i < 10; $i++) {
+ if (is_file($dir . '/configuration.php')) {
+ return $dir;
+ }
+ // Also check for the media/templates structure (works in dev too)
+ if (is_dir($dir . '/media/templates')) {
+ return $dir;
+ }
+ $parent = dirname($dir);
+ if ($parent === $dir) {
+ break;
+ }
+ $dir = $parent;
+ }
+
+ // Fallback for dev: if JPATH_ROOT is defined, use it
+ if (defined('JPATH_ROOT')) {
+ return JPATH_ROOT;
+ }
+
+ self::log('WARNING: Could not auto-detect Joomla root. Pass it explicitly.');
+ return dirname(__FILE__);
+ }
+
+ /**
+ * Log a message (CLI: stdout, web: Joomla enqueueMessage if available).
+ */
+ private static function log(string $message): void
+ {
+ if (defined('MOKO_CLI') || PHP_SAPI === 'cli') {
+ echo $message . PHP_EOL;
+ }
+ }
+
+ /**
+ * Dry-run mode: report what would be added without writing.
+ *
+ * @return array File path => list of missing variable names.
+ */
+ public static function dryRun(?string $joomlaRoot = null): array
+ {
+ $tplDir = self::resolveTemplateDir();
+ $root = $joomlaRoot ?? self::detectJoomlaRoot();
+ $report = [];
+
+ foreach (self::PALETTES as $pair) {
+ $starterPath = $tplDir . '/' . $pair['starter'];
+ $userPath = $root . '/' . sprintf($pair['user'], self::TPL);
+
+ if (!is_file($starterPath) || !is_file($userPath)) {
+ continue;
+ }
+
+ $starterVars = self::extractVarsWithContext($starterPath);
+ $userVars = self::extractVarNames($userPath);
+
+ $missing = [];
+ foreach ($starterVars as $name => $declaration) {
+ if (!isset($userVars[$name])) {
+ $missing[] = $name;
+ }
+ }
+
+ if (!empty($missing)) {
+ $report[$userPath] = $missing;
+ }
+ }
+
+ return $report;
+ }
+}
+
+// CLI entry point
+if (PHP_SAPI === 'cli' && realpath($argv[0] ?? '') === realpath(__FILE__)) {
+ $dryRun = in_array('--dry-run', $argv, true);
+
+ echo "MokoCassiopeia CSS Variable Sync\n";
+ echo str_repeat('─', 40) . "\n\n";
+
+ if ($dryRun) {
+ echo "DRY RUN — no files will be modified\n\n";
+ $report = MokoCssVarSync::dryRun();
+ if (empty($report)) {
+ echo "All custom palettes are up to date.\n";
+ } else {
+ foreach ($report as $file => $vars) {
+ echo "MISSING in {$file}:\n";
+ foreach ($vars as $var) {
+ echo " - {$var}\n";
+ }
+ echo "\n";
+ }
+ }
+ } else {
+ MokoCssVarSync::run();
+ }
+
+ echo "\nDone.\n";
+}
diff --git a/src/templateDetails.xml b/src/templateDetails.xml
index 3301e86..e586ad2 100644
--- a/src/templateDetails.xml
+++ b/src/templateDetails.xml
@@ -36,7 +36,8 @@
MokoCassiopeia
- 03.09.01
+ 03.09.02
+ script.php
2026-03-08
Jonathan Miller || Moko Consulting
hello@mokoconsulting.tech
@@ -49,6 +50,8 @@
index.php
joomla.asset.json
offline.php
+ script.php
+ sync_custom_vars.php
templateDetails.xml
html
language
@@ -264,6 +267,8 @@
+
+
@@ -293,6 +298,12 @@
+
+
+
diff --git a/src/templates/dark.custom.css b/src/templates/dark.custom.css
index a5b2781..fea9660 100644
--- a/src/templates/dark.custom.css
+++ b/src/templates/dark.custom.css
@@ -512,6 +512,40 @@ color-scheme: dark;
--vm-vendor-menu-link-active: var(--primary);
--vm-vendor-menu-hover-bg: var(--tertiary-bg);
+/* ===== HERO VARIANTS ===== */
+/* Primary — deep navy, dark overlay */
+--hero-primary-bg-color: #0d1e3a;
+--hero-primary-overlay: linear-gradient(rgba(13, 30, 58, .65), rgba(13, 30, 58, .65));
+--hero-primary-color: #f1f5f9;
+
+/* Secondary — darker navy, heavier overlay */
+--hero-secondary-bg-color: #080f1e;
+--hero-secondary-overlay: linear-gradient(rgba(8, 15, 30, .80), rgba(8, 15, 30, .80));
+--hero-secondary-color: #f1f5f9;
+
+/* ===== BLOCK COLORS (top-a / top-b / bottom-a / bottom-b) ===== */
+--block-color-1: var(--secondary-bg);
+--block-text-1: var(--body-color);
+
+--block-color-2: var(--accent-color-primary);
+--block-text-2: #fff;
+
+--block-color-3: rgba(238, 194, 52, .15);
+--block-text-3: var(--body-color);
+
+--block-color-4: rgba(74, 166, 100, .15);
+--block-text-4: var(--body-color);
+
+/* ===== BLOCK COLOR OVERRIDES ===== */
+--block-highlight-bg: var(--accent-color-primary);
+--block-highlight-text: #fff;
+
+--block-cta-bg: var(--color-primary);
+--block-cta-text: #f1f5f9;
+
+--block-alert-bg: var(--danger, #c23a31);
+--block-alert-text: #fff;
+
/* ===== GABLE ===== */
--gab-blue: #4d9fff;
--gab-green: #5cb85c;
diff --git a/src/templates/light.custom.css b/src/templates/light.custom.css
index 5fe651c..378830d 100644
--- a/src/templates/light.custom.css
+++ b/src/templates/light.custom.css
@@ -511,6 +511,40 @@ color-scheme: light;
--vm-vendor-menu-link-active: var(--primary);
--vm-vendor-menu-hover-bg: var(--secondary-bg);
+/* ===== HERO VARIANTS ===== */
+/* Primary — sky blue, light overlay */
+--hero-primary-bg-color: var(--color-primary);
+--hero-primary-overlay: linear-gradient(rgba(163, 205, 226, .45), rgba(163, 205, 226, .45));
+--hero-primary-color: var(--color-primary);
+
+/* Secondary — navy, stronger overlay */
+--hero-secondary-bg-color: var(--color-primary);
+--hero-secondary-overlay: linear-gradient(rgba(17, 40, 85, .75), rgba(17, 40, 85, .75));
+--hero-secondary-color: #f1f5f9;
+
+/* ===== BLOCK COLORS (top-a / top-b / bottom-a / bottom-b) ===== */
+--block-color-1: var(--color-primary);
+--block-text-1: var(--body-color);
+
+--block-color-2: var(--accent-color-primary);
+--block-text-2: #fff;
+
+--block-color-3: var(--warning, #eec234);
+--block-text-3: var(--body-color);
+
+--block-color-4: var(--success-bg-subtle, #eef7f0);
+--block-text-4: var(--body-color);
+
+/* ===== BLOCK COLOR OVERRIDES ===== */
+--block-highlight-bg: var(--accent-color-primary);
+--block-highlight-text: #fff;
+
+--block-cta-bg: var(--color-primary);
+--block-cta-text: #fff;
+
+--block-alert-bg: var(--danger, #a51f18);
+--block-alert-text: #fff;
+
/* ===== GABLE ===== */
--gab-blue: #0066cc;
--gab-green: #28a745;
diff --git a/src/templates/theme-test.html b/src/templates/theme-test.html
new file mode 100644
index 0000000..04a248e
--- /dev/null
+++ b/src/templates/theme-test.html
@@ -0,0 +1,611 @@
+
+
+
+
+
+
+MokoCassiopeia — Theme Test Sheet
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+MokoCassiopeia Theme Test Sheet
+Visual reference for CSS variables, Bootstrap components, hero variants, and block color system. Toggle light/dark mode with the button in the top-right corner.
+
+
+
+
+1. Brand & Theme Colors
+
+
+
+ --color-primary
+
+
+
+ --accent-color-primary
+
+
+
+ --accent-color-secondary
+
+
+
+ --body-bg
+
+
+
+ --body-color
+
+
+
+ --secondary-bg
+
+
+
+ --tertiary-bg
+
+
+
+ --border-color
+
+
+
+
+2. Bootstrap Color Palette
+
+
+
+ --primary
+
+
+
+ --secondary
+
+
+
+ --success
+
+
+
+ --info
+
+
+
+ --warning
+
+
+
+ --danger
+
+
+
+ --light
+
+
+
+ --dark
+
+
+
+
+3. Gray Scale
+
+
+
+ --gray-100
+
+
+
+ --gray-200
+
+
+
+ --gray-300
+
+
+
+ --gray-400
+
+
+
+ --gray-500
+
+
+
+ --gray-600
+
+
+
+ --gray-700
+
+
+
+ --gray-800
+
+
+
+ --gray-900
+
+
+
+
+4. Standard Colors
+
+
+
+ --blue
+
+
+
+ --indigo
+
+
+
+ --purple
+
+
+
+ --pink
+
+
+
+ --red
+
+
+
+ --orange
+
+
+
+ --yellow
+
+
+
+ --green
+
+
+
+ --teal
+
+
+
+ --cyan
+
+
+
+
+5. Typography
+
+ Heading 1 h1
+ Heading 2 h2
+ Heading 3 h3
+ Heading 4 h4
+ Heading 5 h5
+ Heading 6 h6
+
+This is regular body text using --body-color on --body-bg. Font family: --body-font-family. Size: --body-font-size (1rem).
+Bold text. Italic text. This is a link. Inline code. Highlighted text.
+This is lead text styled with --muted-color.
+
+
+6. Link Colors
+
+ Variable Preview
+ --link-colorSample link
+ --link-hover-colorHover state
+ --color-linkcolor-link value
+ --color-hovercolor-hover value
+
+
+
+7. Buttons
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+8. Cards
+
+
+
+ Card Header
+
+ Card Title
+ Card body using --card-bg, --card-color, and --card-border-color.
+
+
+
+
+
+
+ Simple Card
+ No header, just body content. Uses the same card variables.
+
+
+
+
+
+9. Form Elements
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+10. Alerts
+
+ Primary alert. Uses --primary-bg-subtle and --primary-text-emphasis.
+
+
+ Success alert. Uses --success-bg-subtle and --success-text-emphasis.
+
+
+ Warning alert. Uses --warning-bg-subtle and --warning-text-emphasis.
+
+
+ Danger alert. Uses --danger-bg-subtle and --danger-text-emphasis.
+
+
+ Info alert. Uses --info-bg-subtle and --info-text-emphasis.
+
+
+
+11. Borders & Shadows
+
+
+ Default border: --border-width / --border-color / --border-radius
+
+
+ --box-shadow-sm
+
+
+ --box-shadow
+
+
+ --box-shadow-lg
+
+
+
+
+12. Navigation Colors
+
+
+
+ --nav-bg-color
+
+
+
+ --nav-text-color
+
+
+
+ --mainmenu-nav-link-color
+
+
+
+
+13. Container Background Variables
+
+ Container BG Color BG Image Border
+ below-topbar --container-below-topbar-bg-color--container-below-topbar-bg-image--container-below-topbar-border
+ top-a --container-top-a-bg-color--container-top-a-bg-image--container-top-a-border
+ top-b --container-top-b-bg-color--container-top-b-bg-image--container-top-b-border
+ bottom-a --container-bottom-a-bg-color--container-bottom-a-bg-image--container-bottom-a-border
+ bottom-b --container-bottom-b-bg-color--container-bottom-b-bg-image--container-bottom-b-border
+ sidebar --container-sidebar-bg-color--container-sidebar-bg-image--container-sidebar-border
+
+
+
+14. Hero Variants NEW
+The .hero#primary and .hero#secondary variants use CSS variables for background color, overlay gradient, and text color. Each adapts automatically with the active theme.
+
+Primary Variant — .hero#primary
+
+
+ Primary Hero
+ Homepage & main landing pages — sky blue tint, softer overlay
+
+
+
+Secondary Variant — .hero#secondary
+
+
+ Secondary Hero
+ Inner pages, events, about — navy overlay, lighter text
+
+
+
+Hero Variable Reference
+
+ Variable Variant Purpose
+ --hero-primary-bg-colorPrimary Fallback background color
+ --hero-primary-overlayPrimary Gradient overlay tint
+ --hero-primary-colorPrimary Text color
+ --hero-secondary-bg-colorSecondary Fallback background color
+ --hero-secondary-overlaySecondary Gradient overlay tint
+ --hero-secondary-colorSecondary Text color
+
+
+
+15. Block Color System NEW
+Modules in top-a, top-b, bottom-a, and bottom-b positions automatically receive brand colors based on their order. No classes needed — :nth-child() handles assignment.
+
+Slot Palette Preview
+
+
+ Slot 1
+ --block-color-1
+
+
+ Slot 2
+ --block-color-2
+
+
+ Slot 3
+ --block-color-3
+
+
+ Slot 4
+ --block-color-4
+
+
+
+Named Override Preview
+
+
+ #block-highlight
+ --block-highlight-bg
+
+
+ #block-cta
+ --block-cta-bg
+
+
+ #block-alert
+ --block-alert-bg
+
+
+
+Block Variable Reference
+
+ Variable Purpose
+ --block-color-1 / --block-text-11st module in position (automatic)
+ --block-color-2 / --block-text-22nd module in position (automatic)
+ --block-color-3 / --block-text-33rd module in position (automatic)
+ --block-color-4 / --block-text-44th module in position (automatic)
+ --block-highlight-bg / --block-highlight-textNamed override for #block-highlight
+ --block-cta-bg / --block-cta-textNamed override for #block-cta
+ --block-alert-bg / --block-alert-textNamed override for #block-alert
+
+
+Override Priority
+
+ Priority Method How Applied
+ 1 (highest) Named module ID (#block-highlight) ID in module HTML + named variable
+ 2 (default) Slot color (--block-color-N) Automatic by :nth-child() order
+
+
+
+16. VirtueMart Surface Colors
+
+
+
+ --vm-surface
+
+
+
+ --vm-surface-2
+
+
+
+ --vm-price-color
+
+
+
+
+17. Gable Colors
+
+
+
+ --gab-blue
+
+
+
+ --gab-green
+
+
+
+ --gab-red
+
+
+
+ --gab-orange
+
+
+
+
+18. Code & Preformatted Text
+Inline code: var(--color-primary)
+/* Example: overriding block slot 1 in colors_custom.css */
+--block-color-1: var(--accent-color-primary);
+--block-text-1: #fff;
+
+/* Hero variant usage in module HTML */
+<div class="hero" id="primary"
+ style="background-image:url('/images/hero/main.jpg')">
+ <div class="col-12 py-5 px-4 text-center">
+ ...content...
+ </div>
+</div>
+
+
+19. Opacity Scale
+
+ 5%
+ 10%
+ 15%
+ 25%
+ 50%
+ 75%
+ 100%
+
+
+
+
+ MokoCassiopeia Theme Test Sheet — v03.09.02 — © 2026 Moko Consulting
+
+
+
+
+
+
+