diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index c975bd9..8f66556 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -111,7 +111,7 @@ jobs:
tablet-tests:
name: Tablet Tests
runs-on: ubuntu-latest
- timeout-minutes: 20
+ timeout-minutes: 45
steps:
- name: Checkout
diff --git a/LICENSE b/LICENSE
index dafb8d0..94526d0 100644
--- a/LICENSE
+++ b/LICENSE
@@ -19,17 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-
----
-
-Third-Party Licenses:
-
-Open Color
-Copyright (c) 2016 Heeyeun Jeong
-MIT License
-https://yeun.github.io/open-color/
-
-Phosphor Icons
-Copyright (c) 2020 Phosphor Icons
-MIT License
-https://phosphoricons.com/
diff --git a/README.md b/README.md
index 5202701..914f6de 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# Vanduo Framework v1.3.4
+# Vanduo Framework v1.3.5
@@ -7,115 +7,34 @@
-
-**Essential just like water is.**
+**Essential just like water is.**
-- **Pure HTML, CSS, JS**
-- **No third party dependencies**
-- **Free and open source.**
+Vanduo is a lightweight, zero-dependency UI framework built with pure HTML, CSS, and JavaScript. It ships 46+ components, responsive utilities, dark mode support, and a flexible theming system.
-## Overview
+[Browse Docs](https://vanduo.dev/#docs)
-A lightweight, pure HTML/CSS/JS framework with **46+ components** for designing beautiful interfaces. Zero runtime dependencies, no mandatory build tools, just clean and simple code.
+## Highlights
-[**Browse Full Documentation →**](https://vanduo.dev/#docs)
-
-## Features
-
-- 🎨 **Pure CSS/JS** - No libraries, no dependencies
-- 🚀 **Lightweight** - Minimal file size, maximum performance
-- 📱 **Responsive** - Mobile-first design approach
-- 🎯 **Utility-First** - Flexible utility classes for rapid development
-- 🧩 **Modular** - Import only what you need
-- ♿ **Accessible** - Built with accessibility in mind (WCAG 2.1 AA)
-- 🌙 **Dark Mode** - Automatic OS preference detection + manual toggle
-- 🌗 **Theme Switcher** - Lightweight light/dark/system toggle with shared preference storage
-- 🎛️ **Theme Customizer** - Real-time color, radius, font, and mode customization
-- 🔍 **SEO-Ready** - Comprehensive meta tags, structured data, and sitemap
-
----
-
-## What's New in v1.3.4
-
-v1.3.4 introduces the Glass Effects system — no breaking changes:
-
-- **New `effects/glass.css` module.** Core `.vd-glass` class with `backdrop-filter` blur, translucent background, specular highlight, and noise texture. Size variants (`vd-glass-sm / md / lg / xl`), modifiers (`vd-glass-tinted`, `vd-glass-floating`, `vd-glass-contrast`), and full `prefers-reduced-transparency` / `@supports not (backdrop-filter)` fallbacks.
-- **Glass design tokens.** `--vd-glass-blur`, `--vd-glass-saturate`, `--vd-glass-bg-opacity`, `--vd-glass-bg-light/dark`, `--vd-glass-border-light/dark`, `--vd-glass-shadow`, `--vd-glass-highlight-alpha`, `--vd-glass-noise-opacity` — all with automatic dark-mode overrides and `--vd-glass-*` aliases.
-- **Component glass variants.** Opt-in `.vd-*-glass` modifier added to Navbar, Modal, Dropdown, Tooltip, Toast, Card, and FAB.
-- **Scroll-activated glass.** `initScrollWatcher()` in `navbar.js` activates `.vd-navbar-glass` transparently at rest and frosted on scroll (configurable via `data-scroll-threshold`). Generic `data-glass-scroll` + `data-glass-sentinel` attribute API (new `glass.js`, `IntersectionObserver`) brings the same pattern to any element via `.is-glass-active`.
-- **vd-hex selection fix.** `VdHexGrid` re-syncs `selectedHex` references after grid regeneration, preventing stale coordinates during rotation.
-
-## What's New in v1.3.3
-
-v1.3.3 is a maintenance release with no breaking changes:
-
-- **Theme default primary alignment.** `ThemeCustomizer` and `ThemeSwitcher` normalize stale `black`/`amber` primary pairs against `localStorage` and `prefers-color-scheme`, so light, dark, and system modes stay consistent with `data-primary` after reloads and OS theme changes.
-- **Documentation site.** Theme customizer demo storage keys align with framework (`vanduo-radius`, `vanduo-font-preference`); changelog and CDN pins updated to v1.3.3.
-
----
-
-## What's New in v1.3.2
-
-v1.3.2 is a component release centered on audio playback, with no breaking changes:
-
-- **New Music Player component.** Ships a zero-dependency HTML5 audio player with play/pause, previous/next, volume, track title, and optional progress, shuffle, and playlist controls.
-- **Framework-friendly API.** Accepts a plain JavaScript tracks array (`[{ name, url }]`), exposes custom events, and supports programmatic control through `window.VanduoMusicPlayer`.
-- **Responsive player layout.** Control wrapping and volume sizing were tuned so the component stays usable across compact, inline, and mobile layouts.
-- **Release coverage and artifacts updated.** Package metadata, generated bundles, `llms.txt`, and release-facing README examples now point at v1.3.2.
-
-The framework now ships **46+ components**, including the new Music Player and the additions below.
-
-| Component | Vanduo Name | Type |
-|---|---|---|
-| Carousel | Flow | CSS + JS |
-| Music Player | Music Player | CSS + JS |
-| Popover | Bubble | CSS + JS |
-| Scrollspy | Waypoint | CSS + JS |
-| Offcanvas | — (enhanced Sidenav) | CSS + JS |
-| Ripple / Waves | Ripple | CSS + JS |
-| Floating Action Button | FAB | CSS-only |
-| Sticky | Affix | CSS + JS |
-| Autocomplete | Suggest | CSS + JS |
-| Form Validation | Validate | JS |
-| Date Picker | Datepicker | CSS + JS |
-| Time Picker | Timepicker | CSS + JS |
-| Stepper | Stepper | CSS + JS |
-| Timeline | Timeline | CSS-only |
-| Rating | Rating | CSS + JS |
-| Transfer / Multi-select | Transfer | CSS + JS |
-| Tree View | Tree | CSS + JS |
-| Spotlight / Feature Discovery | Spotlight | CSS + JS |
-
----
+- Pure CSS/JS with no runtime dependencies
+- Modular architecture with optional per-component imports
+- Built-in dark/light/system theme switching
+- Runtime Theme Customizer for color, font, and radius tokens
+- Accessibility-focused components and utilities
## Quick Start
-### Option 1: CDN (Recommended)
-
-The quickest way to get started — no install, no build step. Add two lines to any HTML file:
+### CDN (recommended)
```html
-
-
-
-
-
+
+
```
-**Pin to a specific version** for production:
-```html
-
-
-
-```
-
-### Option 2: Download
-
-[**Download the dist/ folder**](https://github.com/vanduo-oss/framework/tree/main/dist) and include locally — no internet connection required at runtime:
+### Local dist files
```html
@@ -123,97 +42,37 @@ The quickest way to get started — no install, no build step. Add two lines to
```
-The `dist/` folder is **self-contained** (CSS, JS, Fonts, Icons).
-
-### Option 3: Source Files
-
-For development or when you need more control, use the unminified source:
-
-```html
-
-
-
-```
-
-### Option 4: With a Bundler (Vite)
-
-> **Requires a build tool.** The imports below use bare module specifiers (`@vanduo-oss/framework`) which browsers cannot resolve on their own. For static HTML files, use the CDN or Download options above.
-
-Scaffold a Vite project and install Vanduo:
+### Package install (bundlers)
```bash
-pnpm create vite my-app --template vanilla
-cd my-app
pnpm add @vanduo-oss/framework
```
-Import in your entry file (e.g. `main.js`):
-
```js
import '@vanduo-oss/framework/css';
import { Vanduo } from '@vanduo-oss/framework';
-Vanduo.init();
-```
-
-**Why pnpm?** pnpm enforces a strict lockfile and creates an isolated `node_modules` structure. Vanduo's `.npmrc` security policies work best with pnpm out of the box.
-
-*(Note: `npm install @vanduo-oss/framework` and `yarn add @vanduo-oss/framework` will also work, but they do not enforce the same strict lockfile and isolated `node_modules` security guarantees.)*
-
----
-
-## LLM Access
-
-This project includes an [`llms.txt`](llms.txt) file — a structured markdown summary designed for AI assistants and LLM-powered code editors. It provides quick access to framework documentation, component references, and usage patterns.
----
-
-## Release Assets (Maintainers)
-
-Use the hardened upload script to attach only approved bundle artifacts from `dist/`:
-
-```bash
-pnpm run release:assets -- v1.3.4
+Vanduo.init();
```
-Notes:
-- If tag is omitted, it defaults to `v` + version from `package.json`.
-- Use `--dry-run` to preview files without uploading.
-
----
-
-## Documentation
+## Docs and Resources
-Comprehensive documentation for all components, utilities, and customization options is available at vanduo.dev.
-
-[**View Documentation**](https://vanduo.dev/#docs)
-
-### Key Capabilities
-
-* **Dark Mode**: Works automatically with system preferences. Can be forced via `data-theme="dark"` on ``.
-* **Theme Switcher**: Lightweight light/dark/system toggle that can coexist with Theme Customizer.
-* **Theme Customizer**: Built-in runtime tool to change colors, fonts, and radius.
-* **Modular Imports**: Import only specific components (e.g., `css/components/buttons.css`) to keep your site lean.
-* **Icons**: Includes [Phosphor Icons](https://phosphoricons.com) (Regular + Fill weights bundled).
-
----
+- Website: [vanduo.dev](https://vanduo.dev)
+- Docs: [vanduo.dev/#docs](https://vanduo.dev/#docs)
+- npm: [@vanduo-oss/framework](https://www.npmjs.com/package/@vanduo-oss/framework)
+- Releases: [GitHub Releases](https://github.com/vanduo-oss/framework/releases)
+- LLM reference: [`llms.txt`](llms.txt)
## Project Structure
-```
+```text
vanduo-framework/
-├── dist/ # Production ready files (minified)
-├── css/
-│ ├── vanduo.css # Main framework file (imports all)
-│ ├── core/ # Foundation (colors, typography, grid)
-│ ├── components/ # UI components (buttons, cards, etc)
-│ ├── utilities/ # Utility classes
-│ └── effects/ # Visual effects
-├── js/
-│ ├── vanduo.js # Main entry point
-│ └── components/ # Component logic
-├── icons/ # Phosphor Icons
-├── fonts/ # Web fonts
-└── tests/ # Framework test suite
+├── dist/ # Production bundles
+├── css/ # Core, components, utilities, effects
+├── js/ # Framework runtime and components
+├── fonts/ # Bundled web fonts
+├── icons/ # Phosphor icons bundle
+└── tests/ # Playwright + linting
```
## Browser Support
@@ -225,12 +84,10 @@ vanduo-framework/
## License
-MIT License - see [LICENSE](LICENSE) file for details.
+MIT - see [LICENSE](LICENSE).
+Third-party notices are listed in [THIRD-PARTY-LICENSES](THIRD-PARTY-LICENSES).
## Credits
-- **Color System**: [Open Color](https://yeun.github.io/open-color/) by Heeyeun Jeong (MIT License)
-- **Icons**: [Phosphor Icons](https://phosphoricons.com) (MIT License)
-
----
-Vanduo Framework - Built with ❤️ for the web.
+- [Open Color](https://yeun.github.io/open-color/) (MIT)
+- [Phosphor Icons](https://phosphoricons.com/) (MIT)
diff --git a/THIRD-PARTY-LICENSES b/THIRD-PARTY-LICENSES
new file mode 100644
index 0000000..29c0520
--- /dev/null
+++ b/THIRD-PARTY-LICENSES
@@ -0,0 +1,14 @@
+Third-Party Licenses
+====================
+
+Open Color
+----------
+Copyright (c) 2016 Heeyeun Jeong
+License: MIT
+Source: https://yeun.github.io/open-color/
+
+Phosphor Icons
+--------------
+Copyright (c) 2020 Phosphor Icons
+License: MIT
+Source: https://phosphoricons.com/
diff --git a/css/components/tooltips.css b/css/components/tooltips.css
index be80964..bb41449 100644
--- a/css/components/tooltips.css
+++ b/css/components/tooltips.css
@@ -5,11 +5,11 @@
:root {
/* Tooltip Colors */
- --tooltip-bg: var(--color-gray-900);
- --tooltip-bg-light: var(--color-white);
+ --tooltip-bg: var(--color-white);
+ --tooltip-bg-light: var(--color-gray-100);
--tooltip-bg-dark: var(--color-gray-900);
- --tooltip-text-color: var(--color-white);
- --tooltip-text-color-light: var(--color-gray-900);
+ --tooltip-text-color: var(--color-gray-900);
+ --tooltip-text-color-light: var(--color-gray-800);
--tooltip-text-color-dark: var(--color-white);
/* Tooltip Spacing (Fibonacci pairs: x/y ratio ~ phi) */
@@ -39,16 +39,16 @@
/* Dark Theme Overrides */
[data-theme="dark"] {
- --tooltip-bg: var(--color-gray-100);
- --tooltip-text-color: var(--color-gray-900);
+ --tooltip-bg: var(--color-gray-900);
+ --tooltip-text-color: var(--color-white);
--tooltip-bg-light: var(--color-gray-800);
--tooltip-text-color-light: var(--color-gray-100);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
- --tooltip-bg: var(--color-gray-100);
- --tooltip-text-color: var(--color-gray-900);
+ --tooltip-bg: var(--color-gray-900);
+ --tooltip-text-color: var(--color-white);
--tooltip-bg-light: var(--color-gray-800);
--tooltip-text-color-light: var(--color-gray-100);
}
diff --git a/css/effects/morph.css b/css/effects/morph.css
new file mode 100644
index 0000000..4178e97
--- /dev/null
+++ b/css/effects/morph.css
@@ -0,0 +1,259 @@
+/**
+ * Vanduo Framework - Water Morph Effect
+ * Liquid wave content-swap animation on click
+ *
+ * Usage:
+ * Add `.vd-morph` (or `data-vd-morph`) to any element.
+ * Inside it, place:
+ * .vd-morph-wave — radial wave layer (auto-created by JS if missing)
+ * .vd-morph-shine — light-sweep layer (auto-created by JS if missing)
+ * .vd-morph-content.vd-morph-current — visible state
+ * .vd-morph-content.vd-morph-next — hidden next state
+ */
+
+:root {
+ --morph-duration: 0.75s;
+ --morph-easing: cubic-bezier(0.2, 0.8, 0.35, 1);
+ --morph-blur-peak: 11px;
+ --morph-wave-color: var(--color-primary, #3b82f6);
+}
+
+/* ========== Base ========== */
+
+.vd-morph,
+[data-vd-morph] {
+ position: relative;
+ overflow: hidden;
+ cursor: pointer;
+ -webkit-tap-highlight-color: transparent;
+}
+
+/* ========== Wave Layer ========== */
+
+.vd-morph-wave {
+ position: absolute;
+ border-radius: 50%;
+ background:
+ radial-gradient(circle at 32% 28%,
+ color-mix(in srgb, #fff 55%, transparent) 0%,
+ color-mix(in srgb, #fff 18%, transparent) 18%,
+ transparent 38%),
+ radial-gradient(circle at 68% 70%,
+ color-mix(in srgb, #fff 30%, transparent) 0%,
+ transparent 30%),
+ radial-gradient(circle at 50% 50%,
+ color-mix(in srgb, var(--morph-wave-color) 96%, #a8d8ff) 0%,
+ color-mix(in srgb, var(--morph-wave-color) 82%, #7ec8ff) 35%,
+ color-mix(in srgb, var(--morph-wave-color) 70%, #5ba4f5) 65%,
+ color-mix(in srgb, var(--morph-wave-color) 90%, #4478f0) 100%);
+ pointer-events: none;
+ opacity: 0;
+ transform: translate(-50%, -50%);
+ will-change: width, height, opacity, filter;
+}
+
+.vd-morph.is-morphing .vd-morph-wave {
+ animation: vd-morph-expand var(--morph-duration) var(--morph-easing) forwards;
+}
+
+.vd-morph.is-morphing .vd-morph-wave::after {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 0;
+ height: 0;
+ border-radius: 50%;
+ background:
+ radial-gradient(circle at 50% 50%,
+ color-mix(in srgb, var(--morph-wave-color) 70%, #b0e0ff) 0%,
+ color-mix(in srgb, var(--morph-wave-color) 50%, #88c0ff) 45%,
+ transparent 80%);
+ transform: translate(-50%, -50%);
+ opacity: 0;
+ animation: vd-morph-expand2 var(--morph-duration) var(--morph-easing) 0.08s forwards;
+}
+
+@keyframes vd-morph-expand {
+ 0% {
+ width: 0;
+ height: 0;
+ opacity: 0;
+ filter: blur(0px) saturate(2);
+ }
+ 6% {
+ opacity: 1;
+ filter: blur(1px) saturate(2.2);
+ }
+ 35% {
+ opacity: 0.92;
+ filter: blur(6px) saturate(1.7);
+ }
+ 68% {
+ width: 360%;
+ height: 360%;
+ opacity: 0.6;
+ filter: blur(var(--morph-blur-peak)) saturate(1.3);
+ }
+ 100% {
+ width: 360%;
+ height: 360%;
+ opacity: 0;
+ filter: blur(7px) saturate(1);
+ }
+}
+
+@keyframes vd-morph-expand2 {
+ 0% {
+ width: 0;
+ height: 0;
+ opacity: 0;
+ filter: blur(0px);
+ }
+ 10% {
+ opacity: 0.7;
+ filter: blur(3px);
+ }
+ 60% {
+ width: 280%;
+ height: 280%;
+ opacity: 0.38;
+ filter: blur(14px);
+ }
+ 100% {
+ width: 280%;
+ height: 280%;
+ opacity: 0;
+ filter: blur(10px);
+ }
+}
+
+/* ========== Shine Sweep ========== */
+
+.vd-morph-shine {
+ position: absolute;
+ top: 0;
+ left: -110%;
+ width: 80%;
+ height: 100%;
+ background: linear-gradient(
+ 105deg,
+ transparent 0%,
+ color-mix(in srgb, #fff 10%, transparent) 35%,
+ color-mix(in srgb, #fff 52%, transparent) 50%,
+ color-mix(in srgb, #fff 10%, transparent) 65%,
+ transparent 100%
+ );
+ pointer-events: none;
+ opacity: 0;
+ transform: skewX(-12deg);
+ will-change: left, opacity;
+}
+
+.vd-morph.is-morphing .vd-morph-shine {
+ animation: vd-morph-shine 0.72s cubic-bezier(0.3, 0.65, 0.5, 1) 0.04s forwards;
+}
+
+@keyframes vd-morph-shine {
+ 0% {
+ left: -110%;
+ opacity: 0;
+ }
+ 15% {
+ opacity: 0.9;
+ }
+ 72% {
+ opacity: 0.4;
+ }
+ 100% {
+ left: 130%;
+ opacity: 0;
+ }
+}
+
+/* ========== Completion Bounce ========== */
+
+.vd-morph.morph-done {
+ animation: vd-morph-bounce 0.32s cubic-bezier(0.34, 1.56, 0.64, 1);
+}
+
+@keyframes vd-morph-bounce {
+ 0% { transform: scale(0.97); }
+ 60% { transform: scale(1.025); }
+ 100% { transform: scale(1); }
+}
+
+/* ========== Content Layers ========== */
+
+.vd-morph-content {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.45rem;
+ pointer-events: none;
+ will-change: opacity, transform, filter;
+}
+
+.vd-morph-current {
+ opacity: 1;
+ transform: scale(1) translateY(0);
+ filter: blur(0px);
+ z-index: 2;
+ transition:
+ opacity 0.28s cubic-bezier(0.4, 0, 0.6, 1),
+ transform 0.28s cubic-bezier(0.4, 0, 0.6, 1),
+ filter 0.28s ease;
+}
+
+.vd-morph-next {
+ opacity: 0;
+ transform: scale(0.86) translateY(4px);
+ filter: blur(3px);
+ z-index: 1;
+ transition:
+ opacity 0.3s cubic-bezier(0, 0, 0.2, 1),
+ transform 0.3s cubic-bezier(0, 0, 0.2, 1),
+ filter 0.3s ease;
+}
+
+.vd-morph.is-morphing .vd-morph-current {
+ opacity: 0;
+ transform: scale(0.82) translateY(-4px);
+ filter: blur(4px);
+ transition-duration: 0.2s;
+ transition-delay: 0s;
+}
+
+.vd-morph.is-morphing .vd-morph-next {
+ opacity: 1;
+ transform: scale(1) translateY(0);
+ filter: blur(0px);
+ transition-duration: 0.3s;
+ transition-delay: 0.25s;
+}
+
+/* ========== Size Variants ========== */
+
+.vd-morph-sm {
+ --morph-duration: 0.5s;
+ --morph-blur-peak: 7px;
+}
+
+.vd-morph-lg {
+ --morph-duration: 1s;
+ --morph-blur-peak: 16px;
+}
+
+/* ========== Accessibility ========== */
+
+@media (prefers-reduced-motion: reduce) {
+ .vd-morph,
+ .vd-morph-content,
+ .vd-morph-wave,
+ .vd-morph-shine {
+ transition: none !important;
+ animation: none !important;
+ }
+}
diff --git a/css/vanduo.css b/css/vanduo.css
index 75c50a6..14afcff 100644
--- a/css/vanduo.css
+++ b/css/vanduo.css
@@ -76,6 +76,7 @@
/* Effects - Phase 7 */
@import url('effects/parallax.css');
@import url('effects/glass.css');
+@import url('effects/morph.css');
/* Components - Phase 9 (New) */
@import url('components/draggable.css');
diff --git a/dist/build-info.json b/dist/build-info.json
index 0b3bfd3..3bc82cc 100644
--- a/dist/build-info.json
+++ b/dist/build-info.json
@@ -1,6 +1,6 @@
{
- "version": "1.3.4",
- "builtAt": "2026-04-14T21:21:55.517Z",
- "commit": "73e3db5",
+ "version": "1.3.5",
+ "builtAt": "2026-04-15T18:39:53.955Z",
+ "commit": "3ca4f62",
"mode": "development+production"
}
\ No newline at end of file
diff --git a/dist/vanduo.cjs.js b/dist/vanduo.cjs.js
index 1add41b..ef1a29d 100644
--- a/dist/vanduo.cjs.js
+++ b/dist/vanduo.cjs.js
@@ -1,4 +1,4 @@
-/*! Vanduo v1.3.4 | Built: 2026-04-14T21:21:55.517Z | git:73e3db5 | development */
+/*! Vanduo v1.3.5 | Built: 2026-04-15T18:39:53.955Z | git:3ca4f62 | development */
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -132,7 +132,7 @@ module.exports = __toCommonJS(index_exports);
// js/vanduo.js
(function() {
"use strict";
- const VANDUO_VERSION = true ? "1.3.4" : "0.0.0-dev";
+ const VANDUO_VERSION = true ? "1.3.5" : "0.0.0-dev";
const Vanduo2 = {
version: VANDUO_VERSION,
components: {},
@@ -6597,6 +6597,117 @@ module.exports = __toCommonJS(index_exports);
window.VanduoGlassScroll = GlassScroll;
})();
+// js/components/morph.js
+(function() {
+ "use strict";
+ const MORPH_DURATION_MS = 750;
+ const Morph = {
+ instances: /* @__PURE__ */ new Map(),
+ init: function() {
+ const elements = document.querySelectorAll(".vd-morph, [data-vd-morph]");
+ elements.forEach(function(el) {
+ if (Morph.instances.has(el)) return;
+ if (el.getAttribute("data-vd-morph") === "manual") return;
+ Morph.initInstance(el);
+ });
+ },
+ initInstance: function(el) {
+ Morph._ensureLayers(el);
+ const cleanup = [];
+ let morphing = false;
+ const handleClick = function(e) {
+ if (morphing) return;
+ Morph._runMorph(el, e, function() {
+ morphing = false;
+ });
+ morphing = true;
+ };
+ el.addEventListener("click", handleClick);
+ cleanup.push(function() {
+ el.removeEventListener("click", handleClick);
+ });
+ this.instances.set(el, { cleanup });
+ },
+ morph: function(el) {
+ if (!el) return;
+ if (!this.instances.has(el)) this.initInstance(el);
+ this._runMorph(el, null, null);
+ },
+ destroy: function(el) {
+ const instance = this.instances.get(el);
+ if (!instance) return;
+ instance.cleanup.forEach(function(fn) {
+ fn();
+ });
+ this.instances.delete(el);
+ },
+ destroyAll: function() {
+ this.instances.forEach(function(_, el) {
+ Morph.destroy(el);
+ });
+ },
+ /* ── Internal helpers ── */
+ _ensureLayers: function(el) {
+ if (!el.querySelector(".vd-morph-wave")) {
+ const wave = document.createElement("span");
+ wave.className = "vd-morph-wave";
+ wave.setAttribute("aria-hidden", "true");
+ el.insertBefore(wave, el.firstChild);
+ }
+ if (!el.querySelector(".vd-morph-shine")) {
+ const shine = document.createElement("span");
+ shine.className = "vd-morph-shine";
+ shine.setAttribute("aria-hidden", "true");
+ const waveEl = el.querySelector(".vd-morph-wave");
+ if (waveEl && waveEl.nextSibling) {
+ el.insertBefore(shine, waveEl.nextSibling);
+ } else {
+ el.insertBefore(shine, el.firstChild);
+ }
+ }
+ },
+ _runMorph: function(el, pointerEvent, onComplete) {
+ const wave = el.querySelector(".vd-morph-wave");
+ if (wave) {
+ const rect = el.getBoundingClientRect();
+ const cx = rect.left + rect.width / 2;
+ const cy = rect.top + rect.height / 2;
+ const px = pointerEvent ? pointerEvent.clientX || cx : cx;
+ const py = pointerEvent ? pointerEvent.clientY || cy : cy;
+ wave.style.left = px - rect.left + "px";
+ wave.style.top = py - rect.top + "px";
+ }
+ el.classList.add("is-morphing");
+ let duration = MORPH_DURATION_MS;
+ const custom = getComputedStyle(el).getPropertyValue("--morph-duration");
+ if (custom) {
+ const parsed = parseFloat(custom);
+ if (!isNaN(parsed)) duration = parsed * (custom.indexOf("ms") !== -1 ? 1 : 1e3);
+ }
+ setTimeout(function() {
+ el.classList.remove("is-morphing");
+ const current = el.querySelector(".vd-morph-current");
+ const next = el.querySelector(".vd-morph-next");
+ if (current && next) {
+ current.classList.remove("vd-morph-current");
+ current.classList.add("vd-morph-next");
+ next.classList.remove("vd-morph-next");
+ next.classList.add("vd-morph-current");
+ }
+ el.classList.add("morph-done");
+ setTimeout(function() {
+ el.classList.remove("morph-done");
+ }, 350);
+ if (typeof onComplete === "function") onComplete();
+ }, duration);
+ }
+ };
+ if (typeof window.Vanduo !== "undefined") {
+ window.Vanduo.register("morph", Morph);
+ }
+ window.VanduoMorph = Morph;
+})();
+
// js/components/flow.js
(function() {
"use strict";
diff --git a/dist/vanduo.cjs.js.map b/dist/vanduo.cjs.js.map
index b6ae493..fdef61d 100644
--- a/dist/vanduo.cjs.js.map
+++ b/dist/vanduo.cjs.js.map
@@ -1,7 +1,7 @@
{
"version": 3,
- "sources": ["../js/index.js", "../js/utils/lifecycle.js", "../js/vanduo.js", "../js/components/code-snippet.js", "../js/components/collapsible.js", "../js/components/dropdown.js", "../js/components/font-switcher.js", "../js/components/grid.js", "../js/components/image-box.js", "../js/components/modals.js", "../js/components/navbar.js", "../js/components/pagination.js", "../js/components/parallax.js", "../js/components/preloader.js", "../js/components/select.js", "../js/components/sidenav.js", "../js/components/tabs.js", "../js/components/theme-customizer.js", "../js/components/theme-switcher.js", "../js/components/toast.js", "../js/components/tooltips.js", "../js/components/doc-search.js", "../js/components/draggable.js", "../js/components/lazy-load.js", "../js/components/glass.js", "../js/components/flow.js", "../js/components/bubble.js", "../js/components/waypoint.js", "../js/components/ripple.js", "../js/components/affix.js", "../js/components/suggest.js", "../js/components/validate.js", "../js/components/datepicker.js", "../js/components/timepicker.js", "../js/components/stepper.js", "../js/components/rating.js", "../js/components/transfer.js", "../js/components/tree.js", "../js/components/spotlight.js", "../js/components/music-player.js"],
- "sourcesContent": ["/**\n * Vanduo Framework - Bundle Entry Point\n * This file imports all framework components for bundling.\n *\n * All component files are side-effect modules that:\n * 1. Define their component object\n * 2. Register with window.Vanduo via Vanduo.register()\n * 3. Expose a convenience global (e.g. window.VanduoTooltips)\n *\n * The IIFE build uses `globalName: 'VanduoBundle'` so that esbuild's\n * wrapper variable does NOT shadow the real `window.Vanduo` that the\n * side-effect scripts create. After the bundle executes, `window.Vanduo`\n * is the fully-populated framework object.\n *\n * For ESM/CJS consumers we re-export `window.Vanduo` as the default\n * and named export so `import { Vanduo }` and `const { Vanduo } = require()`\n * both work.\n */\n\n// Utilities (must load first \u2014 helpers defines `ready()`, `safeStorageGet()` etc.)\nimport './utils/helpers.js';\nimport './utils/lifecycle.js';\n\n// Core framework object (creates window.Vanduo)\nimport './vanduo.js';\n\n// Components (each registers itself with window.Vanduo)\nimport './components/code-snippet.js';\nimport './components/collapsible.js';\nimport './components/dropdown.js';\nimport './components/font-switcher.js';\nimport './components/grid.js';\nimport './components/image-box.js';\nimport './components/modals.js';\nimport './components/navbar.js';\nimport './components/pagination.js';\nimport './components/parallax.js';\nimport './components/preloader.js';\nimport './components/select.js';\nimport './components/sidenav.js';\nimport './components/tabs.js';\nimport './components/theme-customizer.js';\nimport './components/theme-switcher.js';\nimport './components/toast.js';\nimport './components/tooltips.js';\nimport './components/doc-search.js';\nimport './components/draggable.js';\nimport './components/lazy-load.js';\n\n// Effects (glass scroll activation)\nimport './components/glass.js';\n\n// Phase 10 (v1.2.7) components\nimport './components/flow.js';\nimport './components/bubble.js';\nimport './components/waypoint.js';\nimport './components/ripple.js';\nimport './components/affix.js';\nimport './components/suggest.js';\nimport './components/validate.js';\nimport './components/datepicker.js';\nimport './components/timepicker.js';\nimport './components/stepper.js';\nimport './components/rating.js';\nimport './components/transfer.js';\nimport './components/tree.js';\nimport './components/spotlight.js';\nimport './components/music-player.js';\n\n// Re-export for ESM / CJS consumers\nconst Vanduo = window.Vanduo;\nexport { Vanduo };\nexport default Vanduo;\n", "/**\n * Vanduo Framework - Lifecycle Manager\n * Central registry for component instances and cleanup\n * Prevents memory leaks in SPAs by tracking event listeners\n */\n\n(function() {\n 'use strict';\n\n /**\n * Lifecycle Manager\n * Simple registry that tracks component instances and their cleanup functions\n */\n const Lifecycle = {\n // Map of element -> { componentName, cleanupFunctions }\n instances: new Map(),\n\n /**\n * Register a component instance\n * @param {HTMLElement} element - The DOM element\n * @param {string} componentName - Name of the component\n * @param {Array} cleanupFns - Functions to call on destroy\n */\n register: function(element, componentName, cleanupFns = []) {\n if (this.instances.has(element)) {\n // Already registered, merge cleanup functions\n const existing = this.instances.get(element);\n existing.cleanup = existing.cleanup.concat(cleanupFns);\n return;\n }\n\n this.instances.set(element, {\n component: componentName,\n cleanup: cleanupFns,\n registeredAt: Date.now()\n });\n },\n\n /**\n * Unregister a single element and run its cleanup\n * @param {HTMLElement} element - The element to unregister\n */\n unregister: function(element) {\n const instance = this.instances.get(element);\n if (!instance) return;\n\n // Run all cleanup functions\n instance.cleanup.forEach(function(fn) {\n try {\n fn();\n } catch (e) {\n console.warn('[Vanduo Lifecycle] Cleanup error:', e);\n }\n });\n\n this.instances.delete(element);\n },\n\n /**\n * Destroy all instances of a specific component\n * @param {string} componentName - Optional component name filter\n */\n destroyAll: function(componentName) {\n const toRemove = [];\n\n this.instances.forEach(function(instance, element) {\n if (!componentName || instance.component === componentName) {\n toRemove.push(element);\n }\n });\n\n toRemove.forEach(function(element) {\n Lifecycle.unregister(element);\n });\n },\n\n /**\n * Destroy all instances within a specific container\n * Useful for SPAs when navigating between pages\n * @param {HTMLElement} container - Container element\n */\n destroyAllInContainer: function(container) {\n const toRemove = [];\n\n this.instances.forEach(function(instance, element) {\n if (container.contains(element)) {\n toRemove.push(element);\n }\n });\n\n toRemove.forEach(function(element) {\n Lifecycle.unregister(element);\n });\n },\n\n /**\n * Get all registered instances (for debugging)\n * @returns {Array} Array of instance info objects\n */\n getAll: function() {\n const result = [];\n this.instances.forEach(function(instance, element) {\n result.push({\n element: element,\n component: instance.component,\n registeredAt: instance.registeredAt\n });\n });\n return result;\n },\n\n /**\n * Check if an element is registered\n * @param {HTMLElement} element - The element to check\n * @returns {boolean}\n */\n has: function(element) {\n return this.instances.has(element);\n }\n };\n\n // Auto-cleanup on page unload\n window.addEventListener('beforeunload', function() {\n Lifecycle.destroyAll();\n });\n\n // Expose globally\n window.VanduoLifecycle = Lifecycle;\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('lifecycle', Lifecycle);\n }\n\n})();\n", "/**\n * Vanduo Framework - Main JavaScript File\n */\n\n(function () {\n 'use strict';\n\n const VANDUO_VERSION = typeof __VANDUO_VERSION__ !== 'undefined' ? __VANDUO_VERSION__ : '0.0.0-dev';\n\n /**\n * Vanduo Framework Object\n */\n const Vanduo = {\n version: VANDUO_VERSION,\n components: {},\n\n /**\n * Initialize framework\n * Call this after DOM is ready and all components are loaded\n */\n init: function () {\n // Initialize components when DOM is ready\n if (typeof ready !== 'undefined') {\n ready(() => {\n this.initComponents();\n });\n } else {\n // Fallback if helpers.js is not loaded\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', () => {\n this.initComponents();\n });\n } else {\n this.initComponents();\n }\n }\n },\n\n /**\n * Initialize all components\n */\n initComponents: function () {\n // Initialize all registered components\n Object.keys(this.components).forEach((name) => {\n const component = this.components[name];\n if (component.init && typeof component.init === 'function') {\n try {\n component.init();\n } catch (e) {\n console.warn('[Vanduo] Failed to initialize component \"' + name + '\":', e);\n }\n }\n });\n\n console.log('Vanduo Framework v' + this.version + ' initialized');\n },\n\n /**\n * Register a component\n * @param {string} name - Component name\n * @param {Object} component - Component object with init method\n */\n register: function (name, component) {\n this.components[name] = component;\n // Note: Components are NOT auto-initialized on registration\n // Call Vanduo.init() explicitly after all components are registered\n },\n\n /**\n * Re-initialize a component (useful after dynamic DOM changes)\n * @param {string} name - Component name\n */\n reinit: function (name) {\n const component = this.components[name];\n if (component && component.init && typeof component.init === 'function') {\n try {\n component.init();\n } catch (e) {\n console.warn('[Vanduo] Failed to reinitialize component \"' + name + '\":', e);\n }\n }\n },\n\n /**\n * Destroy all component instances and clean up event listeners\n * Uses lifecycle manager for memory leak prevention\n */\n destroyAll: function () {\n // First, destroy components that have their own destroyAll\n const names = Object.keys(this.components);\n for (let i = 0; i < names.length; i++) {\n const component = this.components[names[i]];\n if (component && component.destroyAll && typeof component.destroyAll === 'function') {\n try {\n component.destroyAll();\n } catch (e) {\n console.warn('[Vanduo] Failed to destroy component \"' + names[i] + '\":', e);\n }\n }\n }\n\n // Then, cleanup any remaining registered elements via lifecycle manager\n if (typeof window.VanduoLifecycle !== 'undefined') {\n window.VanduoLifecycle.destroyAll();\n }\n },\n\n /**\n * Get component instance\n * @param {string} name - Component name\n * @returns {Object|null}\n */\n getComponent: function (name) {\n return this.components[name] || null;\n }\n };\n\n // Expose to global scope\n window.Vanduo = Vanduo;\n\n})();\n", "/**\n * Vanduo Framework - Code Snippet Component\n * Copyable code blocks with tabs, syntax highlighting, and HTML extraction\n */\n\n(function () {\n 'use strict';\n\n /**\n * Code Snippet Component\n */\n const CodeSnippet = {\n _snippetIdCounter: 0,\n\n getSnippetInstanceId: function (snippet) {\n if (snippet.dataset.codeSnippetId) {\n return snippet.dataset.codeSnippetId;\n }\n\n const baseId = (snippet.id || '').trim();\n if (baseId) {\n snippet.dataset.codeSnippetId = `snippet-${baseId}`;\n return snippet.dataset.codeSnippetId;\n }\n\n this._snippetIdCounter += 1;\n snippet.dataset.codeSnippetId = `snippet-auto-${this._snippetIdCounter}`;\n return snippet.dataset.codeSnippetId;\n },\n\n addListener: function (snippet, target, event, handler) {\n if (!target) return;\n target.addEventListener(event, handler);\n if (!snippet._codeSnippetCleanup) {\n snippet._codeSnippetCleanup = [];\n }\n snippet._codeSnippetCleanup.push(() => target.removeEventListener(event, handler));\n },\n\n /**\n * Initialize all code snippet components\n */\n init: function () {\n const snippets = document.querySelectorAll('.vd-code-snippet');\n\n snippets.forEach(snippet => {\n if (!snippet.dataset.initialized) {\n this.initSnippet(snippet);\n }\n });\n },\n\n /**\n * Initialize a single code snippet\n * @param {HTMLElement} snippet - Code snippet container element\n */\n initSnippet: function (snippet) {\n snippet.dataset.initialized = 'true';\n snippet._codeSnippetCleanup = [];\n\n // Handle collapsible toggle\n const toggle = snippet.querySelector('.vd-code-snippet-toggle');\n const content = snippet.querySelector('.vd-code-snippet-content');\n\n if (toggle && content) {\n this.initCollapsible(snippet, toggle, content);\n }\n\n // Handle tabs\n const tabs = snippet.querySelectorAll('.vd-code-snippet-tab');\n const panes = snippet.querySelectorAll('.vd-code-snippet-pane');\n\n if (tabs.length > 0) {\n this.initTabs(snippet, tabs, panes);\n }\n\n // Handle copy button\n const copyBtn = snippet.querySelector('.vd-code-snippet-copy');\n if (copyBtn) {\n this.initCopyButton(snippet, copyBtn);\n }\n\n // Handle HTML extraction\n const extractPanes = snippet.querySelectorAll('[data-extract]');\n extractPanes.forEach(pane => {\n this.extractHtml(pane);\n });\n\n // Handle line numbers\n const lineNumberPanes = snippet.querySelectorAll('.has-line-numbers');\n lineNumberPanes.forEach(pane => {\n this.addLineNumbers(pane);\n });\n },\n\n /**\n * Initialize collapsible functionality\n * @param {HTMLElement} snippet - Code snippet container\n * @param {HTMLElement} toggle - Toggle button\n * @param {HTMLElement} content - Collapsible content\n */\n initCollapsible: function (snippet, toggle, content) {\n // Set initial state\n const isExpanded = snippet.dataset.expanded === 'true';\n toggle.setAttribute('aria-expanded', isExpanded);\n content.dataset.visible = isExpanded;\n\n this.addListener(snippet, toggle, 'click', () => {\n const expanded = snippet.dataset.expanded === 'true';\n snippet.dataset.expanded = !expanded;\n toggle.setAttribute('aria-expanded', !expanded);\n content.dataset.visible = !expanded;\n\n // Extract HTML on first expand if needed\n if (!expanded) {\n const extractPanes = content.querySelectorAll('[data-extract]:not([data-extracted])');\n extractPanes.forEach(pane => {\n this.extractHtml(pane);\n });\n }\n\n // Dispatch event\n const event = new CustomEvent('codesnippet:toggle', {\n bubbles: true,\n detail: { snippet, expanded: !expanded }\n });\n snippet.dispatchEvent(event);\n });\n },\n\n /**\n * Initialize tab functionality\n * @param {HTMLElement} snippet - Code snippet container\n * @param {NodeList} tabs - Tab buttons\n * @param {NodeList} panes - Code panes\n */\n initTabs: function (snippet, tabs, panes) {\n const snippetId = this.getSnippetInstanceId(snippet);\n\n // Set up ARIA attributes\n const tabList = snippet.querySelector('.vd-code-snippet-tabs');\n if (tabList) {\n tabList.setAttribute('role', 'tablist');\n }\n\n tabs.forEach((tab, index) => {\n const lang = tab.dataset.lang;\n const isActive = tab.classList.contains('is-active');\n\n // Set ARIA attributes\n tab.setAttribute('role', 'tab');\n tab.setAttribute('aria-selected', isActive);\n tab.setAttribute('tabindex', isActive ? '0' : '-1');\n tab.id = tab.id || `code-tab-${snippetId}-${lang || 'tab'}-${index}`;\n\n // Find corresponding pane\n const pane = snippet.querySelector(`.vd-code-snippet-pane[data-lang=\"${lang}\"]`);\n if (pane) {\n pane.id = pane.id || `code-pane-${snippetId}-${lang || 'pane'}-${index}`;\n pane.setAttribute('role', 'tabpanel');\n tab.setAttribute('aria-controls', pane.id);\n pane.setAttribute('aria-labelledby', tab.id);\n }\n\n // Click handler\n this.addListener(snippet, tab, 'click', () => {\n this.switchTab(snippet, tab, tabs, panes);\n });\n\n // Keyboard navigation\n this.addListener(snippet, tab, 'keydown', (e) => {\n this.handleTabKeydown(e, snippet, tabs, panes);\n });\n });\n },\n\n /**\n * Switch to a specific tab\n * @param {HTMLElement} snippet - Code snippet container\n * @param {HTMLElement} activeTab - Tab to activate\n * @param {NodeList} tabs - All tab buttons\n * @param {NodeList} panes - All code panes\n */\n switchTab: function (snippet, activeTab, tabs, panes) {\n const lang = activeTab.dataset.lang;\n\n // Deactivate all tabs\n tabs.forEach(tab => {\n tab.classList.remove('is-active');\n tab.setAttribute('aria-selected', 'false');\n tab.setAttribute('tabindex', '-1');\n });\n\n // Hide all panes\n panes.forEach(pane => {\n pane.classList.remove('is-active');\n });\n\n // Activate selected tab\n activeTab.classList.add('is-active');\n activeTab.setAttribute('aria-selected', 'true');\n activeTab.setAttribute('tabindex', '0');\n\n // Show corresponding pane\n const activePane = snippet.querySelector(`.vd-code-snippet-pane[data-lang=\"${lang}\"]`);\n if (activePane) {\n activePane.classList.add('is-active');\n }\n\n // Dispatch event\n const event = new CustomEvent('codesnippet:tabchange', {\n bubbles: true,\n detail: { snippet, tab: activeTab, lang }\n });\n snippet.dispatchEvent(event);\n },\n\n /**\n * Handle keyboard navigation for tabs\n * @param {KeyboardEvent} e - Keyboard event\n * @param {HTMLElement} snippet - Code snippet container\n * @param {NodeList} tabs - All tab buttons\n * @param {NodeList} panes - All code panes\n */\n handleTabKeydown: function (e, snippet, tabs, panes) {\n const tabArray = Array.from(tabs);\n const currentIndex = tabArray.indexOf(e.target);\n let newIndex;\n\n switch (e.key) {\n case 'ArrowLeft':\n e.preventDefault();\n newIndex = currentIndex > 0 ? currentIndex - 1 : tabArray.length - 1;\n break;\n case 'ArrowRight':\n e.preventDefault();\n newIndex = currentIndex < tabArray.length - 1 ? currentIndex + 1 : 0;\n break;\n case 'Home':\n e.preventDefault();\n newIndex = 0;\n break;\n case 'End':\n e.preventDefault();\n newIndex = tabArray.length - 1;\n break;\n default:\n return;\n }\n\n if (newIndex !== currentIndex) {\n tabArray[newIndex].focus();\n this.switchTab(snippet, tabArray[newIndex], tabs, panes);\n }\n },\n\n /**\n * Initialize copy button\n * @param {HTMLElement} snippet - Code snippet container\n * @param {HTMLElement} copyBtn - Copy button element\n */\n initCopyButton: function (snippet, copyBtn) {\n this.addListener(snippet, copyBtn, 'click', async () => {\n await this.copyCode(snippet, copyBtn);\n });\n },\n\n /**\n * Copy code to clipboard\n * @param {HTMLElement} snippet - Code snippet container\n * @param {HTMLElement} copyBtn - Copy button element\n */\n copyCode: async function (snippet, copyBtn) {\n const activePane = snippet.querySelector('.vd-code-snippet-pane.is-active') ||\n snippet.querySelector('.vd-code-snippet-pane');\n\n if (!activePane) {\n console.warn('CodeSnippet: No code pane found');\n return;\n }\n\n const codeElement = activePane.querySelector('code') || activePane;\n const code = codeElement.textContent;\n\n let copySuccess;\n try {\n await navigator.clipboard.writeText(code);\n copySuccess = true;\n } catch (_err) {\n // Fallback for older browsers\n copySuccess = this.fallbackCopy(code);\n }\n this.showCopyFeedback(copyBtn, copySuccess);\n\n // Dispatch event\n const event = new CustomEvent('codesnippet:copy', {\n bubbles: true,\n detail: { snippet, code, success: copySuccess }\n });\n snippet.dispatchEvent(event);\n },\n\n /**\n * Fallback copy method for older browsers\n * @param {string} text - Text to copy\n * @returns {boolean} Success status\n */\n fallbackCopy: function (text) {\n const textarea = document.createElement('textarea');\n textarea.value = text;\n textarea.style.position = 'fixed';\n textarea.style.left = '-9999px';\n textarea.style.top = '-9999px';\n document.body.appendChild(textarea);\n textarea.focus();\n textarea.select();\n\n let success = false;\n try {\n success = document.execCommand('copy');\n } catch (err) {\n console.warn('CodeSnippet: Fallback copy failed', err);\n }\n\n document.body.removeChild(textarea);\n return success;\n },\n\n /**\n * Show copy feedback\n * @param {HTMLElement} copyBtn - Copy button element\n * @param {boolean} success - Whether copy was successful\n */\n showCopyFeedback: function (copyBtn, success) {\n if (success) {\n copyBtn.classList.add('is-copied');\n\n // Announce to screen readers\n const announcement = document.createElement('span');\n announcement.setAttribute('role', 'status');\n announcement.setAttribute('aria-live', 'polite');\n announcement.className = 'sr-only';\n announcement.textContent = 'Code copied to clipboard';\n copyBtn.appendChild(announcement);\n\n setTimeout(() => {\n copyBtn.classList.remove('is-copied');\n if (announcement.parentNode) {\n announcement.parentNode.removeChild(announcement);\n }\n }, 2000);\n }\n },\n\n /**\n * Extract HTML from a demo element\n * @param {HTMLElement} pane - Code pane with data-extract attribute\n */\n extractHtml: function (pane) {\n const selector = pane.dataset.extract;\n if (!selector) return;\n\n const source = document.querySelector(selector);\n if (!source) {\n console.warn(`CodeSnippet: Source element not found: ${selector}`);\n return;\n }\n\n // Get inner HTML\n let html = source.innerHTML;\n\n // Format the HTML\n html = this.formatHtml(html);\n\n // Escape for display\n html = this.escapeHtml(html);\n\n // Apply syntax highlighting\n html = this.highlightHtml(html);\n\n // Set content via DOM API to avoid string-based HTML insertion\n const codeEl = document.createElement('code');\n codeEl.innerHTML = html;\n pane.replaceChildren(codeEl);\n pane.dataset.extracted = 'true';\n },\n\n /**\n * Format HTML with proper indentation\n * @param {string} html - Raw HTML string\n * @returns {string} Formatted HTML\n */\n formatHtml: function (html) {\n // Remove leading/trailing whitespace\n html = html.trim();\n\n // Simple formatting: normalize whitespace\n // Split by tags, then rejoin with proper indentation\n const lines = html.split('\\n');\n let indent = 0;\n const indentSize = 2;\n const formattedLines = [];\n\n lines.forEach(line => {\n line = line.trim();\n if (!line) return;\n\n // Check for closing tags at start\n if (line.match(/^<\\/\\w/)) {\n indent = Math.max(0, indent - indentSize);\n }\n\n formattedLines.push(' '.repeat(indent) + line);\n\n // Check for opening tags (not self-closing)\n // Use short fixed-length regex + indexOf to prevent ReDoS\n const hasOpenTag = /<[a-zA-Z]/.test(line);\n const isSelfClosing = line.includes('/>');\n if (hasOpenTag && !isSelfClosing) {\n // Don't indent for void elements\n if (!line.match(/<(br|hr|img|input|meta|link|area|base|col|embed|param|source|track|wbr)/i)) {\n // Only indent if not also closing on same line\n if (!line.match(/<\\/\\w+>$/)) {\n indent += indentSize;\n }\n }\n }\n });\n\n return formattedLines.join('\\n');\n },\n\n /**\n * Escape HTML entities for display\n * @param {string} html - HTML string\n * @returns {string} Escaped HTML\n */\n escapeHtml: function (html) {\n const div = document.createElement('div');\n div.textContent = html;\n return div.innerHTML;\n },\n\n /**\n * Apply syntax highlighting to HTML\n * @param {string} html - Escaped HTML string\n * @returns {string} HTML with syntax highlighting spans\n */\n highlightHtml: function (html) {\n // Highlight HTML tags\n html = html.replace(/(<\\/?)([\\w-]+)/g, '$1$2');\n\n // Highlight attributes\n html = html.replace(/([\\w-]+)(=)("|')/g, '$1$2$3');\n\n // Highlight attribute values (strings)\n html = html.replace(/("|')([^&]*)("|')/g, '$1$2$3');\n\n // Highlight comments\n html = html.replace(/(<!--)(.*?)(-->)/g, '$1$2$3');\n\n return html;\n },\n\n /**\n * Apply syntax highlighting to CSS\n * @param {string} css - CSS string\n * @returns {string} CSS with syntax highlighting spans\n */\n highlightCss: function (css) {\n // Highlight selectors \u2014 use non-backtracking bounded pattern\n css = css.replace(/([.#]?[a-zA-Z][a-zA-Z0-9_-]{0,200})(\\s*\\{)/g, '$1$2');\n\n // Highlight properties \u2014 use non-backtracking bounded pattern\n css = css.replace(/([a-zA-Z][a-zA-Z0-9_-]{0,200})(\\s*:)/g, '$1$2');\n\n // Highlight values\n css = css.replace(/:\\s*([^;{}]+)(;)/g, ': $1$2');\n\n // Highlight units\n css = css.replace(/(\\d+)(px|rem|em|%|vh|vw|deg|s|ms)/g, '$1$2');\n\n // Highlight comments\n css = css.replace(/(\\/\\*[\\s\\S]*?\\*\\/)/g, '$1');\n\n return css;\n },\n\n /**\n * Apply syntax highlighting to JavaScript\n * @param {string} js - JavaScript string\n * @returns {string} JS with syntax highlighting spans\n */\n highlightJs: function (js) {\n // Highlight keywords\n const keywords = ['const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while', 'switch', 'case', 'break', 'continue', 'new', 'this', 'class', 'extends', 'import', 'export', 'default', 'async', 'await', 'try', 'catch', 'throw', 'typeof', 'instanceof'];\n keywords.forEach(kw => {\n const regex = new RegExp(`\\\\b(${kw})\\\\b`, 'g');\n js = js.replace(regex, '$1');\n });\n\n // Highlight strings (limit to 10 000 chars to prevent polynomial backtracking)\n js = js.replace(/('(?:[^'\\\\]|\\\\.){0,10000}'|\"(?:[^\"\\\\]|\\\\.){0,10000}\"|`(?:[^`\\\\]|\\\\.){0,10000}`)/g, '$1');\n\n // Highlight numbers\n js = js.replace(/\\b(\\d+\\.?\\d*)\\b/g, '$1');\n\n // Highlight function calls\n js = js.replace(/\\b([\\w]+)(\\s*\\()/g, '$1$2');\n\n // Highlight comments\n js = js.replace(/(\\/\\/.*$)/gm, '$1');\n js = js.replace(/(\\/\\*[\\s\\S]*?\\*\\/)/g, '$1');\n\n return js;\n },\n\n /**\n * Add line numbers to a code pane\n * @param {HTMLElement} pane - Code pane element\n */\n addLineNumbers: function (pane) {\n const code = pane.querySelector('code');\n if (!code) return;\n\n const lines = code.innerHTML.split('\\n');\n const lineCount = lines.length;\n\n // Create line numbers container\n const lineNumbers = document.createElement('div');\n lineNumbers.className = 'vd-code-snippet-line-numbers';\n lineNumbers.setAttribute('aria-hidden', 'true');\n\n for (let i = 1; i <= lineCount; i++) {\n const lineNum = document.createElement('span');\n lineNum.textContent = i;\n lineNumbers.appendChild(lineNum);\n }\n\n // Wrap code content\n const codeWrapper = document.createElement('div');\n codeWrapper.className = 'vd-code-snippet-code';\n codeWrapper.appendChild(code.cloneNode(true));\n\n // Replace code with new structure\n code.parentNode.removeChild(code);\n pane.appendChild(lineNumbers);\n pane.appendChild(codeWrapper);\n },\n\n /**\n * Programmatically expand a code snippet\n * @param {string|HTMLElement} snippet - Snippet selector or element\n */\n expand: function (snippet) {\n if (typeof snippet === 'string') {\n snippet = document.querySelector(snippet);\n }\n if (!snippet) return;\n\n snippet.dataset.expanded = 'true';\n const toggle = snippet.querySelector('.vd-code-snippet-toggle');\n const content = snippet.querySelector('.vd-code-snippet-content');\n\n if (toggle) toggle.setAttribute('aria-expanded', 'true');\n if (content) content.dataset.visible = 'true';\n },\n\n /**\n * Programmatically collapse a code snippet\n * @param {string|HTMLElement} snippet - Snippet selector or element\n */\n collapse: function (snippet) {\n if (typeof snippet === 'string') {\n snippet = document.querySelector(snippet);\n }\n if (!snippet) return;\n\n snippet.dataset.expanded = 'false';\n const toggle = snippet.querySelector('.vd-code-snippet-toggle');\n const content = snippet.querySelector('.vd-code-snippet-content');\n\n if (toggle) toggle.setAttribute('aria-expanded', 'false');\n if (content) content.dataset.visible = 'false';\n },\n\n /**\n * Programmatically switch to a specific language tab\n * @param {string|HTMLElement} snippet - Snippet selector or element\n * @param {string} lang - Language to switch to (html, css, js)\n */\n showLang: function (snippet, lang) {\n if (typeof snippet === 'string') {\n snippet = document.querySelector(snippet);\n }\n if (!snippet) return;\n\n const tab = snippet.querySelector(`.vd-code-snippet-tab[data-lang=\"${lang}\"]`);\n const tabs = snippet.querySelectorAll('.vd-code-snippet-tab');\n const panes = snippet.querySelectorAll('.vd-code-snippet-pane');\n\n if (tab) {\n this.switchTab(snippet, tab, tabs, panes);\n }\n },\n\n /**\n * Destroy a code snippet instance and clean up listeners\n * @param {string|HTMLElement} snippet - Snippet selector or element\n */\n destroy: function (snippet) {\n if (typeof snippet === 'string') {\n snippet = document.querySelector(snippet);\n }\n if (!snippet) return;\n\n if (snippet._codeSnippetCleanup) {\n snippet._codeSnippetCleanup.forEach(fn => fn());\n delete snippet._codeSnippetCleanup;\n }\n\n delete snippet.dataset.initialized;\n },\n\n /**\n * Destroy all code snippet instances\n */\n destroyAll: function () {\n const snippets = document.querySelectorAll('.vd-code-snippet[data-initialized=\"true\"]');\n snippets.forEach(snippet => this.destroy(snippet));\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('codeSnippet', CodeSnippet);\n }\n\n // Also expose globally for convenience\n window.CodeSnippet = CodeSnippet;\n\n})();\n", "/**\n * Vanduo Framework - Collapsible Component\n * JavaScript functionality for collapsible/accordion components\n */\n\n(function() {\n 'use strict';\n\n /**\n * Collapsible Component\n */\n const Collapsible = {\n // Store initialized containers and their cleanup functions\n instances: new Map(),\n\n /**\n * Initialize collapsible components\n */\n init: function() {\n const collapsibles = document.querySelectorAll('.vd-collapsible, .accordion');\n\n collapsibles.forEach(container => {\n if (this.instances.has(container)) {\n return;\n }\n this.initCollapsible(container);\n });\n },\n\n /**\n * Initialize a collapsible container\n * @param {HTMLElement} container - Collapsible container\n */\n initCollapsible: function(container) {\n const isAccordion = container.classList.contains('accordion');\n const items = container.querySelectorAll('.vd-collapsible-item, .accordion-item');\n const cleanupFunctions = [];\n\n items.forEach(item => {\n const header = item.querySelector('.vd-collapsible-header, .accordion-header');\n const body = item.querySelector('.vd-collapsible-body, .accordion-body');\n const trigger = item.querySelector('.vd-collapsible-trigger, .accordion-trigger') || header;\n\n if (!header || !body) {\n return;\n }\n\n // Set initial state\n if (item.classList.contains('is-open')) {\n this.openItem(item, body, false);\n } else {\n this.closeItem(item, body, false);\n }\n\n // Add click handler\n const clickHandler = (e) => {\n e.preventDefault();\n this.toggleItem(item, body, container, isAccordion);\n };\n trigger.addEventListener('click', clickHandler);\n cleanupFunctions.push(() => trigger.removeEventListener('click', clickHandler));\n });\n\n this.instances.set(container, { cleanup: cleanupFunctions });\n },\n \n /**\n * Toggle collapsible item\n * @param {HTMLElement} item - Collapsible item\n * @param {HTMLElement} body - Collapsible body\n * @param {HTMLElement} container - Collapsible container\n * @param {boolean} isAccordion - Whether in accordion mode\n */\n toggleItem: function(item, body, container, isAccordion) {\n const isOpen = item.classList.contains('is-open');\n \n if (isOpen) {\n this.closeItem(item, body);\n } else {\n // If accordion mode, close other open items\n if (isAccordion) {\n const otherOpenItems = container.querySelectorAll('.vd-collapsible-item.is-open, .accordion-item.is-open');\n otherOpenItems.forEach(otherItem => {\n if (otherItem !== item) {\n const otherBody = otherItem.querySelector('.vd-collapsible-body, .accordion-body');\n this.closeItem(otherItem, otherBody);\n }\n });\n }\n \n this.openItem(item, body);\n }\n },\n \n /**\n * Open collapsible item\n * @param {HTMLElement} item - Collapsible item\n * @param {HTMLElement} body - Collapsible body\n * @param {boolean} animate - Whether to animate\n */\n openItem: function(item, body, animate = true) {\n if (!animate) {\n body.style.transition = 'none';\n }\n \n item.classList.add('is-open');\n item.setAttribute('aria-expanded', 'true');\n \n // Set max-height to actual height\n const height = body.scrollHeight;\n body.style.maxHeight = `${height}px`;\n \n // Reset transition after a brief delay\n if (!animate) {\n setTimeout(() => {\n body.style.transition = '';\n }, 0);\n }\n \n // Dispatch event\n item.dispatchEvent(new CustomEvent('collapsible:open', { bubbles: true }));\n },\n \n /**\n * Close collapsible item\n * @param {HTMLElement} item - Collapsible item\n * @param {HTMLElement} body - Collapsible body\n * @param {boolean} animate - Whether to animate\n */\n closeItem: function(item, body, animate = true) {\n if (!animate) {\n body.style.transition = 'none';\n }\n \n item.classList.remove('is-open');\n item.setAttribute('aria-expanded', 'false');\n body.style.maxHeight = '0';\n \n // Reset transition after a brief delay\n if (!animate) {\n setTimeout(() => {\n body.style.transition = '';\n }, 0);\n }\n \n // Dispatch event\n item.dispatchEvent(new CustomEvent('collapsible:close', { bubbles: true }));\n },\n \n /**\n * Open item programmatically\n * @param {HTMLElement|string} item - Collapsible item or selector\n */\n open: function(item) {\n const el = typeof item === 'string' ? document.querySelector(item) : item;\n if (el) {\n const body = el.querySelector('.vd-collapsible-body, .accordion-body');\n if (body) {\n this.openItem(el, body);\n }\n }\n },\n \n /**\n * Close item programmatically\n * @param {HTMLElement|string} item - Collapsible item or selector\n */\n close: function(item) {\n const el = typeof item === 'string' ? document.querySelector(item) : item;\n if (el) {\n const body = el.querySelector('.vd-collapsible-body, .accordion-body');\n if (body) {\n this.closeItem(el, body);\n }\n }\n },\n \n /**\n * Toggle item programmatically\n * @param {HTMLElement|string} item - Collapsible item or selector\n */\n toggle: function(item) {\n const el = typeof item === 'string' ? document.querySelector(item) : item;\n if (el) {\n const body = el.querySelector('.vd-collapsible-body, .accordion-body');\n const container = el.closest('.vd-collapsible, .accordion');\n const isAccordion = container && container.classList.contains('accordion');\n\n if (body) {\n this.toggleItem(el, body, container, isAccordion);\n }\n }\n },\n\n /**\n * Destroy a collapsible instance and clean up event listeners\n * @param {HTMLElement} container - Collapsible container\n */\n destroy: function(container) {\n const instance = this.instances.get(container);\n if (!instance) return;\n\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(container);\n },\n\n /**\n * Destroy all collapsible instances\n */\n destroyAll: function() {\n this.instances.forEach((instance, container) => {\n this.destroy(container);\n });\n }\n };\n \n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('collapsible', Collapsible);\n }\n \n // Expose globally\n window.VanduoCollapsible = Collapsible;\n \n})();\n\n", "/**\n * Vanduo Framework - Dropdown Component\n * JavaScript functionality for dropdown menus\n */\n\n(function() {\n 'use strict';\n\n /**\n * Dropdown Component\n */\n const Dropdown = {\n // Store initialized dropdowns and their cleanup functions\n instances: new Map(),\n\n /**\n * Initialize dropdown components\n */\n init: function() {\n const dropdowns = document.querySelectorAll('.vd-dropdown');\n\n dropdowns.forEach(dropdown => {\n if (this.instances.has(dropdown)) {\n return;\n }\n this.initDropdown(dropdown);\n });\n },\n\n /**\n * Initialize a single dropdown\n * @param {HTMLElement} dropdown - Dropdown container\n */\n initDropdown: function(dropdown) {\n const toggle = dropdown.querySelector('.vd-dropdown-toggle');\n const menu = dropdown.querySelector('.vd-dropdown-menu');\n\n if (!toggle || !menu) {\n return;\n }\n\n const cleanupFunctions = [];\n\n // Set ARIA attributes\n toggle.setAttribute('aria-haspopup', 'true');\n toggle.setAttribute('aria-expanded', 'false');\n menu.setAttribute('role', 'menu');\n menu.setAttribute('aria-hidden', 'true');\n\n // Toggle on click\n const toggleClickHandler = (e) => {\n e.preventDefault();\n e.stopPropagation();\n this.toggleDropdown(dropdown, toggle, menu);\n };\n toggle.addEventListener('click', toggleClickHandler);\n cleanupFunctions.push(() => toggle.removeEventListener('click', toggleClickHandler));\n\n // Close on outside click\n const documentClickHandler = (e) => {\n if (!dropdown.contains(e.target) && menu.classList.contains('is-open')) {\n this.closeDropdown(dropdown, toggle, menu);\n }\n };\n document.addEventListener('click', documentClickHandler);\n cleanupFunctions.push(() => document.removeEventListener('click', documentClickHandler));\n\n // Keyboard navigation\n const keydownHandler = (e) => {\n this.handleKeydown(e, dropdown, toggle, menu);\n };\n toggle.addEventListener('keydown', keydownHandler);\n cleanupFunctions.push(() => toggle.removeEventListener('keydown', keydownHandler));\n\n // Handle item clicks\n const items = menu.querySelectorAll('.vd-dropdown-item:not(.disabled):not(.is-disabled)');\n items.forEach(item => {\n const itemClickHandler = (e) => {\n e.preventDefault();\n this.selectItem(item, dropdown, toggle, menu);\n };\n item.addEventListener('click', itemClickHandler);\n cleanupFunctions.push(() => item.removeEventListener('click', itemClickHandler));\n\n const itemKeydownHandler = (e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n this.selectItem(item, dropdown, toggle, menu);\n }\n };\n item.addEventListener('keydown', itemKeydownHandler);\n cleanupFunctions.push(() => item.removeEventListener('keydown', itemKeydownHandler));\n });\n\n this.instances.set(dropdown, { toggle, menu, cleanup: cleanupFunctions, typeaheadBuffer: '', typeaheadTimer: null });\n },\n \n /**\n * Toggle dropdown\n * @param {HTMLElement} dropdown - Dropdown container\n * @param {HTMLElement} toggle - Toggle button\n * @param {HTMLElement} menu - Dropdown menu\n */\n toggleDropdown: function(dropdown, toggle, menu) {\n const isOpen = menu.classList.contains('is-open');\n \n if (isOpen) {\n this.closeDropdown(dropdown, toggle, menu);\n } else {\n this.openDropdown(dropdown, toggle, menu);\n }\n },\n \n /**\n * Open dropdown\n * @param {HTMLElement} dropdown - Dropdown container\n * @param {HTMLElement} toggle - Toggle button\n * @param {HTMLElement} menu - Dropdown menu\n */\n openDropdown: function(dropdown, toggle, menu) {\n // Close other open dropdowns\n const otherOpen = document.querySelectorAll('.vd-dropdown-menu.is-open');\n otherOpen.forEach(otherMenu => {\n if (otherMenu !== menu) {\n const otherDropdown = otherMenu.closest('.vd-dropdown');\n const otherToggle = otherDropdown.querySelector('.vd-dropdown-toggle');\n this.closeDropdown(otherDropdown, otherToggle, otherMenu);\n }\n });\n \n dropdown.classList.add('is-open');\n menu.classList.add('is-open');\n toggle.setAttribute('aria-expanded', 'true');\n menu.setAttribute('aria-hidden', 'false');\n \n // Position menu\n this.positionMenu(dropdown, menu);\n \n // Focus first item\n const firstItem = menu.querySelector('.vd-dropdown-item:not(.disabled):not(.is-disabled)');\n if (firstItem) {\n setTimeout(() => firstItem.focus(), 0);\n }\n },\n \n /**\n * Close dropdown\n * @param {HTMLElement} dropdown - Dropdown container\n * @param {HTMLElement} toggle - Toggle button\n * @param {HTMLElement} menu - Dropdown menu\n */\n closeDropdown: function(dropdown, toggle, menu) {\n dropdown.classList.remove('is-open');\n menu.classList.remove('is-open');\n toggle.setAttribute('aria-expanded', 'false');\n menu.setAttribute('aria-hidden', 'true');\n \n // Return focus to toggle\n toggle.focus();\n },\n \n /**\n * Position dropdown menu\n * @param {HTMLElement} dropdown - Dropdown container\n * @param {HTMLElement} menu - Dropdown menu\n */\n positionMenu: function(dropdown, menu) {\n const rect = dropdown.getBoundingClientRect();\n const menuRect = menu.getBoundingClientRect();\n const viewportWidth = window.innerWidth;\n const viewportHeight = window.innerHeight;\n const padding = 8;\n \n // Check if menu overflows right\n if (rect.left + menuRect.width > viewportWidth - padding) {\n menu.classList.add('vd-dropdown-menu-end');\n menu.classList.remove('vd-dropdown-menu-start');\n }\n \n // Check if menu overflows bottom (for top positioning)\n if (menu.classList.contains('dropdown-menu-top')) {\n if (rect.top - menuRect.height < padding) {\n menu.classList.remove('vd-dropdown-menu-top');\n }\n } else {\n if (rect.bottom + menuRect.height > viewportHeight - padding) {\n menu.classList.add('vd-dropdown-menu-top');\n }\n }\n },\n \n /**\n * Handle keyboard navigation\n * @param {KeyboardEvent} e - Keyboard event\n * @param {HTMLElement} dropdown - Dropdown container\n * @param {HTMLElement} toggle - Toggle button\n * @param {HTMLElement} menu - Dropdown menu\n */\n handleKeydown: function(e, dropdown, toggle, menu) {\n const isOpen = menu.classList.contains('is-open');\n const items = Array.from(menu.querySelectorAll('.vd-dropdown-item:not(.disabled):not(.is-disabled)'));\n const currentIndex = items.findIndex(item => item === document.activeElement);\n \n switch (e.key) {\n case 'Enter':\n case ' ':\n case 'ArrowDown':\n e.preventDefault();\n if (!isOpen) {\n this.openDropdown(dropdown, toggle, menu);\n } else if (e.key === 'ArrowDown') {\n const nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;\n items[nextIndex].focus();\n }\n break;\n \n case 'ArrowUp':\n if (isOpen) {\n e.preventDefault();\n const prevIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;\n items[prevIndex].focus();\n }\n break;\n \n case 'Escape':\n if (isOpen) {\n e.preventDefault();\n this.closeDropdown(dropdown, toggle, menu);\n }\n break;\n \n case 'Home':\n if (isOpen) {\n e.preventDefault();\n items[0].focus();\n }\n break;\n \n case 'End':\n if (isOpen) {\n e.preventDefault();\n items[items.length - 1].focus();\n }\n break;\n\n default:\n // Typeahead: jump to matching item when typing printable characters\n if (isOpen && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {\n // Per-instance typeahead state to avoid cross-instance corruption\n const instance = this.instances.get(dropdown);\n if (!instance) break;\n clearTimeout(instance.typeaheadTimer);\n instance.typeaheadBuffer += e.key.toLowerCase();\n\n const match = items.find(item =>\n item.textContent.trim().toLowerCase().startsWith(instance.typeaheadBuffer)\n );\n if (match) {\n match.focus();\n }\n\n instance.typeaheadTimer = setTimeout(() => {\n instance.typeaheadBuffer = '';\n }, 500);\n }\n break;\n }\n },\n \n /**\n * Select dropdown item\n * @param {HTMLElement} item - Dropdown item\n * @param {HTMLElement} dropdown - Dropdown container\n * @param {HTMLElement} toggle - Toggle button\n * @param {HTMLElement} menu - Dropdown menu\n */\n selectItem: function(item, dropdown, toggle, menu) {\n // Remove active from all items\n menu.querySelectorAll('.vd-dropdown-item').forEach(i => {\n i.classList.remove('active', 'is-active');\n });\n \n // Add active to selected item\n item.classList.add('active', 'is-active');\n \n // Update toggle text if it's a button\n if (toggle.tagName === 'BUTTON' || toggle.classList.contains('btn')) {\n toggle.textContent = item.textContent.trim();\n }\n \n // Close dropdown\n this.closeDropdown(dropdown, toggle, menu);\n \n // Dispatch event\n item.dispatchEvent(new CustomEvent('dropdown:select', { \n bubbles: true,\n detail: { item, value: item.dataset.value || item.textContent }\n }));\n },\n \n /**\n * Open dropdown programmatically\n * @param {HTMLElement|string} dropdown - Dropdown container or selector\n */\n open: function(dropdown) {\n const el = typeof dropdown === 'string' ? document.querySelector(dropdown) : dropdown;\n if (el) {\n const toggle = el.querySelector('.vd-dropdown-toggle');\n const menu = el.querySelector('.vd-dropdown-menu');\n if (toggle && menu) {\n this.openDropdown(el, toggle, menu);\n }\n }\n },\n \n /**\n * Close dropdown programmatically\n * @param {HTMLElement|string} dropdown - Dropdown container or selector\n */\n close: function(dropdown) {\n const el = typeof dropdown === 'string' ? document.querySelector(dropdown) : dropdown;\n if (el) {\n const toggle = el.querySelector('.vd-dropdown-toggle');\n const menu = el.querySelector('.vd-dropdown-menu');\n if (toggle && menu) {\n this.closeDropdown(el, toggle, menu);\n }\n }\n },\n\n /**\n * Destroy a dropdown instance and clean up event listeners\n * @param {HTMLElement} dropdown - Dropdown element\n */\n destroy: function(dropdown) {\n const instance = this.instances.get(dropdown);\n if (!instance) return;\n\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(dropdown);\n },\n\n /**\n * Destroy all dropdown instances\n */\n destroyAll: function() {\n this.instances.forEach((instance, dropdown) => {\n this.destroy(dropdown);\n });\n }\n };\n \n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('dropdown', Dropdown);\n }\n \n // Expose globally\n window.VanduoDropdown = Dropdown;\n \n})();\n\n", "/**\n * Vanduo Framework - Font Switcher\n * Handles font selection and persistence for previewing different typefaces\n */\n\n(function() {\n 'use strict';\n\n const FontSwitcher = {\n STORAGE_KEY: 'vanduo-font-preference',\n isInitialized: false,\n\n // Available fonts configuration\n fonts: {\n 'system': {\n name: 'System Default',\n family: null // Uses CSS default\n },\n 'inter': {\n name: 'Inter',\n family: \"'Inter', sans-serif\"\n },\n 'source-sans': {\n name: 'Source Sans 3',\n family: \"'Source Sans 3', sans-serif\"\n },\n 'fira-sans': {\n name: 'Fira Sans',\n family: \"'Fira Sans', sans-serif\"\n },\n 'ibm-plex': {\n name: 'IBM Plex Sans',\n family: \"'IBM Plex Sans', sans-serif\"\n },\n 'jetbrains-mono': {\n name: 'JetBrains Mono',\n family: \"'JetBrains Mono', monospace\"\n },\n 'ubuntu': {\n name: 'Ubuntu',\n family: \"'Ubuntu', sans-serif\",\n category: 'sans-serif',\n description: 'Friendly, humanist sans-serif'\n },\n 'open-sans': {\n name: 'Open Sans',\n family: \"'Open Sans', sans-serif\",\n category: 'sans-serif',\n description: 'Neutral, highly readable'\n },\n 'rubik': {\n name: 'Rubik',\n family: \"'Rubik', sans-serif\",\n category: 'sans-serif',\n description: 'Modern, geometric'\n },\n 'titillium-web': {\n name: 'Titillium Web',\n family: \"'Titillium Web', sans-serif\",\n category: 'sans-serif',\n description: 'Technical, elegant'\n }\n },\n\n init: function() {\n this.state = {\n preference: this.getPreference()\n };\n\n if (this.isInitialized) {\n this.applyFont();\n this.renderUI();\n this.updateUI();\n return;\n }\n\n this.isInitialized = true;\n\n this.applyFont();\n this.renderUI();\n\n console.log('Vanduo Font Switcher initialized');\n },\n\n /**\n * Get saved font preference from localStorage\n * @returns {string} Font key or 'ubuntu' (default)\n */\n getPreference: function() {\n return this.getStorageValue(this.STORAGE_KEY, 'ubuntu');\n },\n\n /**\n * Set font preference and apply it\n * @param {string} fontKey - The font key to apply\n */\n setPreference: function(fontKey) {\n if (!this.fonts[fontKey]) {\n console.warn('Unknown font:', fontKey);\n return;\n }\n\n this.state.preference = fontKey;\n this.setStorageValue(this.STORAGE_KEY, fontKey);\n this.applyFont();\n this.updateUI();\n\n // Dispatch custom event for other components to listen to\n const event = new CustomEvent('font:change', {\n bubbles: true,\n detail: { font: fontKey, fontData: this.fonts[fontKey] }\n });\n document.dispatchEvent(event);\n },\n\n /**\n * Apply the current font preference to the document\n */\n applyFont: function() {\n const fontKey = this.state.preference;\n\n if (fontKey === 'system') {\n // Remove data-font attribute to use system default\n document.documentElement.removeAttribute('data-font');\n } else {\n // Set data-font attribute which triggers CSS variable override\n document.documentElement.setAttribute('data-font', fontKey);\n }\n },\n\n /**\n * Initialize UI elements with data-toggle=\"font\"\n */\n renderUI: function() {\n const toggles = document.querySelectorAll('[data-toggle=\"font\"]');\n\n toggles.forEach(toggle => {\n if (toggle.getAttribute('data-font-initialized') === 'true') {\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n }\n return;\n }\n\n if (toggle.tagName === 'SELECT') {\n // Set initial value\n toggle.value = this.state.preference;\n\n // Listen for changes\n const onChange = (e) => {\n this.setPreference(e.target.value);\n };\n toggle.addEventListener('change', onChange);\n toggle._fontToggleHandler = onChange;\n } else {\n // Button implementation - cycle through fonts\n const onClick = () => {\n const fontKeys = Object.keys(this.fonts);\n const currentIndex = fontKeys.indexOf(this.state.preference);\n const nextIndex = (currentIndex + 1) % fontKeys.length;\n this.setPreference(fontKeys[nextIndex]);\n };\n toggle.addEventListener('click', onClick);\n toggle._fontToggleHandler = onClick;\n }\n\n toggle.setAttribute('data-font-initialized', 'true');\n });\n },\n\n /**\n * Update all UI elements to reflect current state\n */\n updateUI: function() {\n const toggles = document.querySelectorAll('[data-toggle=\"font\"]');\n\n toggles.forEach(toggle => {\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n } else {\n // Update button text if it has a label span\n const label = toggle.querySelector('.font-current-label');\n if (label) {\n label.textContent = this.fonts[this.state.preference].name;\n }\n }\n });\n },\n\n /**\n * Get the current font preference\n * @returns {string} Current font key\n */\n getCurrentFont: function() {\n return this.state.preference;\n },\n\n /**\n * Get font data for a given key\n * @param {string} fontKey - The font key\n * @returns {Object|null} Font data or null\n */\n getFontData: function(fontKey) {\n return this.fonts[fontKey] || null;\n },\n\n destroyAll: function() {\n const toggles = document.querySelectorAll('[data-toggle=\"font\"][data-font-initialized=\"true\"]');\n toggles.forEach(toggle => {\n if (toggle._fontToggleHandler) {\n const eventName = toggle.tagName === 'SELECT' ? 'change' : 'click';\n toggle.removeEventListener(eventName, toggle._fontToggleHandler);\n delete toggle._fontToggleHandler;\n }\n toggle.removeAttribute('data-font-initialized');\n });\n\n this.isInitialized = false;\n },\n\n getStorageValue: function(key, fallback) {\n if (typeof window.safeStorageGet === 'function') {\n return window.safeStorageGet(key, fallback);\n }\n try {\n const value = localStorage.getItem(key);\n return value !== null ? value : fallback;\n } catch (_e) {\n return fallback;\n }\n },\n\n setStorageValue: function(key, value) {\n if (typeof window.safeStorageSet === 'function') {\n return window.safeStorageSet(key, value);\n }\n try {\n localStorage.setItem(key, value);\n return true;\n } catch (_e) {\n return false;\n }\n }\n };\n\n // Register component\n if (window.Vanduo) {\n window.Vanduo.register('fontSwitcher', FontSwitcher);\n }\n\n // Expose globally for convenience\n window.FontSwitcher = FontSwitcher;\n})();\n", "/**\n * Vanduo Framework - Grid Layout Component\n * Toggle between standard 12-column and Fibonacci grid modes\n * via data-layout-mode attribute and toggle buttons\n */\n\n(function () {\n 'use strict';\n\n const supportsHas = (function () {\n try {\n return CSS.supports('selector(:has(*))');\n } catch (_e) {\n return false;\n }\n })();\n\n /**\n * Grid Layout Component\n */\n const GridLayout = {\n instances: new Map(),\n\n /**\n * Initialize all grid layout containers\n */\n init: function () {\n const containers = document.querySelectorAll('[data-layout-mode]');\n\n containers.forEach(function (container) {\n if (this.instances.has(container)) {\n return;\n }\n this.initContainer(container);\n }.bind(this));\n\n this.initToggleButtons();\n },\n\n /**\n * Initialize a single grid container\n * @param {HTMLElement} container - Element with data-layout-mode\n */\n initContainer: function (container) {\n const mode = container.getAttribute('data-layout-mode') || 'standard';\n const cleanupFunctions = [];\n\n this.applyMode(container, mode);\n\n container.setAttribute('role', 'region');\n container.setAttribute('aria-label', 'Grid layout: ' + mode + ' mode');\n\n this.instances.set(container, {\n cleanup: cleanupFunctions,\n mode: mode\n });\n },\n\n /**\n * Initialize toggle buttons that target grid containers\n */\n initToggleButtons: function () {\n const toggleButtons = document.querySelectorAll('[data-grid-toggle]');\n\n toggleButtons.forEach(function (button) {\n if (button.getAttribute('data-grid-initialized') === 'true') {\n return;\n }\n\n const clickHandler = function (e) {\n e.preventDefault();\n const targetSelector = button.getAttribute('data-grid-toggle');\n let target;\n\n if (targetSelector) {\n target = document.querySelector(targetSelector);\n } else {\n target = button.closest('[data-layout-mode]');\n }\n\n if (target) {\n this.toggle(target);\n }\n }.bind(this);\n\n button.addEventListener('click', clickHandler);\n button.setAttribute('data-grid-initialized', 'true');\n button.setAttribute('aria-pressed', 'false');\n\n button._gridCleanup = function () {\n button.removeEventListener('click', clickHandler);\n button.removeAttribute('data-grid-initialized');\n button.removeAttribute('aria-pressed');\n };\n }.bind(this));\n },\n\n /**\n * Apply Fibonacci grid-template-columns inline for browsers without :has()\n * @param {HTMLElement} container - Grid container\n */\n applyFibFallback: function (container) {\n if (supportsHas) return;\n\n const rows = container.querySelectorAll('.vd-row, .row');\n rows.forEach(function (row) {\n const cols = row.querySelectorAll(':scope > [class*=\"vd-col-\"], :scope > [class*=\"col-\"]');\n const count = cols.length;\n\n if (count === 1) {\n row.style.gridTemplateColumns = '1fr';\n } else if (count === 2) {\n row.style.gridTemplateColumns = '1fr 1.618fr';\n } else if (count === 3) {\n row.style.gridTemplateColumns = '2fr 3fr 5fr';\n } else if (count === 4) {\n row.style.gridTemplateColumns = '1fr 2fr 3fr 5fr';\n } else {\n row.style.gridTemplateColumns = 'repeat(' + count + ', 1fr)';\n }\n });\n },\n\n /**\n * Remove inline grid-template-columns fallback\n * @param {HTMLElement} container - Grid container\n */\n removeFibFallback: function (container) {\n const rows = container.querySelectorAll('.vd-row, .row');\n rows.forEach(function (row) {\n row.style.gridTemplateColumns = '';\n });\n },\n\n /**\n * Apply a layout mode to a container\n * @param {HTMLElement} container - Target container\n * @param {string} mode - 'fibonacci' or 'standard'\n */\n applyMode: function (container, mode) {\n container.classList.remove('vd-grid-standard', 'vd-grid-fibonacci');\n\n if (mode === 'fibonacci') {\n container.classList.add('vd-grid-fibonacci');\n this.applyFibFallback(container);\n } else {\n container.classList.add('vd-grid-standard');\n this.removeFibFallback(container);\n }\n\n container.setAttribute('data-layout-mode', mode);\n container.setAttribute('aria-label', 'Grid layout: ' + mode + ' mode');\n\n // Update associated toggle button states\n const toggleButtons = document.querySelectorAll('[data-grid-toggle]');\n toggleButtons.forEach(function (btn) {\n const targetSelector = btn.getAttribute('data-grid-toggle');\n if (targetSelector && container.matches(targetSelector)) {\n const isActive = (mode === 'fibonacci');\n if (isActive) {\n btn.classList.add('is-active');\n } else {\n btn.classList.remove('is-active');\n }\n btn.setAttribute('aria-pressed', isActive ? 'true' : 'false');\n }\n });\n\n // Store mode in instance\n const instance = this.instances.get(container);\n if (instance) {\n instance.mode = mode;\n }\n\n // Dispatch custom event\n let event;\n try {\n event = new CustomEvent('grid:modechange', {\n bubbles: true,\n detail: {\n container: container,\n mode: mode\n }\n });\n } catch (_e) {\n event = document.createEvent('CustomEvent');\n event.initCustomEvent('grid:modechange', true, true, {\n container: container,\n mode: mode\n });\n }\n container.dispatchEvent(event);\n },\n\n /**\n * Toggle between standard and fibonacci modes\n * @param {HTMLElement|string} container - Container element or selector\n */\n toggle: function (container) {\n if (typeof container === 'string') {\n container = document.querySelector(container);\n }\n if (!container) return;\n\n const currentMode = container.getAttribute('data-layout-mode') || 'standard';\n const newMode = (currentMode === 'fibonacci') ? 'standard' : 'fibonacci';\n this.applyMode(container, newMode);\n },\n\n /**\n * Set a specific mode\n * @param {HTMLElement|string} container - Container element or selector\n * @param {string} mode - 'fibonacci' or 'standard'\n */\n setMode: function (container, mode) {\n if (typeof container === 'string') {\n container = document.querySelector(container);\n }\n if (!container) return;\n if (mode !== 'fibonacci' && mode !== 'standard') return;\n\n this.applyMode(container, mode);\n },\n\n /**\n * Get the current mode of a container\n * @param {HTMLElement|string} container - Container element or selector\n * @returns {string|null} Current mode or null\n */\n getMode: function (container) {\n if (typeof container === 'string') {\n container = document.querySelector(container);\n }\n if (!container) return null;\n return container.getAttribute('data-layout-mode') || 'standard';\n },\n\n /**\n * Destroy a single grid layout instance\n * @param {HTMLElement} container - Grid container\n */\n destroy: function (container) {\n const instance = this.instances.get(container);\n if (!instance) return;\n\n instance.cleanup.forEach(function (fn) { fn(); });\n container.classList.remove('vd-grid-standard', 'vd-grid-fibonacci');\n container.removeAttribute('aria-label');\n this.removeFibFallback(container);\n this.instances.delete(container);\n },\n\n /**\n * Destroy all grid layout instances and clean up toggle buttons\n */\n destroyAll: function () {\n this.instances.forEach(function (instance, container) {\n this.destroy(container);\n }.bind(this));\n\n const toggleButtons = document.querySelectorAll('[data-grid-initialized=\"true\"]');\n toggleButtons.forEach(function (button) {\n if (button._gridCleanup) {\n button._gridCleanup();\n delete button._gridCleanup;\n }\n });\n }\n };\n\n // Register with Vanduo framework\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('gridLayout', GridLayout);\n }\n\n // Expose globally\n window.VanduoGridLayout = GridLayout;\n\n})();\n", "/**\n * Vanduo Framework - Image Box Component\n * Lightbox-style image enlargement with smooth transitions\n * \n * Features:\n * - Click to enlarge images with data-image-box attribute\n * - Smooth scale and opacity transitions\n * - Dismiss via click, ESC key, or scroll\n * - Magnifying glass cursor on hover\n * - Accessible with ARIA attributes\n * - Reduced motion support\n */\n\n(function () {\n 'use strict';\n\n /**\n * Image Box Component\n */\n const ImageBox = {\n backdrop: null,\n container: null,\n img: null,\n closeBtn: null,\n caption: null,\n currentTrigger: null,\n scrollThreshold: 50,\n initialScrollY: 0,\n isOpen: false,\n\n // Store cleanup functions for event listeners\n _cleanupFunctions: [],\n\n /**\n * Initialize Image Box component\n */\n init: function () {\n this.createBackdrop();\n this.bindTriggers();\n },\n\n /**\n * Create backdrop elements\n */\n createBackdrop: function () {\n // Prevent duplicate backdrop creation\n if (this.backdrop || document.querySelector('.vd-image-box-backdrop')) {\n // If backdrop already exists in DOM, reuse it\n if (!this.backdrop) {\n this.backdrop = document.querySelector('.vd-image-box-backdrop');\n this.container = this.backdrop.querySelector('.vd-image-box-container');\n this.img = this.backdrop.querySelector('.vd-image-box-img');\n this.closeBtn = this.backdrop.querySelector('.vd-image-box-close');\n this.caption = this.backdrop.querySelector('.vd-image-box-caption');\n this.bindBackdropEvents();\n }\n return;\n }\n\n // Create backdrop\n this.backdrop = document.createElement('div');\n this.backdrop.className = 'vd-image-box-backdrop';\n this.backdrop.setAttribute('role', 'dialog');\n this.backdrop.setAttribute('aria-modal', 'true');\n this.backdrop.setAttribute('aria-label', 'Image viewer');\n this.backdrop.setAttribute('tabindex', '-1');\n\n // Create container\n this.container = document.createElement('div');\n this.container.className = 'vd-image-box-container';\n\n // Create image\n this.img = document.createElement('img');\n this.img.className = 'vd-image-box-img';\n this.img.alt = '';\n\n // Create close button\n this.closeBtn = document.createElement('button');\n this.closeBtn.className = 'vd-image-box-close';\n this.closeBtn.setAttribute('aria-label', 'Close image viewer');\n this.closeBtn.innerHTML = '×';\n\n // Create caption element\n this.caption = document.createElement('div');\n this.caption.className = 'vd-image-box-caption';\n\n // Assemble\n this.container.appendChild(this.img);\n this.backdrop.appendChild(this.closeBtn);\n this.backdrop.appendChild(this.container);\n this.backdrop.appendChild(this.caption);\n document.body.appendChild(this.backdrop);\n\n // Bind backdrop events\n this.bindBackdropEvents();\n },\n\n /**\n * Bind events to backdrop elements\n */\n bindBackdropEvents: function () {\n const self = this;\n\n // Close on backdrop click (but not when clicking the image)\n const backdropClickHandler = function (e) {\n if (e.target === self.backdrop || e.target === self.container) {\n self.close();\n }\n };\n this.backdrop.addEventListener('click', backdropClickHandler);\n this._cleanupFunctions.push(() => this.backdrop.removeEventListener('click', backdropClickHandler));\n\n // Close on image click\n const imgClickHandler = function () {\n self.close();\n };\n this.img.addEventListener('click', imgClickHandler);\n this._cleanupFunctions.push(() => this.img.removeEventListener('click', imgClickHandler));\n\n // Close on close button click\n const closeBtnHandler = function () {\n self.close();\n };\n this.closeBtn.addEventListener('click', closeBtnHandler);\n this._cleanupFunctions.push(() => this.closeBtn.removeEventListener('click', closeBtnHandler));\n\n // ESC key handler\n const escHandler = function (e) {\n if (e.key === 'Escape' && self.isOpen) {\n self.close();\n }\n };\n document.addEventListener('keydown', escHandler);\n this._cleanupFunctions.push(() => document.removeEventListener('keydown', escHandler));\n\n // Scroll handler for dismissal\n const scrollHandler = function () {\n if (!self.isOpen) return;\n\n const currentScrollY = window.scrollY;\n const scrollDelta = Math.abs(currentScrollY - self.initialScrollY);\n\n if (scrollDelta > self.scrollThreshold) {\n self.close();\n }\n };\n window.addEventListener('scroll', scrollHandler, { passive: true });\n this._cleanupFunctions.push(() => window.removeEventListener('scroll', scrollHandler));\n },\n\n /**\n * Bind triggers to all images with data-image-box attribute\n */\n bindTriggers: function () {\n const self = this;\n const triggers = document.querySelectorAll('[data-image-box]');\n\n triggers.forEach(function (trigger) {\n // Skip if already initialized\n if (trigger.dataset.imageBoxInitialized) return;\n trigger.dataset.imageBoxInitialized = 'true';\n\n // Add trigger class\n trigger.classList.add('vd-image-box-trigger');\n\n // Handle broken images\n if (trigger.tagName === 'IMG') {\n // Check if already in error state\n if (trigger.complete && trigger.naturalWidth === 0) {\n trigger.classList.add('is-broken');\n }\n\n // Listen for error events\n const errorHandler = function () {\n trigger.classList.add('is-broken');\n };\n trigger.addEventListener('error', errorHandler);\n\n // Listen for successful load\n const loadHandler = function () {\n trigger.classList.remove('is-broken');\n };\n trigger.addEventListener('load', loadHandler);\n }\n\n // Bind click event\n const clickHandler = function (e) {\n e.preventDefault();\n self.open(trigger);\n };\n trigger.addEventListener('click', clickHandler);\n\n // Store cleanup\n trigger._imageBoxCleanup = () => trigger.removeEventListener('click', clickHandler);\n\n // Keyboard accessibility for non-button triggers\n if (trigger.tagName !== 'BUTTON' && trigger.tagName !== 'A') {\n trigger.setAttribute('role', 'button');\n trigger.setAttribute('tabindex', '0');\n trigger.setAttribute('aria-label', 'View enlarged image');\n\n const keyHandler = function (e) {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n self.open(trigger);\n }\n };\n trigger.addEventListener('keydown', keyHandler);\n\n const originalCleanup = trigger._imageBoxCleanup;\n trigger._imageBoxCleanup = () => {\n originalCleanup();\n trigger.removeEventListener('keydown', keyHandler);\n };\n }\n });\n },\n\n /**\n * Open image box\n * @param {HTMLElement} trigger - The trigger element\n */\n open: function (trigger) {\n if (this.isOpen) return;\n\n this.currentTrigger = trigger;\n this.isOpen = true;\n this.initialScrollY = window.scrollY;\n\n // Get image source - support dual images (thumbnail + full-size)\n // data-image-box-full-src takes precedence for the lightbox\n const imgSrc = trigger.dataset.imageBoxFullSrc ||\n trigger.dataset.imageBoxSrc ||\n trigger.src ||\n trigger.href;\n\n if (!imgSrc) {\n console.warn('[Vanduo ImageBox] No image source found for trigger:', trigger);\n return;\n }\n\n // Get caption\n const captionText = trigger.dataset.imageBoxCaption || trigger.alt || '';\n\n // Set image source\n this.img.src = imgSrc;\n this.img.alt = trigger.alt || '';\n\n // Set caption\n if (captionText) {\n this.caption.textContent = captionText;\n this.caption.style.display = 'block';\n } else {\n this.caption.style.display = 'none';\n }\n\n // Calculate scrollbar width and lock body scroll\n const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;\n document.body.style.setProperty('--scrollbar-width', `${scrollbarWidth}px`);\n document.body.classList.add('body-image-box-open');\n\n // Show backdrop\n this.backdrop.classList.add('is-visible');\n\n // Focus management\n this.backdrop.focus();\n\n // Dispatch event\n trigger.dispatchEvent(new CustomEvent('imageBox:open', {\n bubbles: true,\n detail: { src: imgSrc }\n }));\n\n // Handle image load\n if (!this.img.complete) {\n this.img.style.opacity = '0';\n this._imgLoadHandler = () => {\n this.img.style.opacity = '';\n };\n this.img.addEventListener('load', this._imgLoadHandler, { once: true });\n }\n },\n\n /**\n * Close image box\n */\n close: function () {\n if (!this.isOpen) return;\n\n this.isOpen = false;\n\n // Hide backdrop\n this.backdrop.classList.remove('is-visible');\n\n // Unlock body scroll\n document.body.classList.remove('body-image-box-open');\n document.body.style.removeProperty('--scrollbar-width');\n\n // Return focus to trigger\n if (this.currentTrigger) {\n this.currentTrigger.focus();\n this.currentTrigger.dispatchEvent(new CustomEvent('imageBox:close', { bubbles: true }));\n this.currentTrigger = null;\n }\n\n // Clear image after transition\n setTimeout(() => {\n if (!this.isOpen) {\n // Clean up load handler if still pending\n if (this._imgLoadHandler) {\n this.img.removeEventListener('load', this._imgLoadHandler);\n this._imgLoadHandler = null;\n }\n this.img.src = '';\n this.img.alt = '';\n }\n }, 300);\n },\n\n /**\n * Reinitialize - useful after dynamic DOM changes\n */\n reinit: function () {\n this.bindTriggers();\n },\n\n /**\n * Destroy component and clean up\n */\n destroy: function () {\n // Close if open\n if (this.isOpen) {\n this.close();\n }\n\n // Remove backdrop\n if (this.backdrop && this.backdrop.parentNode) {\n this.backdrop.parentNode.removeChild(this.backdrop);\n }\n\n // Run cleanup functions\n this._cleanupFunctions.forEach(fn => fn());\n this._cleanupFunctions = [];\n\n // Remove trigger bindings\n const triggers = document.querySelectorAll('[data-image-box-initialized]');\n triggers.forEach(trigger => {\n trigger.classList.remove('vd-image-box-trigger');\n if (trigger._imageBoxCleanup) {\n trigger._imageBoxCleanup();\n delete trigger._imageBoxCleanup;\n }\n delete trigger.dataset.imageBoxInitialized;\n });\n\n this.backdrop = null;\n this.container = null;\n this.img = null;\n this.closeBtn = null;\n this.caption = null;\n this.currentTrigger = null;\n this.isOpen = false;\n },\n\n destroyAll: function () {\n this.destroy();\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('imageBox', ImageBox);\n }\n\n // Expose globally\n window.VanduoImageBox = ImageBox;\n\n})();\n", "/**\n * Vanduo Framework - Modals Component\n * JavaScript functionality for modal dialogs\n */\n\n(function () {\n 'use strict';\n\n /**\n * Modals Component\n */\n const Modals = {\n modals: new Map(),\n openModals: [],\n zIndexCounter: 1050,\n\n // Store trigger cleanup functions\n _triggerCleanups: [],\n // Shared ESC key handler (installed once)\n _sharedEscHandler: null,\n\n /**\n * Initialize modals\n */\n init: function () {\n const modals = document.querySelectorAll('.vd-modal');\n\n modals.forEach(modal => {\n if (this.modals.has(modal)) {\n return;\n }\n this.initModal(modal);\n });\n\n // Handle data-modal triggers\n const triggers = document.querySelectorAll('[data-modal]');\n triggers.forEach(trigger => {\n if (trigger.dataset.modalTriggerInitialized) return;\n trigger.dataset.modalTriggerInitialized = 'true';\n\n const triggerClickHandler = (e) => {\n e.preventDefault();\n const modalId = trigger.dataset.modal;\n const modal = document.querySelector(modalId);\n if (modal) {\n this.open(modal);\n }\n };\n trigger.addEventListener('click', triggerClickHandler);\n this._triggerCleanups.push(() => trigger.removeEventListener('click', triggerClickHandler));\n });\n },\n\n /**\n * Initialize a single modal\n * @param {HTMLElement} modal - Modal element\n */\n initModal: function (modal) {\n const backdrop = this.createBackdrop(modal);\n const closeButtons = modal.querySelectorAll('.vd-modal-close, [data-dismiss=\"modal\"]');\n const dialog = modal.querySelector('.vd-modal-dialog');\n\n if (!dialog) {\n return;\n }\n\n const cleanupFunctions = [];\n\n // Set ARIA attributes\n modal.setAttribute('role', 'dialog');\n modal.setAttribute('aria-modal', 'true');\n modal.setAttribute('aria-hidden', 'true');\n\n // Generate ID if not exists\n if (!modal.id) {\n modal.id = 'modal-' + Math.random().toString(36).substr(2, 9);\n }\n\n // Set aria-labelledby\n const title = modal.querySelector('.vd-modal-title');\n if (title && !title.id) {\n title.id = modal.id + '-title';\n modal.setAttribute('aria-labelledby', title.id);\n }\n\n // Close button handlers\n closeButtons.forEach(button => {\n const closeHandler = () => {\n this.close(modal);\n };\n button.addEventListener('click', closeHandler);\n cleanupFunctions.push(() => button.removeEventListener('click', closeHandler));\n });\n\n // Backdrop click handler\n const backdropClickHandler = (e) => {\n if (e.target === backdrop && modal.dataset.backdrop !== 'static') {\n this.close(modal);\n }\n };\n backdrop.addEventListener('click', backdropClickHandler);\n cleanupFunctions.push(() => backdrop.removeEventListener('click', backdropClickHandler));\n\n // ESC key handler \u2014 use a single shared handler instead of one-per-modal\n if (!this._sharedEscHandler) {\n this._sharedEscHandler = (e) => {\n if (e.key === 'Escape' && this.openModals.length > 0) {\n const topModal = this.openModals[this.openModals.length - 1];\n if (topModal.dataset.keyboard !== 'false') {\n this.close(topModal);\n }\n }\n };\n document.addEventListener('keydown', this._sharedEscHandler);\n }\n\n this.modals.set(modal, { backdrop, dialog, trapHandler: null, cleanup: cleanupFunctions });\n },\n\n /**\n * Create backdrop element\n * @param {HTMLElement} modal - Modal element\n * @returns {HTMLElement} Backdrop element\n */\n createBackdrop: function (modal) {\n let backdrop = modal.querySelector('.vd-modal-backdrop');\n\n if (!backdrop) {\n backdrop = document.createElement('div');\n backdrop.className = 'vd-modal-backdrop';\n document.body.appendChild(backdrop);\n }\n\n return backdrop;\n },\n\n /**\n * Open modal\n * @param {HTMLElement|string} modal - Modal element or selector\n */\n open: function (modal) {\n const el = typeof modal === 'string' ? document.querySelector(modal) : modal;\n\n if (!el) {\n console.warn('[Vanduo Modals] Modal element not found:', modal);\n return;\n }\n\n if (!this.modals.has(el)) {\n console.warn('[Vanduo Modals] Modal not initialized:', el);\n return;\n }\n\n const modalData = this.modals.get(el);\n const { backdrop, dialog: _dialog } = modalData;\n\n // Increment z-index for stacking\n this.zIndexCounter += 10;\n el.style.zIndex = this.zIndexCounter;\n backdrop.style.zIndex = this.zIndexCounter - 1;\n\n // Add to open modals stack\n this.openModals.push(el);\n\n // Show backdrop\n backdrop.classList.add('is-visible');\n\n // Show modal\n el.classList.add('is-open');\n el.setAttribute('aria-hidden', 'false');\n\n // Lock body scroll\n if (this.openModals.length === 1) {\n document.body.classList.add('body-modal-open');\n const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;\n if (scrollbarWidth > 0) {\n document.body.style.paddingRight = `${scrollbarWidth}px`;\n }\n }\n\n // Focus trap (store handler for cleanup)\n const trapHandler = this.trapFocus(el);\n modalData.trapHandler = trapHandler;\n\n // Auto-focus first focusable element\n setTimeout(() => {\n const firstFocusable = this.getFocusableElements(el)[0];\n if (firstFocusable) {\n firstFocusable.focus();\n }\n }, 100);\n\n // Dispatch event\n el.dispatchEvent(new CustomEvent('modal:open', { bubbles: true }));\n },\n\n /**\n * Close modal\n * @param {HTMLElement|string} modal - Modal element or selector\n */\n close: function (modal) {\n const el = typeof modal === 'string' ? document.querySelector(modal) : modal;\n\n if (!el) {\n console.warn('[Vanduo Modals] Modal element not found:', modal);\n return;\n }\n\n if (!this.modals.has(el)) {\n console.warn('[Vanduo Modals] Modal not initialized:', el);\n return;\n }\n\n const modalData = this.modals.get(el);\n const { backdrop, trapHandler } = modalData;\n\n // Remove focus trap event listener to prevent memory leak\n if (trapHandler) {\n el.removeEventListener('keydown', trapHandler);\n modalData.trapHandler = null;\n }\n\n // Remove from open modals stack\n const index = this.openModals.indexOf(el);\n if (index > -1) {\n this.openModals.splice(index, 1);\n }\n\n // Hide modal\n el.classList.remove('is-open');\n el.setAttribute('aria-hidden', 'true');\n\n // Hide backdrop if no other modals open\n if (this.openModals.length === 0) {\n backdrop.classList.remove('is-visible');\n document.body.classList.remove('body-modal-open');\n document.body.style.paddingRight = '';\n // Reset z-index counter to prevent indefinite growth\n this.zIndexCounter = 1050;\n } else {\n // Show backdrop for top modal\n const topModal = this.openModals[this.openModals.length - 1];\n const topBackdrop = this.modals.get(topModal).backdrop;\n topBackdrop.classList.add('is-visible');\n }\n\n // Return focus to trigger\n const trigger = document.querySelector(`[data-modal=\"#${el.id}\"]`);\n if (trigger) {\n trigger.focus();\n }\n\n // Dispatch event\n el.dispatchEvent(new CustomEvent('modal:close', { bubbles: true }));\n },\n\n /**\n * Trap focus within modal\n * @param {HTMLElement} modal - Modal element\n * @returns {Function} The trap handler function for cleanup\n */\n trapFocus: function (modal) {\n const self = this;\n\n const trapHandler = function (e) {\n if (e.key !== 'Tab') {\n return;\n }\n\n const focusableElements = self.getFocusableElements(modal);\n const firstElement = focusableElements[0];\n const lastElement = focusableElements[focusableElements.length - 1];\n\n if (e.shiftKey) {\n // Shift + Tab\n if (document.activeElement === firstElement) {\n e.preventDefault();\n lastElement.focus();\n }\n } else {\n // Tab\n if (document.activeElement === lastElement) {\n e.preventDefault();\n firstElement.focus();\n }\n }\n };\n\n modal.addEventListener('keydown', trapHandler);\n return trapHandler;\n },\n\n /**\n * Get focusable elements within modal\n * @param {HTMLElement} modal - Modal element\n * @returns {Array} Focusable elements\n */\n getFocusableElements: function (modal) {\n const selector = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex=\"-1\"])';\n return Array.from(modal.querySelectorAll(selector)).filter(el => {\n return !el.hasAttribute('disabled') &&\n el.offsetWidth > 0 &&\n el.offsetHeight > 0;\n });\n },\n\n /**\n * Toggle modal\n * @param {HTMLElement|string} modal - Modal element or selector\n */\n toggle: function (modal) {\n const el = typeof modal === 'string' ? document.querySelector(modal) : modal;\n if (el) {\n if (el.classList.contains('is-open')) {\n this.close(el);\n } else {\n this.open(el);\n }\n }\n },\n\n /**\n * Destroy a modal instance and clean up event listeners\n * @param {HTMLElement} modal - Modal element\n */\n destroy: function (modal) {\n const modalData = this.modals.get(modal);\n if (!modalData) return;\n\n // Close if open\n if (modal.classList.contains('is-open')) {\n this.close(modal);\n }\n\n // Run all cleanup functions\n if (modalData.cleanup) {\n modalData.cleanup.forEach(fn => fn());\n }\n\n // Remove created backdrop\n if (modalData.backdrop && modalData.backdrop.parentNode) {\n modalData.backdrop.parentNode.removeChild(modalData.backdrop);\n }\n\n this.modals.delete(modal);\n },\n\n /**\n * Destroy all modal instances\n */\n destroyAll: function () {\n this.modals.forEach((data, modal) => {\n this.destroy(modal);\n });\n // Clean up trigger listeners\n this._triggerCleanups.forEach(fn => fn());\n this._triggerCleanups = [];\n // Remove shared ESC handler\n if (this._sharedEscHandler) {\n document.removeEventListener('keydown', this._sharedEscHandler);\n this._sharedEscHandler = null;\n }\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('modals', Modals);\n }\n\n // Expose globally\n window.VanduoModals = Modals;\n\n})();\n\n", "/**\n * Vanduo Framework - Navbar Component\n * JavaScript functionality for navbar mobile menu\n */\n\n(function () {\n 'use strict';\n\n /**\n * Navbar Component\n */\n const Navbar = {\n // Store initialized navbars and their cleanup functions\n instances: new Map(),\n\n /**\n * Get the breakpoint value from CSS variable or use fallback\n * @returns {number} Breakpoint in pixels\n */\n getBreakpoint: function () {\n const root = getComputedStyle(document.documentElement);\n const breakpointValue = root.getPropertyValue('--breakpoint-lg').trim();\n\n // Parse the value (could be \"992px\" or just \"992\")\n const parsed = parseInt(breakpointValue, 10);\n return isNaN(parsed) ? 992 : parsed;\n },\n\n /**\n * Initialize navbar component\n */\n init: function () {\n const navbars = document.querySelectorAll('.vd-navbar');\n\n navbars.forEach(navbar => {\n // Skip if already initialized\n if (this.instances.has(navbar)) {\n return;\n }\n this.initNavbar(navbar);\n });\n },\n\n /**\n * Initialize scroll-aware glass/transparent behaviour for a navbar.\n * Adds/removes `.vd-navbar-scrolled` when the page scrolls past a threshold.\n * Threshold: `data-scroll-threshold` attribute (px) or the navbar's own height.\n * @param {HTMLElement} navbar - Navbar element\n * @returns {Function|null} Cleanup function, or null if not applicable\n */\n initScrollWatcher: function (navbar) {\n const isGlass = navbar.classList.contains('vd-navbar-glass');\n const isTransparent = navbar.classList.contains('vd-navbar-transparent');\n\n if (!isGlass && !isTransparent) {\n return null;\n }\n\n const getThreshold = () => {\n const attr = parseInt(navbar.dataset.scrollThreshold, 10);\n return isNaN(attr) ? (navbar.offsetHeight || 60) : attr;\n };\n\n const onScroll = () => {\n const scrolled = window.scrollY > getThreshold();\n navbar.classList.toggle('vd-navbar-scrolled', scrolled);\n };\n\n onScroll(); // set initial state without waiting for first scroll\n window.addEventListener('scroll', onScroll, { passive: true });\n\n return () => window.removeEventListener('scroll', onScroll);\n },\n\n /**\n * Initialize a single navbar\n * @param {HTMLElement} navbar - Navbar element\n */\n initNavbar: function (navbar) {\n const toggle = navbar.querySelector('.vd-navbar-toggle, .vd-navbar-burger');\n const menu = navbar.querySelector('.vd-navbar-menu');\n const overlay = navbar.querySelector('.vd-navbar-overlay') || this.createOverlay(navbar);\n\n // Store cleanup functions for this navbar instance\n const cleanupFunctions = [];\n\n // Wire up scroll-aware glass/transparent behaviour regardless of mobile menu\n const scrollWatcherCleanup = this.initScrollWatcher(navbar);\n if (scrollWatcherCleanup) {\n cleanupFunctions.push(scrollWatcherCleanup);\n }\n\n if (!toggle || !menu) {\n // Still store the instance so scroll-watcher cleanup is tracked\n if (cleanupFunctions.length) {\n this.instances.set(navbar, { toggle: null, menu: null, overlay: null, cleanup: cleanupFunctions });\n }\n return;\n }\n\n // Toggle menu on button click\n const toggleClickHandler = (e) => {\n e.preventDefault();\n e.stopPropagation();\n this.toggleMenu(navbar, toggle, menu, overlay);\n };\n toggle.addEventListener('click', toggleClickHandler);\n cleanupFunctions.push(() => toggle.removeEventListener('click', toggleClickHandler));\n\n // Close menu on overlay click\n if (overlay) {\n const overlayClickHandler = () => {\n this.closeMenu(navbar, toggle, menu, overlay);\n };\n overlay.addEventListener('click', overlayClickHandler);\n cleanupFunctions.push(() => overlay.removeEventListener('click', overlayClickHandler));\n }\n\n // Close menu on escape key\n const keydownHandler = (e) => {\n if (e.key === 'Escape' && menu.classList.contains('is-open')) {\n this.closeMenu(navbar, toggle, menu, overlay);\n }\n };\n document.addEventListener('keydown', keydownHandler);\n cleanupFunctions.push(() => document.removeEventListener('keydown', keydownHandler));\n\n // Close menu on window resize (if resizing to desktop)\n let resizeTimer;\n const resizeHandler = () => {\n clearTimeout(resizeTimer);\n resizeTimer = setTimeout(() => {\n const breakpoint = this.getBreakpoint();\n if (window.innerWidth >= breakpoint && menu.classList.contains('is-open')) {\n this.closeMenu(navbar, toggle, menu, overlay);\n }\n }, 250);\n };\n window.addEventListener('resize', resizeHandler);\n cleanupFunctions.push(() => {\n clearTimeout(resizeTimer);\n window.removeEventListener('resize', resizeHandler);\n });\n\n // Close menu when clicking outside\n const documentClickHandler = (e) => {\n if (menu.classList.contains('is-open') &&\n !navbar.contains(e.target) &&\n !menu.contains(e.target)) {\n this.closeMenu(navbar, toggle, menu, overlay);\n }\n };\n document.addEventListener('click', documentClickHandler);\n cleanupFunctions.push(() => document.removeEventListener('click', documentClickHandler));\n\n // Handle dropdown toggles in mobile menu\n const dropdownToggles = menu.querySelectorAll('.vd-navbar-dropdown > .vd-nav-link, .vd-navbar-dropdown > .nav-link');\n dropdownToggles.forEach(dropdownToggle => {\n const dropdownClickHandler = (e) => {\n const breakpoint = this.getBreakpoint();\n if (window.innerWidth < breakpoint) {\n e.preventDefault();\n const dropdown = dropdownToggle.parentElement;\n const dropdownMenu = dropdown.querySelector('.vd-navbar-dropdown-menu');\n\n if (dropdownMenu) {\n dropdownMenu.classList.toggle('is-open');\n }\n }\n };\n dropdownToggle.addEventListener('click', dropdownClickHandler);\n cleanupFunctions.push(() => dropdownToggle.removeEventListener('click', dropdownClickHandler));\n });\n\n // Store instance with cleanup functions\n this.instances.set(navbar, {\n toggle,\n menu,\n overlay,\n cleanup: cleanupFunctions\n });\n },\n\n /**\n * Destroy a navbar instance and clean up event listeners\n * @param {HTMLElement} navbar - Navbar element\n */\n destroy: function (navbar) {\n const instance = this.instances.get(navbar);\n if (!instance) {\n return;\n }\n\n // Run all cleanup functions\n instance.cleanup.forEach(fn => fn());\n\n // Remove created overlay if it exists\n if (instance.overlay && instance.overlay.parentNode) {\n instance.overlay.parentNode.removeChild(instance.overlay);\n }\n\n // Remove from instances map\n this.instances.delete(navbar);\n },\n\n /**\n * Destroy all navbar instances\n */\n destroyAll: function () {\n this.instances.forEach((instance, navbar) => {\n this.destroy(navbar);\n });\n },\n\n /**\n * Toggle mobile menu\n * @param {HTMLElement} navbar - Navbar element\n * @param {HTMLElement} toggle - Toggle button\n * @param {HTMLElement} menu - Menu element\n * @param {HTMLElement} overlay - Overlay element\n */\n toggleMenu: function (navbar, toggle, menu, overlay) {\n const isOpen = menu.classList.contains('is-open');\n\n if (isOpen) {\n this.closeMenu(navbar, toggle, menu, overlay);\n } else {\n this.openMenu(navbar, toggle, menu, overlay);\n }\n },\n\n /**\n * Open mobile menu\n * @param {HTMLElement} navbar - Navbar element\n * @param {HTMLElement} toggle - Toggle button\n * @param {HTMLElement} menu - Menu element\n * @param {HTMLElement} overlay - Overlay element\n */\n openMenu: function (navbar, toggle, menu, overlay) {\n menu.classList.add('is-open');\n toggle.classList.add('is-active');\n\n if (overlay) {\n overlay.classList.add('is-active');\n }\n\n // Prevent body scroll when menu is open (use class to avoid conflicts with modals)\n document.body.classList.add('body-navbar-open');\n\n // Set ARIA attributes\n toggle.setAttribute('aria-expanded', 'true');\n menu.setAttribute('aria-hidden', 'false');\n },\n\n /**\n * Close mobile menu\n * @param {HTMLElement} navbar - Navbar element\n * @param {HTMLElement} toggle - Toggle button\n * @param {HTMLElement} menu - Menu element\n * @param {HTMLElement} overlay - Overlay element\n */\n closeMenu: function (navbar, toggle, menu, overlay) {\n menu.classList.remove('is-open');\n toggle.classList.remove('is-active');\n\n if (overlay) {\n overlay.classList.remove('is-active');\n }\n\n // Restore body scroll\n document.body.classList.remove('body-navbar-open');\n\n // Close all dropdown menus\n const dropdownMenus = menu.querySelectorAll('.vd-navbar-dropdown-menu.is-open');\n dropdownMenus.forEach(dropdownMenu => {\n dropdownMenu.classList.remove('is-open');\n });\n\n // Set ARIA attributes\n toggle.setAttribute('aria-expanded', 'false');\n menu.setAttribute('aria-hidden', 'true');\n },\n\n /**\n * Create overlay element if it doesn't exist\n * @param {HTMLElement} navbar - Navbar element\n * @returns {HTMLElement} Overlay element\n */\n createOverlay: function (_navbar) {\n const overlay = document.createElement('div');\n overlay.className = 'vd-navbar-overlay';\n document.body.appendChild(overlay);\n return overlay;\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('navbar', Navbar);\n }\n\n // Expose globally\n window.VanduoNavbar = Navbar;\n\n})();\n", "/**\n * Vanduo Framework - Pagination Component\n * JavaScript functionality for dynamic pagination\n */\n\n(function() {\n 'use strict';\n\n /**\n * Pagination Component\n */\n const Pagination = {\n // Store initialized paginations and their cleanup functions\n instances: new Map(),\n\n /**\n * Initialize pagination components\n */\n init: function() {\n const paginations = document.querySelectorAll('.vd-pagination[data-pagination]');\n\n paginations.forEach(pagination => {\n if (this.instances.has(pagination)) {\n return;\n }\n this.initPagination(pagination);\n });\n },\n\n /**\n * Initialize a pagination\n * @param {HTMLElement} pagination - Pagination container\n */\n initPagination: function(pagination) {\n const totalPages = parseInt(pagination.dataset.totalPages) || 1;\n const currentPage = parseInt(pagination.dataset.currentPage) || 1;\n const maxVisible = parseInt(pagination.dataset.maxVisible) || 7;\n\n this.render(pagination, {\n totalPages: totalPages,\n currentPage: currentPage,\n maxVisible: maxVisible\n });\n\n // Handle clicks (event delegation)\n const clickHandler = (e) => {\n const link = e.target.closest('.vd-pagination-link');\n if (!link || link.closest('.vd-pagination-item.disabled') || link.closest('.vd-pagination-item.active')) {\n return;\n }\n\n e.preventDefault();\n\n const item = link.closest('.vd-pagination-item');\n const page = item.dataset.page;\n\n if (page) {\n this.goToPage(pagination, parseInt(page));\n } else if (item.classList.contains('pagination-prev')) {\n this.prevPage(pagination);\n } else if (item.classList.contains('pagination-next')) {\n this.nextPage(pagination);\n }\n };\n pagination.addEventListener('click', clickHandler);\n\n this.instances.set(pagination, {\n cleanup: [() => pagination.removeEventListener('click', clickHandler)]\n });\n },\n \n /**\n * Render pagination\n * @param {HTMLElement} pagination - Pagination container\n * @param {Object} options - Pagination options\n */\n render: function(pagination, options) {\n const { totalPages, currentPage, maxVisible } = options;\n \n if (totalPages <= 1) {\n pagination.innerHTML = '';\n return;\n }\n \n let html = '';\n \n // Previous button\n html += `