diff --git a/README.md b/README.md index 7990a81..e162760 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ Vanduo is a lightweight, zero-dependency UI framework built with pure HTML, CSS, - Pure CSS/JS with no runtime dependencies - Modular architecture with optional per-component imports - 47+ components, including Expanding Cards and animated Timeline controls in v1.3.8 -- Niche canvas hex-grid support is distributed as `@vanduo-oss/hex-grid` +- Current Theme Customizer defaults in this worktree are `charcoal` for neutral color and `ubuntu` for font family +- Niche canvas hex-grid support is distributed as [`@vanduo-oss/hex-grid`](https://www.npmjs.com/package/@vanduo-oss/hex-grid) - Built-in dark/light/system theme switching - Runtime Theme Customizer for color, font, and radius tokens - Accessibility-focused components and utilities diff --git a/css/components/collapsible.css b/css/components/collapsible.css index e4db3ec..34f3398 100644 --- a/css/components/collapsible.css +++ b/css/components/collapsible.css @@ -75,6 +75,8 @@ user-select: none; transition: var(--transition-bg); border: none; + -webkit-appearance: none; + appearance: none; width: 100%; text-align: left; font-family: var(--font-family-sans); @@ -88,7 +90,9 @@ background-color: var(--collapsible-header-bg-hover); } +.vd-collapsible-header:focus, .vd-collapsible-header:focus-visible, +.accordion-header:focus, .accordion-header:focus-visible { outline: none; } @@ -122,11 +126,15 @@ text-decoration: none; background: none; border: none; + -webkit-appearance: none; + appearance: none; padding: 0; cursor: pointer; } +.vd-collapsible-trigger:focus, .vd-collapsible-trigger:focus-visible, +.accordion-trigger:focus, .accordion-trigger:focus-visible { outline: none; } diff --git a/css/core/colors.css b/css/core/colors.css index f089e2c..df1bf2d 100644 --- a/css/core/colors.css +++ b/css/core/colors.css @@ -312,6 +312,18 @@ --slate-8: #1e293b; --slate-9: #0f172a; + /* --- Charcoal Scale (Deep cool charcoal) --- */ + --charcoal-0: #f6f8fa; + --charcoal-1: #eaeef2; + --charcoal-2: #d0d7de; + --charcoal-3: #afb8c1; + --charcoal-4: #8c959f; + --charcoal-5: #6e7781; + --charcoal-6: #57606a; + --charcoal-7: #424a53; + --charcoal-8: #2d333b; + --charcoal-9: #24292f; + /* --- Zinc Scale (Slightly warm gray) --- */ --zinc-0: #fafafa; --zinc-1: #f4f4f5; @@ -946,7 +958,21 @@ * These rules remap --gray-* variables based on [data-neutral] attribute * ═════════════════════════════════════════════════════════════════════════ */ -/* Gray Neutral (default - no override needed) */ +/* Charcoal Neutral (default) */ +[data-neutral="charcoal"] { + --gray-0: var(--charcoal-0); + --gray-1: var(--charcoal-1); + --gray-2: var(--charcoal-2); + --gray-3: var(--charcoal-3); + --gray-4: var(--charcoal-4); + --gray-5: var(--charcoal-5); + --gray-6: var(--charcoal-6); + --gray-7: var(--charcoal-7); + --gray-8: var(--charcoal-8); + --gray-9: var(--charcoal-9); +} + +/* Gray Neutral */ [data-neutral="gray"] { --gray-0: #f8f9fa; --gray-1: #f1f3f5; diff --git a/css/core/tokens.css b/css/core/tokens.css index 06cda62..bb05ce2 100644 --- a/css/core/tokens.css +++ b/css/core/tokens.css @@ -84,7 +84,7 @@ /* ============================================ * TYPOGRAPHY TOKENS * ============================================ */ - --vd-font-family-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --vd-font-family-base: 'Ubuntu', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; --vd-font-family-mono: 'JetBrains Mono', 'Fira Code', monospace; /* Font Sizes (Fibonacci scale) */ diff --git a/css/core/typography.css b/css/core/typography.css index b50b8b8..d2c44cd 100644 --- a/css/core/typography.css +++ b/css/core/typography.css @@ -5,7 +5,7 @@ :root { /* Font Families */ - --font-family-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --font-family-sans: 'Ubuntu', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --font-family-serif: Georgia, "Times New Roman", Times, serif; --font-family-mono: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace; } diff --git a/dist/build-info.json b/dist/build-info.json index e1360ba..5dbdc6b 100644 --- a/dist/build-info.json +++ b/dist/build-info.json @@ -1,6 +1,6 @@ { "version": "1.3.8", - "builtAt": "2026-05-06T18:32:43.703Z", - "commit": "6042eac", + "builtAt": "2026-05-10T18:43:09.818Z", + "commit": "fd58e56", "mode": "development+production" } \ No newline at end of file diff --git a/dist/vanduo.cjs.js b/dist/vanduo.cjs.js index 05a8e5f..9894825 100644 --- a/dist/vanduo.cjs.js +++ b/dist/vanduo.cjs.js @@ -1,4 +1,4 @@ -/*! Vanduo v1.3.8 | Built: 2026-05-06T18:32:43.703Z | git:6042eac | development */ +/*! Vanduo v1.3.8 | Built: 2026-05-10T18:43:09.818Z | git:fd58e56 | development */ var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; @@ -1217,7 +1217,7 @@ module.exports = __toCommonJS(index_exports); preference: this.getPreference() }; if (!this.fonts[this.state.preference]) { - this.state.preference = "lato"; + this.state.preference = "ubuntu"; this.setStorageValue(this.STORAGE_KEY, this.state.preference); } if (this.isInitialized) { @@ -1233,10 +1233,10 @@ module.exports = __toCommonJS(index_exports); }, /** * Get saved font preference from localStorage - * @returns {string} Font key or 'lato' (default) + * @returns {string} Font key or 'ubuntu' (default) */ getPreference: function() { - return this.getStorageValue(this.STORAGE_KEY, "lato"); + return this.getStorageValue(this.STORAGE_KEY, "ubuntu"); }, /** * Set font preference and apply it @@ -3904,9 +3904,9 @@ module.exports = __toCommonJS(index_exports); DEFAULTS: { PRIMARY_LIGHT: "black", PRIMARY_DARK: "amber", - NEUTRAL: "neutral", + NEUTRAL: "charcoal", RADIUS: "0.5", - FONT: "lato", + FONT: "ubuntu", THEME: "system" }, // Primary color definitions (Open Color based) @@ -3932,6 +3932,7 @@ module.exports = __toCommonJS(index_exports); }, // Neutral color definitions NEUTRAL_COLORS: { + "charcoal": { name: "Charcoal", color: "#2d333b" }, "slate": { name: "Slate", color: "#64748b" }, "gray": { name: "Gray", color: "#6b7280" }, "zinc": { name: "Zinc", color: "#71717a" }, diff --git a/dist/vanduo.cjs.js.map b/dist/vanduo.cjs.js.map index af59880..0e45a09 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/morph.js", "../js/components/expanding-cards.js", "../js/components/timeline.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, water morph)\nimport './components/glass.js';\nimport './components/morph.js';\nimport './components/expanding-cards.js';\nimport './components/timeline.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 // Reset auto-position classes before computing a new state.\n menu.classList.remove('vd-dropdown-menu-end', 'vd-dropdown-menu-start', 'vd-dropdown-menu-top');\n\n // Directional wrappers explicitly control placement in CSS.\n if (dropdown.classList.contains('vd-dropdown-dropup')) {\n menu.classList.add('vd-dropdown-menu-top');\n return;\n }\n\n if (dropdown.classList.contains('vd-dropdown-dropright') || dropdown.classList.contains('vd-dropdown-dropleft')) {\n return;\n }\n\n // Check if menu overflows right.\n if (rect.left + menuRect.width > viewportWidth - padding) {\n menu.classList.add('vd-dropdown-menu-end');\n } else {\n menu.classList.add('vd-dropdown-menu-start');\n }\n\n // Flip above trigger when there is not enough room below.\n if (rect.bottom + menuRect.height > viewportHeight - padding && rect.top - menuRect.height > padding) {\n menu.classList.add('vd-dropdown-menu-top');\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 '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 'lato': {\n name: 'Lato',\n family: \"'Lato', sans-serif\",\n category: 'sans-serif',\n description: 'Friendly, rounded sans-serif'\n }\n },\n\n init: function() {\n this.state = {\n preference: this.getPreference()\n };\n if (!this.fonts[this.state.preference]) {\n this.state.preference = 'lato';\n this.setStorageValue(this.STORAGE_KEY, this.state.preference);\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 'lato' (default)\n */\n getPreference: function() {\n return this.getStorageValue(this.STORAGE_KEY, 'lato');\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 getPortalState: function (modal) {\n if (!modal._vdPortalState) {\n modal._vdPortalState = {\n originalParent: null,\n originalNextSibling: null,\n placeholder: null\n };\n }\n return modal._vdPortalState;\n },\n\n portalToBody: function (modal) {\n if (!modal || modal.parentNode === document.body) {\n return;\n }\n\n const state = this.getPortalState(modal);\n state.originalParent = modal.parentNode;\n state.originalNextSibling = modal.nextSibling;\n\n if (!state.placeholder) {\n state.placeholder = document.createComment('vd-modal-placeholder');\n }\n\n state.originalParent.insertBefore(state.placeholder, modal);\n document.body.appendChild(modal);\n modal.dataset.vdPortaled = 'true';\n },\n\n restoreFromPortal: function (modal) {\n if (!modal) {\n return;\n }\n\n const state = this.getPortalState(modal);\n if (!state.placeholder) {\n delete modal.dataset.vdPortaled;\n return;\n }\n\n if (state.placeholder.parentNode) {\n state.placeholder.parentNode.insertBefore(modal, state.placeholder);\n state.placeholder.parentNode.removeChild(state.placeholder);\n } else if (state.originalParent && state.originalParent.isConnected) {\n if (state.originalNextSibling && state.originalNextSibling.parentNode === state.originalParent) {\n state.originalParent.insertBefore(modal, state.originalNextSibling);\n } else {\n state.originalParent.appendChild(modal);\n }\n }\n\n state.originalParent = null;\n state.originalNextSibling = null;\n state.placeholder = null;\n delete modal.dataset.vdPortaled;\n },\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 this.portalToBody(el);\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 this.restoreFromPortal(el);\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 if (modal.classList.contains('is-open')) {\n const index = this.openModals.indexOf(modal);\n if (index > -1) {\n this.openModals.splice(index, 1);\n }\n modalData.backdrop.classList.remove('is-visible');\n modal.classList.remove('is-open');\n modal.setAttribute('aria-hidden', 'true');\n if (this.openModals.length === 0) {\n document.body.classList.remove('body-modal-open');\n document.body.style.paddingRight = '';\n this.zIndexCounter = 1050;\n }\n }\n\n this.restoreFromPortal(modal);\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 += `
  • `;\n html += `Previous`;\n html += `
  • `;\n \n // Calculate page range\n const pages = this.calculatePages(currentPage, totalPages, maxVisible);\n \n // Page numbers\n let lastPage = 0;\n pages.forEach(page => {\n if (page === 'ellipsis') {\n html += `
  • \u2026
  • `;\n } else {\n if (page !== lastPage + 1 && lastPage > 0) {\n html += `
  • \u2026
  • `;\n }\n const safePage = Number(page);\n html += `
  • `;\n html += `${safePage}`;\n html += `
  • `;\n lastPage = page;\n }\n });\n \n // Next button\n html += `
  • `;\n html += `Next`;\n html += `
  • `;\n \n pagination.innerHTML = html;\n \n // Update data attributes\n pagination.dataset.currentPage = currentPage;\n },\n \n /**\n * Calculate which pages to show\n * @param {number} currentPage - Current page\n * @param {number} totalPages - Total pages\n * @param {number} maxVisible - Maximum visible pages\n * @returns {Array} Array of page numbers or 'ellipsis'\n */\n calculatePages: function(currentPage, totalPages, maxVisible) {\n const pages = [];\n const half = Math.floor(maxVisible / 2);\n \n if (totalPages <= maxVisible) {\n // Show all pages\n for (let i = 1; i <= totalPages; i++) {\n pages.push(i);\n }\n } else {\n // Always show first page\n pages.push(1);\n \n let start = Math.max(2, currentPage - half);\n let end = Math.min(totalPages - 1, currentPage + half);\n \n // Adjust if we're near the start\n if (currentPage <= half + 1) {\n end = Math.min(totalPages - 1, maxVisible - 1);\n }\n \n // Adjust if we're near the end\n if (currentPage >= totalPages - half) {\n start = Math.max(2, totalPages - maxVisible + 2);\n }\n \n // Add ellipsis before if needed\n if (start > 2) {\n pages.push('ellipsis');\n }\n \n // Add middle pages\n for (let i = start; i <= end; i++) {\n pages.push(i);\n }\n \n // Add ellipsis after if needed\n if (end < totalPages - 1) {\n pages.push('ellipsis');\n }\n \n // Always show last page\n if (totalPages > 1) {\n pages.push(totalPages);\n }\n }\n \n return pages;\n },\n \n /**\n * Go to specific page\n * @param {HTMLElement} pagination - Pagination container\n * @param {number} page - Page number\n */\n goToPage: function(pagination, page) {\n const totalPages = parseInt(pagination.dataset.totalPages) || 1;\n const maxVisible = parseInt(pagination.dataset.maxVisible) || 7;\n \n if (page < 1 || page > totalPages) {\n return;\n }\n \n this.render(pagination, {\n totalPages: totalPages,\n currentPage: page,\n maxVisible: maxVisible\n });\n \n // Dispatch event\n pagination.dispatchEvent(new CustomEvent('pagination:change', {\n bubbles: true,\n detail: { page, totalPages }\n }));\n },\n \n /**\n * Go to previous page\n * @param {HTMLElement} pagination - Pagination container\n */\n prevPage: function(pagination) {\n const currentPage = parseInt(pagination.dataset.currentPage) || 1;\n if (currentPage > 1) {\n this.goToPage(pagination, currentPage - 1);\n }\n },\n \n /**\n * Go to next page\n * @param {HTMLElement} pagination - Pagination container\n */\n nextPage: function(pagination) {\n const currentPage = parseInt(pagination.dataset.currentPage) || 1;\n const totalPages = parseInt(pagination.dataset.totalPages) || 1;\n if (currentPage < totalPages) {\n this.goToPage(pagination, currentPage + 1);\n }\n },\n \n /**\n * Update pagination\n * @param {HTMLElement|string} pagination - Pagination container or selector\n * @param {Object} options - Pagination options\n */\n update: function(pagination, options) {\n const el = typeof pagination === 'string' ? document.querySelector(pagination) : pagination;\n if (el) {\n if (options.totalPages !== undefined) {\n el.dataset.totalPages = options.totalPages;\n }\n if (options.currentPage !== undefined) {\n el.dataset.currentPage = options.currentPage;\n }\n if (options.maxVisible !== undefined) {\n el.dataset.maxVisible = options.maxVisible;\n }\n \n this.render(el, {\n totalPages: parseInt(el.dataset.totalPages) || 1,\n currentPage: parseInt(el.dataset.currentPage) || 1,\n maxVisible: parseInt(el.dataset.maxVisible) || 7\n });\n }\n },\n\n /**\n * Destroy a pagination instance and clean up event listeners\n * @param {HTMLElement} pagination - Pagination container\n */\n destroy: function(pagination) {\n const instance = this.instances.get(pagination);\n if (!instance) return;\n\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(pagination);\n },\n\n /**\n * Destroy all pagination instances\n */\n destroyAll: function() {\n this.instances.forEach((instance, pagination) => {\n this.destroy(pagination);\n });\n }\n };\n \n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('pagination', Pagination);\n }\n \n // Expose globally\n window.VanduoPagination = Pagination;\n \n})();\n\n", "/**\n * Vanduo Framework - Parallax Component\n * JavaScript functionality for parallax scroll effects\n */\n\n(function () {\n 'use strict';\n\n /**\n * Parallax Component\n */\n const Parallax = {\n parallaxElements: new Map(),\n ticking: false,\n isMobile: window.innerWidth < 768,\n reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,\n isInitialized: false,\n _onScroll: null,\n _onResize: null,\n\n /**\n * Initialize parallax components\n */\n init: function () {\n if (this.isInitialized) {\n this.refresh();\n return;\n }\n\n this.isInitialized = true;\n\n // Check for reduced motion preference\n if (this.reducedMotion) {\n return; // Don't initialize if user prefers reduced motion\n }\n\n const parallaxElements = document.querySelectorAll('.vd-parallax');\n\n parallaxElements.forEach(element => {\n if (!element.dataset.parallaxInitialized) {\n this.initParallax(element);\n }\n });\n\n // Handle scroll\n this.handleScroll();\n this._onScroll = () => {\n this.handleScroll();\n };\n window.addEventListener('scroll', this._onScroll, { passive: true });\n\n // Handle resize\n this._onResize = () => {\n this.isMobile = window.innerWidth < 768;\n this.updateAll();\n };\n window.addEventListener('resize', this._onResize);\n },\n\n /**\n * Initialize a parallax element\n * @param {HTMLElement} element - Parallax container\n */\n initParallax: function (element) {\n element.dataset.parallaxInitialized = 'true';\n\n // Check if disabled on mobile\n const disableMobile = element.classList.contains('parallax-disable-mobile');\n if (disableMobile && this.isMobile) {\n return;\n }\n\n const layers = element.querySelectorAll('.vd-parallax-layer, .vd-parallax-bg');\n const speed = this.getSpeed(element);\n const direction = element.classList.contains('parallax-horizontal') ? 'horizontal' : 'vertical';\n\n this.parallaxElements.set(element, {\n layers: Array.from(layers),\n speed: speed,\n direction: direction,\n disableMobile: disableMobile\n });\n\n // Initial update\n this.updateParallax(element);\n },\n\n /**\n * Get parallax speed from element\n * @param {HTMLElement} element - Parallax element\n * @returns {number} Speed multiplier\n */\n getSpeed: function (element) {\n if (element.classList.contains('parallax-slow')) {\n return 0.5;\n } else if (element.classList.contains('parallax-fast')) {\n return 1.5;\n }\n return 1; // Default medium speed\n },\n\n /**\n * Handle scroll event\n */\n handleScroll: function () {\n if (!this.ticking) {\n window.requestAnimationFrame(() => {\n this.updateAll();\n this.ticking = false;\n });\n this.ticking = true;\n }\n },\n\n /**\n * Update all parallax elements\n */\n updateAll: function () {\n this.parallaxElements.forEach((config, element) => {\n // Skip if disabled on mobile\n if (config.disableMobile && this.isMobile) {\n return;\n }\n\n this.updateParallax(element);\n });\n },\n\n /**\n * Update parallax for a single element\n * @param {HTMLElement} element - Parallax element\n */\n updateParallax: function (element) {\n const config = this.parallaxElements.get(element);\n if (!config) {\n return;\n }\n\n const rect = element.getBoundingClientRect();\n const windowHeight = window.innerHeight;\n const elementTop = rect.top;\n const elementHeight = rect.height;\n\n // Calculate scroll progress (0 to 1)\n const scrollProgress = Math.max(0, Math.min(1,\n (windowHeight - elementTop) / (windowHeight + elementHeight)\n ));\n\n // Calculate offset based on speed and direction\n const offset = (scrollProgress - 0.5) * config.speed * 100;\n\n config.layers.forEach((layer, _index) => {\n // Different layers can have different speeds\n const layerSpeed = layer.dataset.parallaxSpeed ? parseFloat(layer.dataset.parallaxSpeed) : 1;\n const layerOffset = offset * layerSpeed;\n\n if (config.direction === 'horizontal') {\n layer.style.transform = `translateX(${layerOffset}px)`;\n } else {\n layer.style.transform = `translateY(${layerOffset}px)`;\n }\n });\n },\n\n /**\n * Destroy parallax element\n * @param {HTMLElement|string} element - Parallax element or selector\n */\n destroy: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.parallaxElements.has(el)) {\n const config = this.parallaxElements.get(el);\n config.layers.forEach(layer => {\n layer.style.transform = '';\n });\n this.parallaxElements.delete(el);\n }\n },\n\n /**\n * Refresh parallax (recalculate positions)\n */\n refresh: function () {\n this.updateAll();\n },\n\n destroyAll: function () {\n this.parallaxElements.forEach((_config, element) => {\n this.destroy(element);\n });\n this.parallaxElements.clear();\n\n if (this._onScroll) {\n window.removeEventListener('scroll', this._onScroll);\n this._onScroll = null;\n }\n\n if (this._onResize) {\n window.removeEventListener('resize', this._onResize);\n this._onResize = null;\n }\n\n this.isInitialized = false;\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('parallax', Parallax);\n }\n\n // Expose globally\n window.VanduoParallax = Parallax;\n\n})();\n\n", "/**\n * Vanduo Framework - Preloader Component\n * JavaScript functionality for progress bars and loaders\n */\n\n(function() {\n 'use strict';\n\n /**\n * Preloader Component\n */\n const Preloader = {\n /**\n * Initialize preloader components\n */\n init: function() {\n const progressBars = document.querySelectorAll('.vd-progress-bar[data-progress], .progress-bar[data-progress]');\n \n progressBars.forEach(bar => {\n if (!bar.dataset.progressInitialized) {\n this.initProgressBar(bar);\n }\n });\n },\n \n /**\n * Initialize a progress bar\n * @param {HTMLElement} bar - Progress bar element\n */\n initProgressBar: function(bar) {\n bar.dataset.progressInitialized = 'true';\n \n const initialValue = parseInt(bar.dataset.progress) || 0;\n this.setProgress(bar, initialValue, false);\n },\n \n /**\n * Set progress value\n * @param {HTMLElement|string} bar - Progress bar element or selector\n * @param {number} value - Progress value (0-100)\n * @param {boolean} animate - Whether to animate\n */\n setProgress: function(bar, value, animate = true) {\n const el = typeof bar === 'string' ? document.querySelector(bar) : bar;\n \n if (!el) {\n return;\n }\n \n // Clamp value between 0 and 100\n value = Math.max(0, Math.min(100, value));\n \n // Update width\n if (animate) {\n el.style.transition = 'width var(--transition-duration-slow) var(--transition-ease)';\n } else {\n el.style.transition = 'none';\n setTimeout(() => {\n el.style.transition = '';\n }, 0);\n }\n \n el.style.width = value + '%';\n el.setAttribute('aria-valuenow', value);\n el.setAttribute('aria-valuemin', 0);\n el.setAttribute('aria-valuemax', 100);\n \n // Update text if exists\n const text = el.querySelector('.vd-progress-text, .progress-text');\n if (text) {\n text.textContent = value + '%';\n }\n \n // Dispatch event\n el.dispatchEvent(new CustomEvent('progress:update', {\n bubbles: true,\n detail: { value, max: 100 }\n }));\n \n // Complete event\n if (value >= 100) {\n el.dispatchEvent(new CustomEvent('progress:complete', {\n bubbles: true,\n detail: { value, max: 100 }\n }));\n }\n },\n \n /**\n * Animate progress from current to target\n * @param {HTMLElement|string} bar - Progress bar element or selector\n * @param {number} targetValue - Target progress value (0-100)\n * @param {number} duration - Animation duration in ms\n */\n animateProgress: function(bar, targetValue, duration = 1000) {\n const el = typeof bar === 'string' ? document.querySelector(bar) : bar;\n \n if (!el) {\n return;\n }\n \n const startValue = parseInt(el.style.width) || 0;\n const difference = targetValue - startValue;\n const startTime = performance.now();\n \n const animate = (currentTime) => {\n const elapsed = currentTime - startTime;\n const progress = Math.min(elapsed / duration, 1);\n \n // Easing function (ease-out)\n const easeOut = 1 - Math.pow(1 - progress, 3);\n const currentValue = startValue + (difference * easeOut);\n \n this.setProgress(el, currentValue, false);\n \n if (progress < 1) {\n requestAnimationFrame(animate);\n }\n };\n \n requestAnimationFrame(animate);\n },\n \n /**\n * Show preloader\n * @param {HTMLElement|string} preloader - Preloader element or selector\n */\n show: function(preloader) {\n const el = typeof preloader === 'string' ? document.querySelector(preloader) : preloader;\n if (el) {\n el.style.display = 'inline-block';\n el.setAttribute('aria-hidden', 'false');\n }\n },\n \n /**\n * Hide preloader\n * @param {HTMLElement|string} preloader - Preloader element or selector\n */\n hide: function(preloader) {\n const el = typeof preloader === 'string' ? document.querySelector(preloader) : preloader;\n if (el) {\n el.style.display = 'none';\n el.setAttribute('aria-hidden', 'true');\n }\n },\n \n /**\n * Toggle preloader\n * @param {HTMLElement|string} preloader - Preloader element or selector\n */\n toggle: function(preloader) {\n const el = typeof preloader === 'string' ? document.querySelector(preloader) : preloader;\n if (el) {\n if (el.style.display === 'none' || el.getAttribute('aria-hidden') === 'true') {\n this.show(el);\n } else {\n this.hide(el);\n }\n }\n },\n\n /**\n * Destroy all progress bar instances\n */\n destroyAll: function() {\n const progressBars = document.querySelectorAll('.vd-progress-bar[data-progress-initialized=\"true\"], .progress-bar[data-progress-initialized=\"true\"]');\n progressBars.forEach(bar => {\n delete bar.dataset.progressInitialized;\n });\n }\n };\n \n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('preloader', Preloader);\n }\n \n // Expose globally\n window.VanduoPreloader = Preloader;\n \n})();\n\n", "/**\n * Vanduo Framework - Select Component\n * Custom select dropdown with enhanced functionality\n */\n\n(function () {\n 'use strict';\n\n /**\n * Select Component\n */\n const Select = {\n // Store initialized selects and their cleanup functions\n instances: new Map(),\n\n /**\n * Initialize select components\n */\n init: function () {\n const selects = document.querySelectorAll('select.vd-custom-select-input, select[data-custom-select]');\n\n selects.forEach(select => {\n if (this.instances.has(select)) {\n return;\n }\n this.initSelect(select);\n });\n },\n\n /**\n * Initialize a single select\n * @param {HTMLSelectElement} select - Select element\n */\n initSelect: function (select) {\n // Skip if already has custom wrapper\n if (select.closest('.vd-custom-select-wrapper')) {\n return;\n }\n\n const cleanupFunctions = [];\n\n // Create wrapper\n const wrapper = document.createElement('div');\n wrapper.className = 'custom-select-wrapper';\n select.parentNode.insertBefore(wrapper, select);\n wrapper.appendChild(select);\n\n // Create custom button\n const button = document.createElement('button');\n button.type = 'button';\n button.className = 'custom-select-button';\n button.setAttribute('aria-haspopup', 'listbox');\n button.setAttribute('aria-expanded', 'false');\n button.setAttribute('aria-labelledby', select.id || this.generateId(select));\n\n // Create dropdown\n const dropdown = document.createElement('div');\n dropdown.className = 'custom-select-dropdown';\n dropdown.setAttribute('role', 'listbox');\n\n // Create search input if searchable\n if (select.dataset.searchable === 'true') {\n const searchWrapper = document.createElement('div');\n searchWrapper.className = 'custom-select-search';\n const searchInput = document.createElement('input');\n searchInput.type = 'text';\n searchInput.className = 'input input-sm';\n searchInput.placeholder = 'Search...';\n searchInput.setAttribute('aria-label', 'Search options');\n searchWrapper.appendChild(searchInput);\n dropdown.appendChild(searchWrapper);\n\n const filterFn = (e) => {\n this.filterOptions(dropdown, e.target.value);\n };\n const searchHandler = typeof debounce === 'function' ? debounce(filterFn, 150) : filterFn;\n searchInput.addEventListener('input', searchHandler);\n cleanupFunctions.push(() => searchInput.removeEventListener('input', searchHandler));\n }\n\n // Build options\n this.buildOptions(select, dropdown, button);\n\n wrapper.appendChild(button);\n wrapper.appendChild(dropdown);\n\n // Update button text\n this.updateButtonText(select, button);\n\n // Event listeners\n const buttonClickHandler = (e) => {\n e.preventDefault();\n e.stopPropagation();\n this.toggleDropdown(button, dropdown);\n };\n button.addEventListener('click', buttonClickHandler);\n cleanupFunctions.push(() => button.removeEventListener('click', buttonClickHandler));\n\n // Close on outside click\n const documentClickHandler = (e) => {\n if (!wrapper.contains(e.target) && dropdown.classList.contains('is-open')) {\n this.closeDropdown(button, dropdown);\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, select, button, dropdown);\n };\n button.addEventListener('keydown', keydownHandler);\n cleanupFunctions.push(() => button.removeEventListener('keydown', keydownHandler));\n\n // Update on select change\n const changeHandler = () => {\n this.updateButtonText(select, button);\n this.updateSelectedOptions(select, dropdown);\n };\n select.addEventListener('change', changeHandler);\n cleanupFunctions.push(() => select.removeEventListener('change', changeHandler));\n\n this.instances.set(select, { wrapper, button, dropdown, cleanup: cleanupFunctions, typeaheadBuffer: '', typeaheadTimer: null });\n },\n\n /**\n * Build options in dropdown\n * @param {HTMLSelectElement} select - Select element\n * @param {HTMLElement} dropdown - Dropdown container\n * @param {HTMLElement} button - Button element\n */\n buildOptions: function (select, dropdown, button) {\n const options = select.querySelectorAll('option');\n const fragment = document.createDocumentFragment();\n\n options.forEach((option, index) => {\n if (option.parentElement.tagName === 'OPTGROUP') {\n // Handle option groups\n const group = option.parentElement;\n if (!dropdown.querySelector(`[data-group=\"${group.label}\"]`)) {\n const groupElement = document.createElement('div');\n groupElement.className = 'custom-select-option-group';\n groupElement.textContent = group.label;\n groupElement.dataset.group = group.label;\n fragment.appendChild(groupElement);\n }\n }\n\n if (option.value === '' && !option.textContent.trim()) {\n return; // Skip empty options\n }\n\n const optionElement = document.createElement('div');\n optionElement.className = 'custom-select-option';\n optionElement.textContent = option.textContent;\n optionElement.setAttribute('role', 'option');\n optionElement.setAttribute('data-value', option.value);\n optionElement.setAttribute('data-index', index);\n\n if (option.selected) {\n optionElement.classList.add('is-selected');\n optionElement.setAttribute('aria-selected', 'true');\n }\n\n if (option.disabled) {\n optionElement.classList.add('is-disabled');\n optionElement.setAttribute('aria-disabled', 'true');\n }\n\n optionElement.addEventListener('click', (_e) => {\n if (!option.disabled) {\n this.selectOption(select, option, optionElement, button, dropdown);\n }\n });\n\n fragment.appendChild(optionElement);\n });\n\n dropdown.appendChild(fragment);\n },\n\n /**\n * Select an option\n * @param {HTMLSelectElement} select - Select element\n * @param {HTMLOptionElement} option - Option element\n * @param {HTMLElement} optionElement - Custom option element\n * @param {HTMLElement} button - Button element\n * @param {HTMLElement} dropdown - Dropdown container\n */\n selectOption: function (select, option, optionElement, button, dropdown) {\n if (select.multiple) {\n // Multi-select\n option.selected = !option.selected;\n optionElement.classList.toggle('is-selected');\n optionElement.setAttribute('aria-selected', option.selected);\n } else {\n // Single select\n select.value = option.value;\n select.dispatchEvent(new Event('change', { bubbles: true }));\n this.closeDropdown(button, dropdown);\n }\n\n this.updateButtonText(select, button);\n },\n\n /**\n * Update button text\n * @param {HTMLSelectElement} select - Select element\n * @param {HTMLElement} button - Button element\n */\n updateButtonText: function (select, button) {\n if (select.multiple) {\n const selected = Array.from(select.selectedOptions);\n if (selected.length === 0) {\n button.textContent = select.dataset.placeholder || 'Select options...';\n } else if (selected.length === 1) {\n button.textContent = selected[0].textContent;\n } else {\n button.textContent = `${selected.length} selected`;\n }\n } else {\n const selectedOption = select.options[select.selectedIndex];\n button.textContent = selectedOption ? selectedOption.textContent : (select.dataset.placeholder || 'Select...');\n }\n },\n\n /**\n * Update selected options in dropdown\n * @param {HTMLSelectElement} select - Select element\n * @param {HTMLElement} dropdown - Dropdown container\n */\n updateSelectedOptions: function (select, dropdown) {\n const options = dropdown.querySelectorAll('.custom-select-option');\n const selectedValues = Array.from(select.selectedOptions).map(opt => opt.value);\n\n options.forEach(optionEl => {\n const value = optionEl.dataset.value;\n if (selectedValues.includes(value)) {\n optionEl.classList.add('is-selected');\n optionEl.setAttribute('aria-selected', 'true');\n } else {\n optionEl.classList.remove('is-selected');\n optionEl.setAttribute('aria-selected', 'false');\n }\n });\n },\n\n /**\n * Toggle dropdown\n * @param {HTMLElement} button - Button element\n * @param {HTMLElement} dropdown - Dropdown container\n */\n toggleDropdown: function (button, dropdown) {\n const isOpen = dropdown.classList.contains('is-open');\n\n if (isOpen) {\n this.closeDropdown(button, dropdown);\n } else {\n this.openDropdown(button, dropdown);\n }\n },\n\n /**\n * Open dropdown\n * @param {HTMLElement} button - Button element\n * @param {HTMLElement} dropdown - Dropdown container\n */\n openDropdown: function (button, dropdown) {\n dropdown.classList.add('is-open');\n button.setAttribute('aria-expanded', 'true');\n\n // Focus first option\n const firstOption = dropdown.querySelector('.custom-select-option:not(.is-disabled)');\n if (firstOption) {\n firstOption.focus();\n }\n },\n\n /**\n * Close dropdown\n * @param {HTMLElement} button - Button element\n * @param {HTMLElement} dropdown - Dropdown container\n */\n closeDropdown: function (button, dropdown) {\n dropdown.classList.remove('is-open');\n button.setAttribute('aria-expanded', 'false');\n },\n\n /**\n * Handle keyboard navigation\n * @param {KeyboardEvent} e - Keyboard event\n * @param {HTMLSelectElement} select - Select element\n * @param {HTMLElement} button - Button element\n * @param {HTMLElement} dropdown - Dropdown container\n */\n handleKeydown: function (e, select, button, dropdown) {\n const isOpen = dropdown.classList.contains('is-open');\n const options = Array.from(dropdown.querySelectorAll('.custom-select-option:not(.is-disabled)'));\n const currentIndex = options.findIndex(opt => opt === document.activeElement);\n\n switch (e.key) {\n case 'Enter':\n case ' ':\n e.preventDefault();\n if (isOpen && currentIndex >= 0) {\n const optionEl = options[currentIndex];\n const option = select.options[parseInt(optionEl.dataset.index)];\n this.selectOption(select, option, optionEl, button, dropdown);\n } else {\n this.openDropdown(button, dropdown);\n }\n break;\n\n case 'Escape':\n if (isOpen) {\n e.preventDefault();\n this.closeDropdown(button, dropdown);\n button.focus();\n }\n break;\n\n case 'ArrowDown':\n e.preventDefault();\n if (!isOpen) {\n this.openDropdown(button, dropdown);\n } else {\n const nextIndex = currentIndex < options.length - 1 ? currentIndex + 1 : 0;\n options[nextIndex].focus();\n }\n break;\n\n case 'ArrowUp':\n e.preventDefault();\n if (isOpen) {\n const prevIndex = currentIndex > 0 ? currentIndex - 1 : options.length - 1;\n options[prevIndex].focus();\n }\n break;\n\n case 'Home':\n if (isOpen) {\n e.preventDefault();\n options[0].focus();\n }\n break;\n\n case 'End':\n if (isOpen) {\n e.preventDefault();\n options[options.length - 1].focus();\n }\n break;\n\n default:\n // Typeahead: jump to matching option 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(select);\n if (!instance) break;\n clearTimeout(instance.typeaheadTimer);\n instance.typeaheadBuffer += e.key.toLowerCase();\n\n const match = options.find(opt =>\n opt.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 * Filter options by search term\n * @param {HTMLElement} dropdown - Dropdown container\n * @param {string} searchTerm - Search term\n */\n filterOptions: function (dropdown, searchTerm) {\n const options = dropdown.querySelectorAll('.vd-custom-select-option');\n const term = searchTerm.toLowerCase();\n\n options.forEach(option => {\n const text = option.textContent.toLowerCase();\n if (text.includes(term)) {\n option.style.display = 'block';\n } else {\n option.style.display = 'none';\n }\n });\n },\n\n /**\n * Generate unique ID\n * @param {HTMLElement} element - Element\n * @returns {string} Generated ID\n */\n generateId: function (element) {\n if (element.id) {\n return element.id;\n }\n const id = 'select-' + Math.random().toString(36).substr(2, 9);\n element.id = id;\n return id;\n },\n\n /**\n * Destroy a select instance and clean up event listeners\n * @param {HTMLSelectElement} select - Select element\n */\n destroy: function (select) {\n const instance = this.instances.get(select);\n if (!instance) return;\n\n instance.cleanup.forEach(fn => fn());\n\n // Unwrap the select element back to its original parent\n if (instance.wrapper && instance.wrapper.parentNode) {\n instance.wrapper.parentNode.insertBefore(select, instance.wrapper);\n instance.wrapper.parentNode.removeChild(instance.wrapper);\n }\n\n this.instances.delete(select);\n },\n\n /**\n * Destroy all select instances\n */\n destroyAll: function () {\n this.instances.forEach((instance, select) => {\n this.destroy(select);\n });\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('select', Select);\n }\n\n})();\n\n", "/**\n * Vanduo Framework - Sidenav Component\n * JavaScript functionality for side navigation drawer\n */\n\n(function() {\n 'use strict';\n\n /**\n * Sidenav Component\n */\n const Sidenav = {\n sidenavs: new Map(),\n breakpoint: 992, // Desktop breakpoint\n restoreDelayMs: 450,\n \n // Global cleanup functions (toggles, resize)\n _globalCleanups: [],\n\n isFixedVariant: function(sidenav) {\n return sidenav.classList.contains('vd-sidenav-fixed') || sidenav.classList.contains('sidenav-fixed');\n },\n\n isPushVariant: function(sidenav) {\n return sidenav.classList.contains('vd-sidenav-push') || sidenav.classList.contains('sidenav-push');\n },\n\n isRightVariant: function(sidenav) {\n return sidenav.classList.contains('vd-sidenav-right') || sidenav.classList.contains('sidenav-right');\n },\n\n getPortalState: function(sidenav) {\n if (!sidenav._vdPortalState) {\n sidenav._vdPortalState = {\n originalParent: null,\n originalNextSibling: null,\n placeholder: null,\n restoreTimer: null,\n restoreHandler: null\n };\n }\n return sidenav._vdPortalState;\n },\n\n cancelScheduledRestore: function(sidenav) {\n const state = this.getPortalState(sidenav);\n\n if (state.restoreHandler) {\n sidenav.removeEventListener('transitionend', state.restoreHandler);\n state.restoreHandler = null;\n }\n\n if (state.restoreTimer) {\n window.clearTimeout(state.restoreTimer);\n state.restoreTimer = null;\n }\n },\n\n portalToBody: function(sidenav) {\n if (!sidenav) {\n return;\n }\n\n if (sidenav.parentNode === document.body) {\n this.cancelScheduledRestore(sidenav);\n return;\n }\n\n const state = this.getPortalState(sidenav);\n this.cancelScheduledRestore(sidenav);\n\n state.originalParent = sidenav.parentNode;\n state.originalNextSibling = sidenav.nextSibling;\n\n if (!state.placeholder) {\n state.placeholder = document.createComment('vd-sidenav-placeholder');\n }\n\n state.originalParent.insertBefore(state.placeholder, sidenav);\n document.body.appendChild(sidenav);\n sidenav.dataset.vdPortaled = 'true';\n },\n\n restoreFromPortal: function(sidenav) {\n if (!sidenav) {\n return;\n }\n\n const state = this.getPortalState(sidenav);\n this.cancelScheduledRestore(sidenav);\n\n if (!state.placeholder) {\n delete sidenav.dataset.vdPortaled;\n return;\n }\n\n if (state.placeholder.parentNode) {\n state.placeholder.parentNode.insertBefore(sidenav, state.placeholder);\n state.placeholder.parentNode.removeChild(state.placeholder);\n } else if (state.originalParent && state.originalParent.isConnected) {\n if (state.originalNextSibling && state.originalNextSibling.parentNode === state.originalParent) {\n state.originalParent.insertBefore(sidenav, state.originalNextSibling);\n } else {\n state.originalParent.appendChild(sidenav);\n }\n }\n\n state.originalParent = null;\n state.originalNextSibling = null;\n state.placeholder = null;\n delete sidenav.dataset.vdPortaled;\n },\n\n scheduleRestoreFromPortal: function(sidenav) {\n if (!sidenav || sidenav.parentNode !== document.body) {\n return;\n }\n\n const state = this.getPortalState(sidenav);\n this.cancelScheduledRestore(sidenav);\n\n const finalizeRestore = () => {\n this.restoreFromPortal(sidenav);\n };\n\n const transitionEndHandler = (event) => {\n if (event.target !== sidenav || event.propertyName !== 'transform') {\n return;\n }\n finalizeRestore();\n };\n\n state.restoreHandler = transitionEndHandler;\n sidenav.addEventListener('transitionend', transitionEndHandler);\n state.restoreTimer = window.setTimeout(() => {\n finalizeRestore();\n }, this.restoreDelayMs);\n },\n\n /**\n * Initialize sidenav components\n */\n init: function() {\n const sidenavs = document.querySelectorAll('.vd-sidenav, .vd-offcanvas');\n\n sidenavs.forEach(sidenav => {\n if (this.sidenavs.has(sidenav)) {\n return;\n }\n this.initSidenav(sidenav);\n });\n\n // Handle toggle buttons\n const toggles = document.querySelectorAll('[data-sidenav-toggle]');\n toggles.forEach(toggle => {\n if (toggle.dataset.sidenavToggleInitialized) return;\n toggle.dataset.sidenavToggleInitialized = 'true';\n\n const toggleClickHandler = (e) => {\n e.preventDefault();\n const targetId = toggle.dataset.sidenavToggle;\n const sidenav = document.querySelector(targetId);\n if (sidenav) {\n this.toggle(sidenav);\n }\n };\n toggle.addEventListener('click', toggleClickHandler);\n this._globalCleanups.push(() => toggle.removeEventListener('click', toggleClickHandler));\n });\n\n // Handle responsive behavior\n this.handleResize();\n const resizeHandler = () => {\n this.handleResize();\n };\n window.addEventListener('resize', resizeHandler);\n this._globalCleanups.push(() => window.removeEventListener('resize', resizeHandler));\n },\n\n /**\n * Initialize a single sidenav\n * @param {HTMLElement} sidenav - Sidenav element\n */\n initSidenav: function(sidenav) {\n // Apply data-vd-position direction class if specified\n const position = sidenav.getAttribute('data-vd-position');\n if (position) {\n const prefix = sidenav.classList.contains('vd-offcanvas') ? 'vd-offcanvas' : 'vd-sidenav';\n sidenav.classList.add(prefix + '-' + position);\n }\n\n const overlay = this.createOverlay(sidenav);\n const closeButton = sidenav.querySelector('.vd-sidenav-close, .vd-offcanvas-close');\n const cleanupFunctions = [];\n\n // Set ARIA attributes\n sidenav.setAttribute('role', 'navigation');\n sidenav.setAttribute('aria-hidden', 'true');\n\n // Close button handler\n if (closeButton) {\n const closeHandler = () => {\n this.close(sidenav);\n };\n closeButton.addEventListener('click', closeHandler);\n cleanupFunctions.push(() => closeButton.removeEventListener('click', closeHandler));\n }\n\n // Overlay click handler\n const overlayClickHandler = () => {\n if (sidenav.dataset.backdrop !== 'static') {\n this.close(sidenav);\n }\n };\n overlay.addEventListener('click', overlayClickHandler);\n cleanupFunctions.push(() => overlay.removeEventListener('click', overlayClickHandler));\n\n // ESC key handler\n const escKeyHandler = (e) => {\n if (e.key === 'Escape' && sidenav.classList.contains('is-open')) {\n if (sidenav.dataset.keyboard !== 'false') {\n this.close(sidenav);\n }\n }\n };\n document.addEventListener('keydown', escKeyHandler);\n cleanupFunctions.push(() => document.removeEventListener('keydown', escKeyHandler));\n\n this.sidenavs.set(sidenav, { overlay, cleanup: cleanupFunctions });\n },\n \n /**\n * Create overlay element\n * @param {HTMLElement} sidenav - Sidenav element\n * @returns {HTMLElement} Overlay element\n */\n createOverlay: function(sidenav) {\n let overlay = sidenav.querySelector('.vd-sidenav-overlay');\n \n if (!overlay) {\n overlay = document.createElement('div');\n overlay.className = 'vd-sidenav-overlay';\n document.body.appendChild(overlay);\n }\n \n return overlay;\n },\n \n /**\n * Open sidenav\n * @param {HTMLElement|string} sidenav - Sidenav element or selector\n */\n open: function(sidenav) {\n const el = typeof sidenav === 'string' ? document.querySelector(sidenav) : sidenav;\n \n if (!el || !this.sidenavs.has(el)) {\n return;\n }\n \n const { overlay } = this.sidenavs.get(el);\n\n this.portalToBody(el);\n \n // Show overlay (if not fixed)\n if (!this.isFixedVariant(el)) {\n overlay.classList.add('is-visible');\n }\n \n // Open sidenav\n el.classList.add('is-open');\n el.setAttribute('aria-hidden', 'false');\n \n // Lock body scroll\n document.body.classList.add('body-sidenav-open');\n \n // Handle push variant\n if (this.isPushVariant(el)) {\n this.handlePushVariant(el, true);\n }\n \n // Dispatch event\n el.dispatchEvent(new CustomEvent('sidenav:open', { bubbles: true }));\n },\n \n /**\n * Close sidenav\n * @param {HTMLElement|string} sidenav - Sidenav element or selector\n */\n close: function(sidenav) {\n const el = typeof sidenav === 'string' ? document.querySelector(sidenav) : sidenav;\n \n if (!el || !this.sidenavs.has(el)) {\n return;\n }\n \n const { overlay } = this.sidenavs.get(el);\n \n // Hide overlay\n overlay.classList.remove('is-visible');\n \n // Close sidenav\n el.classList.remove('is-open');\n el.setAttribute('aria-hidden', 'true');\n \n // Unlock body scroll\n document.body.classList.remove('body-sidenav-open');\n \n // Handle push variant\n if (this.isPushVariant(el)) {\n this.handlePushVariant(el, false);\n }\n \n // Dispatch event\n el.dispatchEvent(new CustomEvent('sidenav:close', { bubbles: true }));\n\n this.scheduleRestoreFromPortal(el);\n },\n \n /**\n * Toggle sidenav\n * @param {HTMLElement|string} sidenav - Sidenav element or selector\n */\n toggle: function(sidenav) {\n const el = typeof sidenav === 'string' ? document.querySelector(sidenav) : sidenav;\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 * Handle push variant\n * @param {HTMLElement} sidenav - Sidenav element\n * @param {boolean} isOpen - Whether sidenav is open\n */\n handlePushVariant: function(sidenav, isOpen) {\n // Find the main content wrapper\n const content = document.querySelector('main, .main-content, .content, [role=\"main\"]') || document.body;\n \n if (isOpen) {\n if (window.innerWidth >= this.breakpoint) {\n if (this.isRightVariant(sidenav)) {\n content.style.marginRight = sidenav.offsetWidth + 'px';\n } else {\n content.style.marginLeft = sidenav.offsetWidth + 'px';\n }\n }\n } else {\n content.style.marginLeft = '';\n content.style.marginRight = '';\n }\n },\n \n /**\n * Handle window resize\n */\n handleResize: function() {\n this.sidenavs.forEach(({ overlay }, sidenav) => {\n // Close overlay sidenavs on resize to desktop if they're open\n if (window.innerWidth >= this.breakpoint) {\n if (this.isFixedVariant(sidenav) && !sidenav.classList.contains('is-open')) {\n // Fixed sidenavs should be visible on desktop\n sidenav.classList.add('is-open');\n sidenav.setAttribute('aria-hidden', 'false');\n overlay.classList.remove('is-visible');\n }\n } else {\n // On mobile, fixed sidenavs become overlay\n if (this.isFixedVariant(sidenav) && sidenav.classList.contains('is-open')) {\n this.close(sidenav);\n }\n }\n });\n },\n\n /**\n * Destroy a sidenav instance and clean up event listeners\n * @param {HTMLElement} sidenav - Sidenav element\n */\n destroy: function(sidenav) {\n const data = this.sidenavs.get(sidenav);\n if (!data) return;\n\n if (sidenav.classList.contains('is-open')) {\n data.overlay.classList.remove('is-visible');\n sidenav.classList.remove('is-open');\n sidenav.setAttribute('aria-hidden', 'true');\n document.body.classList.remove('body-sidenav-open');\n }\n\n this.restoreFromPortal(sidenav);\n\n data.cleanup.forEach(fn => fn());\n\n // Remove created overlay\n if (data.overlay && data.overlay.parentNode) {\n data.overlay.parentNode.removeChild(data.overlay);\n }\n\n this.sidenavs.delete(sidenav);\n },\n\n /**\n * Destroy all sidenav instances\n */\n destroyAll: function() {\n this.sidenavs.forEach((data, sidenav) => {\n this.destroy(sidenav);\n });\n this._globalCleanups.forEach(fn => fn());\n this._globalCleanups = [];\n }\n };\n \n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('sidenav', Sidenav);\n }\n \n // Expose globally\n window.VanduoSidenav = Sidenav;\n \n})();\n\n", "/**\n * Vanduo Framework - Tabs Component\n * Tabbed content navigation with keyboard support\n */\n\n(function() {\n 'use strict';\n\n /**\n * Tabs Component\n */\n const Tabs = {\n // Store initialized tab containers and their cleanup functions\n instances: new Map(),\n\n /**\n * Initialize all tab components\n */\n init: function() {\n const tabContainers = document.querySelectorAll('.vd-tabs, [data-tabs]');\n\n tabContainers.forEach(container => {\n if (this.instances.has(container)) {\n return;\n }\n this.initTabs(container);\n });\n },\n\n /**\n * Initialize a single tab container\n * @param {HTMLElement} container - Tabs container element\n */\n initTabs: function(container) {\n const tabList = container.querySelector('.vd-tab-list, [role=\"tablist\"]');\n const tabLinks = container.querySelectorAll('.vd-tab-link, [data-tab]');\n const tabPanes = container.querySelectorAll('.vd-tab-pane, [data-tab-pane]');\n\n if (!tabList || tabLinks.length === 0) return;\n\n const cleanupFunctions = [];\n\n // Set up ARIA attributes\n tabList.setAttribute('role', 'tablist');\n\n tabLinks.forEach((link, index) => {\n const tabId = this.getTabId(link, index);\n const pane = this.findPane(container, tabId, tabPanes);\n\n // Set up tab attributes\n link.setAttribute('role', 'tab');\n link.setAttribute('aria-selected', link.classList.contains('is-active') ? 'true' : 'false');\n link.setAttribute('tabindex', link.classList.contains('is-active') ? '0' : '-1');\n\n if (!link.id) {\n link.id = `tab-btn-${tabId}`;\n }\n\n // Set up pane attributes\n if (pane) {\n pane.setAttribute('role', 'tabpanel');\n pane.setAttribute('aria-labelledby', link.id);\n if (!pane.id) {\n pane.id = `tab-pane-${tabId}`;\n }\n link.setAttribute('aria-controls', pane.id);\n }\n\n // Click handler\n const clickHandler = (e) => {\n e.preventDefault();\n if (!link.classList.contains('disabled') && !link.disabled) {\n this.activateTab(container, link, tabLinks, tabPanes);\n }\n };\n link.addEventListener('click', clickHandler);\n cleanupFunctions.push(() => link.removeEventListener('click', clickHandler));\n\n // Keyboard navigation\n const keydownHandler = (e) => {\n this.handleKeydown(e, container, link, tabLinks, tabPanes);\n };\n link.addEventListener('keydown', keydownHandler);\n cleanupFunctions.push(() => link.removeEventListener('keydown', keydownHandler));\n });\n\n // Ensure one tab is active\n const activeTab = container.querySelector('.vd-tab-link.is-active, [data-tab].is-active');\n if (!activeTab && tabLinks.length > 0) {\n this.activateTab(container, tabLinks[0], tabLinks, tabPanes);\n }\n\n this.instances.set(container, { cleanup: cleanupFunctions });\n },\n\n /**\n * Get the pane identifier associated with a tab link.\n * @param {HTMLElement} link - Tab link element\n * @param {number} [fallbackIndex] - Optional fallback index\n * @returns {string} Tab identifier\n */\n getTabId: function(link, fallbackIndex) {\n return link.dataset.tabTarget\n || link.dataset.tab\n || link.getAttribute('href')?.replace('#', '')\n || (typeof fallbackIndex === 'number' ? `tab-${fallbackIndex}` : link.id);\n },\n\n /**\n * Find the pane associated with a tab\n * @param {HTMLElement} container - Tabs container\n * @param {string} tabId - Tab identifier\n * @param {NodeList} tabPanes - All tab panes\n * @returns {HTMLElement|null} The matching pane\n */\n findPane: function(container, tabId, tabPanes) {\n // Try data attribute first\n let pane = container.querySelector(`[data-tab-pane=\"${tabId}\"]`);\n\n // Try ID\n if (!pane) {\n pane = container.querySelector(`#${tabId}`);\n }\n\n // Try matching by index\n if (!pane) {\n const tabLinks = container.querySelectorAll('.vd-tab-link, [data-tab]');\n tabLinks.forEach((link, index) => {\n const linkTabId = this.getTabId(link, index);\n if (linkTabId === tabId && tabPanes[index]) {\n pane = tabPanes[index];\n }\n });\n }\n\n return pane;\n },\n\n /**\n * Activate a tab\n * @param {HTMLElement} container - Tabs container\n * @param {HTMLElement} tab - Tab to activate\n * @param {NodeList} allTabs - All tab links\n * @param {NodeList} allPanes - All tab panes\n */\n activateTab: function(container, tab, allTabs, allPanes) {\n const tabIndex = Array.from(allTabs).indexOf(tab);\n const tabId = this.getTabId(tab, tabIndex);\n\n // Deactivate all tabs\n allTabs.forEach(t => {\n t.classList.remove('is-active');\n t.setAttribute('aria-selected', 'false');\n t.setAttribute('tabindex', '-1');\n\n // Also handle parent li if exists\n if (t.parentElement && t.parentElement.classList.contains('tab-item')) {\n t.parentElement.classList.remove('is-active');\n }\n });\n\n // Hide all panes\n allPanes.forEach(p => {\n p.classList.remove('is-active');\n });\n\n // Activate selected tab\n tab.classList.add('is-active');\n tab.setAttribute('aria-selected', 'true');\n tab.setAttribute('tabindex', '0');\n\n // Also handle parent li if exists\n if (tab.parentElement && tab.parentElement.classList.contains('tab-item')) {\n tab.parentElement.classList.add('is-active');\n }\n\n // Show corresponding pane\n const pane = this.findPane(container, tabId, allPanes);\n if (pane) {\n pane.classList.add('is-active');\n }\n\n // Dispatch custom event\n const event = new CustomEvent('tab:change', {\n bubbles: true,\n detail: {\n tab: tab,\n pane: pane,\n tabId: tabId\n }\n });\n container.dispatchEvent(event);\n },\n\n /**\n * Handle keyboard navigation\n * @param {KeyboardEvent} e - Keyboard event\n * @param {HTMLElement} container - Tabs container\n * @param {HTMLElement} currentTab - Currently focused tab\n * @param {NodeList} allTabs - All tab links\n * @param {NodeList} allPanes - All tab panes\n */\n handleKeydown: function(e, container, currentTab, allTabs, allPanes) {\n const isVertical = container.classList.contains('vd-tabs-vertical') || container.classList.contains('tabs-vertical');\n const tabs = Array.from(allTabs).filter(t => !t.classList.contains('disabled') && !t.disabled);\n const currentIndex = tabs.indexOf(currentTab);\n\n let newIndex = currentIndex;\n\n switch (e.key) {\n case 'ArrowLeft':\n if (!isVertical) {\n e.preventDefault();\n newIndex = currentIndex > 0 ? currentIndex - 1 : tabs.length - 1;\n }\n break;\n\n case 'ArrowRight':\n if (!isVertical) {\n e.preventDefault();\n newIndex = currentIndex < tabs.length - 1 ? currentIndex + 1 : 0;\n }\n break;\n\n case 'ArrowUp':\n if (isVertical) {\n e.preventDefault();\n newIndex = currentIndex > 0 ? currentIndex - 1 : tabs.length - 1;\n }\n break;\n\n case 'ArrowDown':\n if (isVertical) {\n e.preventDefault();\n newIndex = currentIndex < tabs.length - 1 ? currentIndex + 1 : 0;\n }\n break;\n\n case 'Home':\n e.preventDefault();\n newIndex = 0;\n break;\n\n case 'End':\n e.preventDefault();\n newIndex = tabs.length - 1;\n break;\n\n case 'Enter':\n case ' ':\n e.preventDefault();\n this.activateTab(container, currentTab, allTabs, allPanes);\n return;\n\n default:\n return;\n }\n\n // Focus and activate new tab\n if (newIndex !== currentIndex) {\n tabs[newIndex].focus();\n this.activateTab(container, tabs[newIndex], allTabs, allPanes);\n }\n },\n\n /**\n * Programmatically show a tab\n * @param {string|HTMLElement} tab - Tab identifier or element\n */\n show: function(tab) {\n let tabElement;\n\n if (typeof tab === 'string') {\n tabElement = document.querySelector(`[data-tab-target=\"${tab}\"], [data-tab=\"${tab}\"], [href=\"#${tab}\"]`);\n } else {\n tabElement = tab;\n }\n\n if (!tabElement) return;\n\n const container = tabElement.closest('.vd-tabs, [data-tabs]');\n if (!container) return;\n\n const allTabs = container.querySelectorAll('.vd-tab-link, [data-tab]');\n const allPanes = container.querySelectorAll('.vd-tab-pane, [data-tab-pane]');\n\n this.activateTab(container, tabElement, allTabs, allPanes);\n },\n\n /**\n * Destroy a tabs instance and clean up event listeners\n * @param {HTMLElement} container - Tabs 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 tabs 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('tabs', Tabs);\n }\n\n})();\n", "/**\n * Vanduo Framework - Theme Customizer\n * A comprehensive theme customization component for the navbar\n * Handles primary color, neutral color, radius, font, and color mode\n */\n\n(function () {\n 'use strict';\n\n const ThemeCustomizer = {\n // Storage keys\n STORAGE_KEYS: {\n PRIMARY: 'vanduo-primary-color',\n NEUTRAL: 'vanduo-neutral-color',\n RADIUS: 'vanduo-radius',\n FONT: 'vanduo-font-preference',\n THEME: 'vanduo-theme-preference'\n },\n\n // Default values\n DEFAULTS: {\n PRIMARY_LIGHT: 'black',\n PRIMARY_DARK: 'amber',\n NEUTRAL: 'neutral',\n RADIUS: '0.5',\n FONT: 'lato',\n THEME: 'system'\n },\n\n // Primary color definitions (Open Color based)\n PRIMARY_COLORS: {\n 'black': { name: 'Black', color: '#000000' },\n 'red': { name: 'Red', color: '#fa5252' },\n 'orange': { name: 'Orange', color: '#fd7e14' },\n 'amber': { name: 'Amber', color: '#f59f00' },\n 'yellow': { name: 'Yellow', color: '#fcc419' },\n 'lime': { name: 'Lime', color: '#82c91e' },\n 'green': { name: 'Green', color: '#40c057' },\n 'emerald': { name: 'Emerald', color: '#20c997' },\n 'teal': { name: 'Teal', color: '#12b886' },\n 'cyan': { name: 'Cyan', color: '#22b8cf' },\n 'sky': { name: 'Sky', color: '#3bc9db' },\n 'blue': { name: 'Blue', color: '#228be6' },\n 'indigo': { name: 'Indigo', color: '#4c6ef5' },\n 'violet': { name: 'Violet', color: '#7950f2' },\n 'purple': { name: 'Purple', color: '#be4bdb' },\n 'fuchsia': { name: 'Fuchsia', color: '#f06595' },\n 'pink': { name: 'Pink', color: '#e64980' },\n 'rose': { name: 'Rose', color: '#ff8787' }\n },\n\n // Neutral color definitions\n NEUTRAL_COLORS: {\n 'slate': { name: 'Slate', color: '#64748b' },\n 'gray': { name: 'Gray', color: '#6b7280' },\n 'zinc': { name: 'Zinc', color: '#71717a' },\n 'neutral': { name: 'Neutral', color: '#737373' },\n 'stone': { name: 'Stone', color: '#78716c' }\n },\n\n // Radius options\n RADIUS_OPTIONS: ['0', '0.125', '0.25', '0.375', '0.5'],\n\n // Font options\n FONT_OPTIONS: {\n 'jetbrains-mono': { name: 'JetBrains Mono', family: \"'JetBrains Mono', monospace\" },\n 'system': { name: 'System Default', family: null },\n 'ubuntu': { name: 'Ubuntu', family: \"'Ubuntu', sans-serif\" },\n 'lato': { name: 'Lato', family: \"'Lato', sans-serif\" },\n 'open-sans': { name: 'Open Sans', family: \"'Open Sans', sans-serif\" }\n },\n\n // Theme mode options\n THEME_MODES: ['system', 'dark', 'light'],\n\n // State\n state: {\n primary: null,\n neutral: null,\n radius: null,\n font: null,\n theme: null,\n isOpen: false\n },\n\n isInitialized: false,\n _cleanup: [],\n\n // DOM references\n elements: {\n customizer: null,\n trigger: null,\n triggers: [],\n panel: null,\n overlay: null\n },\n\n /**\n * Initialize the Theme Customizer\n */\n init: function () {\n if (this.isInitialized) {\n this.bindExistingElements();\n this.bindTriggerEvents();\n this.bindPanelEvents();\n this.updateUI();\n return;\n }\n\n this.isInitialized = true;\n this._cleanup = [];\n\n this.loadPreferences();\n this.applyAllPreferences();\n this.bindExistingElements();\n this.bindEvents();\n\n console.log('Vanduo Theme Customizer initialized');\n },\n\n addListener: function (target, event, handler, options) {\n if (!target) return;\n target.addEventListener(event, handler, options);\n this._cleanup.push(() => target.removeEventListener(event, handler, options));\n },\n\n /**\n * Get default primary color based on theme\n */\n getDefaultPrimary: function (theme) {\n if (theme === 'system') {\n if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {\n return this.DEFAULTS.PRIMARY_DARK;\n }\n return this.DEFAULTS.PRIMARY_LIGHT;\n }\n return theme === 'dark' ? this.DEFAULTS.PRIMARY_DARK : this.DEFAULTS.PRIMARY_LIGHT;\n },\n\n /**\n * Load preferences from localStorage\n */\n loadPreferences: function () {\n this.state.theme = this.getStorageValue(this.STORAGE_KEYS.THEME, this.DEFAULTS.THEME);\n this.state.primary = this.getStorageValue(this.STORAGE_KEYS.PRIMARY, this.getDefaultPrimary(this.state.theme));\n this._normalizeDefaultPrimaryIfStaleWithStoredTheme();\n this.state.neutral = this.getStorageValue(this.STORAGE_KEYS.NEUTRAL, this.DEFAULTS.NEUTRAL);\n this.state.radius = this.getStorageValue(this.STORAGE_KEYS.RADIUS, this.DEFAULTS.RADIUS);\n this.state.font = this.getStorageValue(this.STORAGE_KEYS.FONT, this.DEFAULTS.FONT);\n },\n\n /**\n * Save a preference to localStorage\n */\n savePreference: function (key, value) {\n this.setStorageValue(key, value);\n },\n\n /**\n * Apply all preferences\n */\n applyAllPreferences: function () {\n this.applyPrimary(this.state.primary);\n this.applyNeutral(this.state.neutral);\n this.applyRadius(this.state.radius);\n this.applyFont(this.state.font);\n this.applyTheme(this.state.theme);\n },\n\n /**\n * Apply primary color\n */\n applyPrimary: function (colorKey) {\n if (!this.PRIMARY_COLORS[colorKey]) {\n colorKey = this.getDefaultPrimary(this.state.theme);\n }\n\n this.state.primary = colorKey;\n document.documentElement.setAttribute('data-primary', colorKey);\n this.savePreference(this.STORAGE_KEYS.PRIMARY, colorKey);\n\n this.dispatchEvent('primary-change', { color: colorKey });\n },\n\n /**\n * Apply neutral color\n */\n applyNeutral: function (neutralKey) {\n if (!this.NEUTRAL_COLORS[neutralKey]) {\n neutralKey = this.DEFAULTS.NEUTRAL;\n }\n\n this.state.neutral = neutralKey;\n document.documentElement.setAttribute('data-neutral', neutralKey);\n this.savePreference(this.STORAGE_KEYS.NEUTRAL, neutralKey);\n\n this.dispatchEvent('neutral-change', { neutral: neutralKey });\n },\n\n /**\n * Apply border radius\n */\n applyRadius: function (radius) {\n if (!this.RADIUS_OPTIONS.includes(radius)) {\n radius = this.DEFAULTS.RADIUS;\n }\n\n this.state.radius = radius;\n document.documentElement.setAttribute('data-radius', radius);\n document.documentElement.style.setProperty('--radius-scale', radius);\n this.savePreference(this.STORAGE_KEYS.RADIUS, radius);\n\n this.dispatchEvent('radius-change', { radius: radius });\n },\n\n /**\n * Apply font family\n */\n applyFont: function (fontKey) {\n if (!this.FONT_OPTIONS[fontKey]) {\n fontKey = this.DEFAULTS.FONT;\n }\n\n this.state.font = fontKey;\n\n if (fontKey === 'system') {\n document.documentElement.removeAttribute('data-font');\n } else {\n document.documentElement.setAttribute('data-font', fontKey);\n }\n\n this.savePreference(this.STORAGE_KEYS.FONT, fontKey);\n\n // Also update the existing FontSwitcher if available\n if (window.FontSwitcher && window.FontSwitcher.setPreference) {\n window.FontSwitcher.state.preference = fontKey;\n window.FontSwitcher.applyFont();\n }\n\n this.dispatchEvent('font-change', { font: fontKey });\n },\n\n /**\n * Apply theme mode\n */\n applyTheme: function (mode) {\n if (!this.THEME_MODES.includes(mode)) {\n mode = this.DEFAULTS.THEME;\n }\n\n // Prevent circular updates\n this._isApplying = true;\n\n // Re-align black/amber when they don't match the effective primary for the target mode.\n // Covers theme toggles and stale localStorage (e.g. amber saved while theme is light).\n if (this.isUsingDefaultPrimary()) {\n const expected = this.getDefaultPrimary(mode);\n if (this.state.primary !== expected) {\n this.applyPrimary(expected);\n }\n }\n\n this.state.theme = mode;\n\n if (mode === 'system') {\n document.documentElement.removeAttribute('data-theme');\n } else {\n document.documentElement.setAttribute('data-theme', mode);\n }\n\n this.savePreference(this.STORAGE_KEYS.THEME, mode);\n\n // Also update the existing ThemeSwitcher if available\n // Only update state, don't call setPreference to avoid circular calls\n if (window.Vanduo && window.Vanduo.components.themeSwitcher) {\n const themeSwitcher = window.Vanduo.components.themeSwitcher;\n if (themeSwitcher.state && themeSwitcher.state.preference !== mode) {\n themeSwitcher.state.preference = mode;\n if (typeof themeSwitcher.setStorageValue === 'function') {\n themeSwitcher.setStorageValue(themeSwitcher.STORAGE_KEY, mode);\n }\n if (typeof themeSwitcher.updateUI === 'function') {\n themeSwitcher.updateUI();\n }\n }\n }\n\n this._isApplying = false;\n this.dispatchEvent('mode-change', { mode: mode });\n },\n\n /**\n * Dispatch custom event\n */\n dispatchEvent: function (type, detail) {\n const event = new CustomEvent('theme:' + type, {\n bubbles: true,\n detail: detail\n });\n document.dispatchEvent(event);\n\n // Also dispatch a general change event\n const changeEvent = new CustomEvent('theme:change', {\n bubbles: true,\n detail: {\n type: type,\n value: detail[Object.keys(detail)[0]],\n state: { ...this.state }\n }\n });\n document.dispatchEvent(changeEvent);\n },\n\n /**\n * Bind to existing DOM elements or create them dynamically\n */\n bindExistingElements: function () {\n // First check for existing full structure\n this.elements.customizer = document.querySelector('.vd-theme-customizer');\n this.elements.triggers = Array.from(document.querySelectorAll('[data-theme-customizer-trigger]'));\n if (!this.elements.trigger && this.elements.triggers.length) {\n this.elements.trigger = this.elements.triggers[0];\n }\n\n if (this.elements.customizer) {\n this.elements.trigger = this.elements.customizer.querySelector('.vd-theme-customizer-trigger') || this.elements.trigger;\n this.elements.panel = this.elements.customizer.querySelector('.vd-theme-customizer-panel');\n this.elements.overlay = this.elements.customizer.querySelector('.vd-theme-customizer-overlay');\n } else {\n // Look for standalone trigger buttons with data attribute\n if (this.elements.triggers.length) {\n this.createDynamicPanel();\n }\n }\n\n // Update UI to reflect current state\n this.updateUI();\n },\n\n /**\n * Create the panel dynamically when only a trigger button exists\n */\n createDynamicPanel: function () {\n if (!this.elements.triggers.length) {\n return;\n }\n this.elements.trigger = this.elements.triggers[0];\n\n // Create overlay\n const overlay = document.createElement('div');\n overlay.className = 'vd-theme-customizer-overlay';\n\n // Create panel\n const panel = document.createElement('div');\n panel.className = 'vd-theme-customizer-panel';\n panel.innerHTML = this.getPanelHTML();\n\n // Append to body\n document.body.appendChild(overlay);\n document.body.appendChild(panel);\n\n // Store references\n this.elements.panel = panel;\n this.elements.overlay = overlay;\n this.elements.customizer = {\n contains: (el) => panel.contains(el) || this.elements.triggers.some((trigger) => trigger.contains(el))\n };\n\n // Position panel below trigger on desktop\n this.positionPanel();\n\n // Bind panel events after creation\n this.bindPanelEvents();\n\n // Reposition on resize\n this.addListener(window, 'resize', () => this.positionPanel());\n },\n\n /**\n * Position the panel below the trigger button on desktop\n */\n positionPanel: function () {\n if (!this.elements.panel || !this.elements.trigger) return;\n const anchorTrigger = this.elements.activeTrigger || this.elements.trigger;\n\n const isMobile = window.innerWidth < 768;\n\n if (isMobile) {\n // Mobile: full height slide-in from right - let CSS handle it\n this.elements.panel.style.top = '';\n this.elements.panel.style.right = '';\n this.elements.panel.style.left = '';\n this.elements.panel.style.height = '';\n this.elements.panel.style.maxHeight = '';\n } else {\n // Desktop: position directly below the trigger button, aligned to its right edge\n const triggerRect = anchorTrigger.getBoundingClientRect();\n const panelWidth = 320; // --customizer-width\n const panelTop = triggerRect.bottom + 8;\n\n // Calculate right position: align panel's right edge with trigger's right edge\n // But ensure it doesn't overflow the viewport\n const viewportWidth = window.innerWidth;\n let panelRight = viewportWidth - triggerRect.right;\n\n // Ensure panel doesn't overflow left side of viewport\n const panelLeft = viewportWidth - panelRight - panelWidth;\n if (panelLeft < 8) {\n panelRight = viewportWidth - panelWidth - 8;\n }\n\n this.elements.panel.style.top = panelTop + 'px';\n this.elements.panel.style.right = panelRight + 'px';\n this.elements.panel.style.left = '';\n this.elements.panel.style.height = 'auto';\n this.elements.panel.style.maxHeight = 'calc(100vh - ' + panelTop + 'px)';\n }\n },\n\n /**\n * Bind events specifically for the panel (called after dynamic creation)\n */\n bindPanelEvents: function () {\n if (!this.elements.panel) return;\n if (this.elements.panel.getAttribute('data-customizer-initialized') === 'true') return;\n\n this.elements.panel.setAttribute('data-customizer-initialized', 'true');\n\n // Primary color swatches\n this.elements.panel.querySelectorAll('[data-color]').forEach(swatch => {\n this.addListener(swatch, 'click', () => {\n this.applyPrimary(swatch.dataset.color);\n this.updateUI();\n });\n });\n\n // Neutral color swatches\n this.elements.panel.querySelectorAll('[data-neutral]').forEach(swatch => {\n this.addListener(swatch, 'click', () => {\n this.applyNeutral(swatch.dataset.neutral);\n this.updateUI();\n });\n });\n\n // Radius buttons\n this.elements.panel.querySelectorAll('[data-radius]').forEach(btn => {\n this.addListener(btn, 'click', () => {\n this.applyRadius(btn.dataset.radius);\n this.updateUI();\n });\n });\n\n // Font selector\n const fontSelect = this.elements.panel.querySelector('[data-customizer-font]');\n if (fontSelect) {\n this.addListener(fontSelect, 'change', (e) => {\n this.applyFont(e.target.value);\n this.updateUI();\n });\n }\n\n // Reset button\n const resetBtn = this.elements.panel.querySelector('.customizer-reset');\n if (resetBtn) {\n this.addListener(resetBtn, 'click', () => {\n this.reset();\n });\n }\n\n // Mobile close button\n const closeBtn = this.elements.panel.querySelector('.customizer-mobile-close');\n if (closeBtn) {\n this.addListener(closeBtn, 'click', () => {\n this.close();\n });\n }\n\n // Overlay click\n if (this.elements.overlay) {\n this.addListener(this.elements.overlay, 'click', () => {\n this.close();\n });\n }\n },\n\n /**\n * Generate panel HTML\n */\n getPanelHTML: function () {\n const esc = typeof escapeHtml === 'function'\n ? escapeHtml\n : function (text) {\n const div = document.createElement('div');\n div.textContent = String(text ?? '');\n return div.innerHTML;\n };\n const safeColor = function (value) {\n const normalized = String(value ?? '').trim();\n // Allow common color formats used by palette values.\n if (/^(#[0-9a-fA-F]{3,8}|rgb[a]?\\([^)]{1,60}\\)|hsl[a]?\\([^)]{1,60}\\)|var\\(--[a-zA-Z0-9_-]{1,40}\\))$/.test(normalized)) {\n return normalized;\n }\n return '#000000';\n };\n\n // Generate primary color swatches\n let primarySwatches = '';\n for (const [key, value] of Object.entries(this.PRIMARY_COLORS)) {\n primarySwatches += ``;\n }\n\n // Generate neutral color swatches\n let neutralSwatches = '';\n for (const [key, value] of Object.entries(this.NEUTRAL_COLORS)) {\n neutralSwatches += ``;\n }\n\n // Generate radius buttons\n let radiusButtons = '';\n this.RADIUS_OPTIONS.forEach(r => {\n radiusButtons += ``;\n });\n\n // Generate font options\n let fontOptions = '';\n for (const [key, value] of Object.entries(this.FONT_OPTIONS)) {\n fontOptions += ``;\n }\n\n return `\n
    \n

    Customize Theme

    \n \n
    \n
    \n\n
    \n \n
    \n ${primarySwatches}\n
    \n
    \n
    \n \n
    \n ${neutralSwatches}\n
    \n
    \n
    \n \n
    \n ${radiusButtons}\n
    \n
    \n
    \n \n \n
    \n
    \n
    \n \n
    \n `;\n },\n\n /**\n * Bind event listeners\n */\n /**\n * Check whether the current primary color is one of the auto-defaults\n * (i.e. the user hasn't explicitly picked a non-default color).\n */\n isUsingDefaultPrimary: function () {\n return this.state.primary === this.DEFAULTS.PRIMARY_LIGHT ||\n this.state.primary === this.DEFAULTS.PRIMARY_DARK;\n },\n\n /**\n * When primary is still one of the auto-default palette keys (black/amber) but\n * localStorage was written under a different theme (or OS changed in system mode),\n * align in-memory state before applyAllPreferences runs \u2014 avoids amber+light / black+dark drift.\n */\n _normalizeDefaultPrimaryIfStaleWithStoredTheme: function () {\n if (!this.isUsingDefaultPrimary()) {\n return;\n }\n const expected = this.getDefaultPrimary(this.state.theme);\n if (this.state.primary !== expected) {\n this.state.primary = expected;\n }\n },\n\n bindEvents: function () {\n this.bindTriggerEvents();\n\n this.bindPanelEvents();\n\n // Listen for OS dark/light changes so we can swap the default primary\n if (window.matchMedia) {\n const mq = window.matchMedia('(prefers-color-scheme: dark)');\n const handler = () => {\n if (this.state.theme === 'system' && this.isUsingDefaultPrimary()) {\n const newDefault = this.getDefaultPrimary('system');\n if (newDefault !== this.state.primary) {\n this.applyPrimary(newDefault);\n this.updateUI();\n }\n }\n };\n mq.addEventListener('change', handler);\n this._cleanup.push(() => mq.removeEventListener('change', handler));\n }\n\n // Close on outside click\n this.addListener(document, 'click', (e) => {\n if (this.state.isOpen && this.elements.customizer && !this.elements.customizer.contains(e.target)) {\n this.close();\n }\n });\n\n // Close on Escape key\n this.addListener(document, 'keydown', (e) => {\n if (e.key === 'Escape' && this.state.isOpen) {\n this.close();\n }\n });\n },\n\n bindTriggerEvents: function () {\n this.elements.triggers.forEach((trigger) => {\n if (trigger.getAttribute('data-customizer-trigger-initialized') === 'true') {\n return;\n }\n this.addListener(trigger, 'click', (e) => {\n e.preventDefault();\n e.stopPropagation();\n this.elements.activeTrigger = trigger;\n this.elements.trigger = trigger;\n this.toggle();\n });\n trigger.setAttribute('data-customizer-trigger-initialized', 'true');\n });\n },\n\n /**\n * Toggle panel open/close\n */\n toggle: function () {\n if (this.state.isOpen) {\n this.close();\n } else {\n this.open();\n }\n },\n\n /**\n * Open the panel\n */\n open: function () {\n this.state.isOpen = true;\n\n // Ensure panel is positioned correctly before opening\n this.positionPanel();\n\n if (this.elements.panel) {\n this.elements.panel.classList.add('is-open');\n }\n this.elements.triggers.forEach((trigger) => trigger.setAttribute('aria-expanded', 'false'));\n if (this.elements.trigger) this.elements.trigger.setAttribute('aria-expanded', 'true');\n if (this.elements.overlay) {\n this.elements.overlay.classList.add('is-active');\n }\n\n this.dispatchEvent('panel-open', { isOpen: true });\n },\n\n /**\n * Close the panel\n */\n close: function () {\n this.state.isOpen = false;\n\n if (this.elements.panel) {\n this.elements.panel.classList.remove('is-open');\n }\n this.elements.triggers.forEach((trigger) => trigger.setAttribute('aria-expanded', 'false'));\n if (this.elements.overlay) {\n this.elements.overlay.classList.remove('is-active');\n }\n\n this.dispatchEvent('panel-close', { isOpen: false });\n },\n\n /**\n * Update UI to reflect current state\n */\n updateUI: function () {\n if (!this.elements.panel) return;\n\n // Update primary color swatches\n this.elements.panel.querySelectorAll('[data-color]').forEach(swatch => {\n swatch.classList.toggle('is-active', swatch.dataset.color === this.state.primary);\n });\n\n // Update neutral color swatches\n this.elements.panel.querySelectorAll('[data-neutral]').forEach(swatch => {\n swatch.classList.toggle('is-active', swatch.dataset.neutral === this.state.neutral);\n });\n\n // Update radius buttons\n this.elements.panel.querySelectorAll('[data-radius]').forEach(btn => {\n btn.classList.toggle('is-active', btn.dataset.radius === this.state.radius);\n });\n\n // Update font selector\n const fontSelect = this.elements.panel.querySelector('[data-customizer-font]');\n if (fontSelect) {\n fontSelect.value = this.state.font;\n }\n\n },\n\n /**\n * Reset all preferences to defaults\n */\n reset: function () {\n this.applyTheme(this.DEFAULTS.THEME);\n this.applyPrimary(this.getDefaultPrimary(this.DEFAULTS.THEME));\n this.applyNeutral(this.DEFAULTS.NEUTRAL);\n this.applyRadius(this.DEFAULTS.RADIUS);\n this.applyFont(this.DEFAULTS.FONT);\n this.updateUI();\n\n this.dispatchEvent('reset', { state: { ...this.state } });\n },\n\n /**\n * Get current state\n */\n getState: function () {\n return { ...this.state };\n },\n\n /**\n * Programmatically set preferences\n */\n setPreferences: function (prefs) {\n if (prefs.primary) this.applyPrimary(prefs.primary);\n if (prefs.neutral) this.applyNeutral(prefs.neutral);\n if (prefs.radius) this.applyRadius(prefs.radius);\n if (prefs.font) this.applyFont(prefs.font);\n if (prefs.theme) this.applyTheme(prefs.theme);\n this.updateUI();\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 destroyAll: function () {\n this._cleanup.forEach(fn => fn());\n this._cleanup = [];\n\n if (this.elements.panel) {\n this.elements.panel.removeAttribute('data-customizer-initialized');\n }\n\n this.close();\n this.isInitialized = false;\n }\n };\n\n // Register component\n if (window.Vanduo) {\n window.Vanduo.register('themeCustomizer', ThemeCustomizer);\n }\n\n // Expose globally for convenience\n window.ThemeCustomizer = ThemeCustomizer;\n})();\n", "/**\n * Vanduo Framework - Theme Switcher\n * Handles light/dark/system theme toggling and persistence\n */\n\n(function () {\n 'use strict';\n\n const ThemeSwitcher = {\n isInitialized: false,\n _mediaQuery: null,\n _onMediaChange: null,\n\n init: function () {\n this.STORAGE_KEY = 'vanduo-theme-preference';\n this.state = {\n preference: this.getPreference() // 'light', 'dark', or 'system'\n };\n\n if (this.isInitialized) {\n this.applyTheme();\n this.renderUI();\n this.updateUI();\n return;\n }\n\n this.isInitialized = true;\n\n this.applyTheme();\n this.listenForSystemChanges();\n this.renderUI();\n\n console.log('Vanduo Theme Switcher initialized');\n },\n\n getPreference: function () {\n return this.getStorageValue(this.STORAGE_KEY, 'system');\n },\n\n setPreference: function (pref) {\n this.state.preference = pref;\n this.setStorageValue(this.STORAGE_KEY, pref);\n this.applyTheme();\n \n // Coordinate with ThemeCustomizer for primary color swap if available\n // Check _isApplying flag to prevent circular updates\n if (window.ThemeCustomizer && window.ThemeCustomizer.applyTheme && !window.ThemeCustomizer._isApplying) {\n window.ThemeCustomizer.applyTheme(pref);\n }\n \n this.updateUI();\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 applyTheme: function () {\n const pref = this.state.preference;\n\n if (pref === 'system') {\n // When in system mode, we remove the data attribute to let the media query take over\n // or we can explicitly set it. Explicitly setting it ensures consistency if we rely on [data-theme]\n // But if we rely on @media in CSS, we might want to remove attributes.\n // However, our CSS strategy uses :root:not([data-theme=\"light\"]) inside media query for system dark fallback\n // which is a bit complex.\n\n // Simpler approach: \n // If preference is system, REMOVE data-theme attribute. Let CSS media queries handle it.\n document.documentElement.removeAttribute('data-theme');\n } else {\n document.documentElement.setAttribute('data-theme', pref);\n }\n },\n\n listenForSystemChanges: function () {\n if (this._mediaQuery && this._onMediaChange) {\n return;\n }\n\n this._mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n this._onMediaChange = _e => {\n if (this.state.preference === 'system') {\n // Re-apply (effectively just to ensure consistency, though removing attribute usually suffices)\n this.applyTheme();\n // Keep default primary (black/amber) aligned when OS scheme changes while in system mode\n if (window.ThemeCustomizer && typeof window.ThemeCustomizer.applyTheme === 'function' && !window.ThemeCustomizer._isApplying) {\n window.ThemeCustomizer.applyTheme('system');\n }\n }\n };\n this._mediaQuery.addEventListener('change', this._onMediaChange);\n },\n\n // Helper to facilitate UI creation if needed, though often UI is in HTML\n renderUI: function () {\n // Look for any uninitialized theme toggles\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"]');\n toggles.forEach(toggle => {\n if (toggle.getAttribute('data-theme-initialized') === 'true') {\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n }\n return;\n }\n\n // Simplified UI Binding - assumes a select or a button cycle\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n const onChange = (e) => {\n this.setPreference(e.target.value);\n };\n toggle.addEventListener('change', onChange);\n toggle._themeToggleHandler = onChange;\n } else {\n // Button implementation (cycle)\n const onClick = () => {\n const modes = ['system', 'light', 'dark'];\n const nextIndex = (modes.indexOf(this.state.preference) + 1) % modes.length;\n this.setPreference(modes[nextIndex]);\n };\n toggle.addEventListener('click', onClick);\n toggle._themeToggleHandler = onClick;\n }\n // Mark as initialized\n toggle.setAttribute('data-theme-initialized', 'true');\n });\n },\n\n updateUI: function () {\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"]');\n toggles.forEach(toggle => {\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n } else {\n // Update button text/icon if needed\n // e.g. toggle.textContent = this.state.preference;\n // For now, assume the user handles visual state or generic text\n\n // If there is an icon or text span inside, update it\n const span = toggle.querySelector('.theme-current-label');\n if (span) {\n span.textContent = this.state.preference.charAt(0).toUpperCase() + this.state.preference.slice(1);\n }\n }\n });\n },\n\n destroyAll: function () {\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"][data-theme-initialized=\"true\"]');\n toggles.forEach(toggle => {\n if (toggle._themeToggleHandler) {\n const eventName = toggle.tagName === 'SELECT' ? 'change' : 'click';\n toggle.removeEventListener(eventName, toggle._themeToggleHandler);\n delete toggle._themeToggleHandler;\n }\n toggle.removeAttribute('data-theme-initialized');\n });\n\n if (this._mediaQuery && this._onMediaChange) {\n this._mediaQuery.removeEventListener('change', this._onMediaChange);\n }\n\n this._mediaQuery = null;\n this._onMediaChange = null;\n this.isInitialized = false;\n }\n };\n\n // Register component\n if (window.Vanduo) {\n window.Vanduo.register('themeSwitcher', ThemeSwitcher);\n }\n})();\n", "/**\n * Vanduo Framework - Toast Component\n * Popup notifications for user feedback\n */\n\n(function() {\n 'use strict';\n\n /**\n * Toast Component\n */\n const Toast = {\n // Default options\n defaults: {\n position: 'top-right',\n duration: 5000,\n dismissible: true,\n showProgress: true,\n pauseOnHover: true\n },\n\n // Container cache\n containers: {},\n\n /**\n * Get or create a toast container for a position\n * @param {string} position - Container position\n * @returns {HTMLElement} Toast container element\n */\n getContainer: function(position) {\n if (this.containers[position]) {\n return this.containers[position];\n }\n\n const container = document.createElement('div');\n container.className = `vd-toast-container vd-toast-container-${position}`;\n container.setAttribute('role', 'status');\n container.setAttribute('aria-live', 'polite');\n container.setAttribute('aria-atomic', 'false');\n document.body.appendChild(container);\n this.containers[position] = container;\n\n return container;\n },\n\n /**\n * Show a toast notification\n * @param {Object|string} options - Toast options or message string\n * @param {string} [type] - Toast type (success, error, warning, info)\n * @param {number} [duration] - Auto-dismiss duration in ms\n * @returns {HTMLElement} Toast element\n */\n show: function(options, type, duration) {\n // Support simple API:\n // - Toast.show('Message', 'success', 3000)\n // - Toast.show('Message', { type: 'success', duration: 3000 })\n if (typeof options === 'string') {\n if (type && typeof type === 'object') {\n options = Object.assign({}, type, {\n message: options\n });\n } else {\n options = {\n message: options,\n type: type,\n duration: duration\n };\n }\n }\n\n const config = Object.assign({}, this.defaults, options);\n const container = this.getContainer(config.position);\n\n // Create toast element\n const toast = document.createElement('div');\n toast.className = 'vd-toast';\n\n if (config.type) {\n toast.classList.add(`vd-toast-${config.type}`);\n }\n\n if (config.solid) {\n toast.classList.add('vd-toast-solid');\n }\n\n if (config.showProgress && config.duration > 0) {\n toast.classList.add('vd-toast-with-progress');\n }\n\n // Build toast content\n let html = '';\n\n // Icon (sanitize custom icons, default icons are trusted SVG)\n if (config.icon) {\n const allowSvg = config.iconAllowSvg === true;\n const safeIcon = typeof sanitizeHtml === 'function'\n ? sanitizeHtml(config.icon, { allowSvg })\n : escapeHtml(config.icon);\n html += `${safeIcon}`;\n } else if (config.type) {\n html += `${this.getDefaultIcon(config.type)}`;\n }\n\n // Local escape helper \u2014 guarantees HTML-safe output even if the\n // global escapeHtml utility is not loaded in the current bundle.\n const _esc = typeof escapeHtml === 'function'\n ? escapeHtml\n : function (s) {\n const d = document.createElement('div');\n d.appendChild(document.createTextNode(s));\n return d.innerHTML;\n };\n\n // Content (escape text to prevent injection)\n html += '
    ';\n if (config.title) {\n html += `
    ${_esc(String(config.title))}
    `;\n }\n if (config.message) {\n html += `
    ${_esc(String(config.message))}
    `;\n }\n html += '
    ';\n\n // Close button\n if (config.dismissible) {\n html += '';\n }\n\n // Progress bar\n if (config.showProgress && config.duration > 0) {\n const safeDuration = parseInt(config.duration, 10) || 0;\n html += `
    `;\n }\n\n toast.innerHTML = html;\n\n // Add to container\n container.appendChild(toast);\n\n toast._toastCleanup = [];\n\n // Set up close button handler\n if (config.dismissible) {\n const closeBtn = toast.querySelector('.vd-toast-close');\n const onClose = () => {\n this.dismiss(toast);\n };\n closeBtn.addEventListener('click', onClose);\n toast._toastCleanup.push(() => closeBtn.removeEventListener('click', onClose));\n }\n\n // Pause on hover\n let timeoutId = null;\n let remainingTime = config.duration;\n let startTime = null;\n\n const startTimer = () => {\n if (config.duration > 0) {\n startTime = Date.now();\n timeoutId = setTimeout(() => {\n this.dismiss(toast);\n }, remainingTime);\n toast._toastTimeoutId = timeoutId;\n\n // Resume progress animation\n const progress = toast.querySelector('.vd-toast-progress');\n if (progress) {\n progress.style.animationPlayState = 'running';\n }\n }\n };\n\n const pauseTimer = () => {\n if (timeoutId) {\n clearTimeout(timeoutId);\n timeoutId = null;\n toast._toastTimeoutId = null;\n remainingTime -= Date.now() - startTime;\n\n // Pause progress animation\n const progress = toast.querySelector('.vd-toast-progress');\n if (progress) {\n progress.style.animationPlayState = 'paused';\n }\n }\n };\n\n if (config.pauseOnHover) {\n toast.addEventListener('mouseenter', pauseTimer);\n toast.addEventListener('mouseleave', startTimer);\n toast._toastCleanup.push(\n () => toast.removeEventListener('mouseenter', pauseTimer),\n () => toast.removeEventListener('mouseleave', startTimer)\n );\n }\n\n // Trigger enter animation\n requestAnimationFrame(() => {\n toast.classList.add('is-visible');\n startTimer();\n });\n\n // Store config on element for later access\n toast._toastConfig = config;\n\n // Dispatch show event\n const showEvent = new CustomEvent('toast:show', {\n bubbles: true,\n detail: { toast, config }\n });\n toast.dispatchEvent(showEvent);\n\n return toast;\n },\n\n /**\n * Dismiss a toast\n * @param {HTMLElement} toast - Toast element to dismiss\n */\n dismiss: function(toast) {\n if (!toast || toast.classList.contains('is-exiting')) return;\n\n if (toast._toastTimeoutId) {\n clearTimeout(toast._toastTimeoutId);\n toast._toastTimeoutId = null;\n }\n\n toast.classList.remove('is-visible');\n toast.classList.add('is-exiting');\n\n // Dispatch dismiss event\n const dismissEvent = new CustomEvent('toast:dismiss', {\n bubbles: true,\n detail: { toast }\n });\n toast.dispatchEvent(dismissEvent);\n\n // Remove after animation\n const handleTransitionEnd = () => {\n toast.removeEventListener('transitionend', handleTransitionEnd);\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n };\n\n toast.addEventListener('transitionend', handleTransitionEnd);\n\n // Fallback removal if transition doesn't fire\n setTimeout(() => {\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n }, 400);\n },\n\n /**\n * Destroy all toasts and containers\n */\n destroyAll: function() {\n Object.keys(this.containers).forEach(position => {\n const container = this.containers[position];\n if (!container) return;\n\n const toasts = container.querySelectorAll('.vd-toast');\n toasts.forEach(toast => {\n if (toast._toastTimeoutId) {\n clearTimeout(toast._toastTimeoutId);\n }\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n });\n\n if (container.parentElement) {\n container.parentElement.removeChild(container);\n }\n });\n\n this.containers = {};\n },\n\n /**\n * Dismiss all toasts\n * @param {string} [position] - Optional position to clear (clears all if not specified)\n */\n dismissAll: function(position) {\n if (position && this.containers[position]) {\n const toasts = this.containers[position].querySelectorAll('.vd-toast');\n toasts.forEach(toast => this.dismiss(toast));\n } else {\n Object.values(this.containers).forEach(container => {\n const toasts = container.querySelectorAll('.vd-toast');\n toasts.forEach(toast => this.dismiss(toast));\n });\n }\n },\n\n /**\n * Get default icon SVG for a type\n * @param {string} type - Toast type\n * @returns {string} SVG icon markup\n */\n getDefaultIcon: function(type) {\n const icons = {\n success: '',\n error: '',\n warning: '',\n info: ''\n };\n\n return icons[type] || '';\n },\n\n /**\n * Convenience methods for common toast types\n */\n success: function(message, options) {\n return this.show(Object.assign({ message, type: 'success' }, options));\n },\n\n error: function(message, options) {\n return this.show(Object.assign({ message, type: 'error' }, options));\n },\n\n warning: function(message, options) {\n return this.show(Object.assign({ message, type: 'warning' }, options));\n },\n\n info: function(message, options) {\n return this.show(Object.assign({ message, type: 'info' }, options));\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('toast', Toast);\n }\n\n // Also expose globally for convenience\n window.Toast = Toast;\n\n})();\n", "/**\n * Vanduo Framework - Tooltips Component\n * JavaScript functionality for tooltips\n */\n\n(function () {\n 'use strict';\n\n /**\n * Tooltips Component\n */\n const Tooltips = {\n tooltips: new Map(),\n delayTimers: new Map(),\n\n /**\n * Sanitize HTML \u2014 delegates to shared sanitizeHtml from helpers.js\n * @param {string} input\n * @param {{ allowSvg?: boolean }} [options]\n * @returns {string} sanitized HTML\n */\n sanitizeHtml: function (input, options) {\n if (typeof sanitizeHtml === 'function') {\n return sanitizeHtml(input, options);\n }\n // Fallback: content originates from developer-authored data attributes,\n // so returning it as-is is no worse than any other inline HTML.\n return input || '';\n },\n\n /**\n * Initialize tooltips\n */\n init: function () {\n const elements = document.querySelectorAll('[data-tooltip], [data-tooltip-html]');\n\n elements.forEach(element => {\n if (this.tooltips.has(element)) {\n return;\n }\n this.initTooltip(element);\n });\n },\n\n /**\n * Initialize a single tooltip\n * @param {HTMLElement} element - Element with tooltip\n */\n initTooltip: function (element) {\n const tooltip = this.createTooltip(element);\n const cleanupFunctions = [];\n\n // Show on hover/focus\n const enterHandler = () => { this.showTooltip(element, tooltip); };\n const leaveHandler = () => { this.hideTooltip(element, tooltip); };\n const focusHandler = () => { this.showTooltip(element, tooltip); };\n const blurHandler = () => { this.hideTooltip(element, tooltip); };\n\n element.addEventListener('mouseenter', enterHandler);\n element.addEventListener('mouseleave', leaveHandler);\n element.addEventListener('focus', focusHandler);\n element.addEventListener('blur', blurHandler);\n\n cleanupFunctions.push(\n () => element.removeEventListener('mouseenter', enterHandler),\n () => element.removeEventListener('mouseleave', leaveHandler),\n () => element.removeEventListener('focus', focusHandler),\n () => element.removeEventListener('blur', blurHandler)\n );\n\n this.tooltips.set(element, { tooltip, cleanup: cleanupFunctions });\n },\n\n /**\n * Create tooltip element\n * @param {HTMLElement} element - Target element\n * @returns {HTMLElement} Tooltip element\n */\n createTooltip: function (element) {\n const tooltip = document.createElement('div');\n tooltip.className = 'vd-tooltip';\n tooltip.setAttribute('role', 'tooltip');\n tooltip.setAttribute('aria-hidden', 'true');\n\n // Generate unique ID and link via aria-describedby\n const tooltipId = 'tooltip-' + Math.random().toString(36).substr(2, 9);\n tooltip.id = tooltipId;\n element.setAttribute('aria-describedby', tooltipId);\n\n // Get content\n const htmlContent = element.dataset.tooltipHtml;\n const textContent = element.dataset.tooltip;\n\n if (htmlContent) {\n const allowSvg = element.hasAttribute('data-tooltip-allow-svg');\n tooltip.innerHTML = this.sanitizeHtml(htmlContent, { allowSvg });\n tooltip.classList.add('vd-tooltip-html');\n } else if (textContent) {\n tooltip.textContent = textContent;\n }\n\n // Get placement\n const placement = element.dataset.tooltipPlacement || element.dataset.placement || 'top';\n tooltip.setAttribute('data-placement', placement);\n tooltip.classList.add(`vd-tooltip-${placement}`);\n\n // Get variant\n if (element.dataset.tooltipVariant) {\n tooltip.classList.add(`vd-tooltip-${element.dataset.tooltipVariant}`);\n }\n\n // Get size\n if (element.dataset.tooltipSize) {\n tooltip.classList.add(`vd-tooltip-${element.dataset.tooltipSize}`);\n }\n\n // Get delay\n const delay = parseInt(element.dataset.tooltipDelay) || 0;\n tooltip.dataset.delay = delay;\n\n document.body.appendChild(tooltip);\n\n return tooltip;\n },\n\n /**\n * Show tooltip\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n showTooltip: function (element, tooltip) {\n const delay = parseInt(tooltip.dataset.delay) || 0;\n\n if (delay > 0) {\n const timer = setTimeout(() => {\n this.positionTooltip(element, tooltip);\n tooltip.classList.add('is-visible');\n tooltip.setAttribute('aria-hidden', 'false');\n }, delay);\n this.delayTimers.set(element, timer);\n } else {\n this.positionTooltip(element, tooltip);\n tooltip.classList.add('is-visible');\n tooltip.setAttribute('aria-hidden', 'false');\n }\n },\n\n /**\n * Hide tooltip\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n hideTooltip: function (element, tooltip) {\n // Clear delay timer if exists\n const timer = this.delayTimers.get(element);\n if (timer) {\n clearTimeout(timer);\n this.delayTimers.delete(element);\n }\n\n tooltip.classList.remove('is-visible');\n tooltip.setAttribute('aria-hidden', 'true');\n },\n\n /**\n * Position tooltip relative to element\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n positionTooltip: function (element, tooltip) {\n const placement = tooltip.dataset.placement || 'top';\n const rect = element.getBoundingClientRect();\n const tooltipRect = tooltip.getBoundingClientRect();\n const scrollTop = window.pageYOffset || document.documentElement.scrollTop;\n const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;\n\n let top = 0;\n let left = 0;\n\n switch (placement) {\n case 'top':\n top = rect.top + scrollTop - tooltipRect.height - 8;\n left = rect.left + scrollLeft + (rect.width / 2) - (tooltipRect.width / 2);\n break;\n case 'bottom':\n top = rect.bottom + scrollTop + 8;\n left = rect.left + scrollLeft + (rect.width / 2) - (tooltipRect.width / 2);\n break;\n case 'left':\n top = rect.top + scrollTop + (rect.height / 2) - (tooltipRect.height / 2);\n left = rect.left + scrollLeft - tooltipRect.width - 8;\n break;\n case 'right':\n top = rect.top + scrollTop + (rect.height / 2) - (tooltipRect.height / 2);\n left = rect.right + scrollLeft + 8;\n break;\n }\n\n // Prevent overflow\n const viewportWidth = window.innerWidth;\n const viewportHeight = window.innerHeight;\n const padding = 8;\n\n if (left < padding) {\n left = padding;\n } else if (left + tooltipRect.width > viewportWidth - padding) {\n left = viewportWidth - tooltipRect.width - padding;\n }\n\n if (top < scrollTop + padding) {\n top = scrollTop + padding;\n } else if (top + tooltipRect.height > scrollTop + viewportHeight - padding) {\n top = scrollTop + viewportHeight - tooltipRect.height - padding;\n }\n\n // Use single style assignment with transform for better performance\n tooltip.style.cssText = `position: absolute; top: 0; left: 0; transform: translate(${left}px, ${top}px);`;\n },\n\n /**\n * Show tooltip programmatically\n * @param {HTMLElement|string} element - Target element or selector\n */\n show: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n this.showTooltip(el, tooltip);\n }\n },\n\n /**\n * Hide tooltip programmatically\n * @param {HTMLElement|string} element - Target element or selector\n */\n hide: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n this.hideTooltip(el, tooltip);\n }\n },\n\n /**\n * Update tooltip content\n * @param {HTMLElement|string} element - Target element or selector\n * @param {string} content - New content\n * @param {boolean} isHtml - Whether content is HTML\n */\n update: function (element, content, isHtml = false) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n if (isHtml) {\n const allowSvg = el.hasAttribute('data-tooltip-allow-svg');\n tooltip.innerHTML = this.sanitizeHtml(content, { allowSvg });\n tooltip.classList.add('vd-tooltip-html');\n } else {\n tooltip.textContent = content;\n tooltip.classList.remove('vd-tooltip-html');\n }\n }\n },\n\n /**\n * Destroy a tooltip instance and clean up\n * @param {HTMLElement} element - Element with tooltip\n */\n destroy: function (element) {\n const data = this.tooltips.get(element);\n if (!data) return;\n\n // Clear any pending timer\n const timer = this.delayTimers.get(element);\n if (timer) {\n clearTimeout(timer);\n this.delayTimers.delete(element);\n }\n\n data.cleanup.forEach(fn => fn());\n\n // Remove tooltip element from DOM\n if (data.tooltip && data.tooltip.parentNode) {\n data.tooltip.parentNode.removeChild(data.tooltip);\n }\n\n this.tooltips.delete(element);\n },\n\n /**\n * Destroy all tooltip instances\n */\n destroyAll: function () {\n this.tooltips.forEach((data, element) => {\n this.destroy(element);\n });\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('tooltips', Tooltips);\n }\n\n // Expose globally\n window.VanduoTooltips = Tooltips;\n\n})();\n", "/**\n * Vanduo Framework - Search Component\n * Client-side search functionality for content pages\n * \n * @example Basic usage (initialize with defaults)\n * // HTML:\n * //
    \n * // \n * //
    \n * //
    \n * \n * @example Custom configuration\n * var search = Search.create({\n * containerSelector: '.my-search',\n * contentSelector: 'article[id]',\n * titleSelector: 'h2, h3',\n * maxResults: 5,\n * onSelect: function(result) {\n * console.log('Selected:', result.title);\n * }\n * });\n * \n * @example With custom data source\n * var search = Search.create({\n * containerSelector: '.my-search',\n * data: [\n * { id: 'item1', title: 'First Item', content: 'Description...', category: 'Category A' },\n * { id: 'item2', title: 'Second Item', content: 'Description...', category: 'Category B' }\n * ]\n * });\n */\n\n(function() {\n 'use strict';\n\n /**\n * Default configuration\n */\n const DEFAULTS = {\n // Behavior\n minQueryLength: 2,\n maxResults: 10,\n debounceMs: 150,\n highlightTag: 'mark',\n keyboardShortcut: true, // Enable Cmd/Ctrl+K shortcut\n \n // Selectors (for DOM-based indexing)\n containerSelector: '.vd-doc-search',\n inputSelector: '.vd-doc-search-input',\n resultsSelector: '.vd-doc-search-results',\n contentSelector: '.doc-content section[id]',\n titleSelector: '.demo-title, h2, h3',\n navSelector: '.doc-nav-link',\n sectionSelector: '.doc-nav-section',\n \n // Content extraction\n excludeFromContent: 'pre, code, script, style',\n maxContentLength: 500,\n \n // Custom data source (alternative to DOM indexing)\n data: null,\n \n // Category icons mapping\n categoryIcons: {\n 'getting-started': 'ph-rocket-launch',\n 'core': 'ph-cube',\n 'components': 'ph-puzzle-piece',\n 'interactive': 'ph-cursor-click',\n 'data-display': 'ph-table',\n 'feedback': 'ph-bell',\n 'meta': 'ph-info',\n 'default': 'ph-file-text'\n },\n \n // Callbacks\n onSelect: null, // function(result) - called when result is selected\n onSearch: null, // function(query, results) - called after search\n onOpen: null, // function() - called when results open\n onClose: null, // function() - called when results close\n \n // Text customization\n emptyTitle: 'No results found',\n emptyText: 'Try different keywords or check spelling',\n placeholder: 'Search...'\n };\n\n /**\n * Search Component Factory\n * Creates a new search instance with the given configuration\n * \n * @param {Object} options - Configuration options\n * @returns {Object} Search instance\n */\n function createSearch(options) {\n const config = Object.assign({}, DEFAULTS, options || {});\n \n // Instance state\n const state = {\n initialized: false,\n index: [],\n results: [],\n activeIndex: -1,\n isOpen: false,\n query: '',\n container: null,\n input: null,\n resultsContainer: null,\n debounceTimer: null,\n boundHandlers: {}\n };\n\n function safeInvokeCallback(name, fn, ...args) {\n try {\n fn(...args);\n } catch (error) {\n console.warn('[Vanduo Search] Callback error in \"' + name + '\":', error);\n }\n }\n\n function setResultsHtml(html) {\n if (!state.resultsContainer) return;\n try {\n state.resultsContainer.innerHTML = html;\n } catch (error) {\n console.warn('[Vanduo Search] Failed to render results:', error);\n }\n }\n\n /**\n * Initialize the search component\n * Idempotent \u2014 safe to call more than once on the same instance.\n * Returns the instance on success, null if required DOM elements are missing.\n */\n function init() {\n if (state.initialized) {\n return instance;\n }\n\n state.container = document.querySelector(config.containerSelector);\n if (!state.container) {\n state.initialized = false;\n return null;\n }\n\n state.input = state.container.querySelector(config.inputSelector);\n state.resultsContainer = state.container.querySelector(config.resultsSelector);\n\n if (!state.input || !state.resultsContainer) {\n state.initialized = false;\n return null;\n }\n\n // Set placeholder if configured\n if (config.placeholder) {\n state.input.setAttribute('placeholder', config.placeholder);\n }\n\n // Build search index\n buildIndex();\n\n // Bind events\n bindEvents();\n\n // Set up ARIA attributes\n setupAria();\n\n state.initialized = true;\n return instance;\n }\n\n /**\n * Build search index from DOM or custom data\n */\n function buildIndex() {\n state.index = [];\n \n // Use custom data if provided\n if (config.data && Array.isArray(config.data)) {\n config.data.forEach(function(item) {\n state.index.push({\n id: item.id || slugify(item.title),\n title: item.title || '',\n category: item.category || '',\n categorySlug: slugify(item.category || ''),\n content: item.content || '',\n keywords: item.keywords || extractKeywordsFromText(item.title + ' ' + item.content),\n url: item.url || '#' + (item.id || slugify(item.title)),\n icon: item.icon || ''\n });\n });\n return;\n }\n\n // Build from DOM\n const sections = document.querySelectorAll(config.contentSelector);\n const categoryMap = buildCategoryMap();\n\n sections.forEach(function(section) {\n const id = section.id;\n if (!id) return;\n\n const titleEl = section.querySelector(config.titleSelector);\n const title = titleEl ? titleEl.textContent.replace(/v[\\d.]+/g, '').trim() : id;\n const category = categoryMap[id] || 'Documentation';\n const content = extractContent(section);\n const keywords = extractKeywords(section, title);\n const iconEl = titleEl ? titleEl.querySelector('i.ph') : null;\n let icon = '';\n if (iconEl && iconEl.classList) {\n for (let ci = 0; ci < iconEl.classList.length; ci++) {\n if (iconEl.classList[ci].indexOf('ph-') === 0) {\n icon = iconEl.classList[ci];\n break;\n }\n }\n }\n\n state.index.push({\n id: id,\n title: title,\n category: category,\n categorySlug: slugify(category),\n content: content,\n keywords: keywords,\n url: '#' + id,\n icon: icon\n });\n });\n }\n\n /**\n * Build a map of section IDs to their categories\n */\n function buildCategoryMap() {\n const map = {};\n let currentCategory = 'Documentation';\n const navItems = document.querySelectorAll(config.navSelector + ', ' + config.sectionSelector);\n\n navItems.forEach(function(item) {\n if (item.classList.contains('doc-nav-section')) {\n currentCategory = item.textContent.trim();\n } else {\n const href = item.getAttribute('href');\n if (href && href.startsWith('#')) {\n const id = href.substring(1);\n map[id] = currentCategory;\n }\n }\n });\n\n return map;\n }\n\n /**\n * Extract searchable content from a section\n */\n function extractContent(section) {\n const clone = section.cloneNode(true);\n \n const toRemove = clone.querySelectorAll(config.excludeFromContent);\n toRemove.forEach(function(el) {\n el.remove();\n });\n\n let text = clone.textContent || '';\n text = text.replace(/\\s+/g, ' ').trim();\n \n return text.substring(0, config.maxContentLength);\n }\n\n /**\n * Extract keywords from a section\n */\n function extractKeywords(section, title) {\n const keywords = [];\n \n // Add title words\n title.toLowerCase().split(/\\s+/).forEach(function(word) {\n if (word.length > 2) {\n keywords.push(word);\n }\n });\n\n // Add class names from code examples\n const codeBlocks = section.querySelectorAll('code');\n codeBlocks.forEach(function(code) {\n const text = code.textContent || '';\n const classMatches = text.match(/\\.([\\w-]+)/g);\n if (classMatches) {\n classMatches.forEach(function(match) {\n keywords.push(match.substring(1).toLowerCase());\n });\n }\n });\n\n // Add data attributes\n const dataAttrs = section.querySelectorAll('[data-tooltip], [data-modal]');\n dataAttrs.forEach(function(el) {\n const attrs = el.getAttributeNames().filter(function(name) {\n return name.startsWith('data-');\n });\n attrs.forEach(function(attr) {\n keywords.push(attr.replace('data-', ''));\n });\n });\n\n return Array.from(new Set(keywords));\n }\n\n /**\n * Extract keywords from text string\n */\n function extractKeywordsFromText(text) {\n const words = text.toLowerCase().split(/\\s+/);\n return words.filter(function(word) {\n return word.length > 2;\n });\n }\n\n /**\n * Convert string to slug\n */\n function slugify(str) {\n return str.toLowerCase().replace(/\\s+/g, '-').replace(/[^a-z0-9-]/g, '');\n }\n\n /**\n * Bind event listeners\n */\n function bindEvents() {\n // Store bound handlers for cleanup\n state.boundHandlers.handleInput = function(e) {\n handleInput(e);\n };\n state.boundHandlers.handleFocus = function() {\n if (state.query.length >= config.minQueryLength) {\n open();\n }\n };\n state.boundHandlers.handleKeydown = function(e) {\n handleKeydown(e);\n };\n state.boundHandlers.handleOutsideClick = function(e) {\n if (!state.container.contains(e.target)) {\n close();\n }\n };\n state.boundHandlers.handleGlobalKeydown = function(e) {\n if (config.keyboardShortcut && (e.metaKey || e.ctrlKey) && e.key === 'k') {\n e.preventDefault();\n state.input.focus();\n state.input.select();\n }\n };\n state.boundHandlers.handleResultClick = function(e) {\n const result = e.target.closest('.vd-doc-search-result');\n if (result) {\n const index = parseInt(result.dataset.index, 10);\n select(index);\n }\n };\n\n // Input events\n state.input.addEventListener('input', state.boundHandlers.handleInput);\n state.input.addEventListener('focus', state.boundHandlers.handleFocus);\n state.input.addEventListener('keydown', state.boundHandlers.handleKeydown);\n\n // Close on outside click\n document.addEventListener('click', state.boundHandlers.handleOutsideClick);\n\n // Global keyboard shortcut\n document.addEventListener('keydown', state.boundHandlers.handleGlobalKeydown);\n\n // Result click handling\n state.resultsContainer.addEventListener('click', state.boundHandlers.handleResultClick);\n }\n\n /**\n * Unbind event listeners\n */\n function unbindEvents() {\n if (state.input) {\n state.input.removeEventListener('input', state.boundHandlers.handleInput);\n state.input.removeEventListener('focus', state.boundHandlers.handleFocus);\n state.input.removeEventListener('keydown', state.boundHandlers.handleKeydown);\n }\n document.removeEventListener('click', state.boundHandlers.handleOutsideClick);\n document.removeEventListener('keydown', state.boundHandlers.handleGlobalKeydown);\n if (state.resultsContainer) {\n state.resultsContainer.removeEventListener('click', state.boundHandlers.handleResultClick);\n }\n }\n\n /**\n * Set up ARIA attributes\n */\n function setupAria() {\n const resultsId = state.resultsContainer.id || 'search-results-' + Math.random().toString(36).substr(2, 9);\n state.resultsContainer.id = resultsId;\n \n state.input.setAttribute('role', 'combobox');\n state.input.setAttribute('aria-autocomplete', 'list');\n state.input.setAttribute('aria-controls', resultsId);\n state.input.setAttribute('aria-expanded', 'false');\n \n state.resultsContainer.setAttribute('role', 'listbox');\n state.resultsContainer.setAttribute('aria-label', 'Search results');\n }\n\n /**\n * Handle input changes\n */\n function handleInput(e) {\n const query = e.target.value.trim();\n\n if (state.debounceTimer) {\n clearTimeout(state.debounceTimer);\n }\n\n state.debounceTimer = setTimeout(function() {\n state.query = query;\n\n if (query.length < config.minQueryLength) {\n close();\n return;\n }\n\n state.results = search(query);\n state.activeIndex = -1;\n render();\n open();\n\n // Callback\n if (typeof config.onSearch === 'function') {\n safeInvokeCallback('onSearch', config.onSearch, query, state.results);\n }\n }, config.debounceMs);\n }\n\n /**\n * Handle keyboard navigation\n */\n function handleKeydown(e) {\n if (!state.isOpen) {\n if (e.key === 'ArrowDown' && state.query.length >= config.minQueryLength) {\n e.preventDefault();\n state.results = search(state.query);\n render();\n open();\n }\n return;\n }\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n navigate(1);\n break;\n\n case 'ArrowUp':\n e.preventDefault();\n navigate(-1);\n break;\n\n case 'Enter':\n e.preventDefault();\n if (state.activeIndex >= 0) {\n select(state.activeIndex);\n } else if (state.results.length > 0) {\n select(0);\n }\n break;\n\n case 'Escape':\n e.preventDefault();\n close();\n break;\n\n case 'Tab':\n close();\n break;\n }\n }\n\n /**\n * Perform search\n */\n function search(query) {\n const terms = query.toLowerCase().split(/\\s+/).filter(function(t) {\n return t.length > 0;\n });\n const scored = [];\n\n state.index.forEach(function(entry) {\n let score = 0;\n const titleLower = entry.title.toLowerCase();\n const categoryLower = entry.category.toLowerCase();\n const contentLower = entry.content.toLowerCase();\n\n terms.forEach(function(term) {\n // Title match - highest priority\n if (titleLower.includes(term)) {\n score += 100;\n if (titleLower === term) {\n score += 50;\n } else if (titleLower.startsWith(term)) {\n score += 25;\n }\n }\n\n // Category match\n if (categoryLower.includes(term)) {\n score += 50;\n }\n\n // Keyword match\n const keywordMatch = entry.keywords.some(function(k) {\n return k.includes(term);\n });\n if (keywordMatch) {\n score += 30;\n }\n\n // Content match\n if (contentLower.includes(term)) {\n score += 10;\n }\n });\n\n if (score > 0) {\n scored.push({\n id: entry.id,\n title: entry.title,\n category: entry.category,\n categorySlug: entry.categorySlug,\n content: entry.content,\n url: entry.url,\n icon: entry.icon,\n score: score\n });\n }\n });\n\n scored.sort(function(a, b) {\n return b.score - a.score;\n });\n\n return scored.slice(0, config.maxResults);\n }\n\n /**\n * Render search results\n */\n function render() {\n if (state.results.length === 0) {\n setResultsHtml(renderEmpty());\n return;\n }\n\n let html = '';\n html += renderFooter();\n\n setResultsHtml(html);\n }\n\n /**\n * Render empty state\n */\n function renderEmpty() {\n return '
    ' +\n '
    ' +\n '
    ' + escapeHtml(config.emptyTitle) + '
    ' +\n '
    ' + escapeHtml(config.emptyText) + '
    ' +\n '
    ';\n }\n\n /**\n * Render footer with keyboard hints\n */\n function renderFooter() {\n return '
    ' +\n '\u2191\u2193 to navigate' +\n '\u21B5 to select' +\n 'esc to close' +\n '
    ';\n }\n\n /**\n * Get icon for category\n */\n function getCategoryIcon(categorySlug) {\n return config.categoryIcons[categorySlug] || config.categoryIcons['default'] || 'ph-file-text';\n }\n\n /**\n * Get excerpt from content\n */\n function getExcerpt(content, query) {\n const terms = query.toLowerCase().split(/\\s+/);\n const contentLower = content.toLowerCase();\n const excerptLength = 100;\n\n let matchPos = -1;\n for (let i = 0; i < terms.length; i++) {\n const pos = contentLower.indexOf(terms[i]);\n if (pos !== -1 && (matchPos === -1 || pos < matchPos)) {\n matchPos = pos;\n }\n }\n\n if (matchPos === -1) {\n return content.substring(0, excerptLength) + '...';\n }\n\n const start = Math.max(0, matchPos - 30);\n const end = Math.min(content.length, matchPos + excerptLength);\n let excerpt = content.substring(start, end);\n\n if (start > 0) {\n excerpt = '...' + excerpt;\n }\n if (end < content.length) {\n excerpt = excerpt + '...';\n }\n\n return excerpt;\n }\n\n /**\n * Highlight matched terms in text\n */\n function highlight(text, query) {\n if (!query) return escapeHtml(text);\n\n const terms = query.toLowerCase().split(/\\s+/).filter(function(t) {\n return t.length > 0;\n });\n let escaped = escapeHtml(text);\n\n terms.forEach(function(term) {\n // Skip overly long terms to prevent ReDoS\n if (term.length > 50) return;\n const regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi');\n escaped = escaped.replace(regex, '<' + config.highlightTag + '>$1');\n });\n\n return escaped;\n }\n\n /**\n * Escape HTML entities\n */\n function escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Navigate results with keyboard\n */\n function navigate(direction) {\n let newIndex = state.activeIndex + direction;\n\n if (newIndex < 0) {\n newIndex = state.results.length - 1;\n } else if (newIndex >= state.results.length) {\n newIndex = 0;\n }\n\n setActiveIndex(newIndex);\n }\n\n /**\n * Set active result index\n */\n function setActiveIndex(index) {\n const prevActive = state.resultsContainer.querySelector('.vd-doc-search-result.is-active');\n if (prevActive) {\n prevActive.classList.remove('is-active');\n prevActive.setAttribute('aria-selected', 'false');\n }\n\n state.activeIndex = index;\n\n const newActive = state.resultsContainer.querySelector('[data-index=\"' + index + '\"]');\n if (newActive) {\n newActive.classList.add('is-active');\n newActive.setAttribute('aria-selected', 'true');\n state.input.setAttribute('aria-activedescendant', 'vd-doc-search-result-' + index);\n newActive.scrollIntoView({ block: 'nearest' });\n }\n }\n\n /**\n * Select a result\n */\n function select(index) {\n const result = state.results[index];\n if (!result) return;\n\n // Close search\n close();\n state.input.value = '';\n state.query = '';\n\n // Custom callback\n if (typeof config.onSelect === 'function') {\n safeInvokeCallback('onSelect', config.onSelect, result);\n return;\n }\n\n // Default behavior: navigate to section\n const section = document.querySelector(result.url);\n if (section) {\n section.scrollIntoView({ behavior: 'smooth', block: 'start' });\n window.history.pushState(null, '', result.url);\n updateSidebarActive(result.id);\n }\n }\n\n /**\n * Update sidebar navigation active state\n */\n function updateSidebarActive(sectionId) {\n const navLinks = document.querySelectorAll(config.navSelector);\n navLinks.forEach(function(link) {\n link.classList.remove('active');\n if (link.getAttribute('href') === '#' + sectionId) {\n link.classList.add('active');\n }\n });\n }\n\n /**\n * Open results dropdown\n */\n function open() {\n if (state.isOpen) return;\n\n state.isOpen = true;\n state.resultsContainer.classList.add('is-open');\n state.input.setAttribute('aria-expanded', 'true');\n\n if (typeof config.onOpen === 'function') {\n safeInvokeCallback('onOpen', config.onOpen);\n }\n }\n\n /**\n * Close results dropdown\n */\n function close() {\n if (!state.isOpen) return;\n\n state.isOpen = false;\n state.activeIndex = -1;\n state.resultsContainer.classList.remove('is-open');\n state.input.setAttribute('aria-expanded', 'false');\n state.input.removeAttribute('aria-activedescendant');\n\n if (typeof config.onClose === 'function') {\n safeInvokeCallback('onClose', config.onClose);\n }\n }\n\n /**\n * Destroy the component\n */\n function destroy() {\n unbindEvents();\n \n state.initialized = false;\n state.index = [];\n state.results = [];\n state.isOpen = false;\n state.query = '';\n\n if (state.debounceTimer) {\n clearTimeout(state.debounceTimer);\n }\n\n if (state.resultsContainer) {\n setResultsHtml('');\n }\n }\n\n /**\n * Rebuild the search index\n */\n function rebuild() {\n buildIndex();\n }\n\n /**\n * Update configuration\n */\n function setConfig(newConfig) {\n Object.assign(config, newConfig);\n }\n\n /**\n * Get current configuration\n */\n function getConfig() {\n return Object.assign({}, config);\n }\n\n /**\n * Get search index\n */\n function getIndex() {\n return state.index.slice();\n }\n\n // Public instance API\n const instance = {\n init: init,\n destroy: destroy,\n rebuild: rebuild,\n search: search,\n open: open,\n close: close,\n setConfig: setConfig,\n getConfig: getConfig,\n getIndex: getIndex\n };\n\n return instance;\n }\n\n /**\n * Search Component (singleton for backward compatibility)\n */\n const Search = {\n // Factory method \u2014 creates and auto-initializes a new independent instance.\n // Always returns the instance so callers retain a reference even if the\n // DOM container is not yet available (they can retry init() later).\n create: function(options) {\n const instance = createSearch(options);\n if (instance) {\n instance.init();\n }\n return instance || null;\n },\n \n // Default instance\n _instance: null,\n \n // Configuration (for default instance)\n config: Object.assign({}, DEFAULTS),\n\n /**\n * Initialize the default search instance\n */\n init: function(options) {\n if (this._instance) {\n this._instance.destroy();\n }\n \n if (options) {\n Object.assign(this.config, options);\n }\n \n this._instance = createSearch(this.config);\n return this._instance ? this._instance.init() : null;\n },\n\n /**\n * Destroy the default instance\n */\n destroy: function() {\n if (this._instance) {\n this._instance.destroy();\n this._instance = null;\n }\n },\n\n destroyAll: function() {\n this.destroy();\n },\n\n /**\n * Rebuild the default instance index\n */\n rebuild: function() {\n if (this._instance) {\n this._instance.rebuild();\n }\n },\n\n /**\n * Search using the default instance\n */\n search: function(query) {\n return this._instance ? this._instance.search(query) : [];\n },\n\n /**\n * Open the default instance\n */\n open: function() {\n if (this._instance) {\n this._instance.open();\n }\n },\n\n /**\n * Close the default instance\n */\n close: function() {\n if (this._instance) {\n this._instance.close();\n }\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('docSearch', Search);\n }\n\n // Expose globally (both names for compatibility)\n window.Search = Search;\n window.DocSearch = Search; // Backward compatibility\n window.VanduoDocSearch = Search; // New name compatibility\n\n})();\n", "/**\n * Vanduo Framework - Draggable Component\n * JavaScript functionality for draggable elements and drop zones\n */\n\n(function () {\n 'use strict';\n\n /**\n * Draggable Component\n */\n const Draggable = {\n // Store initialized draggables and their cleanup functions\n instances: new Map(),\n // Store current drag state\n currentDrag: null,\n // Store touch state\n touchState: null,\n // Feedback element\n feedbackElement: null,\n // Shared selector used by init and touch reorder\n containerSelector: '.vd-draggable-container, .vd-draggable-container-vertical',\n\n /**\n * Initialize draggable components\n */\n init: function () {\n const draggables = document.querySelectorAll('.vd-draggable, [data-draggable]');\n\n draggables.forEach(element => {\n if (this.instances.has(element)) {\n return;\n }\n this.initDraggable(element);\n });\n\n const containers = document.querySelectorAll(this.containerSelector);\n containers.forEach(container => {\n if (!this.instances.has(container)) {\n this.initContainer(container);\n }\n });\n\n const dropZones = document.querySelectorAll('.vd-drop-zone');\n dropZones.forEach(zone => {\n if (!this.instances.has(zone)) {\n this.initDropZone(zone);\n }\n });\n\n this.createFeedbackElement();\n },\n\n /**\n * Initialize a single draggable element\n * @param {HTMLElement} element - Draggable element\n */\n initDraggable: function (element) {\n const cleanupFunctions = [];\n\n // Make element draggable if not already\n if (!element.hasAttribute('draggable')) {\n element.setAttribute('draggable', 'true');\n }\n\n // Accessibility: add ARIA attributes\n if (!element.hasAttribute('tabindex')) {\n element.setAttribute('tabindex', '0');\n }\n element.setAttribute('role', 'option');\n element.setAttribute('aria-roledescription', 'draggable item');\n element.setAttribute('aria-grabbed', 'false');\n\n // Handle drag start\n const dragStartHandler = (e) => {\n this.handleDragStart(e, element);\n };\n element.addEventListener('dragstart', dragStartHandler);\n cleanupFunctions.push(() => element.removeEventListener('dragstart', dragStartHandler));\n\n // Handle drag\n const dragHandler = (e) => {\n this.handleDrag(e, element);\n };\n element.addEventListener('drag', dragHandler);\n cleanupFunctions.push(() => element.removeEventListener('drag', dragHandler));\n\n // Handle drag end\n const dragEndHandler = (e) => {\n this.handleDragEnd(e, element);\n };\n element.addEventListener('dragend', dragEndHandler);\n cleanupFunctions.push(() => element.removeEventListener('dragend', dragEndHandler));\n\n // Handle touch start (for mobile)\n const touchStartHandler = (e) => {\n this.handleTouchStart(e, element);\n };\n element.addEventListener('touchstart', touchStartHandler);\n cleanupFunctions.push(() => element.removeEventListener('touchstart', touchStartHandler));\n\n // Handle touch move (for mobile)\n // { passive: false } is required so that e.preventDefault() works\n // once the drag threshold is reached \u2014 without it, modern browsers\n // treat the listener as passive and silently ignore preventDefault().\n const touchMoveHandler = (e) => {\n this.handleTouchMove(e, element);\n };\n element.addEventListener('touchmove', touchMoveHandler, { passive: false });\n cleanupFunctions.push(() => element.removeEventListener('touchmove', touchMoveHandler));\n\n // Handle touch end (for mobile)\n const touchEndHandler = (e) => {\n this.handleTouchEnd(e, element);\n };\n element.addEventListener('touchend', touchEndHandler, { passive: false });\n cleanupFunctions.push(() => element.removeEventListener('touchend', touchEndHandler));\n\n // Handle touch cancel (for mobile)\n const touchCancelHandler = (e) => {\n this.handleTouchEnd(e, element);\n };\n element.addEventListener('touchcancel', touchCancelHandler);\n cleanupFunctions.push(() => element.removeEventListener('touchcancel', touchCancelHandler));\n\n // Keyboard navigation\n const keydownHandler = (e) => {\n this.handleKeydown(e, element);\n };\n element.addEventListener('keydown', keydownHandler);\n cleanupFunctions.push(() => element.removeEventListener('keydown', keydownHandler));\n\n this.instances.set(element, { cleanup: cleanupFunctions });\n },\n\n /**\n * Initialize a draggable container\n * @param {HTMLElement} container - Draggable container\n */\n initContainer: function (container) {\n // Accessibility: add ARIA role to container\n container.setAttribute('role', 'listbox');\n container.setAttribute('aria-label', container.getAttribute('aria-label') || 'Draggable items');\n\n const items = container.querySelectorAll('.vd-draggable-item');\n items.forEach(item => {\n if (!this.instances.has(item)) {\n this.initDraggable(item);\n }\n });\n\n const cleanupFunctions = [];\n\n // Handle drag enter\n const dragEnterHandler = (e) => {\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n };\n\n // Handle drag over for auto-sorting\n const dragOverHandler = (e) => {\n e.preventDefault(); // Necessary to allow drop\n e.dataTransfer.dropEffect = 'move';\n\n if (!this.currentDrag) return;\n const draggingEl = this.currentDrag.element;\n\n // Only reorder if dragging an item that belongs to this container (or if we support cross-container drag, but keep simple)\n if (!container.contains(draggingEl)) return;\n\n // Prevent jumps when the dragging finalizes with 0,0 coordinates near the end\n if (e.clientX === 0 && e.clientY === 0) return;\n\n this.handleReorder(container, draggingEl, e.clientX, e.clientY);\n };\n\n // Handle drop\n const dropHandler = (e) => {\n e.preventDefault(); // crucial to prevent the browser's default handling and snapping back\n };\n\n container.addEventListener('dragenter', dragEnterHandler);\n container.addEventListener('dragover', dragOverHandler);\n container.addEventListener('drop', dropHandler);\n\n cleanupFunctions.push(() => {\n container.removeEventListener('dragenter', dragEnterHandler);\n container.removeEventListener('dragover', dragOverHandler);\n container.removeEventListener('drop', dropHandler);\n });\n\n this.instances.set(container, { cleanup: cleanupFunctions });\n },\n\n /**\n * Initialize a drop zone\n * @param {HTMLElement} zone - Drop zone element\n */\n initDropZone: function (zone) {\n const cleanupFunctions = [];\n\n // Accessibility: add ARIA role to drop zone\n zone.setAttribute('role', 'region');\n zone.setAttribute('aria-dropeffect', 'move');\n if (!zone.hasAttribute('aria-label')) {\n zone.setAttribute('aria-label', 'Drop zone');\n }\n\n // Handle drag over\n const dragOverHandler = (e) => {\n e.preventDefault();\n this.handleDragOver(e, zone);\n };\n zone.addEventListener('dragover', dragOverHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragover', dragOverHandler));\n\n // Handle drag enter\n const dragEnterHandler = (e) => {\n e.preventDefault();\n this.handleDragEnter(e, zone);\n };\n zone.addEventListener('dragenter', dragEnterHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragenter', dragEnterHandler));\n\n // Handle drag leave\n const dragLeaveHandler = (e) => {\n this.handleDragLeave(e, zone);\n };\n zone.addEventListener('dragleave', dragLeaveHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragleave', dragLeaveHandler));\n\n // Handle drop\n const dropHandler = (e) => {\n e.preventDefault();\n this.handleDrop(e, zone);\n };\n zone.addEventListener('drop', dropHandler);\n cleanupFunctions.push(() => zone.removeEventListener('drop', dropHandler));\n\n this.instances.set(zone, { cleanup: cleanupFunctions });\n },\n\n /**\n * Create feedback element for drag operations\n */\n createFeedbackElement: function () {\n if (!this.feedbackElement) {\n // Reuse existing element if present\n const existing = document.querySelector('.vd-drag-feedback');\n if (existing) {\n this.feedbackElement = existing;\n return;\n }\n\n this.feedbackElement = document.createElement('div');\n this.feedbackElement.className = 'vd-drag-feedback hidden';\n this.feedbackElement.setAttribute('role', 'presentation');\n document.body.appendChild(this.feedbackElement);\n }\n },\n\n /**\n * Handle drag start event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDragStart: function (e, element) {\n // Add dragging class\n element.classList.add('is-dragging');\n\n // Accessibility: update aria-grabbed\n element.setAttribute('aria-grabbed', 'true');\n\n // Store drag state\n this.currentDrag = {\n element: element,\n initialPosition: { x: e.clientX, y: e.clientY },\n initialBounds: element.getBoundingClientRect(),\n data: this.getData(element)\n };\n\n // Set drag data\n e.dataTransfer.effectAllowed = 'move';\n e.dataTransfer.setData('text/plain', this.currentDrag.data);\n\n // We no longer suppress the native ghost image or manually update feedback\n // for mouse drags, relying on the browser's native rendering instead.\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:start', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: e.clientX, y: e.clientY }\n }\n }));\n },\n\n /**\n * Handle drag event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDrag: function (e, element) {\n // Guard against null state (race condition on fast interactions)\n if (!this.currentDrag) return;\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:drag', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: e.clientX, y: e.clientY },\n delta: {\n x: e.clientX - this.currentDrag.initialPosition.x,\n y: e.clientY - this.currentDrag.initialPosition.y\n }\n }\n }));\n },\n\n /**\n * Handle drag end event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDragEnd: function (e, element) {\n // Remove dragging class\n element.classList.remove('is-dragging');\n element.classList.add('is-dropped');\n setTimeout(() => element.classList.remove('is-dropped'), 300);\n\n // Accessibility: update aria-grabbed\n element.setAttribute('aria-grabbed', 'false');\n\n // Hide feedback\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n\n // Guard against null state\n const data = this.currentDrag?.data || this.getData(element);\n const initialPos = this.currentDrag?.initialPosition || { x: 0, y: 0 };\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:end', {\n bubbles: true,\n detail: {\n element: element,\n data: data,\n position: { x: e.clientX, y: e.clientY },\n delta: {\n x: e.clientX - initialPos.x,\n y: e.clientY - initialPos.y\n }\n }\n }));\n\n // Reset drag state\n this.currentDrag = null;\n },\n\n /**\n * Handle touch start event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchStart: function (e, element) {\n // Don't prevent default here \u2014 it blocks scrolling.\n // We only prevent default in touchmove once drag threshold is reached.\n const touch = e.touches[0];\n const rect = element.getBoundingClientRect();\n this.touchState = {\n element: element,\n startX: touch.clientX,\n startY: touch.clientY,\n lastX: touch.clientX,\n lastY: touch.clientY,\n // Keep preview anchored to the original grab point.\n offsetX: touch.clientX - rect.left,\n offsetY: touch.clientY - rect.top,\n startTime: Date.now(),\n isDragging: false\n };\n },\n\n /**\n * Handle touch move event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchMove: function (e, element) {\n if (!this.touchState) return;\n\n const touch = e.touches[0];\n this.touchState.lastX = touch.clientX;\n this.touchState.lastY = touch.clientY;\n const deltaX = touch.clientX - this.touchState.startX;\n const deltaY = touch.clientY - this.touchState.startY;\n\n // Only start dragging if moved a minimum distance\n if (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10) {\n // Now we know it's a drag, not a scroll \u2014 prevent default\n if (e.cancelable) e.preventDefault();\n\n if (!this.touchState.isDragging) {\n this.touchState.isDragging = true;\n element.classList.add('is-dragging');\n element.setAttribute('aria-grabbed', 'true');\n\n // Store drag state\n this.currentDrag = {\n element: element,\n initialPosition: { x: this.touchState.startX, y: this.touchState.startY },\n initialBounds: element.getBoundingClientRect(),\n data: this.getData(element),\n // Preserve where inside the element the drag started for accurate ghost positioning.\n offsetX: this.touchState.offsetX,\n offsetY: this.touchState.offsetY\n };\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:start', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: touch.clientX, y: touch.clientY }\n }\n }));\n }\n\n // Update feedback\n this.updateFeedback(touch.clientX, touch.clientY);\n\n // Dispatch event\n if (this.currentDrag) {\n element.dispatchEvent(new CustomEvent('draggable:drag', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: touch.clientX, y: touch.clientY },\n delta: { x: deltaX, y: deltaY }\n }\n }));\n\n this.updateTouchDropZone(touch.clientX, touch.clientY);\n\n // Reorder for touch\n const container = element.closest(this.containerSelector);\n if (container && container.contains(element)) {\n this.handleReorder(container, element, touch.clientX, touch.clientY);\n }\n }\n }\n },\n\n /**\n * Handle touch end event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchEnd: function (e, element) {\n if (this.touchState && this.touchState.isDragging) {\n if (e.cancelable) e.preventDefault();\n const endTouch = e.changedTouches?.[0];\n const endPosition = {\n x: endTouch?.clientX ?? this.touchState.lastX ?? this.touchState.startX,\n y: endTouch?.clientY ?? this.touchState.lastY ?? this.touchState.startY\n };\n\n const dropZone = this.resolveDropZoneAtPoint(endPosition.x, endPosition.y) || this.touchState.overZone;\n if (dropZone) {\n this.dispatchDrop(dropZone, endPosition);\n } else if (this.touchState.overZone) {\n this.touchState.overZone.classList.remove('is-drag-over');\n }\n\n element.classList.remove('is-dragging');\n element.classList.add('is-dropped');\n element.setAttribute('aria-grabbed', 'false');\n setTimeout(() => element.classList.remove('is-dropped'), 300);\n\n // Hide feedback\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n\n // Dispatch event\n const data = this.currentDrag?.data || this.getData(element);\n const startX = this.touchState?.startX || 0;\n const startY = this.touchState?.startY || 0;\n\n element.dispatchEvent(new CustomEvent('draggable:end', {\n bubbles: true,\n detail: {\n element: element,\n data: data,\n position: endPosition,\n delta: {\n x: endPosition.x - startX,\n y: endPosition.y - startY\n }\n }\n }));\n }\n\n // Reset states\n this.touchState = null;\n this.currentDrag = null;\n },\n\n /**\n * Handle drag over event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} _zone - Drop zone element\n */\n handleDragOver: function (e, _zone) {\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n },\n\n /**\n * Handle drag enter event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDragEnter: function (e, zone) {\n e.preventDefault();\n zone.classList.add('is-drag-over');\n },\n\n /**\n * Handle drag leave event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDragLeave: function (e, zone) {\n zone.classList.remove('is-drag-over');\n },\n\n /**\n * Handle drop event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDrop: function (e, zone) {\n e.preventDefault();\n this.dispatchDrop(zone, { x: e.clientX, y: e.clientY });\n },\n\n /**\n * Resolve a drop zone from viewport coordinates\n * @param {number} x\n * @param {number} y\n * @returns {HTMLElement|null}\n */\n resolveDropZoneAtPoint: function (x, y) {\n if (!Number.isFinite(x) || !Number.isFinite(y)) return null;\n\n // Prefer the full stacking list so overlays/top elements don't hide real drop targets.\n if (typeof document.elementsFromPoint === 'function') {\n const stacked = document.elementsFromPoint(x, y);\n for (const element of stacked) {\n const zone = element.closest('.vd-drop-zone');\n if (zone) return zone;\n }\n }\n\n const target = document.elementFromPoint(x, y);\n const targetZone = target ? target.closest('.vd-drop-zone') : null;\n if (targetZone) return targetZone;\n\n // Last-resort fallback for mobile emulation edge cases.\n const zones = document.querySelectorAll('.vd-drop-zone');\n for (const zone of zones) {\n const rect = zone.getBoundingClientRect();\n if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {\n return zone;\n }\n }\n\n return null;\n },\n\n /**\n * Track and update active drop-zone hover state on touch devices\n * @param {number} x\n * @param {number} y\n */\n updateTouchDropZone: function (x, y) {\n if (!this.touchState) return;\n\n const nextZone = this.resolveDropZoneAtPoint(x, y);\n const prevZone = this.touchState.overZone || null;\n\n if (prevZone && prevZone !== nextZone) {\n prevZone.classList.remove('is-drag-over');\n }\n\n if (nextZone && nextZone !== prevZone) {\n nextZone.classList.add('is-drag-over');\n }\n\n this.touchState.overZone = nextZone || null;\n },\n\n /**\n * Dispatch a normalized drop event for mouse and touch flows\n * @param {HTMLElement} zone\n * @param {{x:number, y:number}} position\n */\n dispatchDrop: function (zone, position) {\n zone.classList.remove('is-drag-over');\n zone.dispatchEvent(new CustomEvent('draggable:drop', {\n bubbles: true,\n detail: {\n zone: zone,\n element: this.currentDrag?.element,\n data: this.currentDrag?.data,\n position: position\n }\n }));\n },\n\n /**\n * Reorder elements in container based on cursor position\n * @param {HTMLElement} container \n * @param {HTMLElement} element \n * @param {number} clientX \n * @param {number} clientY \n */\n handleReorder: function (container, element, clientX, clientY) {\n const isVertical = container.classList.contains('vd-draggable-container-vertical');\n const siblings = [...container.querySelectorAll('.vd-draggable-item:not(.is-dragging), .vd-draggable:not(.is-dragging)')];\n\n const nextSibling = siblings.reduce((closest, child) => {\n const box = child.getBoundingClientRect();\n const offset = isVertical\n ? clientY - box.top - box.height / 2\n : clientX - box.left - box.width / 2;\n\n if (offset < 0 && offset > closest.offset) {\n return { offset: offset, element: child };\n } else {\n return closest;\n }\n }, { offset: Number.NEGATIVE_INFINITY }).element;\n\n if (nextSibling == null) {\n container.appendChild(element);\n } else {\n container.insertBefore(element, nextSibling);\n }\n },\n\n /**\n * Handle keyboard events\n * @param {KeyboardEvent} e - Keyboard event\n * @param {HTMLElement} element - Draggable element\n */\n handleKeydown: function (e, element) {\n switch (e.key) {\n case 'Enter':\n case ' ':\n e.preventDefault();\n // Trigger click or custom action\n element.click();\n break;\n case 'Escape':\n // Cancel drag if in progress\n if (element.classList.contains('is-dragging')) {\n element.classList.remove('is-dragging');\n element.setAttribute('aria-grabbed', 'false');\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n this.currentDrag = null;\n }\n break;\n case 'ArrowUp':\n case 'ArrowLeft': {\n e.preventDefault();\n const prev = element.previousElementSibling;\n if (prev && (prev.classList.contains('vd-draggable') || prev.classList.contains('vd-draggable-item'))) {\n element.parentNode.insertBefore(element, prev);\n element.focus();\n element.dispatchEvent(new CustomEvent('draggable:reorder', {\n bubbles: true,\n detail: { element: element, direction: 'up' }\n }));\n }\n break;\n }\n case 'ArrowDown':\n case 'ArrowRight': {\n e.preventDefault();\n const next = element.nextElementSibling;\n if (next && (next.classList.contains('vd-draggable') || next.classList.contains('vd-draggable-item'))) {\n element.parentNode.insertBefore(next, element);\n element.focus();\n element.dispatchEvent(new CustomEvent('draggable:reorder', {\n bubbles: true,\n detail: { element: element, direction: 'down' }\n }));\n }\n break;\n }\n }\n },\n\n /**\n * Get data from draggable element\n * @param {HTMLElement} element - Draggable element\n * @returns {string} Data associated with the element\n */\n getData: function (element) {\n return element.dataset.draggable || element.textContent.trim();\n },\n\n /**\n * Update drag feedback element\n * @param {number} x - Current X coordinate\n * @param {number} y - Current Y coordinate\n */\n updateFeedback: function (x, y) {\n if (!this.currentDrag) return;\n\n // Show feedback\n this.feedbackElement.classList.remove('hidden');\n\n // Update feedback content\n const rect = this.currentDrag.initialBounds;\n this.feedbackElement.innerHTML = '';\n const clone = this.currentDrag.element.cloneNode(true);\n this.feedbackElement.appendChild(clone);\n\n // Set styles\n const offsetX = this.currentDrag.offsetX ?? 20;\n const offsetY = this.currentDrag.offsetY ?? 20;\n Object.assign(this.feedbackElement.style, {\n left: (x - offsetX) + 'px',\n top: (y - offsetY) + 'px',\n width: rect.width + 'px',\n height: rect.height + 'px'\n });\n },\n\n /**\n * Make an element draggable programmatically\n * @param {HTMLElement|string} element - Element or selector\n * @param {Object} options - Configuration options\n */\n makeDraggable: function (element, options = {}) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n\n if (el && !this.instances.has(el)) {\n // Add classes and attributes\n el.classList.add('vd-draggable');\n el.setAttribute('draggable', 'true');\n\n // Set options\n if (options.data) {\n el.dataset.draggable = options.data;\n }\n\n // Initialize\n this.initDraggable(el);\n }\n },\n\n /**\n * Remove draggable functionality from an element\n * @param {HTMLElement|string} element - Element or selector\n */\n removeDraggable: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n\n if (el && this.instances.has(el)) {\n // Clean up\n const instance = this.instances.get(el);\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n\n // Remove classes and attributes\n el.classList.remove('vd-draggable');\n el.removeAttribute('draggable');\n el.removeAttribute('data-draggable');\n }\n },\n\n /**\n * Destroy a draggable instance and clean up event listeners\n * @param {HTMLElement} element - Draggable element\n */\n destroy: function (element) {\n this.removeDraggable(element);\n },\n\n /**\n * Destroy all draggable instances\n */\n destroyAll: function () {\n const instances = Array.from(this.instances.keys());\n instances.forEach(element => this.destroy(element));\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('draggable', Draggable);\n }\n\n // Expose globally\n window.VanduoDraggable = Draggable;\n})();\n", "/**\n * Vanduo Framework \u2013 LazyLoad Component\n * v1.2.1\n *\n * Provides two levels of API:\n *\n * LOW-LEVEL (generic IntersectionObserver wrapper)\n * VanduoLazyLoad.observe(element, callback, options?)\n * VanduoLazyLoad.unobserve(element)\n * VanduoLazyLoad.unobserveAll()\n *\n * HIGH-LEVEL (HTML-section fetcher, mirrors vanduo-docs SPA behaviour)\n * VanduoLazyLoad.loadSection(url, containerEl, options?)\n * options: { placeholder, threshold, rootMargin, onLoaded, onError }\n * placeholder: 'skeleton' | 'spinner' | HTMLString (default: 'skeleton')\n *\n * ATTRIBUTE-DRIVEN (zero-JS usage, wired by init())\n *
    \u2026
    \n *\n * EVENTS dispatched on the host element:\n * lazysection:loading \u2014 fetch started\n * lazysection:loaded \u2014 content injected\n * lazysection:error \u2014 fetch failed\n */\n\n(function () {\n 'use strict';\n\n /* \u2500\u2500 Private state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /** @type {Map} */\n const _observerMap = new Map();\n\n /* \u2500\u2500 Security helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Returns true only if `url` resolves to the same origin as the page\n * (relative paths and same-origin absolute URLs are allowed).\n * Cross-origin URLs are rejected to prevent SSRF-style fetch abuse.\n * @param {string} url\n * @returns {boolean}\n */\n function _isSafeUrl(url) {\n try {\n // Relative URLs (no origin) are always safe\n const resolved = new URL(url, window.location.href);\n return resolved.origin === window.location.origin;\n } catch (_) {\n return false;\n }\n }\n\n /**\n * Safely inject fetched HTML into a container by parsing it with\n * DOMParser (avoids script execution) and replacing children via\n * standard DOM APIs instead of raw innerHTML assignment.\n * @param {Element} containerEl\n * @param {string} html\n */\n function _safeInjectHtml(containerEl, html) {\n const parser = new DOMParser();\n const doc = parser.parseFromString(html.trim(), 'text/html'); // CodeQL [js/xss-through-dom] Intentional: parsed nodes are sanitized below before adoption into live DOM\n\n const DANGEROUS_TAGS = ['SCRIPT', 'IFRAME', 'OBJECT', 'EMBED', 'FORM', 'BASE', 'LINK', 'META', 'STYLE'];\n for (const tag of DANGEROUS_TAGS) {\n const els = doc.querySelectorAll(tag);\n for (let i = els.length - 1; i >= 0; i--) {\n els[i].parentNode.removeChild(els[i]);\n }\n }\n\n // Strip dangerous attributes (e.g. onerror, javascript: urls) from all elements\n function _sanitizeNode(node) {\n if (node.nodeType === Node.ELEMENT_NODE) {\n const attrs = node.attributes;\n for (let i = attrs.length - 1; i >= 0; i--) {\n const attrName = attrs[i].name.toLowerCase();\n const attrValue = attrs[i].value.toLowerCase();\n const trimmedValue = attrValue.trim();\n if (\n attrName.startsWith('on') ||\n trimmedValue.startsWith('javascript:') ||\n trimmedValue.startsWith('data:') ||\n trimmedValue.startsWith('vbscript:')\n ) {\n node.removeAttribute(attrs[i].name);\n }\n }\n const children = node.childNodes;\n for (let i = 0; i < children.length; i++) {\n _sanitizeNode(children[i]);\n }\n }\n }\n _sanitizeNode(doc.body);\n\n // Collect all top-level body children\n const nodes = Array.from(doc.body.childNodes);\n // Clear container and append parsed nodes\n while (containerEl.firstChild) {\n containerEl.removeChild(containerEl.firstChild);\n }\n nodes.forEach(function (node) {\n containerEl.appendChild(document.adoptNode(node));\n });\n }\n\n /* \u2500\u2500 Placeholder HTML \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n function _skeletonHtml() {\n return '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + 'Loading\u2026
    ';\n }\n\n function _spinnerHtml() {\n return '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + 'Loading\u2026
    ';\n }\n\n function _resolvePlaceholder(placeholder) {\n if (!placeholder || placeholder === 'skeleton') return _skeletonHtml();\n if (placeholder === 'spinner') return _spinnerHtml();\n // Caller-supplied HTML string\n return placeholder;\n }\n\n /* \u2500\u2500 Dispatch helper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n function _dispatch(el, eventName, detail) {\n el.dispatchEvent(new CustomEvent(eventName, { bubbles: true, detail: detail || {} }));\n }\n\n /* \u2500\u2500 VanduoLazyLoad \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const VanduoLazyLoad = {\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * LOW-LEVEL API\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Observe an element. `callback` is invoked once when the element\n * enters the viewport, then the element is automatically unobserved.\n *\n * @param {Element} element\n * @param {function(Element): void} callback\n * @param {{ threshold?: number, rootMargin?: string }} [options]\n */\n observe: function (element, callback, options) {\n if (!(element instanceof Element)) {\n console.warn('[VanduoLazyLoad] observe() requires a DOM Element.');\n return;\n }\n if (typeof callback !== 'function') {\n console.warn('[VanduoLazyLoad] observe() requires a callback function.');\n return;\n }\n // Already observed \u2014 ignore\n if (_observerMap.has(element)) return;\n\n const threshold = (options && options.threshold != null) ? options.threshold : 0;\n const rootMargin = (options && options.rootMargin) ? options.rootMargin : '0px';\n\n const observer = new IntersectionObserver(function (entries, obs) {\n entries.forEach(function (entry) {\n if (entry.isIntersecting) {\n obs.unobserve(entry.target);\n _observerMap.delete(entry.target);\n try {\n callback(entry.target);\n } catch (e) {\n console.error('[VanduoLazyLoad] Callback threw:', e);\n }\n }\n });\n }, { threshold: threshold, rootMargin: rootMargin });\n\n _observerMap.set(element, observer);\n observer.observe(element);\n },\n\n /**\n * Stop observing an element that was previously passed to observe().\n * @param {Element} element\n */\n unobserve: function (element) {\n const observer = _observerMap.get(element);\n if (observer) {\n observer.unobserve(element);\n _observerMap.delete(element);\n }\n },\n\n /**\n * Stop observing ALL currently observed elements.\n */\n unobserveAll: function () {\n _observerMap.forEach(function (observer, element) {\n observer.unobserve(element);\n });\n _observerMap.clear();\n },\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * HIGH-LEVEL API\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Fetch an HTML partial and inject it into `containerEl` when the\n * container enters the viewport. A placeholder is shown immediately.\n *\n * @param {string} url URL of the HTML partial to fetch\n * @param {Element} containerEl Target element whose content will be replaced\n * @param {{\n * placeholder?: 'skeleton'|'spinner'|string,\n * threshold?: number,\n * rootMargin?: string,\n * onLoaded?: function(Element): void,\n * onError?: function(Error): void\n * }} [options]\n */\n loadSection: function (url, containerEl, options) {\n if (typeof url !== 'string' || !url) {\n console.warn('[VanduoLazyLoad] loadSection() requires a non-empty URL string.');\n return;\n }\n if (!(containerEl instanceof Element)) {\n console.warn('[VanduoLazyLoad] loadSection() requires a DOM Element as containerEl.');\n return;\n }\n // Reject cross-origin URLs to prevent SSRF-style fetch abuse\n if (!_isSafeUrl(url)) {\n console.error('[VanduoLazyLoad] loadSection() blocked cross-origin URL:', url);\n return;\n }\n\n const opts = options || {};\n // Placeholders are known-safe static HTML strings built internally\n const placeholderHtml = _resolvePlaceholder(opts.placeholder);\n\n // Use _safeInjectHtml instead of innerHTML to prevent XSS from caller-supplied placeholders\n _safeInjectHtml(containerEl, placeholderHtml);\n _dispatch(containerEl, 'lazysection:loading', { url: url });\n\n // Fetch when visible\n this.observe(containerEl, function () {\n const controller = new window.AbortController();\n const timeoutId = setTimeout(function () { controller.abort(); }, 10000);\n\n window.fetch(url, { signal: controller.signal })\n .then(function (res) {\n clearTimeout(timeoutId);\n if (!res.ok) throw new Error('HTTP ' + res.status);\n return res.text();\n })\n .then(function (html) {\n // Use DOMParser to parse fetched content safely, avoiding\n // raw innerHTML assignment of externally-sourced strings\n _safeInjectHtml(containerEl, html);\n _dispatch(containerEl, 'lazysection:loaded', { url: url });\n // Re-init Vanduo components inside the new content\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.init();\n }\n if (typeof opts.onLoaded === 'function') {\n opts.onLoaded(containerEl);\n }\n })\n .catch(function (err) {\n // Build error node via DOM APIs \u2014 no dynamic HTML strings\n const alertEl = document.createElement('div');\n alertEl.className = 'vd-alert vd-alert-error';\n alertEl.setAttribute('role', 'alert');\n const msgEl = document.createElement('span');\n msgEl.textContent = 'Failed to load content. ';\n const detailEl = document.createElement('small');\n detailEl.style.opacity = '0.7';\n detailEl.textContent = err.message;\n alertEl.appendChild(msgEl);\n alertEl.appendChild(detailEl);\n while (containerEl.firstChild) {\n containerEl.removeChild(containerEl.firstChild);\n }\n containerEl.appendChild(alertEl);\n _dispatch(containerEl, 'lazysection:error', { url: url, error: err });\n console.error('[VanduoLazyLoad] loadSection failed:', err);\n if (typeof opts.onError === 'function') {\n opts.onError(err);\n }\n });\n }, { threshold: opts.threshold, rootMargin: opts.rootMargin });\n },\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * ATTRIBUTE-DRIVEN INIT\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Scan the DOM for [data-vd-lazy] elements and wire them up.\n * Safe to call multiple times \u2014 already-observed elements are skipped.\n */\n init: function () {\n const self = this;\n const elements = document.querySelectorAll('[data-vd-lazy]');\n elements.forEach(function (el) {\n // Skip already-observed or already-loaded elements\n if (_observerMap.has(el) || el.dataset.vdLazyState === 'loading' || el.dataset.vdLazyState === 'loaded') return;\n\n const url = el.getAttribute('data-vd-lazy');\n if (!url) return;\n\n el.dataset.vdLazyState = 'loading';\n const placeholder = el.getAttribute('data-vd-lazy-placeholder') || 'skeleton';\n\n self.loadSection(url, el, {\n placeholder: placeholder,\n onLoaded: function () {\n el.dataset.vdLazyState = 'loaded';\n },\n onError: function () {\n el.dataset.vdLazyState = 'error';\n }\n });\n });\n }\n };\n\n /* \u2500\u2500 Register with Vanduo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('LazyLoad', VanduoLazyLoad);\n }\n\n /* \u2500\u2500 Global convenience alias \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n window.VanduoLazyLoad = VanduoLazyLoad;\n\n})();\n", "/**\n * Vanduo Framework - Glass Scroll Activation\n * Generic scroll-aware glass activation via IntersectionObserver.\n *\n * Usage:\n * Add `data-glass-scroll` to any element carrying a `.vd-glass*` class.\n * By default the previous sibling is used as the sentinel.\n * Point to a custom sentinel via `data-glass-sentinel=\"\"`.\n *\n * Behaviour:\n * - Adds `.is-glass-active` when the sentinel leaves the viewport (scrolled past).\n * - Removes `.is-glass-active` when the sentinel re-enters the viewport.\n */\n(function () {\n 'use strict';\n\n const GlassScroll = {\n /** @type {Map} */\n observers: new Map(),\n\n init: function () {\n document.querySelectorAll('[data-glass-scroll]').forEach(el => {\n if (this.observers.has(el)) return;\n this.initElement(el);\n });\n },\n\n /**\n * Wire up a single scroll-activated glass element.\n * @param {HTMLElement} el\n */\n initElement: function (el) {\n const sentinelSelector = el.dataset.glassSentinel;\n let sentinel;\n\n if (sentinelSelector) {\n sentinel = document.querySelector(sentinelSelector);\n }\n\n if (!sentinel) {\n // Fall back to the previous sibling element\n sentinel = el.previousElementSibling;\n }\n\n if (!sentinel) {\n // No sentinel available \u2014 activate immediately so glass is always shown\n el.classList.add('is-glass-active');\n return;\n }\n\n const observer = new IntersectionObserver(\n (entries) => {\n entries.forEach(entry => {\n // Active when sentinel is NOT intersecting (scrolled past it)\n el.classList.toggle('is-glass-active', !entry.isIntersecting);\n });\n },\n { threshold: 0, rootMargin: '0px' }\n );\n\n observer.observe(sentinel);\n this.observers.set(el, observer);\n },\n\n /**\n * Disconnect and remove a single element's observer.\n * @param {HTMLElement} el\n */\n destroy: function (el) {\n const observer = this.observers.get(el);\n if (observer) {\n observer.disconnect();\n this.observers.delete(el);\n }\n },\n\n destroyAll: function () {\n this.observers.forEach((observer, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('glassScroll', GlassScroll);\n }\n\n window.VanduoGlassScroll = GlassScroll;\n})();\n", "/**\n * Vanduo Framework - Water Morph Effect Component\n * Liquid wave content-swap animation on click\n *\n * Usage:\n * Add .vd-morph or [data-vd-morph] to any element.\n * Provide .vd-morph-content.vd-morph-current and .vd-morph-content.vd-morph-next children.\n * Wave and shine layers are auto-created if absent.\n *\n * JS API:\n * VanduoMorph.morph(el) \u2014 trigger morph programmatically (from center)\n * VanduoMorph.destroy(el) \u2014 tear down listeners for one element\n * VanduoMorph.destroyAll() \u2014 tear down all instances\n */\n\n(function () {\n 'use strict';\n\n const MORPH_DURATION_MS = 750;\n\n const Morph = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-morph, [data-vd-morph]');\n elements.forEach(function (el) {\n if (Morph.instances.has(el)) return;\n if (el.getAttribute('data-vd-morph') === 'manual') return;\n Morph.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n Morph._ensureLayers(el);\n\n const cleanup = [];\n let morphing = false;\n\n const handleClick = function (e) {\n if (morphing) return;\n Morph._runMorph(el, e, function () { morphing = false; });\n morphing = true;\n };\n\n el.addEventListener('click', handleClick);\n cleanup.push(function () { el.removeEventListener('click', handleClick); });\n\n this.instances.set(el, { cleanup: cleanup });\n },\n\n morph: function (el) {\n if (!el) return;\n if (!this.instances.has(el)) this.initInstance(el);\n this._runMorph(el, null, null);\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(function (fn) { fn(); });\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) { Morph.destroy(el); });\n },\n\n /* \u2500\u2500 Internal helpers \u2500\u2500 */\n\n _ensureLayers: function (el) {\n if (!el.querySelector('.vd-morph-wave')) {\n const wave = document.createElement('span');\n wave.className = 'vd-morph-wave';\n wave.setAttribute('aria-hidden', 'true');\n el.insertBefore(wave, el.firstChild);\n }\n if (!el.querySelector('.vd-morph-shine')) {\n const shine = document.createElement('span');\n shine.className = 'vd-morph-shine';\n shine.setAttribute('aria-hidden', 'true');\n const waveEl = el.querySelector('.vd-morph-wave');\n if (waveEl && waveEl.nextSibling) {\n el.insertBefore(shine, waveEl.nextSibling);\n } else {\n el.insertBefore(shine, el.firstChild);\n }\n }\n },\n\n _runMorph: function (el, pointerEvent, onComplete) {\n const wave = el.querySelector('.vd-morph-wave');\n if (wave) {\n const rect = el.getBoundingClientRect();\n const cx = rect.left + rect.width / 2;\n const cy = rect.top + rect.height / 2;\n const px = pointerEvent ? (pointerEvent.clientX || cx) : cx;\n const py = pointerEvent ? (pointerEvent.clientY || cy) : cy;\n wave.style.left = (px - rect.left) + 'px';\n wave.style.top = (py - rect.top) + 'px';\n }\n\n el.classList.add('is-morphing');\n\n let duration = MORPH_DURATION_MS;\n const custom = getComputedStyle(el).getPropertyValue('--morph-duration');\n if (custom) {\n const parsed = parseFloat(custom);\n if (!isNaN(parsed)) duration = parsed * (custom.indexOf('ms') !== -1 ? 1 : 1000);\n }\n\n setTimeout(function () {\n el.classList.remove('is-morphing');\n\n const current = el.querySelector('.vd-morph-current');\n const next = el.querySelector('.vd-morph-next');\n if (current && next) {\n current.classList.remove('vd-morph-current');\n current.classList.add('vd-morph-next');\n next.classList.remove('vd-morph-next');\n next.classList.add('vd-morph-current');\n }\n\n if (typeof onComplete === 'function') onComplete();\n }, duration);\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('morph', Morph);\n }\n\n window.VanduoMorph = Morph;\n\n})();\n", "/**\n * Vanduo Framework \u2014 Expanding flex cards\n * Click / Enter / Space / Arrow keys to change the active card.\n *\n * Usage:\n *
    \n * Use data-vd-expanding-cards=\"manual\" to skip auto-init.\n */\n\n(function () {\n 'use strict';\n\n const ExpandingCards = {\n instances: new Map(),\n\n init: function () {\n document.querySelectorAll('.vd-expanding-cards').forEach(function (el) {\n if (el.getAttribute('data-vd-expanding-cards') === 'manual') return;\n if (ExpandingCards.instances.has(el)) return;\n ExpandingCards.initContainer(el);\n });\n },\n\n initContainer: function (container) {\n const cleanup = [];\n\n const getCards = function () {\n return Array.prototype.slice.call(container.querySelectorAll('.vd-expanding-card'));\n };\n\n const setActive = function (card) {\n const cards = getCards();\n if (!card || cards.indexOf(card) === -1) return;\n cards.forEach(function (c) {\n c.classList.toggle('is-active', c === card);\n });\n card.focus({ preventScroll: true });\n };\n\n const onClick = function (e) {\n const t = e.target;\n const card = t.closest ? t.closest('.vd-expanding-card') : null;\n if (!card || !container.contains(card)) return;\n setActive(card);\n };\n\n const onKeydown = function (e) {\n if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'Home' && e.key !== 'End') {\n return;\n }\n const cards = getCards().filter(function (c) {\n return c.offsetParent !== null || c.getClientRects().length > 0;\n });\n if (!cards.length) return;\n const activeEl = document.activeElement;\n let idx = cards.indexOf(activeEl);\n if (idx < 0) {\n idx = cards.findIndex(function (c) {\n return c.classList.contains('is-active');\n });\n }\n if (idx < 0) idx = 0;\n\n if (e.key === 'ArrowLeft') {\n e.preventDefault();\n setActive(cards[Math.max(0, idx - 1)]);\n } else if (e.key === 'ArrowRight') {\n e.preventDefault();\n setActive(cards[Math.min(cards.length - 1, idx + 1)]);\n } else if (e.key === 'Home') {\n e.preventDefault();\n setActive(cards[0]);\n } else if (e.key === 'End') {\n e.preventDefault();\n setActive(cards[cards.length - 1]);\n }\n };\n\n container.addEventListener('click', onClick);\n cleanup.push(function () {\n container.removeEventListener('click', onClick);\n });\n\n container.addEventListener('keydown', onKeydown);\n cleanup.push(function () {\n container.removeEventListener('keydown', onKeydown);\n });\n\n getCards().forEach(function (card) {\n if (!card.hasAttribute('tabindex')) {\n card.setAttribute('tabindex', '0');\n }\n card.setAttribute('role', 'button');\n if (!card.hasAttribute('aria-pressed')) {\n card.setAttribute('aria-pressed', card.classList.contains('is-active') ? 'true' : 'false');\n }\n });\n\n const syncAria = function () {\n getCards().forEach(function (card) {\n card.setAttribute('aria-pressed', card.classList.contains('is-active') ? 'true' : 'false');\n });\n };\n\n const mo = new MutationObserver(syncAria);\n mo.observe(container, { attributes: true, subtree: true, attributeFilter: ['class'] });\n cleanup.push(function () {\n mo.disconnect();\n });\n syncAria();\n\n ExpandingCards.instances.set(container, { cleanup: cleanup });\n },\n\n destroy: function (container) {\n const inst = this.instances.get(container);\n if (!inst) return;\n inst.cleanup.forEach(function (fn) {\n fn();\n });\n this.instances.delete(container);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) {\n ExpandingCards.destroy(el);\n });\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('expandingCards', ExpandingCards);\n }\n\n window.VanduoExpandingCards = ExpandingCards;\n})();\n", "/**\n * Vanduo Framework \u2014 Timeline animated reveal\n * Opt-in: add `.vd-timeline-animated` to a `.vd-timeline` container.\n * Uses IntersectionObserver to add `.is-revealed` per item with staggered delays.\n * Optional: `.vd-timeline-playback` with `[data-vd-timeline-prev|next|play|pause]` for stepped control.\n */\n\n(function () {\n 'use strict';\n\n const STAGGER_MS = 140;\n const MAX_STAGGER_INDEX = 7;\n const PLAY_INTERVAL_MS = 800;\n\n function countRevealedPrefix(items) {\n let count = 0;\n for (let i = 0; i < items.length; i++) {\n if (!items[i].classList.contains('is-revealed')) break;\n count++;\n }\n return count;\n }\n\n function findPlaybackControls(container) {\n return container.parentElement || document.body;\n }\n\n function initPlayback(container, items, cleanup) {\n items.forEach(function (item) {\n item.classList.remove('is-revealed');\n });\n\n const scope = findPlaybackControls(container);\n const prevBtn = scope.querySelector('[data-vd-timeline-prev]');\n const nextBtn = scope.querySelector('[data-vd-timeline-next]');\n const playBtn = scope.querySelector('[data-vd-timeline-play]');\n const pauseBtn = scope.querySelector('[data-vd-timeline-pause]');\n\n let playTimer = null;\n\n function updateNavButtons() {\n const k = countRevealedPrefix(items);\n const n = items.length;\n if (prevBtn) {\n const atStart = k === 0;\n prevBtn.disabled = atStart;\n prevBtn.setAttribute('aria-disabled', atStart ? 'true' : 'false');\n }\n if (nextBtn) {\n const atEnd = k >= n;\n nextBtn.disabled = atEnd;\n nextBtn.setAttribute('aria-disabled', atEnd ? 'true' : 'false');\n }\n if (playBtn) {\n playBtn.setAttribute('aria-pressed', playTimer ? 'true' : 'false');\n }\n if (pauseBtn) {\n pauseBtn.disabled = !playTimer;\n }\n }\n\n function stepNext() {\n const k = countRevealedPrefix(items);\n if (k < items.length) {\n items[k].classList.add('is-revealed');\n }\n updateNavButtons();\n }\n\n function stepPrev() {\n const k = countRevealedPrefix(items);\n if (k > 0) {\n items[k - 1].classList.remove('is-revealed');\n }\n updateNavButtons();\n }\n\n function play() {\n if (playTimer) return;\n playTimer = setInterval(function () {\n if (countRevealedPrefix(items) >= items.length) {\n pause();\n return;\n }\n stepNext();\n }, PLAY_INTERVAL_MS);\n updateNavButtons();\n }\n\n function pause() {\n if (playTimer) {\n clearInterval(playTimer);\n playTimer = null;\n }\n updateNavButtons();\n }\n\n function addClick(el, fn) {\n if (!el) return;\n const handler = function (e) {\n e.preventDefault();\n fn();\n };\n el.addEventListener('click', handler);\n cleanup.push(function () {\n el.removeEventListener('click', handler);\n });\n }\n\n addClick(prevBtn, stepPrev);\n addClick(nextBtn, stepNext);\n addClick(playBtn, play);\n addClick(pauseBtn, pause);\n\n cleanup.push(function () {\n pause();\n });\n\n updateNavButtons();\n\n return {\n stepNext: stepNext,\n stepPrev: stepPrev,\n play: play,\n pause: pause\n };\n }\n\n const Timeline = {\n instances: new Map(),\n\n init: function () {\n document.querySelectorAll('.vd-timeline.vd-timeline-animated').forEach(function (el) {\n if (Timeline.instances.has(el)) return;\n Timeline.initInstance(el);\n });\n },\n\n reinit: function () {\n Timeline.destroyAll();\n Timeline.init();\n },\n\n initInstance: function (container) {\n const cleanup = [];\n const items = Array.prototype.filter.call(container.children, function (child) {\n return child.classList && child.classList.contains('vd-timeline-item');\n });\n\n items.forEach(function (item, i) {\n const idx = Math.min(i, MAX_STAGGER_INDEX);\n item.style.setProperty('--vd-timeline-reveal-delay', (idx * STAGGER_MS) + 'ms');\n });\n\n const reducedMotion = typeof window.matchMedia === 'function'\n && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n if (reducedMotion) {\n items.forEach(function (item) {\n item.classList.add('is-revealed');\n });\n Timeline.instances.set(container, { cleanup: cleanup });\n return;\n }\n\n const playback = container.classList && container.classList.contains('vd-timeline-playback');\n\n if (playback) {\n const playbackApi = initPlayback(container, items, cleanup);\n Timeline.instances.set(container, { cleanup: cleanup, playback: playbackApi });\n return;\n }\n\n if (typeof IntersectionObserver === 'undefined') {\n items.forEach(function (item) {\n item.classList.add('is-revealed');\n });\n Timeline.instances.set(container, { cleanup: cleanup });\n return;\n }\n\n const observer = new IntersectionObserver(function (entries) {\n entries.forEach(function (entry) {\n if (!entry.isIntersecting) return;\n entry.target.classList.add('is-revealed');\n observer.unobserve(entry.target);\n });\n }, {\n root: null,\n rootMargin: '0px 0px -10% 0px',\n threshold: 0.15\n });\n\n items.forEach(function (item) {\n observer.observe(item);\n });\n\n cleanup.push(function () {\n observer.disconnect();\n });\n\n Timeline.instances.set(container, { cleanup: cleanup });\n },\n\n destroy: function (container) {\n const inst = this.instances.get(container);\n if (!inst) return;\n inst.cleanup.forEach(function (fn) {\n fn();\n });\n this.instances.delete(container);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) {\n Timeline.destroy(el);\n });\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('timeline', Timeline);\n }\n\n window.VanduoTimeline = Timeline;\n})();\n", "/**\n * Vanduo Framework - Flow (Carousel/Slider) Component\n * Touch-enabled carousel with slide/fade transitions, autoplay, indicators\n */\n\n(function () {\n 'use strict';\n\n const Flow = {\n instances: new Map(),\n\n init: function () {\n const carousels = document.querySelectorAll('.vd-flow, .vd-carousel');\n carousels.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const track = el.querySelector('.vd-flow-track');\n if (!track) return;\n\n const slides = Array.from(track.querySelectorAll('.vd-flow-slide'));\n if (slides.length === 0) return;\n\n const isFade = el.classList.contains('vd-flow-fade');\n const autoplay = el.hasAttribute('data-vd-autoplay');\n const interval = parseInt(el.getAttribute('data-vd-interval'), 10) || 5000;\n const loop = el.getAttribute('data-vd-loop') !== 'false';\n\n const state = {\n current: 0,\n total: slides.length,\n autoplayTimer: null,\n isFade: isFade,\n loop: loop,\n isDragging: false,\n startX: 0,\n currentX: 0,\n threshold: 50\n };\n\n const cleanup = [];\n\n // Set initial active slide\n slides.forEach((slide, i) => {\n slide.setAttribute('role', 'group');\n slide.setAttribute('aria-roledescription', 'slide');\n slide.setAttribute('aria-label', 'Slide ' + (i + 1) + ' of ' + slides.length);\n if (i === 0) slide.classList.add('is-active');\n });\n\n el.setAttribute('role', 'region');\n el.setAttribute('aria-roledescription', 'carousel');\n if (!el.getAttribute('aria-label')) {\n el.setAttribute('aria-label', 'Carousel');\n }\n\n // Live region for announcements\n const liveRegion = document.createElement('div');\n liveRegion.setAttribute('aria-live', 'polite');\n liveRegion.setAttribute('aria-atomic', 'true');\n liveRegion.className = 'sr-only';\n liveRegion.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);';\n el.appendChild(liveRegion);\n\n const goTo = (index, announce) => {\n if (announce === undefined) announce = true;\n let target = index;\n if (state.loop) {\n target = ((index % state.total) + state.total) % state.total;\n } else {\n target = Math.max(0, Math.min(index, state.total - 1));\n }\n\n const prev = state.current;\n state.current = target;\n\n if (state.isFade) {\n slides.forEach((s, i) => {\n s.classList.toggle('is-active', i === target);\n });\n } else {\n track.style.transform = 'translateX(-' + (target * 100) + '%)';\n }\n\n // Update indicators\n const indicators = el.querySelectorAll('.vd-flow-indicator');\n indicators.forEach((ind, i) => {\n ind.classList.toggle('is-active', i === target);\n ind.setAttribute('aria-selected', i === target ? 'true' : 'false');\n });\n\n // Update slide ARIA\n slides.forEach((s, i) => {\n s.setAttribute('aria-hidden', i !== target ? 'true' : 'false');\n });\n\n if (announce) {\n liveRegion.textContent = 'Slide ' + (target + 1) + ' of ' + state.total;\n }\n\n el.dispatchEvent(new CustomEvent('flow:change', {\n detail: { current: target, previous: prev, total: state.total }\n }));\n };\n\n const next = () => goTo(state.current + 1);\n const prev = () => goTo(state.current - 1);\n\n // Controls\n const prevBtn = el.querySelector('.vd-flow-prev');\n const nextBtn = el.querySelector('.vd-flow-next');\n\n if (prevBtn) {\n const h = () => prev();\n prevBtn.addEventListener('click', h);\n cleanup.push(() => prevBtn.removeEventListener('click', h));\n }\n if (nextBtn) {\n const h = () => next();\n nextBtn.addEventListener('click', h);\n cleanup.push(() => nextBtn.removeEventListener('click', h));\n }\n\n // Indicators\n const indicators = el.querySelectorAll('.vd-flow-indicator');\n indicators.forEach((ind, i) => {\n ind.setAttribute('role', 'tab');\n ind.setAttribute('aria-selected', i === 0 ? 'true' : 'false');\n ind.setAttribute('aria-label', 'Go to slide ' + (i + 1));\n const h = () => goTo(i);\n ind.addEventListener('click', h);\n cleanup.push(() => ind.removeEventListener('click', h));\n });\n\n // Keyboard navigation\n const keyHandler = (e) => {\n if (e.key === 'ArrowLeft') { prev(); e.preventDefault(); }\n if (e.key === 'ArrowRight') { next(); e.preventDefault(); }\n };\n el.setAttribute('tabindex', '0');\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n\n // Touch / pointer support\n const pointerDown = (e) => {\n state.isDragging = true;\n state.startX = e.clientX || (e.touches && e.touches[0].clientX) || 0;\n state.currentX = state.startX;\n el.classList.add('is-dragging');\n };\n\n const pointerMove = (e) => {\n if (!state.isDragging) return;\n state.currentX = e.clientX || (e.touches && e.touches[0].clientX) || 0;\n };\n\n const pointerUp = () => {\n if (!state.isDragging) return;\n state.isDragging = false;\n el.classList.remove('is-dragging');\n const diff = state.startX - state.currentX;\n if (Math.abs(diff) > state.threshold) {\n if (diff > 0) next();\n else prev();\n }\n };\n\n el.addEventListener('mousedown', pointerDown);\n el.addEventListener('mousemove', pointerMove);\n el.addEventListener('mouseup', pointerUp);\n el.addEventListener('mouseleave', pointerUp);\n el.addEventListener('touchstart', pointerDown, { passive: true });\n el.addEventListener('touchmove', pointerMove, { passive: true });\n el.addEventListener('touchend', pointerUp);\n\n cleanup.push(\n () => el.removeEventListener('mousedown', pointerDown),\n () => el.removeEventListener('mousemove', pointerMove),\n () => el.removeEventListener('mouseup', pointerUp),\n () => el.removeEventListener('mouseleave', pointerUp),\n () => el.removeEventListener('touchstart', pointerDown),\n () => el.removeEventListener('touchmove', pointerMove),\n () => el.removeEventListener('touchend', pointerUp)\n );\n\n // Autoplay\n const startAutoplay = () => {\n stopAutoplay();\n state.autoplayTimer = setInterval(next, interval);\n };\n\n const stopAutoplay = () => {\n if (state.autoplayTimer) {\n clearInterval(state.autoplayTimer);\n state.autoplayTimer = null;\n }\n };\n\n if (autoplay) {\n startAutoplay();\n const pauseHandler = () => stopAutoplay();\n const resumeHandler = () => startAutoplay();\n el.addEventListener('mouseenter', pauseHandler);\n el.addEventListener('mouseleave', resumeHandler);\n el.addEventListener('focusin', pauseHandler);\n el.addEventListener('focusout', resumeHandler);\n cleanup.push(\n () => el.removeEventListener('mouseenter', pauseHandler),\n () => el.removeEventListener('mouseleave', resumeHandler),\n () => el.removeEventListener('focusin', pauseHandler),\n () => el.removeEventListener('focusout', resumeHandler),\n () => stopAutoplay()\n );\n }\n\n // Initial ARIA state\n goTo(0, false);\n\n this.instances.set(el, {\n cleanup: cleanup,\n goTo: goTo,\n next: next,\n prev: prev,\n getState: () => ({ ...state })\n });\n },\n\n goTo: function (el, index) {\n const instance = this.instances.get(el);\n if (instance) instance.goTo(index);\n },\n\n next: function (el) {\n const instance = this.instances.get(el);\n if (instance) instance.next();\n },\n\n prev: function (el) {\n const instance = this.instances.get(el);\n if (instance) instance.prev();\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('flow', Flow);\n }\n\n window.VanduoFlow = Flow;\n\n})();\n", "/**\n * Vanduo Framework - Bubble (Popover) Component\n * Click-triggered rich HTML popover, reuses tooltip positioning concepts\n */\n\n(function () {\n 'use strict';\n\n const Bubble = {\n instances: new Map(),\n _globalCleanups: [],\n\n init: function () {\n const triggers = document.querySelectorAll('[data-vd-bubble], [data-vd-popover]');\n triggers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n\n if (this._globalCleanups.length === 0) {\n const outsideClick = (e) => {\n this.instances.forEach((inst, trigger) => {\n if (!inst.popover.contains(e.target) && !trigger.contains(e.target)) {\n this.hide(trigger);\n }\n });\n };\n const escHandler = (e) => {\n if (e.key === 'Escape') {\n this.instances.forEach((_, trigger) => this.hide(trigger));\n }\n };\n document.addEventListener('click', outsideClick, true);\n document.addEventListener('keydown', escHandler);\n this._globalCleanups.push(\n () => document.removeEventListener('click', outsideClick, true),\n () => document.removeEventListener('keydown', escHandler)\n );\n }\n },\n\n initInstance: function (trigger) {\n const cleanup = [];\n const placement = trigger.getAttribute('data-vd-bubble-placement') ||\n trigger.getAttribute('data-vd-popover-placement') || 'bottom';\n\n // Build popover element\n const popover = document.createElement('div');\n popover.className = 'vd-bubble-content';\n popover.setAttribute('role', 'dialog');\n popover.setAttribute('aria-modal', 'false');\n popover.setAttribute('data-placement', placement);\n\n const title = trigger.getAttribute('data-vd-bubble-title') ||\n trigger.getAttribute('data-vd-popover-title');\n const content = trigger.getAttribute('data-vd-bubble') ||\n trigger.getAttribute('data-vd-popover') || '';\n const htmlContent = trigger.getAttribute('data-vd-bubble-html') ||\n trigger.getAttribute('data-vd-popover-html');\n const allowSvg = trigger.hasAttribute('data-vd-bubble-allow-svg') ||\n trigger.hasAttribute('data-vd-popover-allow-svg');\n\n if (title) {\n const header = document.createElement('div');\n header.className = 'vd-bubble-header';\n const titleSpan = document.createElement('span');\n titleSpan.textContent = title;\n const closeBtn = document.createElement('button');\n closeBtn.className = 'vd-bubble-close';\n closeBtn.setAttribute('aria-label', 'Close');\n closeBtn.innerHTML = '×';\n header.appendChild(titleSpan);\n header.appendChild(closeBtn);\n popover.appendChild(header);\n\n const closeHandler = (e) => { e.stopPropagation(); this.hide(trigger); };\n closeBtn.addEventListener('click', closeHandler);\n cleanup.push(() => closeBtn.removeEventListener('click', closeHandler));\n }\n\n const body = document.createElement('div');\n body.className = 'vd-bubble-body';\n if (htmlContent) {\n if (typeof sanitizeHtml === 'function') {\n body.innerHTML = sanitizeHtml(htmlContent, { allowSvg });\n } else {\n body.textContent = htmlContent;\n }\n } else {\n body.textContent = content;\n }\n popover.appendChild(body);\n\n document.body.appendChild(popover);\n\n // ARIA on trigger\n const popId = 'vd-bubble-' + Math.random().toString(36).slice(2, 9);\n popover.id = popId;\n trigger.setAttribute('aria-haspopup', 'dialog');\n trigger.setAttribute('aria-expanded', 'false');\n trigger.setAttribute('aria-controls', popId);\n\n // Toggle on click\n const toggleHandler = (e) => {\n e.stopPropagation();\n if (popover.classList.contains('is-visible')) {\n this.hide(trigger);\n } else {\n this.hideAll();\n this.show(trigger);\n }\n };\n trigger.addEventListener('click', toggleHandler);\n cleanup.push(() => trigger.removeEventListener('click', toggleHandler));\n\n this.instances.set(trigger, { popover, cleanup, placement });\n },\n\n position: function (trigger, popover, placement) {\n const rect = trigger.getBoundingClientRect();\n const popRect = popover.getBoundingClientRect();\n const gap = 10;\n let top, left;\n\n switch (placement) {\n case 'top':\n top = rect.top - popRect.height - gap + window.scrollY;\n left = rect.left + (rect.width - popRect.width) / 2 + window.scrollX;\n break;\n case 'left':\n top = rect.top + (rect.height - popRect.height) / 2 + window.scrollY;\n left = rect.left - popRect.width - gap + window.scrollX;\n break;\n case 'right':\n top = rect.top + (rect.height - popRect.height) / 2 + window.scrollY;\n left = rect.right + gap + window.scrollX;\n break;\n default: // bottom\n top = rect.bottom + gap + window.scrollY;\n left = rect.left + (rect.width - popRect.width) / 2 + window.scrollX;\n }\n\n // Clamp to viewport\n left = Math.max(8, Math.min(left, window.innerWidth - popRect.width - 8));\n top = Math.max(8, top);\n\n popover.style.top = top + 'px';\n popover.style.left = left + 'px';\n },\n\n show: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n const { popover, placement } = instance;\n\n popover.style.display = 'block';\n popover.classList.add('is-visible');\n trigger.setAttribute('aria-expanded', 'true');\n\n requestAnimationFrame(() => {\n this.position(trigger, popover, placement);\n });\n\n trigger.dispatchEvent(new CustomEvent('bubble:show', {\n bubbles: true,\n detail: { trigger: trigger, placement: placement }\n }));\n },\n\n hide: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n instance.popover.classList.remove('is-visible');\n trigger.setAttribute('aria-expanded', 'false');\n trigger.dispatchEvent(new CustomEvent('bubble:hide', {\n bubbles: true,\n detail: { trigger: trigger }\n }));\n },\n\n hideAll: function () {\n this.instances.forEach((_, trigger) => this.hide(trigger));\n },\n\n destroy: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n if (instance.popover.parentNode) {\n instance.popover.parentNode.removeChild(instance.popover);\n }\n trigger.removeAttribute('aria-haspopup');\n trigger.removeAttribute('aria-expanded');\n trigger.removeAttribute('aria-controls');\n this.instances.delete(trigger);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, trigger) => this.destroy(trigger));\n this._globalCleanups.forEach(fn => fn());\n this._globalCleanups = [];\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('bubble', Bubble);\n }\n\n window.VanduoBubble = Bubble;\n\n})();\n", "/**\n * Vanduo Framework - Waypoint (Scrollspy) Component\n * Highlights navigation links based on scroll position using IntersectionObserver\n */\n\n(function () {\n 'use strict';\n\n const Waypoint = {\n instances: new Map(),\n\n init: function () {\n const navs = document.querySelectorAll('[data-vd-waypoint-nav], [data-vd-scrollspy-nav]');\n navs.forEach(nav => {\n if (this.instances.has(nav)) return;\n this.initInstance(nav);\n });\n },\n\n initInstance: function (nav) {\n const links = Array.from(nav.querySelectorAll('a[href^=\"#\"]'));\n if (links.length === 0) return;\n\n const cleanup = [];\n const offset = parseInt(nav.getAttribute('data-vd-waypoint-offset') || '80', 10);\n const sections = [];\n\n links.forEach(link => {\n const id = link.getAttribute('href').slice(1);\n const section = document.getElementById(id);\n if (section) {\n section.setAttribute('data-vd-waypoint-section', '');\n sections.push({ id, link, section });\n }\n });\n\n if (sections.length === 0) return;\n\n const activeSections = new Set();\n\n const setActive = (id) => {\n links.forEach(l => l.classList.remove('is-active'));\n const target = links.find(l => l.getAttribute('href') === '#' + id);\n if (target) {\n target.classList.add('is-active');\n nav.dispatchEvent(new CustomEvent('waypoint:change', {\n detail: { activeId: id, link: target }\n }));\n }\n };\n\n const rootMargin = '-' + offset + 'px 0px -40% 0px';\n\n const observer = new IntersectionObserver((entries) => {\n entries.forEach(entry => {\n if (entry.isIntersecting) {\n activeSections.add(entry.target.id);\n } else {\n activeSections.delete(entry.target.id);\n }\n });\n\n // Pick the topmost visible section\n for (let i = 0; i < sections.length; i++) {\n if (activeSections.has(sections[i].id)) {\n setActive(sections[i].id);\n return;\n }\n }\n }, {\n rootMargin: rootMargin,\n threshold: 0\n });\n\n sections.forEach(s => observer.observe(s.section));\n\n // Smooth scroll on click\n links.forEach(link => {\n const clickHandler = (e) => {\n e.preventDefault();\n const id = link.getAttribute('href').slice(1);\n const section = document.getElementById(id);\n if (section) {\n section.scrollIntoView({ behavior: 'smooth' });\n setActive(id);\n }\n };\n link.addEventListener('click', clickHandler);\n cleanup.push(() => link.removeEventListener('click', clickHandler));\n });\n\n cleanup.push(() => observer.disconnect());\n\n this.instances.set(nav, { observer, cleanup, sections, setActive });\n },\n\n refresh: function (nav) {\n this.destroy(nav);\n this.initInstance(nav);\n },\n\n destroy: function (nav) {\n const instance = this.instances.get(nav);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(nav);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, nav) => this.destroy(nav));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('waypoint', Waypoint);\n }\n\n window.VanduoWaypoint = Waypoint;\n\n})();\n", "/**\n * Vanduo Framework - Ripple (Waves Effect) Component\n * Adds expanding circle animation on click at pointer position\n */\n\n(function () {\n 'use strict';\n\n const Ripple = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-ripple, [data-vd-ripple]');\n elements.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n\n const createWave = (e) => {\n const rect = el.getBoundingClientRect();\n const size = Math.max(rect.width, rect.height);\n const x = (e.clientX || (e.touches && e.touches[0].clientX) || rect.left + rect.width / 2) - rect.left - size / 2;\n const y = (e.clientY || (e.touches && e.touches[0].clientY) || rect.top + rect.height / 2) - rect.top - size / 2;\n\n const wave = document.createElement('span');\n wave.className = 'vd-ripple-wave';\n wave.style.width = size + 'px';\n wave.style.height = size + 'px';\n wave.style.left = x + 'px';\n wave.style.top = y + 'px';\n\n el.appendChild(wave);\n\n wave.addEventListener('animationend', () => {\n if (wave.parentNode) wave.parentNode.removeChild(wave);\n });\n };\n\n el.addEventListener('mousedown', createWave);\n el.addEventListener('touchstart', createWave, { passive: true });\n\n cleanup.push(\n () => el.removeEventListener('mousedown', createWave),\n () => el.removeEventListener('touchstart', createWave)\n );\n\n this.instances.set(el, { cleanup });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n // Remove any lingering wave elements\n el.querySelectorAll('.vd-ripple-wave').forEach(w => w.remove());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('ripple', Ripple);\n }\n\n window.VanduoRipple = Ripple;\n\n})();\n", "/**\n * Vanduo Framework - Affix (Sticky) Component\n * Uses IntersectionObserver to toggle .is-stuck within the nearest scrollable parent\n */\n\n(function () {\n 'use strict';\n\n function isScrollable(element) {\n if (!element || element === document.body) return false;\n\n const style = window.getComputedStyle(element);\n const overflowY = style.overflowY;\n const overflowX = style.overflowX;\n const canScrollY = /(auto|scroll|overlay)/.test(overflowY) && element.scrollHeight > element.clientHeight;\n const canScrollX = /(auto|scroll|overlay)/.test(overflowX) && element.scrollWidth > element.clientWidth;\n\n return canScrollY || canScrollX;\n }\n\n function getScrollParent(element) {\n let parent = element.parentElement;\n\n while (parent && parent !== document.body && parent !== document.documentElement) {\n if (isScrollable(parent)) return parent;\n parent = parent.parentElement;\n }\n\n return null;\n }\n\n const Affix = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-affix, .vd-sticky, [data-vd-affix]');\n elements.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const parsedOffset = parseInt(el.getAttribute('data-vd-affix-offset') || '0', 10);\n const offset = Number.isNaN(parsedOffset) ? 0 : parsedOffset;\n const scrollParent = getScrollParent(el);\n let isStuck = false;\n\n const sentinel = document.createElement('div');\n sentinel.style.cssText = 'display:block;height:1px;margin-bottom:-1px;visibility:hidden;pointer-events:none;';\n el.parentNode.insertBefore(sentinel, el);\n\n el.style.setProperty('--affix-top-offset', offset + 'px');\n\n function stick() {\n if (isStuck) return;\n isStuck = true;\n el.classList.add('is-stuck');\n el.dispatchEvent(new CustomEvent('affix:stuck', {\n bubbles: true,\n detail: {\n offset: offset,\n root: scrollParent || window\n }\n }));\n }\n\n function unstick() {\n if (!isStuck) return;\n isStuck = false;\n el.classList.remove('is-stuck');\n el.dispatchEvent(new CustomEvent('affix:unstuck', {\n bubbles: true,\n detail: {\n offset: offset,\n root: scrollParent || window\n }\n }));\n }\n\n const observer = new IntersectionObserver(function (entries) {\n entries.forEach(entry => {\n if (!entry.isIntersecting) {\n stick();\n } else {\n unstick();\n }\n });\n }, {\n root: scrollParent,\n rootMargin: '-' + offset + 'px 0px 0px 0px',\n threshold: 0\n });\n\n observer.observe(sentinel);\n\n cleanup.push(\n () => observer.disconnect(),\n () => { if (sentinel.parentNode) sentinel.parentNode.removeChild(sentinel); },\n () => {\n el.classList.remove('is-stuck');\n el.style.removeProperty('--affix-top-offset');\n }\n );\n\n this.instances.set(el, { cleanup, observer, sentinel, scrollParent });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n el.classList.remove('is-stuck');\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('affix', Affix);\n }\n\n window.VanduoAffix = Affix;\n\n})();\n", "/**\n * Vanduo Framework - Suggest (Autocomplete) Component\n * Dropdown suggestion list with keyboard navigation, static/async data sources\n */\n\n(function () {\n 'use strict';\n\n /**\n * Escape HTML entities to prevent XSS when inserting into innerHTML\n * @param {string} text\n * @returns {string}\n */\n function _escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Allow same-origin URLs by default, or an explicit allowlist of origins.\n * @param {string} url\n * @param {string[]} allowlist\n * @returns {boolean}\n */\n function _isSafeUrl(url, allowlist) {\n try {\n const resolved = new URL(url, window.location.href);\n if (resolved.origin === window.location.origin) return true;\n return allowlist.includes(resolved.origin);\n } catch (_e) {\n return false;\n }\n }\n\n const Suggest = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-suggest], [data-vd-autocomplete]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const minChars = parseInt(input.getAttribute('data-vd-suggest-min-chars') || '1', 10);\n const url = input.getAttribute('data-vd-suggest-url') || '';\n const allowlistAttr = input.getAttribute('data-vd-suggest-allowlist') || '';\n const allowlist = allowlistAttr\n .split(',')\n .map(value => value.trim())\n .filter(Boolean);\n const staticData = input.getAttribute('data-vd-suggest') || input.getAttribute('data-vd-autocomplete') || '';\n let items = [];\n\n try { items = JSON.parse(staticData); } catch (_e) {\n items = staticData.split(',').map(s => s.trim()).filter(Boolean);\n }\n\n // Wrap input if not already wrapped\n let wrapper = input.closest('.vd-suggest-wrapper, .vd-autocomplete-wrapper');\n if (!wrapper) {\n wrapper = document.createElement('div');\n wrapper.className = 'vd-suggest-wrapper';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n }\n\n // Create dropdown list\n const list = document.createElement('ul');\n list.className = 'vd-suggest-list';\n list.setAttribute('role', 'listbox');\n const listId = 'vd-suggest-' + Math.random().toString(36).slice(2, 9);\n list.id = listId;\n wrapper.appendChild(list);\n\n // ARIA\n input.setAttribute('role', 'combobox');\n input.setAttribute('aria-autocomplete', 'list');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('aria-controls', listId);\n input.setAttribute('autocomplete', 'off');\n\n let highlighted = -1;\n let currentItems = [];\n let debounceTimer = null;\n\n const renderItems = (filtered, query) => {\n list.innerHTML = '';\n currentItems = filtered;\n highlighted = -1;\n\n if (filtered.length === 0) {\n const empty = document.createElement('li');\n empty.className = 'vd-suggest-empty';\n empty.textContent = 'No results';\n list.appendChild(empty);\n return;\n }\n\n filtered.forEach((item, i) => {\n const li = document.createElement('li');\n li.className = 'vd-suggest-item';\n li.setAttribute('role', 'option');\n li.id = listId + '-item-' + i;\n\n const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);\n if (query) {\n // Escape HTML first to prevent XSS, then highlight matches in the safe string\n const escaped = _escapeHtml(text);\n const re = new RegExp('(' + query.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi');\n li.innerHTML = escaped.replace(re, '$1');\n } else {\n li.textContent = text;\n }\n\n li.addEventListener('click', () => selectItem(i));\n list.appendChild(li);\n });\n };\n\n const open = () => {\n list.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n };\n\n const close = () => {\n list.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n highlighted = -1;\n input.removeAttribute('aria-activedescendant');\n };\n\n const selectItem = (index) => {\n const item = currentItems[index];\n const value = typeof item === 'object' ? (item.value || item.label || String(item)) : String(item);\n input.value = value;\n close();\n input.dispatchEvent(new CustomEvent('suggest:select', {\n detail: { value, item, index },\n bubbles: true\n }));\n };\n\n const highlight = (index) => {\n const listItems = list.querySelectorAll('.vd-suggest-item');\n listItems.forEach(li => li.classList.remove('is-highlighted'));\n if (index >= 0 && index < listItems.length) {\n highlighted = index;\n listItems[index].classList.add('is-highlighted');\n input.setAttribute('aria-activedescendant', listItems[index].id);\n listItems[index].scrollIntoView({ block: 'nearest' });\n }\n };\n\n const doSearch = async (query) => {\n if (query.length < minChars) { close(); return; }\n\n let filtered;\n if (url) {\n try {\n if (!_isSafeUrl(url, allowlist)) {\n console.warn('[VanduoSuggest] Blocked non-allowlisted URL:', url);\n filtered = [];\n } else {\n const separator = url.includes('?') ? '&' : '?';\n const res = await window.fetch(url + separator + 'q=' + encodeURIComponent(query));\n filtered = await res.json();\n }\n } catch (_e) {\n filtered = [];\n }\n } else {\n const lower = query.toLowerCase();\n filtered = items.filter(item => {\n const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);\n return text.toLowerCase().includes(lower);\n });\n }\n\n renderItems(filtered, query);\n if (filtered.length > 0) open();\n else open(); // show \"no results\"\n };\n\n const inputHandler = () => {\n clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => doSearch(input.value), 200);\n };\n\n const keyHandler = (e) => {\n if (!list.classList.contains('is-open')) {\n if (e.key === 'ArrowDown') { doSearch(input.value); e.preventDefault(); }\n return;\n }\n\n const total = currentItems.length;\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n highlight(highlighted < total - 1 ? highlighted + 1 : 0);\n break;\n case 'ArrowUp':\n e.preventDefault();\n highlight(highlighted > 0 ? highlighted - 1 : total - 1);\n break;\n case 'Enter':\n e.preventDefault();\n if (highlighted >= 0) selectItem(highlighted);\n break;\n case 'Escape':\n close();\n break;\n }\n };\n\n const blurHandler = () => {\n setTimeout(close, 200);\n };\n\n input.addEventListener('input', inputHandler);\n input.addEventListener('keydown', keyHandler);\n input.addEventListener('blur', blurHandler);\n input.addEventListener('focus', () => { if (input.value.length >= minChars) doSearch(input.value); });\n\n cleanup.push(\n () => input.removeEventListener('input', inputHandler),\n () => input.removeEventListener('keydown', keyHandler),\n () => input.removeEventListener('blur', blurHandler),\n () => clearTimeout(debounceTimer),\n () => { if (list.parentNode) list.parentNode.removeChild(list); }\n );\n\n this.instances.set(input, { cleanup, list, close });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('suggest', Suggest);\n }\n\n window.VanduoSuggest = Suggest;\n\n})();\n", "/**\n * Vanduo Framework - Validate (Form Validation) Component\n * Declarative validation via data attributes with real-time and on-submit modes\n */\n\n(function () {\n 'use strict';\n\n const Validate = {\n instances: new Map(),\n\n rules: {\n required: (value) => value.trim().length > 0,\n email: (value) => /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(value),\n url: (value) => { try { new URL(value); return true; } catch (_e) { return false; } },\n number: (value) => !isNaN(parseFloat(value)) && isFinite(value),\n min: (value, param) => value.length >= parseInt(param, 10),\n max: (value, param) => value.length <= parseInt(param, 10),\n minVal: (value, param) => parseFloat(value) >= parseFloat(param),\n maxVal: (value, param) => parseFloat(value) <= parseFloat(param),\n pattern: (value, param) => {\n try {\n // Cap regex length to prevent ReDoS from excessively complex patterns\n if (param.length > 100) return false;\n return new RegExp(param).test(value);\n } catch (_e) { return false; }\n },\n match: (value, param) => {\n try {\n const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(param) : param;\n const other = document.querySelector('[name=\"' + escaped + '\"]');\n return other ? value === other.value : false;\n } catch (_e) {\n return false;\n }\n }\n },\n\n messages: {\n required: 'This field is required',\n email: 'Please enter a valid email address',\n url: 'Please enter a valid URL',\n number: 'Please enter a valid number',\n min: 'Minimum {0} characters required',\n max: 'Maximum {0} characters allowed',\n minVal: 'Value must be at least {0}',\n maxVal: 'Value must be at most {0}',\n pattern: 'Invalid format',\n match: 'Fields do not match'\n },\n\n init: function () {\n const forms = document.querySelectorAll('[data-vd-validate], .vd-validate');\n forms.forEach(form => {\n if (this.instances.has(form)) return;\n this.initInstance(form);\n });\n },\n\n initInstance: function (form) {\n const cleanup = [];\n const mode = form.getAttribute('data-vd-validate-mode') || 'blur'; // blur | input | submit\n const fields = form.querySelectorAll('[data-vd-rules]');\n\n const validateField = (field) => {\n const rulesStr = field.getAttribute('data-vd-rules') || '';\n const rules = rulesStr.split('|').map(r => r.trim()).filter(Boolean);\n const value = field.value;\n const errors = [];\n\n for (const rule of rules) {\n const [name, ...params] = rule.split(':');\n const param = params.join(':');\n const validator = this.rules[name];\n\n if (validator && !validator(value, param)) {\n const customMsg = field.getAttribute('data-vd-msg-' + name);\n let msg = customMsg || this.messages[name] || 'Invalid';\n if (param) msg = msg.replace('{0}', param);\n errors.push(msg);\n break; // one error at a time\n }\n }\n\n this.setFieldState(field, errors);\n return errors.length === 0;\n };\n\n const validateAll = () => {\n let valid = true;\n fields.forEach(field => {\n if (!validateField(field)) valid = false;\n });\n return valid;\n };\n\n // Per-field listeners\n fields.forEach(field => {\n if (mode === 'input' || mode === 'blur') {\n const eventType = mode === 'input' ? 'input' : 'blur';\n const handler = () => validateField(field);\n field.addEventListener(eventType, handler);\n cleanup.push(() => field.removeEventListener(eventType, handler));\n\n if (mode === 'blur') {\n const inputClear = () => {\n if (field.classList.contains('is-invalid') || field.classList.contains('is-valid')) {\n validateField(field);\n }\n };\n field.addEventListener('input', inputClear);\n cleanup.push(() => field.removeEventListener('input', inputClear));\n }\n }\n });\n\n // Form submit\n const submitHandler = (e) => {\n const valid = validateAll();\n if (!valid) {\n e.preventDefault();\n e.stopPropagation();\n // Focus first invalid field\n const firstInvalid = form.querySelector('.is-invalid');\n if (firstInvalid) firstInvalid.focus();\n }\n form.dispatchEvent(new CustomEvent('validate:submit', {\n detail: { valid },\n bubbles: true\n }));\n };\n\n form.addEventListener('submit', submitHandler);\n cleanup.push(() => form.removeEventListener('submit', submitHandler));\n\n this.instances.set(form, { cleanup, validateAll, validateField });\n },\n\n setFieldState: function (field, errors) {\n const wrapper = field.closest('.vd-form-group') || field.parentElement;\n let errorEl = wrapper.querySelector('.vd-validate-error');\n\n field.classList.remove('is-valid', 'is-invalid');\n\n if (errors.length > 0) {\n field.classList.add('is-invalid');\n field.setAttribute('aria-invalid', 'true');\n if (!errorEl) {\n errorEl = document.createElement('div');\n errorEl.className = 'vd-validate-error';\n errorEl.id = 'vd-err-' + Math.random().toString(36).slice(2, 9);\n errorEl.setAttribute('role', 'alert');\n wrapper.appendChild(errorEl);\n }\n errorEl.textContent = errors[0];\n errorEl.style.display = '';\n field.setAttribute('aria-describedby', errorEl.id);\n } else if (field.value.trim()) {\n field.classList.add('is-valid');\n field.removeAttribute('aria-invalid');\n if (errorEl) errorEl.style.display = 'none';\n } else {\n field.removeAttribute('aria-invalid');\n if (errorEl) errorEl.style.display = 'none';\n }\n },\n\n validateForm: function (form) {\n const instance = this.instances.get(form);\n return instance ? instance.validateAll() : false;\n },\n\n addRule: function (name, validator, message) {\n this.rules[name] = validator;\n if (message) this.messages[name] = message;\n },\n\n destroy: function (form) {\n const instance = this.instances.get(form);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(form);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, form) => this.destroy(form));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('validate', Validate);\n }\n\n window.VanduoValidate = Validate;\n\n})();\n", "/**\n * Vanduo Framework - Datepicker Component\n * Calendar popup attached to input field with month/year navigation\n */\n\n(function () {\n 'use strict';\n\n const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];\n const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];\n\n function escapeRegexChar(c) {\n return c.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n }\n\n function buildParseFormat(format) {\n let regex = '^';\n const order = [];\n let i = 0;\n while (i < format.length) {\n const slice = format.slice(i);\n if (slice.toLowerCase().startsWith('yyyy')) {\n regex += '(\\\\d{4})';\n order.push('y');\n i += 4;\n } else if (slice.toLowerCase().startsWith('mm')) {\n regex += '(\\\\d{2})';\n order.push('m');\n i += 2;\n } else if (slice.toLowerCase().startsWith('dd')) {\n regex += '(\\\\d{2})';\n order.push('d');\n i += 2;\n } else {\n regex += escapeRegexChar(format[i]);\n i++;\n }\n }\n regex += '$';\n return { regex: new RegExp(regex), order };\n }\n\n function parseDateFromFormat(value, format) {\n if (!value || !format) return null;\n const { regex, order } = buildParseFormat(format);\n const m = value.trim().match(regex);\n if (!m) return null;\n let y;\n let mo;\n let d;\n let ci = 1;\n for (let k = 0; k < order.length; k++) {\n const part = order[k];\n const v = parseInt(m[ci++], 10);\n if (Number.isNaN(v)) return null;\n if (part === 'y') y = v;\n else if (part === 'm') mo = v - 1;\n else if (part === 'd') d = v;\n }\n if (y === undefined || mo === undefined || d === undefined) return null;\n const dt = new Date(y, mo, d);\n if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== d) return null;\n return dt;\n }\n\n function formatDate(d, format) {\n const yyyy = String(d.getFullYear());\n const mm = String(d.getMonth() + 1).padStart(2, '0');\n const dd = String(d.getDate()).padStart(2, '0');\n let out = '';\n let i = 0;\n while (i < format.length) {\n const slice = format.slice(i);\n if (slice.toLowerCase().startsWith('yyyy')) {\n out += yyyy;\n i += 4;\n } else if (slice.toLowerCase().startsWith('mm')) {\n out += mm;\n i += 2;\n } else if (slice.toLowerCase().startsWith('dd')) {\n out += dd;\n i += 2;\n } else {\n out += format[i];\n i++;\n }\n }\n return out;\n }\n\n function dateKey(d) {\n return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');\n }\n\n function addDays(d, n) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n x.setDate(x.getDate() + n);\n return x;\n }\n\n function addMonthsClamped(d, n) {\n return new Date(d.getFullYear(), d.getMonth() + n, d.getDate());\n }\n\n function parseYmdLocal(ymd) {\n if (!ymd || typeof ymd !== 'string') return null;\n const m = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(ymd.trim());\n if (!m) return null;\n const y = +m[1];\n const mo = +m[2] - 1;\n const day = +m[3];\n const dt = new Date(y, mo, day);\n if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== day) return null;\n return dt;\n }\n\n function startOfWeekSunday(d) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const day = x.getDay();\n x.setDate(x.getDate() - day);\n return x;\n }\n\n function endOfWeekSunday(d) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const day = x.getDay();\n x.setDate(x.getDate() + (6 - day));\n return x;\n }\n\n const Datepicker = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-datepicker]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const format = input.getAttribute('data-vd-datepicker-format') || 'YYYY-MM-DD';\n const minStr = input.getAttribute('data-vd-datepicker-min');\n const maxStr = input.getAttribute('data-vd-datepicker-max');\n const minDate = minStr ? parseYmdLocal(minStr) : null;\n const maxDate = maxStr ? parseYmdLocal(maxStr) : null;\n\n const today = new Date();\n let viewYear = today.getFullYear();\n let viewMonth = today.getMonth();\n let selectedDate = null;\n let viewMode = 'days'; // days | months | years\n let focusedDate = null;\n /** Prevents focus() after close from immediately re-opening the popup */\n let skipNextFocusOpen = false;\n\n const isDisabled = (d) => {\n if (minDate) {\n const t = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n if (t < minDate.getTime()) return true;\n }\n if (maxDate) {\n const t = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n if (t > maxDate.getTime()) return true;\n }\n return false;\n };\n\n const ensureMonthInRange = (y, m) => {\n if (!minDate && !maxDate) return { y: y, m: m };\n const first = new Date(y, m, 1);\n const last = new Date(y, m + 1, 0);\n if (minDate && last.getTime() < minDate.getTime()) {\n return { y: minDate.getFullYear(), m: minDate.getMonth() };\n }\n if (maxDate && first.getTime() > maxDate.getTime()) {\n return { y: maxDate.getFullYear(), m: maxDate.getMonth() };\n }\n return { y: y, m: m };\n };\n\n const firstSelectableInMonth = (y, m) => {\n const last = new Date(y, m + 1, 0).getDate();\n for (let day = 1; day <= last; day++) {\n const dt = new Date(y, m, day);\n if (!isDisabled(dt)) return dt;\n }\n return new Date(y, m, 1);\n };\n\n if (input.value) {\n const trimmed = input.value.trim();\n let parsed = parseDateFromFormat(trimmed, format);\n if (!parsed) {\n const fallback = new Date(trimmed);\n if (!isNaN(fallback.getTime())) parsed = fallback;\n }\n if (parsed) {\n selectedDate = parsed;\n viewYear = parsed.getFullYear();\n viewMonth = parsed.getMonth();\n }\n }\n\n const clampedInit = ensureMonthInRange(viewYear, viewMonth);\n viewYear = clampedInit.y;\n viewMonth = clampedInit.m;\n\n // Create popup\n const popup = document.createElement('div');\n popup.className = 'vd-datepicker-popup';\n popup.setAttribute('role', 'dialog');\n popup.setAttribute('aria-label', 'Choose date');\n popup.tabIndex = -1;\n\n const wrapper = document.createElement('div');\n wrapper.className = 'vd-suggest-wrapper';\n wrapper.style.position = 'relative';\n wrapper.style.display = 'inline-block';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n wrapper.appendChild(popup);\n\n const isSameDay = (a, b) => a && b &&\n a.getFullYear() === b.getFullYear() &&\n a.getMonth() === b.getMonth() &&\n a.getDate() === b.getDate();\n\n const selectDate = (date) => {\n selectedDate = date;\n viewYear = date.getFullYear();\n viewMonth = date.getMonth();\n input.value = formatDate(date, format);\n skipNextFocusOpen = true;\n close();\n input.dispatchEvent(new CustomEvent('datepicker:select', {\n detail: { date: date, formatted: input.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n input.focus();\n };\n\n const focusFocusedDay = () => {\n if (viewMode !== 'days' || !focusedDate) return;\n const key = dateKey(focusedDate);\n const btn = popup.querySelector('[data-vd-date=\"' + key + '\"]');\n if (btn && !btn.classList.contains('is-outside') && btn.getAttribute('aria-disabled') !== 'true') {\n btn.focus();\n }\n };\n\n const skipDisabled = (d, stepDir, maxSteps) => {\n let x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const step = stepDir > 0 ? 1 : -1;\n for (let i = 0; i < maxSteps; i++) {\n if (!isDisabled(x)) return x;\n x = addDays(x, step);\n }\n return d;\n };\n\n const createDayBtn = (day, outside, date) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-day';\n btn.textContent = day;\n btn.setAttribute('role', 'gridcell');\n\n if (outside) {\n btn.classList.add('is-outside');\n btn.tabIndex = -1;\n btn.setAttribute('aria-disabled', 'true');\n return btn;\n }\n\n btn.setAttribute('data-vd-date', dateKey(date));\n\n if (date && isSameDay(date, today)) btn.classList.add('is-today');\n if (date && isSameDay(date, selectedDate)) btn.classList.add('is-selected');\n if (date && isDisabled(date)) {\n btn.classList.add('is-disabled');\n btn.setAttribute('aria-disabled', 'true');\n btn.tabIndex = -1;\n return btn;\n }\n\n if (date) {\n const isFocused = focusedDate && isSameDay(date, focusedDate);\n btn.tabIndex = isFocused ? 0 : -1;\n\n btn.addEventListener('click', () => {\n selectedDate = date;\n viewYear = date.getFullYear();\n viewMonth = date.getMonth();\n focusedDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());\n input.value = formatDate(date, format);\n skipNextFocusOpen = true;\n close();\n input.dispatchEvent(new CustomEvent('datepicker:select', {\n detail: { date: date, formatted: input.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n input.focus();\n });\n }\n\n return btn;\n };\n\n const render = () => {\n popup.innerHTML = '';\n\n // Header\n const header = document.createElement('div');\n header.className = 'vd-datepicker-header';\n\n const prevBtn = document.createElement('button');\n prevBtn.type = 'button';\n prevBtn.className = 'vd-datepicker-prev';\n prevBtn.innerHTML = '‹';\n prevBtn.setAttribute('aria-label', 'Previous');\n\n const nextBtn = document.createElement('button');\n nextBtn.type = 'button';\n nextBtn.className = 'vd-datepicker-next';\n nextBtn.innerHTML = '›';\n nextBtn.setAttribute('aria-label', 'Next');\n\n const title = document.createElement('span');\n title.className = 'vd-datepicker-title';\n\n if (viewMode === 'days') {\n title.textContent = MONTHS[viewMonth] + ' ' + viewYear;\n title.addEventListener('click', () => { viewMode = 'months'; render(); });\n prevBtn.addEventListener('click', () => { viewMonth--; if (viewMonth < 0) { viewMonth = 11; viewYear--; } render(); });\n nextBtn.addEventListener('click', () => { viewMonth++; if (viewMonth > 11) { viewMonth = 0; viewYear++; } render(); });\n } else if (viewMode === 'months') {\n title.textContent = String(viewYear);\n title.addEventListener('click', () => { viewMode = 'years'; render(); });\n prevBtn.addEventListener('click', () => { viewYear--; render(); });\n nextBtn.addEventListener('click', () => { viewYear++; render(); });\n } else {\n const decadeStart = Math.floor(viewYear / 10) * 10;\n title.textContent = decadeStart + ' - ' + (decadeStart + 9);\n prevBtn.addEventListener('click', () => { viewYear -= 10; render(); });\n nextBtn.addEventListener('click', () => { viewYear += 10; render(); });\n }\n\n header.appendChild(prevBtn);\n header.appendChild(title);\n header.appendChild(nextBtn);\n popup.appendChild(header);\n\n if (viewMode === 'days') {\n const gridWrap = document.createElement('div');\n gridWrap.className = 'vd-datepicker-grid';\n gridWrap.setAttribute('role', 'grid');\n gridWrap.setAttribute('aria-label', 'Calendar');\n\n const weekdays = document.createElement('div');\n weekdays.className = 'vd-datepicker-weekdays';\n weekdays.setAttribute('role', 'row');\n DAYS.forEach(function (d) {\n const span = document.createElement('span');\n span.setAttribute('role', 'columnheader');\n span.setAttribute('aria-label', d);\n span.textContent = d;\n weekdays.appendChild(span);\n });\n gridWrap.appendChild(weekdays);\n\n const firstDay = new Date(viewYear, viewMonth, 1).getDay();\n const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();\n const daysInPrev = new Date(viewYear, viewMonth, 0).getDate();\n\n const cells = [];\n\n for (let i = firstDay - 1; i >= 0; i--) {\n const dayNum = daysInPrev - i;\n const prevMonth = viewMonth === 0 ? 11 : viewMonth - 1;\n const prevYear = viewMonth === 0 ? viewYear - 1 : viewYear;\n const date = new Date(prevYear, prevMonth, dayNum);\n cells.push({ day: dayNum, outside: true, date: date });\n }\n\n for (let d = 1; d <= daysInMonth; d++) {\n const date = new Date(viewYear, viewMonth, d);\n cells.push({ day: d, outside: false, date: date });\n }\n\n const totalCells = firstDay + daysInMonth;\n const remaining = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7);\n for (let i = 1; i <= remaining; i++) {\n const date = new Date(viewYear, viewMonth + 1, i);\n cells.push({ day: i, outside: true, date: date });\n }\n\n for (let r = 0; r < cells.length; r += 7) {\n const row = document.createElement('div');\n row.className = 'vd-datepicker-row';\n row.setAttribute('role', 'row');\n for (let c = 0; c < 7; c++) {\n const cell = cells[r + c];\n row.appendChild(createDayBtn(cell.day, cell.outside, cell.date));\n }\n gridWrap.appendChild(row);\n }\n\n popup.appendChild(gridWrap);\n } else if (viewMode === 'months') {\n const grid = document.createElement('div');\n grid.className = 'vd-datepicker-months';\n MONTHS.forEach((name, i) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-month-btn';\n btn.textContent = name.slice(0, 3);\n if (selectedDate && selectedDate.getFullYear() === viewYear && selectedDate.getMonth() === i) {\n btn.classList.add('is-selected');\n }\n btn.addEventListener('click', () => { viewMonth = i; viewMode = 'days'; render(); });\n grid.appendChild(btn);\n });\n popup.appendChild(grid);\n } else {\n const grid = document.createElement('div');\n grid.className = 'vd-datepicker-years';\n const decadeStart = Math.floor(viewYear / 10) * 10;\n for (let y = decadeStart - 1; y <= decadeStart + 10; y++) {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-year-btn';\n btn.textContent = y;\n if (selectedDate && selectedDate.getFullYear() === y) btn.classList.add('is-selected');\n if (y < decadeStart || y > decadeStart + 9) btn.style.opacity = '0.4';\n btn.addEventListener('click', () => { viewYear = y; viewMode = 'months'; render(); });\n grid.appendChild(btn);\n }\n popup.appendChild(grid);\n }\n };\n\n const handleGridKeydown = (e) => {\n if (!popup.classList.contains('is-open') || viewMode !== 'days') return;\n const grid = popup.querySelector('.vd-datepicker-grid');\n if (!grid || !grid.contains(e.target)) return;\n\n const key = e.key;\n if (key !== 'ArrowLeft' && key !== 'ArrowRight' && key !== 'ArrowUp' && key !== 'ArrowDown' &&\n key !== 'Home' && key !== 'End' && key !== 'PageUp' && key !== 'PageDown' &&\n key !== 'Enter' && key !== ' ' && key !== 'Escape') {\n return;\n }\n\n if (key === 'Escape') {\n e.preventDefault();\n e.stopPropagation();\n skipNextFocusOpen = true;\n close();\n input.focus();\n return;\n }\n\n if (!focusedDate) {\n focusedDate = firstSelectableInMonth(viewYear, viewMonth);\n }\n\n if (key === 'Enter' || key === ' ') {\n e.preventDefault();\n if (focusedDate && !isDisabled(focusedDate)) {\n selectDate(new Date(focusedDate.getFullYear(), focusedDate.getMonth(), focusedDate.getDate()));\n }\n return;\n }\n\n e.preventDefault();\n\n let next = new Date(focusedDate.getFullYear(), focusedDate.getMonth(), focusedDate.getDate());\n let skipDir = 1;\n\n if (key === 'ArrowLeft') {\n next = addDays(next, -1);\n skipDir = -1;\n } else if (key === 'ArrowRight') {\n next = addDays(next, 1);\n skipDir = 1;\n } else if (key === 'ArrowUp') {\n next = addDays(next, -7);\n skipDir = -1;\n } else if (key === 'ArrowDown') {\n next = addDays(next, 7);\n skipDir = 1;\n } else if (key === 'Home') {\n next = startOfWeekSunday(next);\n skipDir = 1;\n } else if (key === 'End') {\n next = endOfWeekSunday(next);\n skipDir = -1;\n } else if (key === 'PageUp') {\n next = addMonthsClamped(next, -1);\n skipDir = -1;\n } else if (key === 'PageDown') {\n next = addMonthsClamped(next, 1);\n skipDir = 1;\n }\n\n next = skipDisabled(next, skipDir, 400);\n\n if (next.getMonth() !== viewMonth || next.getFullYear() !== viewYear) {\n viewYear = next.getFullYear();\n viewMonth = next.getMonth();\n const cl = ensureMonthInRange(viewYear, viewMonth);\n viewYear = cl.y;\n viewMonth = cl.m;\n }\n\n focusedDate = next;\n render();\n requestAnimationFrame(focusFocusedDay);\n };\n\n const open = () => {\n viewMode = 'days';\n if (selectedDate) {\n viewYear = selectedDate.getFullYear();\n viewMonth = selectedDate.getMonth();\n }\n const cl = ensureMonthInRange(viewYear, viewMonth);\n viewYear = cl.y;\n viewMonth = cl.m;\n\n if (selectedDate) {\n focusedDate = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate());\n } else {\n focusedDate = firstSelectableInMonth(viewYear, viewMonth);\n }\n\n render();\n popup.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n requestAnimationFrame(focusFocusedDay);\n };\n\n const close = () => {\n popup.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n viewMode = 'days';\n };\n\n const focusHandler = () => {\n if (skipNextFocusOpen) {\n skipNextFocusOpen = false;\n return;\n }\n open();\n };\n const outsideHandler = (e) => {\n if (!wrapper.contains(e.target)) close();\n };\n const escHandler = (e) => {\n if (e.key === 'Escape' && popup.classList.contains('is-open')) {\n skipNextFocusOpen = true;\n close();\n input.focus();\n }\n };\n\n input.addEventListener('focus', focusHandler);\n document.addEventListener('click', outsideHandler, true);\n document.addEventListener('keydown', escHandler);\n popup.addEventListener('keydown', handleGridKeydown);\n\n input.setAttribute('aria-haspopup', 'dialog');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('autocomplete', 'off');\n\n cleanup.push(\n () => input.removeEventListener('focus', focusHandler),\n () => document.removeEventListener('click', outsideHandler, true),\n () => document.removeEventListener('keydown', escHandler),\n () => popup.removeEventListener('keydown', handleGridKeydown)\n );\n\n this.instances.set(input, { cleanup: cleanup, open: open, close: close, popup: popup });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('datepicker', Datepicker);\n }\n\n window.VanduoDatepicker = Datepicker;\n\n})();\n", "/**\n * Vanduo Framework - Timepicker Component\n * Dropdown time selection with 12h/24h format and configurable step intervals\n */\n\n(function () {\n 'use strict';\n\n const Timepicker = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-timepicker]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const is24h = input.getAttribute('data-vd-timepicker-format') === '24h';\n const step = parseInt(input.getAttribute('data-vd-timepicker-step') || '30', 10);\n\n // Create wrapper\n let wrapper = input.closest('.vd-suggest-wrapper');\n if (!wrapper) {\n wrapper = document.createElement('div');\n wrapper.style.position = 'relative';\n wrapper.style.display = 'inline-block';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n }\n\n // Create popup\n const popup = document.createElement('div');\n popup.className = 'vd-timepicker-popup';\n popup.setAttribute('role', 'listbox');\n wrapper.appendChild(popup);\n\n // Generate time slots\n const times = [];\n for (let h = 0; h < 24; h++) {\n for (let m = 0; m < 60; m += step) {\n const hh24 = String(h).padStart(2, '0');\n const mm = String(m).padStart(2, '0');\n\n if (is24h) {\n times.push({ display: hh24 + ':' + mm, value: hh24 + ':' + mm });\n } else {\n const period = h < 12 ? 'AM' : 'PM';\n const h12 = h === 0 ? 12 : (h > 12 ? h - 12 : h);\n const display = h12 + ':' + mm + ' ' + period;\n times.push({ display, value: hh24 + ':' + mm });\n }\n }\n }\n\n const render = () => {\n popup.innerHTML = '';\n times.forEach(t => {\n const item = document.createElement('div');\n item.className = 'vd-timepicker-item';\n item.setAttribute('role', 'option');\n item.textContent = t.display;\n\n if (input.value === t.value || input.value === t.display) {\n item.classList.add('is-selected');\n }\n\n item.addEventListener('click', () => {\n input.value = t.display;\n popup.querySelectorAll('.vd-timepicker-item').forEach(i => i.classList.remove('is-selected'));\n item.classList.add('is-selected');\n close();\n input.dispatchEvent(new CustomEvent('timepicker:select', {\n detail: { display: t.display, value: t.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n });\n\n popup.appendChild(item);\n });\n };\n\n const open = () => {\n render();\n popup.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n // Scroll to selected\n const selected = popup.querySelector('.is-selected');\n if (selected) selected.scrollIntoView({ block: 'center' });\n };\n\n const close = () => {\n popup.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n };\n\n const focusHandler = () => open();\n const outsideHandler = (e) => {\n if (!wrapper.contains(e.target)) close();\n };\n const escHandler = (e) => { if (e.key === 'Escape') close(); };\n\n input.addEventListener('focus', focusHandler);\n document.addEventListener('click', outsideHandler, true);\n document.addEventListener('keydown', escHandler);\n input.setAttribute('aria-haspopup', 'listbox');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('autocomplete', 'off');\n input.readOnly = true;\n\n cleanup.push(\n () => input.removeEventListener('focus', focusHandler),\n () => document.removeEventListener('click', outsideHandler, true),\n () => document.removeEventListener('keydown', escHandler)\n );\n\n this.instances.set(input, { cleanup, open, close });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('timepicker', Timepicker);\n }\n\n window.VanduoTimepicker = Timepicker;\n\n})();\n", "/**\n * Vanduo Framework - Stepper Component\n * Multi-step progress indicator with state management\n */\n\n(function () {\n 'use strict';\n\n const Stepper = {\n instances: new Map(),\n\n init: function () {\n const steppers = document.querySelectorAll('.vd-stepper');\n steppers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const items = Array.from(el.querySelectorAll('.vd-stepper-item'));\n const isClickable = el.classList.contains('vd-stepper-clickable');\n let currentIndex = items.findIndex(i => i.classList.contains('is-active'));\n if (currentIndex === -1) currentIndex = 0;\n\n const setStep = (index) => {\n if (index < 0 || index >= items.length) return;\n const prev = currentIndex;\n currentIndex = index;\n\n items.forEach((item, i) => {\n item.classList.remove('is-active', 'is-completed');\n if (i < index) item.classList.add('is-completed');\n else if (i === index) item.classList.add('is-active');\n });\n\n el.dispatchEvent(new CustomEvent('stepper:change', {\n detail: { current: index, previous: prev, total: items.length },\n bubbles: true\n }));\n };\n\n if (isClickable) {\n items.forEach((item, i) => {\n const handler = () => setStep(i);\n item.addEventListener('click', handler);\n cleanup.push(() => item.removeEventListener('click', handler));\n });\n }\n\n // Initialize current state\n setStep(currentIndex);\n\n this.instances.set(el, {\n cleanup,\n setStep,\n next: () => setStep(currentIndex + 1),\n prev: () => setStep(currentIndex - 1),\n getCurrent: () => currentIndex\n });\n },\n\n setStep: function (el, index) {\n const inst = this.instances.get(el);\n if (inst) inst.setStep(index);\n },\n\n next: function (el) {\n const inst = this.instances.get(el);\n if (inst) inst.next();\n },\n\n prev: function (el) {\n const inst = this.instances.get(el);\n if (inst) inst.prev();\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('stepper', Stepper);\n }\n\n window.VanduoStepper = Stepper;\n\n})();\n", "/**\n * Vanduo Framework - Rating Component\n * Star-based rating input with hover preview and read-only mode\n */\n\n(function () {\n 'use strict';\n\n const Rating = {\n instances: new Map(),\n\n init: function () {\n const ratings = document.querySelectorAll('[data-vd-rating]');\n ratings.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const max = parseInt(el.getAttribute('data-vd-rating-max') || '5', 10);\n const initialValue = parseFloat(el.getAttribute('data-vd-rating-value') || '0');\n const readonly = el.classList.contains('vd-rating-readonly') || el.hasAttribute('data-vd-rating-readonly');\n let currentValue = initialValue;\n\n el.classList.add('vd-rating');\n el.setAttribute('role', 'radiogroup');\n el.setAttribute('aria-label', el.getAttribute('aria-label') || 'Rating');\n\n // Clear existing stars\n el.innerHTML = '';\n\n // Create stars\n const stars = [];\n for (let i = 1; i <= max; i++) {\n const star = document.createElement('button');\n star.type = 'button';\n star.className = 'vd-rating-star';\n star.setAttribute('role', 'radio');\n star.setAttribute('aria-label', i + ' star' + (i > 1 ? 's' : ''));\n star.setAttribute('aria-checked', i <= currentValue ? 'true' : 'false');\n if (readonly) star.tabIndex = -1;\n stars.push(star);\n el.appendChild(star);\n }\n\n // Value display\n const valueDisplay = document.createElement('span');\n valueDisplay.className = 'vd-rating-value';\n valueDisplay.textContent = currentValue > 0 ? currentValue.toString() : '';\n el.appendChild(valueDisplay);\n\n const updateStars = (value) => {\n stars.forEach((star, i) => {\n star.classList.remove('is-active', 'is-half');\n const starNum = i + 1;\n if (starNum <= Math.floor(value)) {\n star.classList.add('is-active');\n } else if (starNum - 0.5 <= value) {\n star.classList.add('is-half');\n }\n star.setAttribute('aria-checked', starNum <= value ? 'true' : 'false');\n });\n valueDisplay.textContent = value > 0 ? value.toString() : '';\n };\n\n updateStars(currentValue);\n\n if (!readonly) {\n stars.forEach((star, i) => {\n const enterHandler = () => {\n stars.forEach((s, j) => {\n s.classList.toggle('is-hovered', j <= i);\n });\n };\n const leaveHandler = () => {\n stars.forEach(s => s.classList.remove('is-hovered'));\n };\n const clickHandler = () => {\n currentValue = i + 1;\n el.setAttribute('data-vd-rating-value', currentValue);\n updateStars(currentValue);\n el.dispatchEvent(new CustomEvent('rating:change', {\n detail: { value: currentValue, max },\n bubbles: true\n }));\n };\n\n star.addEventListener('mouseenter', enterHandler);\n star.addEventListener('mouseleave', leaveHandler);\n star.addEventListener('click', clickHandler);\n\n cleanup.push(\n () => star.removeEventListener('mouseenter', enterHandler),\n () => star.removeEventListener('mouseleave', leaveHandler),\n () => star.removeEventListener('click', clickHandler)\n );\n });\n\n // Keyboard\n const keyHandler = (e) => {\n if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {\n e.preventDefault();\n if (currentValue < max) {\n currentValue++;\n updateStars(currentValue);\n stars[currentValue - 1].focus();\n el.dispatchEvent(new CustomEvent('rating:change', { detail: { value: currentValue, max }, bubbles: true }));\n }\n } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {\n e.preventDefault();\n if (currentValue > 1) {\n currentValue--;\n updateStars(currentValue);\n stars[currentValue - 1].focus();\n el.dispatchEvent(new CustomEvent('rating:change', { detail: { value: currentValue, max }, bubbles: true }));\n }\n }\n };\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n }\n\n this.instances.set(el, {\n cleanup,\n getValue: () => currentValue,\n setValue: (v) => { currentValue = v; updateStars(v); }\n });\n },\n\n getValue: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getValue() : 0;\n },\n\n setValue: function (el, value) {\n const inst = this.instances.get(el);\n if (inst) inst.setValue(value);\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('rating', Rating);\n }\n\n window.VanduoRating = Rating;\n\n})();\n", "/**\n * Vanduo Framework - Transfer (Dual-list) Component\n * Source-to-target list transfer with search, select-all, and move actions\n */\n\n(function () {\n 'use strict';\n\n const Transfer = {\n instances: new Map(),\n\n init: function () {\n const transfers = document.querySelectorAll('[data-vd-transfer]');\n transfers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n el.classList.add('vd-transfer');\n\n let sourceData, targetData;\n try {\n const raw = JSON.parse(el.getAttribute('data-vd-transfer') || '[]');\n sourceData = raw.map((item, i) => ({\n id: item.id || 'item-' + i,\n label: item.label || item.text || String(item),\n selected: false\n }));\n } catch (_e) {\n sourceData = [];\n }\n targetData = [];\n\n const sourceSelected = new Set();\n const targetSelected = new Set();\n\n const render = () => {\n el.innerHTML = '';\n\n // Source panel\n const sourcePanel = createPanel('Source', sourceData, sourceSelected, 'source');\n // Actions\n const actions = document.createElement('div');\n actions.className = 'vd-transfer-actions';\n\n const moveRightBtn = document.createElement('button');\n moveRightBtn.type = 'button';\n moveRightBtn.className = 'vd-transfer-btn';\n moveRightBtn.innerHTML = '›';\n moveRightBtn.setAttribute('aria-label', 'Move to target');\n moveRightBtn.disabled = sourceSelected.size === 0;\n moveRightBtn.addEventListener('click', () => moveRight());\n\n const moveLeftBtn = document.createElement('button');\n moveLeftBtn.type = 'button';\n moveLeftBtn.className = 'vd-transfer-btn';\n moveLeftBtn.innerHTML = '‹';\n moveLeftBtn.setAttribute('aria-label', 'Move to source');\n moveLeftBtn.disabled = targetSelected.size === 0;\n moveLeftBtn.addEventListener('click', () => moveLeft());\n\n actions.appendChild(moveRightBtn);\n actions.appendChild(moveLeftBtn);\n\n // Target panel\n const targetPanel = createPanel('Target', targetData, targetSelected, 'target');\n\n el.appendChild(sourcePanel);\n el.appendChild(actions);\n el.appendChild(targetPanel);\n };\n\n const createPanel = (title, data, selected, _side) => {\n const panel = document.createElement('div');\n panel.className = 'vd-transfer-panel';\n\n const header = document.createElement('div');\n header.className = 'vd-transfer-header';\n const titleSpan = document.createElement('span');\n titleSpan.textContent = title;\n const count = document.createElement('span');\n count.className = 'vd-transfer-count';\n count.textContent = selected.size + '/' + data.length;\n header.appendChild(titleSpan);\n header.appendChild(count);\n panel.appendChild(header);\n\n // Search\n const searchDiv = document.createElement('div');\n searchDiv.className = 'vd-transfer-search';\n const searchInput = document.createElement('input');\n searchInput.type = 'text';\n searchInput.placeholder = 'Search...';\n searchInput.setAttribute('aria-label', 'Search ' + title.toLowerCase());\n searchDiv.appendChild(searchInput);\n panel.appendChild(searchDiv);\n\n // List\n const list = document.createElement('ul');\n list.className = 'vd-transfer-list';\n list.setAttribute('role', 'listbox');\n\n const renderList = (filter) => {\n list.innerHTML = '';\n const filtered = filter ? data.filter(d => {\n const label = (d.label || d.text || String(d)).toLowerCase();\n return label.includes(filter.toLowerCase());\n }) : data;\n filtered.forEach(item => {\n const li = document.createElement('li');\n li.className = 'vd-transfer-item';\n li.setAttribute('role', 'option');\n if (selected.has(item.id)) li.classList.add('is-selected');\n\n const checkbox = document.createElement('input');\n checkbox.type = 'checkbox';\n checkbox.checked = selected.has(item.id);\n checkbox.setAttribute('aria-label', item.label);\n\n const label = document.createElement('span');\n label.textContent = item.label;\n\n li.addEventListener('click', () => {\n if (selected.has(item.id)) selected.delete(item.id);\n else selected.add(item.id);\n render();\n });\n\n li.appendChild(checkbox);\n li.appendChild(label);\n list.appendChild(li);\n });\n };\n\n searchInput.addEventListener('input', () => renderList(searchInput.value));\n renderList('');\n\n panel.appendChild(list);\n return panel;\n };\n\n const moveRight = () => {\n const toMove = sourceData.filter(d => sourceSelected.has(d.id));\n sourceData = sourceData.filter(d => !sourceSelected.has(d.id));\n targetData = targetData.concat(toMove);\n sourceSelected.clear();\n render();\n fireChange();\n };\n\n const moveLeft = () => {\n const toMove = targetData.filter(d => targetSelected.has(d.id));\n targetData = targetData.filter(d => !targetSelected.has(d.id));\n sourceData = sourceData.concat(toMove);\n targetSelected.clear();\n render();\n fireChange();\n };\n\n const fireChange = () => {\n el.dispatchEvent(new CustomEvent('transfer:change', {\n detail: {\n source: sourceData.map(d => d.id),\n target: targetData.map(d => d.id)\n },\n bubbles: true\n }));\n };\n\n render();\n\n this.instances.set(el, {\n cleanup,\n getTarget: () => targetData.map(d => d.id),\n getSource: () => sourceData.map(d => d.id)\n });\n },\n\n getSelected: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getTarget() : [];\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n el.innerHTML = '';\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('transfer', Transfer);\n }\n\n window.VanduoTransfer = Transfer;\n\n})();\n", "/**\n * Vanduo Framework - Tree View Component\n * Hierarchical collapsible tree with checkbox selection and keyboard navigation\n */\n\n(function () {\n 'use strict';\n\n const Tree = {\n instances: new Map(),\n\n init: function () {\n const trees = document.querySelectorAll('[data-vd-tree]');\n trees.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const cascade = el.getAttribute('data-vd-tree-cascade') !== 'false';\n\n let data;\n try { data = JSON.parse(el.getAttribute('data-vd-tree') || '[]'); } catch (_e) { data = []; }\n\n el.classList.add('vd-tree');\n el.setAttribute('role', 'tree');\n\n const render = (items, parent) => {\n parent.innerHTML = '';\n items.forEach(item => {\n const node = document.createElement('li');\n node.className = 'vd-tree-node';\n node.setAttribute('role', 'treeitem');\n node.setAttribute('aria-expanded', item.open ? 'true' : 'false');\n if (item.open) node.classList.add('is-open');\n\n const content = document.createElement('div');\n content.className = 'vd-tree-node-content';\n\n // Toggle\n if (item.children && item.children.length > 0) {\n const toggle = document.createElement('button');\n toggle.type = 'button';\n toggle.className = 'vd-tree-toggle';\n toggle.setAttribute('aria-label', 'Toggle');\n toggle.addEventListener('click', (e) => {\n e.stopPropagation();\n item.open = !item.open;\n node.classList.toggle('is-open');\n node.setAttribute('aria-expanded', item.open ? 'true' : 'false');\n });\n content.appendChild(toggle);\n } else {\n const ph = document.createElement('span');\n ph.className = 'vd-tree-toggle-placeholder';\n content.appendChild(ph);\n }\n\n // Checkbox\n if (el.hasAttribute('data-vd-tree-checkbox')) {\n const cb = document.createElement('input');\n cb.type = 'checkbox';\n cb.className = 'vd-tree-checkbox';\n cb.checked = !!item.checked;\n cb.setAttribute('aria-label', item.label);\n cb.addEventListener('change', (e) => {\n e.stopPropagation();\n item.checked = cb.checked;\n if (cascade && item.children) {\n setChildChecked(item.children, cb.checked);\n render(data, el);\n }\n el.dispatchEvent(new CustomEvent('tree:check', {\n detail: { id: item.id, checked: cb.checked, label: item.label },\n bubbles: true\n }));\n });\n content.appendChild(cb);\n }\n\n // Icon\n if (item.icon) {\n const icon = document.createElement('span');\n icon.className = 'vd-tree-icon ' + item.icon;\n content.appendChild(icon);\n }\n\n // Label\n const label = document.createElement('span');\n label.className = 'vd-tree-label';\n label.textContent = item.label || '';\n content.appendChild(label);\n\n node.appendChild(content);\n\n // Children\n if (item.children && item.children.length > 0) {\n const childList = document.createElement('ul');\n childList.className = 'vd-tree-children';\n childList.setAttribute('role', 'group');\n render(item.children, childList);\n node.appendChild(childList);\n }\n\n parent.appendChild(node);\n });\n };\n\n const setChildChecked = (items, checked) => {\n items.forEach(item => {\n item.checked = checked;\n if (item.children) setChildChecked(item.children, checked);\n });\n };\n\n // Keyboard\n const keyHandler = (e) => {\n const focused = document.activeElement;\n if (!el.contains(focused)) return;\n\n const nodes = Array.from(el.querySelectorAll('.vd-tree-node-content'));\n const idx = nodes.indexOf(focused.closest('.vd-tree-node-content'));\n if (idx === -1) return;\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n if (idx < nodes.length - 1) {\n const next = nodes[idx + 1].querySelector('.vd-tree-toggle, .vd-tree-label');\n if (next) next.focus();\n }\n break;\n case 'ArrowUp':\n e.preventDefault();\n if (idx > 0) {\n const prev = nodes[idx - 1].querySelector('.vd-tree-toggle, .vd-tree-label');\n if (prev) prev.focus();\n }\n break;\n }\n };\n\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n\n render(data, el);\n\n this.instances.set(el, {\n cleanup,\n getData: () => data,\n getChecked: () => {\n const checked = [];\n const collect = (items) => {\n items.forEach(i => {\n if (i.checked) checked.push(i.id || i.label);\n if (i.children) collect(i.children);\n });\n };\n collect(data);\n return checked;\n }\n });\n },\n\n getChecked: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getChecked() : [];\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n el.innerHTML = '';\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('tree', Tree);\n }\n\n window.VanduoTree = Tree;\n\n})();\n", "/**\n * Vanduo Framework - Spotlight (Feature Discovery) Component\n * Guided tour with overlay highlight and step-through tooltip\n */\n\n(function () {\n 'use strict';\n\n const Spotlight = {\n _active: false,\n _steps: [],\n _currentStep: 0,\n _elements: {},\n _cleanup: [],\n _boundTriggers: new WeakMap(),\n _triggerElement: null,\n\n init: function () {\n const triggers = document.querySelectorAll('[data-vd-spotlight]');\n\n triggers.forEach(trigger => {\n if (this._boundTriggers.has(trigger)) return;\n\n const clickHandler = (event) => {\n event.preventDefault();\n\n const steps = this._parseSteps(trigger.getAttribute('data-vd-spotlight'));\n if (steps.length === 0) return;\n\n this.start(steps, { trigger });\n };\n\n trigger.addEventListener('click', clickHandler);\n this._boundTriggers.set(trigger, clickHandler);\n });\n },\n\n _parseSteps: function (raw) {\n if (typeof raw !== 'string' || raw.trim() === '') return [];\n\n try {\n const parsed = JSON.parse(raw);\n return this._normalizeSteps(parsed);\n } catch (error) {\n console.error('VanduoSpotlight: invalid data-vd-spotlight payload.', error);\n return [];\n }\n },\n\n _normalizeStep: function (step) {\n if (!step || typeof step !== 'object') return null;\n\n const target = step.target;\n const hasSelectorTarget = typeof target === 'string' && target.trim() !== '';\n const hasElementTarget = typeof Element !== 'undefined' && target instanceof Element;\n\n if (!hasSelectorTarget && !hasElementTarget) return null;\n\n const title = typeof step.title === 'string' ? step.title : '';\n const description = typeof step.description === 'string'\n ? step.description\n : (typeof step.content === 'string' ? step.content : '');\n\n return {\n target,\n title,\n description\n };\n },\n\n _normalizeSteps: function (steps) {\n if (!Array.isArray(steps)) return [];\n\n return steps\n .map(step => this._normalizeStep(step))\n .filter(Boolean);\n },\n\n start: function (steps, options) {\n if (this._active) this.stop();\n\n const normalizedSteps = this._normalizeSteps(steps);\n if (normalizedSteps.length === 0) return;\n\n const startOptions = options || {};\n\n this._steps = normalizedSteps;\n this._currentStep = 0;\n this._active = true;\n this._triggerElement = startOptions.trigger || (document.activeElement instanceof HTMLElement ? document.activeElement : null);\n\n // Create overlay\n const overlay = document.createElement('div');\n overlay.className = 'vd-spotlight-overlay';\n overlay.setAttribute('aria-hidden', 'true');\n document.body.appendChild(overlay);\n\n // Create tooltip\n const tooltip = document.createElement('div');\n tooltip.className = 'vd-spotlight-tooltip';\n tooltip.setAttribute('role', 'dialog');\n tooltip.setAttribute('aria-modal', 'true');\n tooltip.tabIndex = -1;\n document.body.appendChild(tooltip);\n\n this._elements = { overlay, tooltip };\n\n // ESC to close\n const escHandler = (e) => { if (e.key === 'Escape') this.stop(); };\n document.addEventListener('keydown', escHandler);\n this._cleanup.push(() => document.removeEventListener('keydown', escHandler));\n\n // Overlay click to close\n overlay.addEventListener('click', () => this.stop());\n\n this._showStep(this._currentStep);\n },\n\n _showStep: function (index) {\n const step = this._steps[index];\n if (!step) return;\n\n const target = typeof step.target === 'string' ? document.querySelector(step.target) : step.target;\n const { tooltip } = this._elements;\n\n // Remove previous highlight\n document.querySelectorAll('.vd-spotlight-target').forEach(el => {\n el.classList.remove('vd-spotlight-target');\n });\n\n // Highlight target\n if (target) {\n target.classList.add('vd-spotlight-target');\n target.scrollIntoView({ behavior: 'smooth', block: 'center' });\n }\n\n // Build tooltip content\n const total = this._steps.length;\n tooltip.innerHTML = '';\n tooltip.removeAttribute('aria-labelledby');\n tooltip.removeAttribute('aria-describedby');\n\n if (step.title) {\n const title = document.createElement('h4');\n title.className = 'vd-spotlight-title';\n title.id = 'vd-spotlight-title-' + index + '-' + Date.now();\n title.textContent = step.title;\n tooltip.appendChild(title);\n tooltip.setAttribute('aria-labelledby', title.id);\n }\n\n if (step.description) {\n const desc = document.createElement('p');\n desc.className = 'vd-spotlight-description';\n desc.id = 'vd-spotlight-description-' + index + '-' + Date.now();\n desc.textContent = step.description;\n tooltip.appendChild(desc);\n tooltip.setAttribute('aria-describedby', desc.id);\n }\n\n // Footer\n const footer = document.createElement('div');\n footer.className = 'vd-spotlight-footer';\n footer.setAttribute('aria-label', 'Step ' + (index + 1) + ' of ' + total);\n\n const counter = document.createElement('span');\n counter.className = 'vd-spotlight-counter';\n counter.textContent = (index + 1) + ' / ' + total;\n\n const actions = document.createElement('div');\n actions.className = 'vd-spotlight-actions';\n\n if (index > 0) {\n const prevBtn = document.createElement('button');\n prevBtn.type = 'button';\n prevBtn.className = 'vd-spotlight-btn';\n prevBtn.textContent = 'Back';\n prevBtn.addEventListener('click', () => this.prev());\n actions.appendChild(prevBtn);\n }\n\n const skipBtn = document.createElement('button');\n skipBtn.type = 'button';\n skipBtn.className = 'vd-spotlight-btn';\n skipBtn.textContent = 'Skip';\n skipBtn.addEventListener('click', () => this.stop());\n actions.appendChild(skipBtn);\n\n if (index < total - 1) {\n const nextBtn = document.createElement('button');\n nextBtn.type = 'button';\n nextBtn.className = 'vd-spotlight-btn vd-spotlight-btn-primary';\n nextBtn.textContent = 'Next';\n nextBtn.addEventListener('click', () => this.next());\n actions.appendChild(nextBtn);\n } else {\n const doneBtn = document.createElement('button');\n doneBtn.type = 'button';\n doneBtn.className = 'vd-spotlight-btn vd-spotlight-btn-primary';\n doneBtn.textContent = 'Done';\n doneBtn.addEventListener('click', () => this.stop());\n actions.appendChild(doneBtn);\n }\n\n footer.appendChild(counter);\n footer.appendChild(actions);\n tooltip.appendChild(footer);\n\n // Position tooltip near target\n if (target) {\n requestAnimationFrame(() => {\n const rect = target.getBoundingClientRect();\n const tRect = tooltip.getBoundingClientRect();\n let top = rect.bottom + 12 + window.scrollY;\n let left = rect.left + (rect.width - tRect.width) / 2 + window.scrollX;\n\n // Keep in viewport\n left = Math.max(8, Math.min(left, window.innerWidth - tRect.width - 8));\n if (top + tRect.height > window.innerHeight + window.scrollY) {\n top = rect.top - tRect.height - 12 + window.scrollY;\n }\n\n tooltip.style.top = top + 'px';\n tooltip.style.left = left + 'px';\n });\n }\n\n document.dispatchEvent(new CustomEvent('spotlight:step', {\n detail: { index, step: index, total, data: step }\n }));\n },\n\n next: function () {\n if (this._currentStep < this._steps.length - 1) {\n this._currentStep++;\n this._showStep(this._currentStep);\n }\n },\n\n prev: function () {\n if (this._currentStep > 0) {\n this._currentStep--;\n this._showStep(this._currentStep);\n }\n },\n\n stop: function () {\n if (!this._active) return;\n\n const total = this._steps.length;\n const detail = {\n completedSteps: total === 0 ? 0 : Math.min(this._currentStep + 1, total),\n total,\n completed: total > 0 && this._currentStep >= total - 1\n };\n\n this._active = false;\n\n document.querySelectorAll('.vd-spotlight-target').forEach(el => {\n el.classList.remove('vd-spotlight-target');\n });\n\n if (this._elements.overlay && this._elements.overlay.parentNode) {\n this._elements.overlay.parentNode.removeChild(this._elements.overlay);\n }\n if (this._elements.tooltip && this._elements.tooltip.parentNode) {\n this._elements.tooltip.parentNode.removeChild(this._elements.tooltip);\n }\n\n this._cleanup.forEach(fn => fn());\n this._cleanup = [];\n this._elements = {};\n this._steps = [];\n this._currentStep = 0;\n\n if (this._triggerElement && this._triggerElement.isConnected && typeof this._triggerElement.focus === 'function') {\n this._triggerElement.focus();\n }\n this._triggerElement = null;\n\n document.dispatchEvent(new CustomEvent('spotlight:end', { detail }));\n },\n\n destroyAll: function () {\n this.stop();\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('spotlight', Spotlight);\n }\n\n window.VanduoSpotlight = Spotlight;\n\n})();\n", "/**\n * Vanduo Framework - Music Player Component\n * HTML5 Audio-based music player with transport controls, volume,\n * and optional shuffle, seek bar, and playlist features.\n *\n * Options (passed to MusicPlayer.init or data-music-player-options):\n * tracks {Array} - [{name, url}] \u2014 required\n * volume {number} - Initial volume 0\u20131 (default 0.5)\n * shuffle {boolean} - Shuffle on init (default false)\n * showProgress {boolean} - Show seek/progress bar (default false)\n * showPlaylist {boolean} - Show expandable playlist panel (default false)\n * autoAdvance {boolean} - Auto-play next track on end (default true)\n * glass {boolean} - Frosted-glass surface styling (default false)\n * detachable {boolean} - Show detach/attach; float above page when detached (default false)\n * floatingPosition {string|null} - After detach: 'bottom-left' or 'bottom-right' (default null = bottom-right)\n * draggable {boolean} - When detached+detachable, drag by handle (default false)\n * minimizable {boolean} - Minimize/expand control (default false)\n * startMinimized {boolean} - On first detach, start minimized (default false)\n * persistPosition {boolean} - Save floating x/y in localStorage (default false)\n * persistKey {string} - Storage key for persist (default from element id)\n *\n * Custom events (all bubble, dispatched on container):\n * musicplayer:play \u2014 playback started\n * musicplayer:pause \u2014 playback paused\n * musicplayer:trackchange \u2014 detail: { index, name, url }\n * musicplayer:volumechange \u2014 detail: { volume }\n * musicplayer:ended \u2014 last track ended (autoAdvance=false only)\n * musicplayer:detach \u2014 after floating player is created\n * musicplayer:attach \u2014 after returned to document flow\n * musicplayer:minimize \u2014 collapsed to minimal controls\n * musicplayer:expand \u2014 full controls visible again\n *\n * Programmatic API:\n * MusicPlayer.init(container?, options?)\n * MusicPlayer.play(container)\n * MusicPlayer.pause(container)\n * MusicPlayer.toggle(container)\n * MusicPlayer.next(container)\n * MusicPlayer.previous(container)\n * MusicPlayer.setVolume(container, value)\n * MusicPlayer.setTrack(container, index)\n * MusicPlayer.shuffle(container)\n * MusicPlayer.detach(container, position?)\n * MusicPlayer.attach(container)\n * MusicPlayer.minimize(container)\n * MusicPlayer.expand(container)\n * MusicPlayer.toggleMinimize(container)\n * MusicPlayer.setPosition(container, 'bottom-left'|'bottom-right'|{x,y})\n * MusicPlayer.getState(container)\n * MusicPlayer.destroy(container)\n * MusicPlayer.destroyAll()\n */\n\n(function () {\n 'use strict';\n\n /* \u2500\u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Fisher-Yates shuffle (returns new array).\n * @param {Array} arr\n * @returns {Array}\n */\n function shuffleArray(arr) {\n const shuffled = arr.slice();\n for (let i = shuffled.length - 1; i > 0; i--) {\n const j = Math.floor(Math.random() * (i + 1));\n const tmp = shuffled[i];\n shuffled[i] = shuffled[j];\n shuffled[j] = tmp;\n }\n return shuffled;\n }\n\n /**\n * Format seconds as m:ss.\n * @param {number} seconds\n * @returns {string}\n */\n function formatTime(seconds) {\n if (!isFinite(seconds) || seconds < 0) return '0:00';\n const m = Math.floor(seconds / 60);\n const s = Math.floor(seconds % 60);\n return m + ':' + (s < 10 ? '0' : '') + s;\n }\n\n /**\n * Set CSS background-size on a range input to visually fill the track.\n * @param {HTMLInputElement} input\n */\n /**\n * @param {string|undefined} id\n * @returns {string}\n */\n function persistStorageKey(id) {\n return 'vanduo:music-player:' + (id && id.trim() ? id.trim() : 'default') + ':pos';\n }\n\n function updateRangeFill(input) {\n const min = parseFloat(input.min) || 0;\n const max = parseFloat(input.max) || 1;\n const val = parseFloat(input.value) || 0;\n const pct = ((val - min) / (max - min)) * 100;\n input.style.setProperty('--fill', pct + '%');\n // Fallback inline gradient for browsers without ::-moz-range-progress\n input.style.backgroundImage =\n 'linear-gradient(to right, var(--music-player-track-fill, currentColor) 0%, ' +\n 'var(--music-player-track-fill, currentColor) ' + pct + '%, ' +\n 'var(--music-player-track-bg, #ccc) ' + pct + '%, ' +\n 'var(--music-player-track-bg, #ccc) 100%)';\n }\n\n /* \u2500\u2500\u2500 Phosphor icon helper (matches framework icon style) \u2500 */\n\n /**\n * Return an element (Phosphor icon).\n * @param {string} name\n * @returns {HTMLElement}\n */\n function icon(name) {\n const el = document.createElement('i');\n el.className = 'ph ph-' + name;\n el.setAttribute('aria-hidden', 'true');\n return el;\n }\n\n /* \u2500\u2500\u2500 Component \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const MusicPlayer = {\n /** @type {Map} */\n instances: new Map(),\n\n /**\n * Default options.\n */\n defaults: {\n tracks: [],\n volume: 0.5,\n shuffle: false,\n showProgress: false,\n showPlaylist: false,\n autoAdvance: true,\n glass: false,\n detachable: false,\n /** @type {null|string} */\n floatingPosition: null,\n draggable: false,\n minimizable: false,\n startMinimized: false,\n persistPosition: false,\n persistKey: '',\n },\n\n /**\n * Auto-initialize all .vd-music-player / [data-music-player] elements.\n * Options can be provided via data-music-player-options (JSON string).\n */\n init: function () {\n document.querySelectorAll('.vd-music-player, [data-music-player]').forEach((el) => {\n if (this.instances.has(el)) return;\n\n let opts = {};\n const attr = el.getAttribute('data-music-player-options');\n if (attr) {\n try { opts = JSON.parse(attr); } catch (_) { /* ignore malformed JSON */ }\n }\n this.initPlayer(el, opts);\n });\n },\n\n /**\n * Initialize a single player element.\n * @param {HTMLElement} container\n * @param {Object} [options]\n */\n initPlayer: function (container, options) {\n const opts = Object.assign({}, this.defaults, options || {});\n\n // Validate and normalise tracks\n const rawTracks = Array.isArray(opts.tracks) ? opts.tracks : [];\n const tracks = rawTracks.filter((t) => t && typeof t.url === 'string' && t.url.trim());\n\n // Build shuffled working copy without mutating opts\n const trackList = opts.shuffle ? shuffleArray(tracks) : tracks.slice();\n\n /* \u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const state = {\n tracks: trackList,\n originalTracks: tracks.slice(),\n currentIndex: 0,\n isPlaying: false,\n volume: Math.max(0, Math.min(1, opts.volume)),\n shuffle: opts.shuffle,\n showProgress: opts.showProgress,\n showPlaylist: opts.showPlaylist,\n autoAdvance: opts.autoAdvance,\n audio: null,\n glass: Boolean(opts.glass),\n detachable: Boolean(opts.detachable),\n floatingPosition: opts.floatingPosition || 'bottom-right',\n draggable: Boolean(opts.draggable) && Boolean(opts.detachable),\n minimizable: Boolean(opts.minimizable),\n startMinimized: Boolean(opts.startMinimized),\n persistPosition: Boolean(opts.persistPosition),\n persistKey: typeof opts.persistKey === 'string' ? opts.persistKey : '',\n isDetached: false,\n isMinimized: false,\n _startMinimizeApplied: false,\n };\n\n /* \u2500\u2500 Audio element \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const audio = new Audio();\n audio.volume = state.volume;\n audio.preload = 'metadata';\n state.audio = audio;\n\n /* \u2500\u2500 Build DOM \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n this._buildDOM(container, state);\n\n // Grab references after DOM build\n const refs = {\n btnPlay: container.querySelector('.vd-music-player-btn-play'),\n btnPrev: container.querySelector('.vd-music-player-btn-prev'),\n btnNext: container.querySelector('.vd-music-player-btn-next'),\n btnShuffle: container.querySelector('.vd-music-player-btn-shuffle'),\n btnPlaylist: container.querySelector('.vd-music-player-btn-playlist'),\n btnDetach: container.querySelector('.vd-music-player-btn-detach'),\n btnAttach: container.querySelector('.vd-music-player-btn-attach'),\n btnMinimize: container.querySelector('.vd-music-player-btn-minimize'),\n dragHandle: container.querySelector('.vd-music-player-drag-handle'),\n trackName: container.querySelector('.vd-music-player-track-name'),\n volumeSlider: container.querySelector('.vd-music-player-volume-slider'),\n volumeIcon: container.querySelector('.vd-music-player-volume-icon'),\n progressBar: container.querySelector('.vd-music-player-progress-bar'),\n timeElapsed: container.querySelector('.vd-music-player-time-elapsed'),\n timeDuration: container.querySelector('.vd-music-player-time-duration'),\n playlistPanel: container.querySelector('.vd-music-player-playlist'),\n };\n\n /* \u2500\u2500 Internal render helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const renderPlayIcon = () => {\n const btn = refs.btnPlay;\n if (!btn) return;\n btn.innerHTML = '';\n btn.appendChild(icon(state.isPlaying ? 'pause' : 'play'));\n btn.setAttribute('aria-label', state.isPlaying ? 'Pause' : 'Play');\n btn.classList.toggle('is-active', state.isPlaying);\n };\n\n const renderTrackName = () => {\n const el = refs.trackName;\n if (!el) return;\n const track = state.tracks[state.currentIndex];\n if (track) {\n el.textContent = track.name || 'Unknown Track';\n el.classList.remove('is-idle');\n } else {\n el.textContent = 'No tracks loaded';\n el.classList.add('is-idle');\n }\n };\n\n const renderVolumeIcon = () => {\n const el = refs.volumeIcon;\n if (!el) return;\n el.innerHTML = '';\n const v = state.volume;\n const name = v === 0 ? 'speaker-none' : v < 0.5 ? 'speaker-low' : 'speaker-high';\n el.appendChild(icon(name));\n };\n\n const renderShuffleBtn = () => {\n const btn = refs.btnShuffle;\n if (!btn) return;\n btn.classList.toggle('is-active', state.shuffle);\n btn.setAttribute('aria-pressed', state.shuffle ? 'true' : 'false');\n };\n\n const renderPlaylistItems = () => {\n const panel = refs.playlistPanel;\n if (!panel) return;\n panel.innerHTML = '';\n state.tracks.forEach((track, i) => {\n const item = document.createElement('button');\n item.className =\n 'vd-music-player-playlist-item' + (i === state.currentIndex ? ' is-active' : '');\n item.type = 'button';\n item.setAttribute('data-index', String(i));\n item.setAttribute('aria-current', i === state.currentIndex ? 'true' : 'false');\n\n const num = document.createElement('span');\n num.className = 'vd-music-player-playlist-num';\n num.textContent = String(i + 1);\n\n const name = document.createElement('span');\n name.className = 'vd-music-player-playlist-name';\n name.textContent = track.name || 'Track ' + (i + 1);\n\n item.appendChild(num);\n item.appendChild(name);\n panel.appendChild(item);\n });\n };\n\n const renderProgress = () => {\n const bar = refs.progressBar;\n if (!bar || !audio.duration) return;\n const pct = (audio.currentTime / audio.duration) * 100;\n bar.value = String(pct);\n updateRangeFill(bar);\n if (refs.timeElapsed) refs.timeElapsed.textContent = formatTime(audio.currentTime);\n if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);\n };\n\n /* \u2500\u2500 Load track \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const loadTrack = (index, autoPlay) => {\n const track = state.tracks[index];\n if (!track) return;\n state.currentIndex = index;\n audio.src = track.url;\n renderTrackName();\n renderPlaylistItems();\n\n // Reset progress\n if (refs.progressBar) {\n refs.progressBar.value = '0';\n updateRangeFill(refs.progressBar);\n }\n if (refs.timeElapsed) refs.timeElapsed.textContent = '0:00';\n if (refs.timeDuration) refs.timeDuration.textContent = '0:00';\n\n container.dispatchEvent(\n new CustomEvent('musicplayer:trackchange', {\n bubbles: true,\n detail: { index, name: track.name, url: track.url },\n })\n );\n\n if (autoPlay) {\n audio.play().catch(() => { /* browser may block autoplay */ });\n }\n };\n\n /* \u2500\u2500 Audio event listeners \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const cleanupFunctions = [];\n\n const onPlay = () => {\n state.isPlaying = true;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:play', { bubbles: true }));\n };\n\n const onPause = () => {\n state.isPlaying = false;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:pause', { bubbles: true }));\n };\n\n const onEnded = () => {\n if (state.autoAdvance && state.tracks.length > 1) {\n const next = (state.currentIndex + 1) % state.tracks.length;\n loadTrack(next, true);\n } else {\n state.isPlaying = false;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:ended', { bubbles: true }));\n }\n };\n\n const onTimeUpdate = () => {\n if (state.showProgress) renderProgress();\n };\n\n const onLoadedMetadata = () => {\n if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);\n if (refs.progressBar) {\n refs.progressBar.max = '100';\n updateRangeFill(refs.progressBar);\n }\n };\n\n audio.addEventListener('play', onPlay);\n audio.addEventListener('pause', onPause);\n audio.addEventListener('ended', onEnded);\n audio.addEventListener('timeupdate', onTimeUpdate);\n audio.addEventListener('loadedmetadata', onLoadedMetadata);\n cleanupFunctions.push(() => {\n audio.removeEventListener('play', onPlay);\n audio.removeEventListener('pause', onPause);\n audio.removeEventListener('ended', onEnded);\n audio.removeEventListener('timeupdate', onTimeUpdate);\n audio.removeEventListener('loadedmetadata', onLoadedMetadata);\n audio.pause();\n audio.src = '';\n });\n\n /* \u2500\u2500 Control button listeners \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n if (refs.btnPlay) {\n const handler = () => {\n if (!audio.src && state.tracks.length) loadTrack(state.currentIndex, false);\n if (state.isPlaying) {\n audio.pause();\n } else {\n audio.play().catch(() => {});\n }\n };\n refs.btnPlay.addEventListener('click', handler);\n cleanupFunctions.push(() => refs.btnPlay.removeEventListener('click', handler));\n\n // Keyboard: Space / Enter (already native for `;\n }\n\n // Generate neutral color swatches\n let neutralSwatches = '';\n for (const [key, value] of Object.entries(this.NEUTRAL_COLORS)) {\n neutralSwatches += ``;\n }\n\n // Generate radius buttons\n let radiusButtons = '';\n this.RADIUS_OPTIONS.forEach(r => {\n radiusButtons += ``;\n });\n\n // Generate font options\n let fontOptions = '';\n for (const [key, value] of Object.entries(this.FONT_OPTIONS)) {\n fontOptions += ``;\n }\n\n return `\n
    \n

    Customize Theme

    \n \n
    \n
    \n\n
    \n \n
    \n ${primarySwatches}\n
    \n
    \n
    \n \n
    \n ${neutralSwatches}\n
    \n
    \n
    \n \n
    \n ${radiusButtons}\n
    \n
    \n
    \n \n \n
    \n
    \n
    \n \n
    \n `;\n },\n\n /**\n * Bind event listeners\n */\n /**\n * Check whether the current primary color is one of the auto-defaults\n * (i.e. the user hasn't explicitly picked a non-default color).\n */\n isUsingDefaultPrimary: function () {\n return this.state.primary === this.DEFAULTS.PRIMARY_LIGHT ||\n this.state.primary === this.DEFAULTS.PRIMARY_DARK;\n },\n\n /**\n * When primary is still one of the auto-default palette keys (black/amber) but\n * localStorage was written under a different theme (or OS changed in system mode),\n * align in-memory state before applyAllPreferences runs \u2014 avoids amber+light / black+dark drift.\n */\n _normalizeDefaultPrimaryIfStaleWithStoredTheme: function () {\n if (!this.isUsingDefaultPrimary()) {\n return;\n }\n const expected = this.getDefaultPrimary(this.state.theme);\n if (this.state.primary !== expected) {\n this.state.primary = expected;\n }\n },\n\n bindEvents: function () {\n this.bindTriggerEvents();\n\n this.bindPanelEvents();\n\n // Listen for OS dark/light changes so we can swap the default primary\n if (window.matchMedia) {\n const mq = window.matchMedia('(prefers-color-scheme: dark)');\n const handler = () => {\n if (this.state.theme === 'system' && this.isUsingDefaultPrimary()) {\n const newDefault = this.getDefaultPrimary('system');\n if (newDefault !== this.state.primary) {\n this.applyPrimary(newDefault);\n this.updateUI();\n }\n }\n };\n mq.addEventListener('change', handler);\n this._cleanup.push(() => mq.removeEventListener('change', handler));\n }\n\n // Close on outside click\n this.addListener(document, 'click', (e) => {\n if (this.state.isOpen && this.elements.customizer && !this.elements.customizer.contains(e.target)) {\n this.close();\n }\n });\n\n // Close on Escape key\n this.addListener(document, 'keydown', (e) => {\n if (e.key === 'Escape' && this.state.isOpen) {\n this.close();\n }\n });\n },\n\n bindTriggerEvents: function () {\n this.elements.triggers.forEach((trigger) => {\n if (trigger.getAttribute('data-customizer-trigger-initialized') === 'true') {\n return;\n }\n this.addListener(trigger, 'click', (e) => {\n e.preventDefault();\n e.stopPropagation();\n this.elements.activeTrigger = trigger;\n this.elements.trigger = trigger;\n this.toggle();\n });\n trigger.setAttribute('data-customizer-trigger-initialized', 'true');\n });\n },\n\n /**\n * Toggle panel open/close\n */\n toggle: function () {\n if (this.state.isOpen) {\n this.close();\n } else {\n this.open();\n }\n },\n\n /**\n * Open the panel\n */\n open: function () {\n this.state.isOpen = true;\n\n // Ensure panel is positioned correctly before opening\n this.positionPanel();\n\n if (this.elements.panel) {\n this.elements.panel.classList.add('is-open');\n }\n this.elements.triggers.forEach((trigger) => trigger.setAttribute('aria-expanded', 'false'));\n if (this.elements.trigger) this.elements.trigger.setAttribute('aria-expanded', 'true');\n if (this.elements.overlay) {\n this.elements.overlay.classList.add('is-active');\n }\n\n this.dispatchEvent('panel-open', { isOpen: true });\n },\n\n /**\n * Close the panel\n */\n close: function () {\n this.state.isOpen = false;\n\n if (this.elements.panel) {\n this.elements.panel.classList.remove('is-open');\n }\n this.elements.triggers.forEach((trigger) => trigger.setAttribute('aria-expanded', 'false'));\n if (this.elements.overlay) {\n this.elements.overlay.classList.remove('is-active');\n }\n\n this.dispatchEvent('panel-close', { isOpen: false });\n },\n\n /**\n * Update UI to reflect current state\n */\n updateUI: function () {\n if (!this.elements.panel) return;\n\n // Update primary color swatches\n this.elements.panel.querySelectorAll('[data-color]').forEach(swatch => {\n swatch.classList.toggle('is-active', swatch.dataset.color === this.state.primary);\n });\n\n // Update neutral color swatches\n this.elements.panel.querySelectorAll('[data-neutral]').forEach(swatch => {\n swatch.classList.toggle('is-active', swatch.dataset.neutral === this.state.neutral);\n });\n\n // Update radius buttons\n this.elements.panel.querySelectorAll('[data-radius]').forEach(btn => {\n btn.classList.toggle('is-active', btn.dataset.radius === this.state.radius);\n });\n\n // Update font selector\n const fontSelect = this.elements.panel.querySelector('[data-customizer-font]');\n if (fontSelect) {\n fontSelect.value = this.state.font;\n }\n\n },\n\n /**\n * Reset all preferences to defaults\n */\n reset: function () {\n this.applyTheme(this.DEFAULTS.THEME);\n this.applyPrimary(this.getDefaultPrimary(this.DEFAULTS.THEME));\n this.applyNeutral(this.DEFAULTS.NEUTRAL);\n this.applyRadius(this.DEFAULTS.RADIUS);\n this.applyFont(this.DEFAULTS.FONT);\n this.updateUI();\n\n this.dispatchEvent('reset', { state: { ...this.state } });\n },\n\n /**\n * Get current state\n */\n getState: function () {\n return { ...this.state };\n },\n\n /**\n * Programmatically set preferences\n */\n setPreferences: function (prefs) {\n if (prefs.primary) this.applyPrimary(prefs.primary);\n if (prefs.neutral) this.applyNeutral(prefs.neutral);\n if (prefs.radius) this.applyRadius(prefs.radius);\n if (prefs.font) this.applyFont(prefs.font);\n if (prefs.theme) this.applyTheme(prefs.theme);\n this.updateUI();\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 destroyAll: function () {\n this._cleanup.forEach(fn => fn());\n this._cleanup = [];\n\n if (this.elements.panel) {\n this.elements.panel.removeAttribute('data-customizer-initialized');\n }\n\n this.close();\n this.isInitialized = false;\n }\n };\n\n // Register component\n if (window.Vanduo) {\n window.Vanduo.register('themeCustomizer', ThemeCustomizer);\n }\n\n // Expose globally for convenience\n window.ThemeCustomizer = ThemeCustomizer;\n})();\n", "/**\n * Vanduo Framework - Theme Switcher\n * Handles light/dark/system theme toggling and persistence\n */\n\n(function () {\n 'use strict';\n\n const ThemeSwitcher = {\n isInitialized: false,\n _mediaQuery: null,\n _onMediaChange: null,\n\n init: function () {\n this.STORAGE_KEY = 'vanduo-theme-preference';\n this.state = {\n preference: this.getPreference() // 'light', 'dark', or 'system'\n };\n\n if (this.isInitialized) {\n this.applyTheme();\n this.renderUI();\n this.updateUI();\n return;\n }\n\n this.isInitialized = true;\n\n this.applyTheme();\n this.listenForSystemChanges();\n this.renderUI();\n\n console.log('Vanduo Theme Switcher initialized');\n },\n\n getPreference: function () {\n return this.getStorageValue(this.STORAGE_KEY, 'system');\n },\n\n setPreference: function (pref) {\n this.state.preference = pref;\n this.setStorageValue(this.STORAGE_KEY, pref);\n this.applyTheme();\n \n // Coordinate with ThemeCustomizer for primary color swap if available\n // Check _isApplying flag to prevent circular updates\n if (window.ThemeCustomizer && window.ThemeCustomizer.applyTheme && !window.ThemeCustomizer._isApplying) {\n window.ThemeCustomizer.applyTheme(pref);\n }\n \n this.updateUI();\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 applyTheme: function () {\n const pref = this.state.preference;\n\n if (pref === 'system') {\n // When in system mode, we remove the data attribute to let the media query take over\n // or we can explicitly set it. Explicitly setting it ensures consistency if we rely on [data-theme]\n // But if we rely on @media in CSS, we might want to remove attributes.\n // However, our CSS strategy uses :root:not([data-theme=\"light\"]) inside media query for system dark fallback\n // which is a bit complex.\n\n // Simpler approach: \n // If preference is system, REMOVE data-theme attribute. Let CSS media queries handle it.\n document.documentElement.removeAttribute('data-theme');\n } else {\n document.documentElement.setAttribute('data-theme', pref);\n }\n },\n\n listenForSystemChanges: function () {\n if (this._mediaQuery && this._onMediaChange) {\n return;\n }\n\n this._mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n this._onMediaChange = _e => {\n if (this.state.preference === 'system') {\n // Re-apply (effectively just to ensure consistency, though removing attribute usually suffices)\n this.applyTheme();\n // Keep default primary (black/amber) aligned when OS scheme changes while in system mode\n if (window.ThemeCustomizer && typeof window.ThemeCustomizer.applyTheme === 'function' && !window.ThemeCustomizer._isApplying) {\n window.ThemeCustomizer.applyTheme('system');\n }\n }\n };\n this._mediaQuery.addEventListener('change', this._onMediaChange);\n },\n\n // Helper to facilitate UI creation if needed, though often UI is in HTML\n renderUI: function () {\n // Look for any uninitialized theme toggles\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"]');\n toggles.forEach(toggle => {\n if (toggle.getAttribute('data-theme-initialized') === 'true') {\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n }\n return;\n }\n\n // Simplified UI Binding - assumes a select or a button cycle\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n const onChange = (e) => {\n this.setPreference(e.target.value);\n };\n toggle.addEventListener('change', onChange);\n toggle._themeToggleHandler = onChange;\n } else {\n // Button implementation (cycle)\n const onClick = () => {\n const modes = ['system', 'light', 'dark'];\n const nextIndex = (modes.indexOf(this.state.preference) + 1) % modes.length;\n this.setPreference(modes[nextIndex]);\n };\n toggle.addEventListener('click', onClick);\n toggle._themeToggleHandler = onClick;\n }\n // Mark as initialized\n toggle.setAttribute('data-theme-initialized', 'true');\n });\n },\n\n updateUI: function () {\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"]');\n toggles.forEach(toggle => {\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n } else {\n // Update button text/icon if needed\n // e.g. toggle.textContent = this.state.preference;\n // For now, assume the user handles visual state or generic text\n\n // If there is an icon or text span inside, update it\n const span = toggle.querySelector('.theme-current-label');\n if (span) {\n span.textContent = this.state.preference.charAt(0).toUpperCase() + this.state.preference.slice(1);\n }\n }\n });\n },\n\n destroyAll: function () {\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"][data-theme-initialized=\"true\"]');\n toggles.forEach(toggle => {\n if (toggle._themeToggleHandler) {\n const eventName = toggle.tagName === 'SELECT' ? 'change' : 'click';\n toggle.removeEventListener(eventName, toggle._themeToggleHandler);\n delete toggle._themeToggleHandler;\n }\n toggle.removeAttribute('data-theme-initialized');\n });\n\n if (this._mediaQuery && this._onMediaChange) {\n this._mediaQuery.removeEventListener('change', this._onMediaChange);\n }\n\n this._mediaQuery = null;\n this._onMediaChange = null;\n this.isInitialized = false;\n }\n };\n\n // Register component\n if (window.Vanduo) {\n window.Vanduo.register('themeSwitcher', ThemeSwitcher);\n }\n})();\n", "/**\n * Vanduo Framework - Toast Component\n * Popup notifications for user feedback\n */\n\n(function() {\n 'use strict';\n\n /**\n * Toast Component\n */\n const Toast = {\n // Default options\n defaults: {\n position: 'top-right',\n duration: 5000,\n dismissible: true,\n showProgress: true,\n pauseOnHover: true\n },\n\n // Container cache\n containers: {},\n\n /**\n * Get or create a toast container for a position\n * @param {string} position - Container position\n * @returns {HTMLElement} Toast container element\n */\n getContainer: function(position) {\n if (this.containers[position]) {\n return this.containers[position];\n }\n\n const container = document.createElement('div');\n container.className = `vd-toast-container vd-toast-container-${position}`;\n container.setAttribute('role', 'status');\n container.setAttribute('aria-live', 'polite');\n container.setAttribute('aria-atomic', 'false');\n document.body.appendChild(container);\n this.containers[position] = container;\n\n return container;\n },\n\n /**\n * Show a toast notification\n * @param {Object|string} options - Toast options or message string\n * @param {string} [type] - Toast type (success, error, warning, info)\n * @param {number} [duration] - Auto-dismiss duration in ms\n * @returns {HTMLElement} Toast element\n */\n show: function(options, type, duration) {\n // Support simple API:\n // - Toast.show('Message', 'success', 3000)\n // - Toast.show('Message', { type: 'success', duration: 3000 })\n if (typeof options === 'string') {\n if (type && typeof type === 'object') {\n options = Object.assign({}, type, {\n message: options\n });\n } else {\n options = {\n message: options,\n type: type,\n duration: duration\n };\n }\n }\n\n const config = Object.assign({}, this.defaults, options);\n const container = this.getContainer(config.position);\n\n // Create toast element\n const toast = document.createElement('div');\n toast.className = 'vd-toast';\n\n if (config.type) {\n toast.classList.add(`vd-toast-${config.type}`);\n }\n\n if (config.solid) {\n toast.classList.add('vd-toast-solid');\n }\n\n if (config.showProgress && config.duration > 0) {\n toast.classList.add('vd-toast-with-progress');\n }\n\n // Build toast content\n let html = '';\n\n // Icon (sanitize custom icons, default icons are trusted SVG)\n if (config.icon) {\n const allowSvg = config.iconAllowSvg === true;\n const safeIcon = typeof sanitizeHtml === 'function'\n ? sanitizeHtml(config.icon, { allowSvg })\n : escapeHtml(config.icon);\n html += `${safeIcon}`;\n } else if (config.type) {\n html += `${this.getDefaultIcon(config.type)}`;\n }\n\n // Local escape helper \u2014 guarantees HTML-safe output even if the\n // global escapeHtml utility is not loaded in the current bundle.\n const _esc = typeof escapeHtml === 'function'\n ? escapeHtml\n : function (s) {\n const d = document.createElement('div');\n d.appendChild(document.createTextNode(s));\n return d.innerHTML;\n };\n\n // Content (escape text to prevent injection)\n html += '
    ';\n if (config.title) {\n html += `
    ${_esc(String(config.title))}
    `;\n }\n if (config.message) {\n html += `
    ${_esc(String(config.message))}
    `;\n }\n html += '
    ';\n\n // Close button\n if (config.dismissible) {\n html += '';\n }\n\n // Progress bar\n if (config.showProgress && config.duration > 0) {\n const safeDuration = parseInt(config.duration, 10) || 0;\n html += `
    `;\n }\n\n toast.innerHTML = html;\n\n // Add to container\n container.appendChild(toast);\n\n toast._toastCleanup = [];\n\n // Set up close button handler\n if (config.dismissible) {\n const closeBtn = toast.querySelector('.vd-toast-close');\n const onClose = () => {\n this.dismiss(toast);\n };\n closeBtn.addEventListener('click', onClose);\n toast._toastCleanup.push(() => closeBtn.removeEventListener('click', onClose));\n }\n\n // Pause on hover\n let timeoutId = null;\n let remainingTime = config.duration;\n let startTime = null;\n\n const startTimer = () => {\n if (config.duration > 0) {\n startTime = Date.now();\n timeoutId = setTimeout(() => {\n this.dismiss(toast);\n }, remainingTime);\n toast._toastTimeoutId = timeoutId;\n\n // Resume progress animation\n const progress = toast.querySelector('.vd-toast-progress');\n if (progress) {\n progress.style.animationPlayState = 'running';\n }\n }\n };\n\n const pauseTimer = () => {\n if (timeoutId) {\n clearTimeout(timeoutId);\n timeoutId = null;\n toast._toastTimeoutId = null;\n remainingTime -= Date.now() - startTime;\n\n // Pause progress animation\n const progress = toast.querySelector('.vd-toast-progress');\n if (progress) {\n progress.style.animationPlayState = 'paused';\n }\n }\n };\n\n if (config.pauseOnHover) {\n toast.addEventListener('mouseenter', pauseTimer);\n toast.addEventListener('mouseleave', startTimer);\n toast._toastCleanup.push(\n () => toast.removeEventListener('mouseenter', pauseTimer),\n () => toast.removeEventListener('mouseleave', startTimer)\n );\n }\n\n // Trigger enter animation\n requestAnimationFrame(() => {\n toast.classList.add('is-visible');\n startTimer();\n });\n\n // Store config on element for later access\n toast._toastConfig = config;\n\n // Dispatch show event\n const showEvent = new CustomEvent('toast:show', {\n bubbles: true,\n detail: { toast, config }\n });\n toast.dispatchEvent(showEvent);\n\n return toast;\n },\n\n /**\n * Dismiss a toast\n * @param {HTMLElement} toast - Toast element to dismiss\n */\n dismiss: function(toast) {\n if (!toast || toast.classList.contains('is-exiting')) return;\n\n if (toast._toastTimeoutId) {\n clearTimeout(toast._toastTimeoutId);\n toast._toastTimeoutId = null;\n }\n\n toast.classList.remove('is-visible');\n toast.classList.add('is-exiting');\n\n // Dispatch dismiss event\n const dismissEvent = new CustomEvent('toast:dismiss', {\n bubbles: true,\n detail: { toast }\n });\n toast.dispatchEvent(dismissEvent);\n\n // Remove after animation\n const handleTransitionEnd = () => {\n toast.removeEventListener('transitionend', handleTransitionEnd);\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n };\n\n toast.addEventListener('transitionend', handleTransitionEnd);\n\n // Fallback removal if transition doesn't fire\n setTimeout(() => {\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n }, 400);\n },\n\n /**\n * Destroy all toasts and containers\n */\n destroyAll: function() {\n Object.keys(this.containers).forEach(position => {\n const container = this.containers[position];\n if (!container) return;\n\n const toasts = container.querySelectorAll('.vd-toast');\n toasts.forEach(toast => {\n if (toast._toastTimeoutId) {\n clearTimeout(toast._toastTimeoutId);\n }\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n });\n\n if (container.parentElement) {\n container.parentElement.removeChild(container);\n }\n });\n\n this.containers = {};\n },\n\n /**\n * Dismiss all toasts\n * @param {string} [position] - Optional position to clear (clears all if not specified)\n */\n dismissAll: function(position) {\n if (position && this.containers[position]) {\n const toasts = this.containers[position].querySelectorAll('.vd-toast');\n toasts.forEach(toast => this.dismiss(toast));\n } else {\n Object.values(this.containers).forEach(container => {\n const toasts = container.querySelectorAll('.vd-toast');\n toasts.forEach(toast => this.dismiss(toast));\n });\n }\n },\n\n /**\n * Get default icon SVG for a type\n * @param {string} type - Toast type\n * @returns {string} SVG icon markup\n */\n getDefaultIcon: function(type) {\n const icons = {\n success: '',\n error: '',\n warning: '',\n info: ''\n };\n\n return icons[type] || '';\n },\n\n /**\n * Convenience methods for common toast types\n */\n success: function(message, options) {\n return this.show(Object.assign({ message, type: 'success' }, options));\n },\n\n error: function(message, options) {\n return this.show(Object.assign({ message, type: 'error' }, options));\n },\n\n warning: function(message, options) {\n return this.show(Object.assign({ message, type: 'warning' }, options));\n },\n\n info: function(message, options) {\n return this.show(Object.assign({ message, type: 'info' }, options));\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('toast', Toast);\n }\n\n // Also expose globally for convenience\n window.Toast = Toast;\n\n})();\n", "/**\n * Vanduo Framework - Tooltips Component\n * JavaScript functionality for tooltips\n */\n\n(function () {\n 'use strict';\n\n /**\n * Tooltips Component\n */\n const Tooltips = {\n tooltips: new Map(),\n delayTimers: new Map(),\n\n /**\n * Sanitize HTML \u2014 delegates to shared sanitizeHtml from helpers.js\n * @param {string} input\n * @param {{ allowSvg?: boolean }} [options]\n * @returns {string} sanitized HTML\n */\n sanitizeHtml: function (input, options) {\n if (typeof sanitizeHtml === 'function') {\n return sanitizeHtml(input, options);\n }\n // Fallback: content originates from developer-authored data attributes,\n // so returning it as-is is no worse than any other inline HTML.\n return input || '';\n },\n\n /**\n * Initialize tooltips\n */\n init: function () {\n const elements = document.querySelectorAll('[data-tooltip], [data-tooltip-html]');\n\n elements.forEach(element => {\n if (this.tooltips.has(element)) {\n return;\n }\n this.initTooltip(element);\n });\n },\n\n /**\n * Initialize a single tooltip\n * @param {HTMLElement} element - Element with tooltip\n */\n initTooltip: function (element) {\n const tooltip = this.createTooltip(element);\n const cleanupFunctions = [];\n\n // Show on hover/focus\n const enterHandler = () => { this.showTooltip(element, tooltip); };\n const leaveHandler = () => { this.hideTooltip(element, tooltip); };\n const focusHandler = () => { this.showTooltip(element, tooltip); };\n const blurHandler = () => { this.hideTooltip(element, tooltip); };\n\n element.addEventListener('mouseenter', enterHandler);\n element.addEventListener('mouseleave', leaveHandler);\n element.addEventListener('focus', focusHandler);\n element.addEventListener('blur', blurHandler);\n\n cleanupFunctions.push(\n () => element.removeEventListener('mouseenter', enterHandler),\n () => element.removeEventListener('mouseleave', leaveHandler),\n () => element.removeEventListener('focus', focusHandler),\n () => element.removeEventListener('blur', blurHandler)\n );\n\n this.tooltips.set(element, { tooltip, cleanup: cleanupFunctions });\n },\n\n /**\n * Create tooltip element\n * @param {HTMLElement} element - Target element\n * @returns {HTMLElement} Tooltip element\n */\n createTooltip: function (element) {\n const tooltip = document.createElement('div');\n tooltip.className = 'vd-tooltip';\n tooltip.setAttribute('role', 'tooltip');\n tooltip.setAttribute('aria-hidden', 'true');\n\n // Generate unique ID and link via aria-describedby\n const tooltipId = 'tooltip-' + Math.random().toString(36).substr(2, 9);\n tooltip.id = tooltipId;\n element.setAttribute('aria-describedby', tooltipId);\n\n // Get content\n const htmlContent = element.dataset.tooltipHtml;\n const textContent = element.dataset.tooltip;\n\n if (htmlContent) {\n const allowSvg = element.hasAttribute('data-tooltip-allow-svg');\n tooltip.innerHTML = this.sanitizeHtml(htmlContent, { allowSvg });\n tooltip.classList.add('vd-tooltip-html');\n } else if (textContent) {\n tooltip.textContent = textContent;\n }\n\n // Get placement\n const placement = element.dataset.tooltipPlacement || element.dataset.placement || 'top';\n tooltip.setAttribute('data-placement', placement);\n tooltip.classList.add(`vd-tooltip-${placement}`);\n\n // Get variant\n if (element.dataset.tooltipVariant) {\n tooltip.classList.add(`vd-tooltip-${element.dataset.tooltipVariant}`);\n }\n\n // Get size\n if (element.dataset.tooltipSize) {\n tooltip.classList.add(`vd-tooltip-${element.dataset.tooltipSize}`);\n }\n\n // Get delay\n const delay = parseInt(element.dataset.tooltipDelay) || 0;\n tooltip.dataset.delay = delay;\n\n document.body.appendChild(tooltip);\n\n return tooltip;\n },\n\n /**\n * Show tooltip\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n showTooltip: function (element, tooltip) {\n const delay = parseInt(tooltip.dataset.delay) || 0;\n\n if (delay > 0) {\n const timer = setTimeout(() => {\n this.positionTooltip(element, tooltip);\n tooltip.classList.add('is-visible');\n tooltip.setAttribute('aria-hidden', 'false');\n }, delay);\n this.delayTimers.set(element, timer);\n } else {\n this.positionTooltip(element, tooltip);\n tooltip.classList.add('is-visible');\n tooltip.setAttribute('aria-hidden', 'false');\n }\n },\n\n /**\n * Hide tooltip\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n hideTooltip: function (element, tooltip) {\n // Clear delay timer if exists\n const timer = this.delayTimers.get(element);\n if (timer) {\n clearTimeout(timer);\n this.delayTimers.delete(element);\n }\n\n tooltip.classList.remove('is-visible');\n tooltip.setAttribute('aria-hidden', 'true');\n },\n\n /**\n * Position tooltip relative to element\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n positionTooltip: function (element, tooltip) {\n const placement = tooltip.dataset.placement || 'top';\n const rect = element.getBoundingClientRect();\n const tooltipRect = tooltip.getBoundingClientRect();\n const scrollTop = window.pageYOffset || document.documentElement.scrollTop;\n const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;\n\n let top = 0;\n let left = 0;\n\n switch (placement) {\n case 'top':\n top = rect.top + scrollTop - tooltipRect.height - 8;\n left = rect.left + scrollLeft + (rect.width / 2) - (tooltipRect.width / 2);\n break;\n case 'bottom':\n top = rect.bottom + scrollTop + 8;\n left = rect.left + scrollLeft + (rect.width / 2) - (tooltipRect.width / 2);\n break;\n case 'left':\n top = rect.top + scrollTop + (rect.height / 2) - (tooltipRect.height / 2);\n left = rect.left + scrollLeft - tooltipRect.width - 8;\n break;\n case 'right':\n top = rect.top + scrollTop + (rect.height / 2) - (tooltipRect.height / 2);\n left = rect.right + scrollLeft + 8;\n break;\n }\n\n // Prevent overflow\n const viewportWidth = window.innerWidth;\n const viewportHeight = window.innerHeight;\n const padding = 8;\n\n if (left < padding) {\n left = padding;\n } else if (left + tooltipRect.width > viewportWidth - padding) {\n left = viewportWidth - tooltipRect.width - padding;\n }\n\n if (top < scrollTop + padding) {\n top = scrollTop + padding;\n } else if (top + tooltipRect.height > scrollTop + viewportHeight - padding) {\n top = scrollTop + viewportHeight - tooltipRect.height - padding;\n }\n\n // Use single style assignment with transform for better performance\n tooltip.style.cssText = `position: absolute; top: 0; left: 0; transform: translate(${left}px, ${top}px);`;\n },\n\n /**\n * Show tooltip programmatically\n * @param {HTMLElement|string} element - Target element or selector\n */\n show: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n this.showTooltip(el, tooltip);\n }\n },\n\n /**\n * Hide tooltip programmatically\n * @param {HTMLElement|string} element - Target element or selector\n */\n hide: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n this.hideTooltip(el, tooltip);\n }\n },\n\n /**\n * Update tooltip content\n * @param {HTMLElement|string} element - Target element or selector\n * @param {string} content - New content\n * @param {boolean} isHtml - Whether content is HTML\n */\n update: function (element, content, isHtml = false) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n if (isHtml) {\n const allowSvg = el.hasAttribute('data-tooltip-allow-svg');\n tooltip.innerHTML = this.sanitizeHtml(content, { allowSvg });\n tooltip.classList.add('vd-tooltip-html');\n } else {\n tooltip.textContent = content;\n tooltip.classList.remove('vd-tooltip-html');\n }\n }\n },\n\n /**\n * Destroy a tooltip instance and clean up\n * @param {HTMLElement} element - Element with tooltip\n */\n destroy: function (element) {\n const data = this.tooltips.get(element);\n if (!data) return;\n\n // Clear any pending timer\n const timer = this.delayTimers.get(element);\n if (timer) {\n clearTimeout(timer);\n this.delayTimers.delete(element);\n }\n\n data.cleanup.forEach(fn => fn());\n\n // Remove tooltip element from DOM\n if (data.tooltip && data.tooltip.parentNode) {\n data.tooltip.parentNode.removeChild(data.tooltip);\n }\n\n this.tooltips.delete(element);\n },\n\n /**\n * Destroy all tooltip instances\n */\n destroyAll: function () {\n this.tooltips.forEach((data, element) => {\n this.destroy(element);\n });\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('tooltips', Tooltips);\n }\n\n // Expose globally\n window.VanduoTooltips = Tooltips;\n\n})();\n", "/**\n * Vanduo Framework - Search Component\n * Client-side search functionality for content pages\n * \n * @example Basic usage (initialize with defaults)\n * // HTML:\n * //
    \n * // \n * //
    \n * //
    \n * \n * @example Custom configuration\n * var search = Search.create({\n * containerSelector: '.my-search',\n * contentSelector: 'article[id]',\n * titleSelector: 'h2, h3',\n * maxResults: 5,\n * onSelect: function(result) {\n * console.log('Selected:', result.title);\n * }\n * });\n * \n * @example With custom data source\n * var search = Search.create({\n * containerSelector: '.my-search',\n * data: [\n * { id: 'item1', title: 'First Item', content: 'Description...', category: 'Category A' },\n * { id: 'item2', title: 'Second Item', content: 'Description...', category: 'Category B' }\n * ]\n * });\n */\n\n(function() {\n 'use strict';\n\n /**\n * Default configuration\n */\n const DEFAULTS = {\n // Behavior\n minQueryLength: 2,\n maxResults: 10,\n debounceMs: 150,\n highlightTag: 'mark',\n keyboardShortcut: true, // Enable Cmd/Ctrl+K shortcut\n \n // Selectors (for DOM-based indexing)\n containerSelector: '.vd-doc-search',\n inputSelector: '.vd-doc-search-input',\n resultsSelector: '.vd-doc-search-results',\n contentSelector: '.doc-content section[id]',\n titleSelector: '.demo-title, h2, h3',\n navSelector: '.doc-nav-link',\n sectionSelector: '.doc-nav-section',\n \n // Content extraction\n excludeFromContent: 'pre, code, script, style',\n maxContentLength: 500,\n \n // Custom data source (alternative to DOM indexing)\n data: null,\n \n // Category icons mapping\n categoryIcons: {\n 'getting-started': 'ph-rocket-launch',\n 'core': 'ph-cube',\n 'components': 'ph-puzzle-piece',\n 'interactive': 'ph-cursor-click',\n 'data-display': 'ph-table',\n 'feedback': 'ph-bell',\n 'meta': 'ph-info',\n 'default': 'ph-file-text'\n },\n \n // Callbacks\n onSelect: null, // function(result) - called when result is selected\n onSearch: null, // function(query, results) - called after search\n onOpen: null, // function() - called when results open\n onClose: null, // function() - called when results close\n \n // Text customization\n emptyTitle: 'No results found',\n emptyText: 'Try different keywords or check spelling',\n placeholder: 'Search...'\n };\n\n /**\n * Search Component Factory\n * Creates a new search instance with the given configuration\n * \n * @param {Object} options - Configuration options\n * @returns {Object} Search instance\n */\n function createSearch(options) {\n const config = Object.assign({}, DEFAULTS, options || {});\n \n // Instance state\n const state = {\n initialized: false,\n index: [],\n results: [],\n activeIndex: -1,\n isOpen: false,\n query: '',\n container: null,\n input: null,\n resultsContainer: null,\n debounceTimer: null,\n boundHandlers: {}\n };\n\n function safeInvokeCallback(name, fn, ...args) {\n try {\n fn(...args);\n } catch (error) {\n console.warn('[Vanduo Search] Callback error in \"' + name + '\":', error);\n }\n }\n\n function setResultsHtml(html) {\n if (!state.resultsContainer) return;\n try {\n state.resultsContainer.innerHTML = html;\n } catch (error) {\n console.warn('[Vanduo Search] Failed to render results:', error);\n }\n }\n\n /**\n * Initialize the search component\n * Idempotent \u2014 safe to call more than once on the same instance.\n * Returns the instance on success, null if required DOM elements are missing.\n */\n function init() {\n if (state.initialized) {\n return instance;\n }\n\n state.container = document.querySelector(config.containerSelector);\n if (!state.container) {\n state.initialized = false;\n return null;\n }\n\n state.input = state.container.querySelector(config.inputSelector);\n state.resultsContainer = state.container.querySelector(config.resultsSelector);\n\n if (!state.input || !state.resultsContainer) {\n state.initialized = false;\n return null;\n }\n\n // Set placeholder if configured\n if (config.placeholder) {\n state.input.setAttribute('placeholder', config.placeholder);\n }\n\n // Build search index\n buildIndex();\n\n // Bind events\n bindEvents();\n\n // Set up ARIA attributes\n setupAria();\n\n state.initialized = true;\n return instance;\n }\n\n /**\n * Build search index from DOM or custom data\n */\n function buildIndex() {\n state.index = [];\n \n // Use custom data if provided\n if (config.data && Array.isArray(config.data)) {\n config.data.forEach(function(item) {\n state.index.push({\n id: item.id || slugify(item.title),\n title: item.title || '',\n category: item.category || '',\n categorySlug: slugify(item.category || ''),\n content: item.content || '',\n keywords: item.keywords || extractKeywordsFromText(item.title + ' ' + item.content),\n url: item.url || '#' + (item.id || slugify(item.title)),\n icon: item.icon || ''\n });\n });\n return;\n }\n\n // Build from DOM\n const sections = document.querySelectorAll(config.contentSelector);\n const categoryMap = buildCategoryMap();\n\n sections.forEach(function(section) {\n const id = section.id;\n if (!id) return;\n\n const titleEl = section.querySelector(config.titleSelector);\n const title = titleEl ? titleEl.textContent.replace(/v[\\d.]+/g, '').trim() : id;\n const category = categoryMap[id] || 'Documentation';\n const content = extractContent(section);\n const keywords = extractKeywords(section, title);\n const iconEl = titleEl ? titleEl.querySelector('i.ph') : null;\n let icon = '';\n if (iconEl && iconEl.classList) {\n for (let ci = 0; ci < iconEl.classList.length; ci++) {\n if (iconEl.classList[ci].indexOf('ph-') === 0) {\n icon = iconEl.classList[ci];\n break;\n }\n }\n }\n\n state.index.push({\n id: id,\n title: title,\n category: category,\n categorySlug: slugify(category),\n content: content,\n keywords: keywords,\n url: '#' + id,\n icon: icon\n });\n });\n }\n\n /**\n * Build a map of section IDs to their categories\n */\n function buildCategoryMap() {\n const map = {};\n let currentCategory = 'Documentation';\n const navItems = document.querySelectorAll(config.navSelector + ', ' + config.sectionSelector);\n\n navItems.forEach(function(item) {\n if (item.classList.contains('doc-nav-section')) {\n currentCategory = item.textContent.trim();\n } else {\n const href = item.getAttribute('href');\n if (href && href.startsWith('#')) {\n const id = href.substring(1);\n map[id] = currentCategory;\n }\n }\n });\n\n return map;\n }\n\n /**\n * Extract searchable content from a section\n */\n function extractContent(section) {\n const clone = section.cloneNode(true);\n \n const toRemove = clone.querySelectorAll(config.excludeFromContent);\n toRemove.forEach(function(el) {\n el.remove();\n });\n\n let text = clone.textContent || '';\n text = text.replace(/\\s+/g, ' ').trim();\n \n return text.substring(0, config.maxContentLength);\n }\n\n /**\n * Extract keywords from a section\n */\n function extractKeywords(section, title) {\n const keywords = [];\n \n // Add title words\n title.toLowerCase().split(/\\s+/).forEach(function(word) {\n if (word.length > 2) {\n keywords.push(word);\n }\n });\n\n // Add class names from code examples\n const codeBlocks = section.querySelectorAll('code');\n codeBlocks.forEach(function(code) {\n const text = code.textContent || '';\n const classMatches = text.match(/\\.([\\w-]+)/g);\n if (classMatches) {\n classMatches.forEach(function(match) {\n keywords.push(match.substring(1).toLowerCase());\n });\n }\n });\n\n // Add data attributes\n const dataAttrs = section.querySelectorAll('[data-tooltip], [data-modal]');\n dataAttrs.forEach(function(el) {\n const attrs = el.getAttributeNames().filter(function(name) {\n return name.startsWith('data-');\n });\n attrs.forEach(function(attr) {\n keywords.push(attr.replace('data-', ''));\n });\n });\n\n return Array.from(new Set(keywords));\n }\n\n /**\n * Extract keywords from text string\n */\n function extractKeywordsFromText(text) {\n const words = text.toLowerCase().split(/\\s+/);\n return words.filter(function(word) {\n return word.length > 2;\n });\n }\n\n /**\n * Convert string to slug\n */\n function slugify(str) {\n return str.toLowerCase().replace(/\\s+/g, '-').replace(/[^a-z0-9-]/g, '');\n }\n\n /**\n * Bind event listeners\n */\n function bindEvents() {\n // Store bound handlers for cleanup\n state.boundHandlers.handleInput = function(e) {\n handleInput(e);\n };\n state.boundHandlers.handleFocus = function() {\n if (state.query.length >= config.minQueryLength) {\n open();\n }\n };\n state.boundHandlers.handleKeydown = function(e) {\n handleKeydown(e);\n };\n state.boundHandlers.handleOutsideClick = function(e) {\n if (!state.container.contains(e.target)) {\n close();\n }\n };\n state.boundHandlers.handleGlobalKeydown = function(e) {\n if (config.keyboardShortcut && (e.metaKey || e.ctrlKey) && e.key === 'k') {\n e.preventDefault();\n state.input.focus();\n state.input.select();\n }\n };\n state.boundHandlers.handleResultClick = function(e) {\n const result = e.target.closest('.vd-doc-search-result');\n if (result) {\n const index = parseInt(result.dataset.index, 10);\n select(index);\n }\n };\n\n // Input events\n state.input.addEventListener('input', state.boundHandlers.handleInput);\n state.input.addEventListener('focus', state.boundHandlers.handleFocus);\n state.input.addEventListener('keydown', state.boundHandlers.handleKeydown);\n\n // Close on outside click\n document.addEventListener('click', state.boundHandlers.handleOutsideClick);\n\n // Global keyboard shortcut\n document.addEventListener('keydown', state.boundHandlers.handleGlobalKeydown);\n\n // Result click handling\n state.resultsContainer.addEventListener('click', state.boundHandlers.handleResultClick);\n }\n\n /**\n * Unbind event listeners\n */\n function unbindEvents() {\n if (state.input) {\n state.input.removeEventListener('input', state.boundHandlers.handleInput);\n state.input.removeEventListener('focus', state.boundHandlers.handleFocus);\n state.input.removeEventListener('keydown', state.boundHandlers.handleKeydown);\n }\n document.removeEventListener('click', state.boundHandlers.handleOutsideClick);\n document.removeEventListener('keydown', state.boundHandlers.handleGlobalKeydown);\n if (state.resultsContainer) {\n state.resultsContainer.removeEventListener('click', state.boundHandlers.handleResultClick);\n }\n }\n\n /**\n * Set up ARIA attributes\n */\n function setupAria() {\n const resultsId = state.resultsContainer.id || 'search-results-' + Math.random().toString(36).substr(2, 9);\n state.resultsContainer.id = resultsId;\n \n state.input.setAttribute('role', 'combobox');\n state.input.setAttribute('aria-autocomplete', 'list');\n state.input.setAttribute('aria-controls', resultsId);\n state.input.setAttribute('aria-expanded', 'false');\n \n state.resultsContainer.setAttribute('role', 'listbox');\n state.resultsContainer.setAttribute('aria-label', 'Search results');\n }\n\n /**\n * Handle input changes\n */\n function handleInput(e) {\n const query = e.target.value.trim();\n\n if (state.debounceTimer) {\n clearTimeout(state.debounceTimer);\n }\n\n state.debounceTimer = setTimeout(function() {\n state.query = query;\n\n if (query.length < config.minQueryLength) {\n close();\n return;\n }\n\n state.results = search(query);\n state.activeIndex = -1;\n render();\n open();\n\n // Callback\n if (typeof config.onSearch === 'function') {\n safeInvokeCallback('onSearch', config.onSearch, query, state.results);\n }\n }, config.debounceMs);\n }\n\n /**\n * Handle keyboard navigation\n */\n function handleKeydown(e) {\n if (!state.isOpen) {\n if (e.key === 'ArrowDown' && state.query.length >= config.minQueryLength) {\n e.preventDefault();\n state.results = search(state.query);\n render();\n open();\n }\n return;\n }\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n navigate(1);\n break;\n\n case 'ArrowUp':\n e.preventDefault();\n navigate(-1);\n break;\n\n case 'Enter':\n e.preventDefault();\n if (state.activeIndex >= 0) {\n select(state.activeIndex);\n } else if (state.results.length > 0) {\n select(0);\n }\n break;\n\n case 'Escape':\n e.preventDefault();\n close();\n break;\n\n case 'Tab':\n close();\n break;\n }\n }\n\n /**\n * Perform search\n */\n function search(query) {\n const terms = query.toLowerCase().split(/\\s+/).filter(function(t) {\n return t.length > 0;\n });\n const scored = [];\n\n state.index.forEach(function(entry) {\n let score = 0;\n const titleLower = entry.title.toLowerCase();\n const categoryLower = entry.category.toLowerCase();\n const contentLower = entry.content.toLowerCase();\n\n terms.forEach(function(term) {\n // Title match - highest priority\n if (titleLower.includes(term)) {\n score += 100;\n if (titleLower === term) {\n score += 50;\n } else if (titleLower.startsWith(term)) {\n score += 25;\n }\n }\n\n // Category match\n if (categoryLower.includes(term)) {\n score += 50;\n }\n\n // Keyword match\n const keywordMatch = entry.keywords.some(function(k) {\n return k.includes(term);\n });\n if (keywordMatch) {\n score += 30;\n }\n\n // Content match\n if (contentLower.includes(term)) {\n score += 10;\n }\n });\n\n if (score > 0) {\n scored.push({\n id: entry.id,\n title: entry.title,\n category: entry.category,\n categorySlug: entry.categorySlug,\n content: entry.content,\n url: entry.url,\n icon: entry.icon,\n score: score\n });\n }\n });\n\n scored.sort(function(a, b) {\n return b.score - a.score;\n });\n\n return scored.slice(0, config.maxResults);\n }\n\n /**\n * Render search results\n */\n function render() {\n if (state.results.length === 0) {\n setResultsHtml(renderEmpty());\n return;\n }\n\n let html = '
      ';\n\n state.results.forEach(function(result, index) {\n const isActive = index === state.activeIndex;\n const icon = result.icon || getCategoryIcon(result.categorySlug);\n const excerpt = getExcerpt(result.content, state.query);\n\n html += '
    • ' +\n '
      ' +\n '' +\n '
      ' +\n '
      ' +\n '
      ' + highlight(result.title, state.query) + '
      ' +\n '
      ' + escapeHtml(result.category) + '
      ' +\n '
      ' + highlight(excerpt, state.query) + '
      ' +\n '
      ' +\n '
    • ';\n });\n\n html += '
    ';\n html += renderFooter();\n\n setResultsHtml(html);\n }\n\n /**\n * Render empty state\n */\n function renderEmpty() {\n return '
    ' +\n '
    ' +\n '
    ' + escapeHtml(config.emptyTitle) + '
    ' +\n '
    ' + escapeHtml(config.emptyText) + '
    ' +\n '
    ';\n }\n\n /**\n * Render footer with keyboard hints\n */\n function renderFooter() {\n return '
    ' +\n '\u2191\u2193 to navigate' +\n '\u21B5 to select' +\n 'esc to close' +\n '
    ';\n }\n\n /**\n * Get icon for category\n */\n function getCategoryIcon(categorySlug) {\n return config.categoryIcons[categorySlug] || config.categoryIcons['default'] || 'ph-file-text';\n }\n\n /**\n * Get excerpt from content\n */\n function getExcerpt(content, query) {\n const terms = query.toLowerCase().split(/\\s+/);\n const contentLower = content.toLowerCase();\n const excerptLength = 100;\n\n let matchPos = -1;\n for (let i = 0; i < terms.length; i++) {\n const pos = contentLower.indexOf(terms[i]);\n if (pos !== -1 && (matchPos === -1 || pos < matchPos)) {\n matchPos = pos;\n }\n }\n\n if (matchPos === -1) {\n return content.substring(0, excerptLength) + '...';\n }\n\n const start = Math.max(0, matchPos - 30);\n const end = Math.min(content.length, matchPos + excerptLength);\n let excerpt = content.substring(start, end);\n\n if (start > 0) {\n excerpt = '...' + excerpt;\n }\n if (end < content.length) {\n excerpt = excerpt + '...';\n }\n\n return excerpt;\n }\n\n /**\n * Highlight matched terms in text\n */\n function highlight(text, query) {\n if (!query) return escapeHtml(text);\n\n const terms = query.toLowerCase().split(/\\s+/).filter(function(t) {\n return t.length > 0;\n });\n let escaped = escapeHtml(text);\n\n terms.forEach(function(term) {\n // Skip overly long terms to prevent ReDoS\n if (term.length > 50) return;\n const regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi');\n escaped = escaped.replace(regex, '<' + config.highlightTag + '>$1');\n });\n\n return escaped;\n }\n\n /**\n * Escape HTML entities\n */\n function escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Navigate results with keyboard\n */\n function navigate(direction) {\n let newIndex = state.activeIndex + direction;\n\n if (newIndex < 0) {\n newIndex = state.results.length - 1;\n } else if (newIndex >= state.results.length) {\n newIndex = 0;\n }\n\n setActiveIndex(newIndex);\n }\n\n /**\n * Set active result index\n */\n function setActiveIndex(index) {\n const prevActive = state.resultsContainer.querySelector('.vd-doc-search-result.is-active');\n if (prevActive) {\n prevActive.classList.remove('is-active');\n prevActive.setAttribute('aria-selected', 'false');\n }\n\n state.activeIndex = index;\n\n const newActive = state.resultsContainer.querySelector('[data-index=\"' + index + '\"]');\n if (newActive) {\n newActive.classList.add('is-active');\n newActive.setAttribute('aria-selected', 'true');\n state.input.setAttribute('aria-activedescendant', 'vd-doc-search-result-' + index);\n newActive.scrollIntoView({ block: 'nearest' });\n }\n }\n\n /**\n * Select a result\n */\n function select(index) {\n const result = state.results[index];\n if (!result) return;\n\n // Close search\n close();\n state.input.value = '';\n state.query = '';\n\n // Custom callback\n if (typeof config.onSelect === 'function') {\n safeInvokeCallback('onSelect', config.onSelect, result);\n return;\n }\n\n // Default behavior: navigate to section\n const section = document.querySelector(result.url);\n if (section) {\n section.scrollIntoView({ behavior: 'smooth', block: 'start' });\n window.history.pushState(null, '', result.url);\n updateSidebarActive(result.id);\n }\n }\n\n /**\n * Update sidebar navigation active state\n */\n function updateSidebarActive(sectionId) {\n const navLinks = document.querySelectorAll(config.navSelector);\n navLinks.forEach(function(link) {\n link.classList.remove('active');\n if (link.getAttribute('href') === '#' + sectionId) {\n link.classList.add('active');\n }\n });\n }\n\n /**\n * Open results dropdown\n */\n function open() {\n if (state.isOpen) return;\n\n state.isOpen = true;\n state.resultsContainer.classList.add('is-open');\n state.input.setAttribute('aria-expanded', 'true');\n\n if (typeof config.onOpen === 'function') {\n safeInvokeCallback('onOpen', config.onOpen);\n }\n }\n\n /**\n * Close results dropdown\n */\n function close() {\n if (!state.isOpen) return;\n\n state.isOpen = false;\n state.activeIndex = -1;\n state.resultsContainer.classList.remove('is-open');\n state.input.setAttribute('aria-expanded', 'false');\n state.input.removeAttribute('aria-activedescendant');\n\n if (typeof config.onClose === 'function') {\n safeInvokeCallback('onClose', config.onClose);\n }\n }\n\n /**\n * Destroy the component\n */\n function destroy() {\n unbindEvents();\n \n state.initialized = false;\n state.index = [];\n state.results = [];\n state.isOpen = false;\n state.query = '';\n\n if (state.debounceTimer) {\n clearTimeout(state.debounceTimer);\n }\n\n if (state.resultsContainer) {\n setResultsHtml('');\n }\n }\n\n /**\n * Rebuild the search index\n */\n function rebuild() {\n buildIndex();\n }\n\n /**\n * Update configuration\n */\n function setConfig(newConfig) {\n Object.assign(config, newConfig);\n }\n\n /**\n * Get current configuration\n */\n function getConfig() {\n return Object.assign({}, config);\n }\n\n /**\n * Get search index\n */\n function getIndex() {\n return state.index.slice();\n }\n\n // Public instance API\n const instance = {\n init: init,\n destroy: destroy,\n rebuild: rebuild,\n search: search,\n open: open,\n close: close,\n setConfig: setConfig,\n getConfig: getConfig,\n getIndex: getIndex\n };\n\n return instance;\n }\n\n /**\n * Search Component (singleton for backward compatibility)\n */\n const Search = {\n // Factory method \u2014 creates and auto-initializes a new independent instance.\n // Always returns the instance so callers retain a reference even if the\n // DOM container is not yet available (they can retry init() later).\n create: function(options) {\n const instance = createSearch(options);\n if (instance) {\n instance.init();\n }\n return instance || null;\n },\n \n // Default instance\n _instance: null,\n \n // Configuration (for default instance)\n config: Object.assign({}, DEFAULTS),\n\n /**\n * Initialize the default search instance\n */\n init: function(options) {\n if (this._instance) {\n this._instance.destroy();\n }\n \n if (options) {\n Object.assign(this.config, options);\n }\n \n this._instance = createSearch(this.config);\n return this._instance ? this._instance.init() : null;\n },\n\n /**\n * Destroy the default instance\n */\n destroy: function() {\n if (this._instance) {\n this._instance.destroy();\n this._instance = null;\n }\n },\n\n destroyAll: function() {\n this.destroy();\n },\n\n /**\n * Rebuild the default instance index\n */\n rebuild: function() {\n if (this._instance) {\n this._instance.rebuild();\n }\n },\n\n /**\n * Search using the default instance\n */\n search: function(query) {\n return this._instance ? this._instance.search(query) : [];\n },\n\n /**\n * Open the default instance\n */\n open: function() {\n if (this._instance) {\n this._instance.open();\n }\n },\n\n /**\n * Close the default instance\n */\n close: function() {\n if (this._instance) {\n this._instance.close();\n }\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('docSearch', Search);\n }\n\n // Expose globally (both names for compatibility)\n window.Search = Search;\n window.DocSearch = Search; // Backward compatibility\n window.VanduoDocSearch = Search; // New name compatibility\n\n})();\n", "/**\n * Vanduo Framework - Draggable Component\n * JavaScript functionality for draggable elements and drop zones\n */\n\n(function () {\n 'use strict';\n\n /**\n * Draggable Component\n */\n const Draggable = {\n // Store initialized draggables and their cleanup functions\n instances: new Map(),\n // Store current drag state\n currentDrag: null,\n // Store touch state\n touchState: null,\n // Feedback element\n feedbackElement: null,\n // Shared selector used by init and touch reorder\n containerSelector: '.vd-draggable-container, .vd-draggable-container-vertical',\n\n /**\n * Initialize draggable components\n */\n init: function () {\n const draggables = document.querySelectorAll('.vd-draggable, [data-draggable]');\n\n draggables.forEach(element => {\n if (this.instances.has(element)) {\n return;\n }\n this.initDraggable(element);\n });\n\n const containers = document.querySelectorAll(this.containerSelector);\n containers.forEach(container => {\n if (!this.instances.has(container)) {\n this.initContainer(container);\n }\n });\n\n const dropZones = document.querySelectorAll('.vd-drop-zone');\n dropZones.forEach(zone => {\n if (!this.instances.has(zone)) {\n this.initDropZone(zone);\n }\n });\n\n this.createFeedbackElement();\n },\n\n /**\n * Initialize a single draggable element\n * @param {HTMLElement} element - Draggable element\n */\n initDraggable: function (element) {\n const cleanupFunctions = [];\n\n // Make element draggable if not already\n if (!element.hasAttribute('draggable')) {\n element.setAttribute('draggable', 'true');\n }\n\n // Accessibility: add ARIA attributes\n if (!element.hasAttribute('tabindex')) {\n element.setAttribute('tabindex', '0');\n }\n element.setAttribute('role', 'option');\n element.setAttribute('aria-roledescription', 'draggable item');\n element.setAttribute('aria-grabbed', 'false');\n\n // Handle drag start\n const dragStartHandler = (e) => {\n this.handleDragStart(e, element);\n };\n element.addEventListener('dragstart', dragStartHandler);\n cleanupFunctions.push(() => element.removeEventListener('dragstart', dragStartHandler));\n\n // Handle drag\n const dragHandler = (e) => {\n this.handleDrag(e, element);\n };\n element.addEventListener('drag', dragHandler);\n cleanupFunctions.push(() => element.removeEventListener('drag', dragHandler));\n\n // Handle drag end\n const dragEndHandler = (e) => {\n this.handleDragEnd(e, element);\n };\n element.addEventListener('dragend', dragEndHandler);\n cleanupFunctions.push(() => element.removeEventListener('dragend', dragEndHandler));\n\n // Handle touch start (for mobile)\n const touchStartHandler = (e) => {\n this.handleTouchStart(e, element);\n };\n element.addEventListener('touchstart', touchStartHandler);\n cleanupFunctions.push(() => element.removeEventListener('touchstart', touchStartHandler));\n\n // Handle touch move (for mobile)\n // { passive: false } is required so that e.preventDefault() works\n // once the drag threshold is reached \u2014 without it, modern browsers\n // treat the listener as passive and silently ignore preventDefault().\n const touchMoveHandler = (e) => {\n this.handleTouchMove(e, element);\n };\n element.addEventListener('touchmove', touchMoveHandler, { passive: false });\n cleanupFunctions.push(() => element.removeEventListener('touchmove', touchMoveHandler));\n\n // Handle touch end (for mobile)\n const touchEndHandler = (e) => {\n this.handleTouchEnd(e, element);\n };\n element.addEventListener('touchend', touchEndHandler, { passive: false });\n cleanupFunctions.push(() => element.removeEventListener('touchend', touchEndHandler));\n\n // Handle touch cancel (for mobile)\n const touchCancelHandler = (e) => {\n this.handleTouchEnd(e, element);\n };\n element.addEventListener('touchcancel', touchCancelHandler);\n cleanupFunctions.push(() => element.removeEventListener('touchcancel', touchCancelHandler));\n\n // Keyboard navigation\n const keydownHandler = (e) => {\n this.handleKeydown(e, element);\n };\n element.addEventListener('keydown', keydownHandler);\n cleanupFunctions.push(() => element.removeEventListener('keydown', keydownHandler));\n\n this.instances.set(element, { cleanup: cleanupFunctions });\n },\n\n /**\n * Initialize a draggable container\n * @param {HTMLElement} container - Draggable container\n */\n initContainer: function (container) {\n // Accessibility: add ARIA role to container\n container.setAttribute('role', 'listbox');\n container.setAttribute('aria-label', container.getAttribute('aria-label') || 'Draggable items');\n\n const items = container.querySelectorAll('.vd-draggable-item');\n items.forEach(item => {\n if (!this.instances.has(item)) {\n this.initDraggable(item);\n }\n });\n\n const cleanupFunctions = [];\n\n // Handle drag enter\n const dragEnterHandler = (e) => {\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n };\n\n // Handle drag over for auto-sorting\n const dragOverHandler = (e) => {\n e.preventDefault(); // Necessary to allow drop\n e.dataTransfer.dropEffect = 'move';\n\n if (!this.currentDrag) return;\n const draggingEl = this.currentDrag.element;\n\n // Only reorder if dragging an item that belongs to this container (or if we support cross-container drag, but keep simple)\n if (!container.contains(draggingEl)) return;\n\n // Prevent jumps when the dragging finalizes with 0,0 coordinates near the end\n if (e.clientX === 0 && e.clientY === 0) return;\n\n this.handleReorder(container, draggingEl, e.clientX, e.clientY);\n };\n\n // Handle drop\n const dropHandler = (e) => {\n e.preventDefault(); // crucial to prevent the browser's default handling and snapping back\n };\n\n container.addEventListener('dragenter', dragEnterHandler);\n container.addEventListener('dragover', dragOverHandler);\n container.addEventListener('drop', dropHandler);\n\n cleanupFunctions.push(() => {\n container.removeEventListener('dragenter', dragEnterHandler);\n container.removeEventListener('dragover', dragOverHandler);\n container.removeEventListener('drop', dropHandler);\n });\n\n this.instances.set(container, { cleanup: cleanupFunctions });\n },\n\n /**\n * Initialize a drop zone\n * @param {HTMLElement} zone - Drop zone element\n */\n initDropZone: function (zone) {\n const cleanupFunctions = [];\n\n // Accessibility: add ARIA role to drop zone\n zone.setAttribute('role', 'region');\n zone.setAttribute('aria-dropeffect', 'move');\n if (!zone.hasAttribute('aria-label')) {\n zone.setAttribute('aria-label', 'Drop zone');\n }\n\n // Handle drag over\n const dragOverHandler = (e) => {\n e.preventDefault();\n this.handleDragOver(e, zone);\n };\n zone.addEventListener('dragover', dragOverHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragover', dragOverHandler));\n\n // Handle drag enter\n const dragEnterHandler = (e) => {\n e.preventDefault();\n this.handleDragEnter(e, zone);\n };\n zone.addEventListener('dragenter', dragEnterHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragenter', dragEnterHandler));\n\n // Handle drag leave\n const dragLeaveHandler = (e) => {\n this.handleDragLeave(e, zone);\n };\n zone.addEventListener('dragleave', dragLeaveHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragleave', dragLeaveHandler));\n\n // Handle drop\n const dropHandler = (e) => {\n e.preventDefault();\n this.handleDrop(e, zone);\n };\n zone.addEventListener('drop', dropHandler);\n cleanupFunctions.push(() => zone.removeEventListener('drop', dropHandler));\n\n this.instances.set(zone, { cleanup: cleanupFunctions });\n },\n\n /**\n * Create feedback element for drag operations\n */\n createFeedbackElement: function () {\n if (!this.feedbackElement) {\n // Reuse existing element if present\n const existing = document.querySelector('.vd-drag-feedback');\n if (existing) {\n this.feedbackElement = existing;\n return;\n }\n\n this.feedbackElement = document.createElement('div');\n this.feedbackElement.className = 'vd-drag-feedback hidden';\n this.feedbackElement.setAttribute('role', 'presentation');\n document.body.appendChild(this.feedbackElement);\n }\n },\n\n /**\n * Handle drag start event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDragStart: function (e, element) {\n // Add dragging class\n element.classList.add('is-dragging');\n\n // Accessibility: update aria-grabbed\n element.setAttribute('aria-grabbed', 'true');\n\n // Store drag state\n this.currentDrag = {\n element: element,\n initialPosition: { x: e.clientX, y: e.clientY },\n initialBounds: element.getBoundingClientRect(),\n data: this.getData(element)\n };\n\n // Set drag data\n e.dataTransfer.effectAllowed = 'move';\n e.dataTransfer.setData('text/plain', this.currentDrag.data);\n\n // We no longer suppress the native ghost image or manually update feedback\n // for mouse drags, relying on the browser's native rendering instead.\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:start', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: e.clientX, y: e.clientY }\n }\n }));\n },\n\n /**\n * Handle drag event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDrag: function (e, element) {\n // Guard against null state (race condition on fast interactions)\n if (!this.currentDrag) return;\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:drag', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: e.clientX, y: e.clientY },\n delta: {\n x: e.clientX - this.currentDrag.initialPosition.x,\n y: e.clientY - this.currentDrag.initialPosition.y\n }\n }\n }));\n },\n\n /**\n * Handle drag end event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDragEnd: function (e, element) {\n // Remove dragging class\n element.classList.remove('is-dragging');\n element.classList.add('is-dropped');\n setTimeout(() => element.classList.remove('is-dropped'), 300);\n\n // Accessibility: update aria-grabbed\n element.setAttribute('aria-grabbed', 'false');\n\n // Hide feedback\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n\n // Guard against null state\n const data = this.currentDrag?.data || this.getData(element);\n const initialPos = this.currentDrag?.initialPosition || { x: 0, y: 0 };\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:end', {\n bubbles: true,\n detail: {\n element: element,\n data: data,\n position: { x: e.clientX, y: e.clientY },\n delta: {\n x: e.clientX - initialPos.x,\n y: e.clientY - initialPos.y\n }\n }\n }));\n\n // Reset drag state\n this.currentDrag = null;\n },\n\n /**\n * Handle touch start event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchStart: function (e, element) {\n // Don't prevent default here \u2014 it blocks scrolling.\n // We only prevent default in touchmove once drag threshold is reached.\n const touch = e.touches[0];\n const rect = element.getBoundingClientRect();\n this.touchState = {\n element: element,\n startX: touch.clientX,\n startY: touch.clientY,\n lastX: touch.clientX,\n lastY: touch.clientY,\n // Keep preview anchored to the original grab point.\n offsetX: touch.clientX - rect.left,\n offsetY: touch.clientY - rect.top,\n startTime: Date.now(),\n isDragging: false\n };\n },\n\n /**\n * Handle touch move event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchMove: function (e, element) {\n if (!this.touchState) return;\n\n const touch = e.touches[0];\n this.touchState.lastX = touch.clientX;\n this.touchState.lastY = touch.clientY;\n const deltaX = touch.clientX - this.touchState.startX;\n const deltaY = touch.clientY - this.touchState.startY;\n\n // Only start dragging if moved a minimum distance\n if (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10) {\n // Now we know it's a drag, not a scroll \u2014 prevent default\n if (e.cancelable) e.preventDefault();\n\n if (!this.touchState.isDragging) {\n this.touchState.isDragging = true;\n element.classList.add('is-dragging');\n element.setAttribute('aria-grabbed', 'true');\n\n // Store drag state\n this.currentDrag = {\n element: element,\n initialPosition: { x: this.touchState.startX, y: this.touchState.startY },\n initialBounds: element.getBoundingClientRect(),\n data: this.getData(element),\n // Preserve where inside the element the drag started for accurate ghost positioning.\n offsetX: this.touchState.offsetX,\n offsetY: this.touchState.offsetY\n };\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:start', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: touch.clientX, y: touch.clientY }\n }\n }));\n }\n\n // Update feedback\n this.updateFeedback(touch.clientX, touch.clientY);\n\n // Dispatch event\n if (this.currentDrag) {\n element.dispatchEvent(new CustomEvent('draggable:drag', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: touch.clientX, y: touch.clientY },\n delta: { x: deltaX, y: deltaY }\n }\n }));\n\n this.updateTouchDropZone(touch.clientX, touch.clientY);\n\n // Reorder for touch\n const container = element.closest(this.containerSelector);\n if (container && container.contains(element)) {\n this.handleReorder(container, element, touch.clientX, touch.clientY);\n }\n }\n }\n },\n\n /**\n * Handle touch end event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchEnd: function (e, element) {\n if (this.touchState && this.touchState.isDragging) {\n if (e.cancelable) e.preventDefault();\n const endTouch = e.changedTouches?.[0];\n const endPosition = {\n x: endTouch?.clientX ?? this.touchState.lastX ?? this.touchState.startX,\n y: endTouch?.clientY ?? this.touchState.lastY ?? this.touchState.startY\n };\n\n const dropZone = this.resolveDropZoneAtPoint(endPosition.x, endPosition.y) || this.touchState.overZone;\n if (dropZone) {\n this.dispatchDrop(dropZone, endPosition);\n } else if (this.touchState.overZone) {\n this.touchState.overZone.classList.remove('is-drag-over');\n }\n\n element.classList.remove('is-dragging');\n element.classList.add('is-dropped');\n element.setAttribute('aria-grabbed', 'false');\n setTimeout(() => element.classList.remove('is-dropped'), 300);\n\n // Hide feedback\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n\n // Dispatch event\n const data = this.currentDrag?.data || this.getData(element);\n const startX = this.touchState?.startX || 0;\n const startY = this.touchState?.startY || 0;\n\n element.dispatchEvent(new CustomEvent('draggable:end', {\n bubbles: true,\n detail: {\n element: element,\n data: data,\n position: endPosition,\n delta: {\n x: endPosition.x - startX,\n y: endPosition.y - startY\n }\n }\n }));\n }\n\n // Reset states\n this.touchState = null;\n this.currentDrag = null;\n },\n\n /**\n * Handle drag over event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} _zone - Drop zone element\n */\n handleDragOver: function (e, _zone) {\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n },\n\n /**\n * Handle drag enter event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDragEnter: function (e, zone) {\n e.preventDefault();\n zone.classList.add('is-drag-over');\n },\n\n /**\n * Handle drag leave event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDragLeave: function (e, zone) {\n zone.classList.remove('is-drag-over');\n },\n\n /**\n * Handle drop event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDrop: function (e, zone) {\n e.preventDefault();\n this.dispatchDrop(zone, { x: e.clientX, y: e.clientY });\n },\n\n /**\n * Resolve a drop zone from viewport coordinates\n * @param {number} x\n * @param {number} y\n * @returns {HTMLElement|null}\n */\n resolveDropZoneAtPoint: function (x, y) {\n if (!Number.isFinite(x) || !Number.isFinite(y)) return null;\n\n // Prefer the full stacking list so overlays/top elements don't hide real drop targets.\n if (typeof document.elementsFromPoint === 'function') {\n const stacked = document.elementsFromPoint(x, y);\n for (const element of stacked) {\n const zone = element.closest('.vd-drop-zone');\n if (zone) return zone;\n }\n }\n\n const target = document.elementFromPoint(x, y);\n const targetZone = target ? target.closest('.vd-drop-zone') : null;\n if (targetZone) return targetZone;\n\n // Last-resort fallback for mobile emulation edge cases.\n const zones = document.querySelectorAll('.vd-drop-zone');\n for (const zone of zones) {\n const rect = zone.getBoundingClientRect();\n if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {\n return zone;\n }\n }\n\n return null;\n },\n\n /**\n * Track and update active drop-zone hover state on touch devices\n * @param {number} x\n * @param {number} y\n */\n updateTouchDropZone: function (x, y) {\n if (!this.touchState) return;\n\n const nextZone = this.resolveDropZoneAtPoint(x, y);\n const prevZone = this.touchState.overZone || null;\n\n if (prevZone && prevZone !== nextZone) {\n prevZone.classList.remove('is-drag-over');\n }\n\n if (nextZone && nextZone !== prevZone) {\n nextZone.classList.add('is-drag-over');\n }\n\n this.touchState.overZone = nextZone || null;\n },\n\n /**\n * Dispatch a normalized drop event for mouse and touch flows\n * @param {HTMLElement} zone\n * @param {{x:number, y:number}} position\n */\n dispatchDrop: function (zone, position) {\n zone.classList.remove('is-drag-over');\n zone.dispatchEvent(new CustomEvent('draggable:drop', {\n bubbles: true,\n detail: {\n zone: zone,\n element: this.currentDrag?.element,\n data: this.currentDrag?.data,\n position: position\n }\n }));\n },\n\n /**\n * Reorder elements in container based on cursor position\n * @param {HTMLElement} container \n * @param {HTMLElement} element \n * @param {number} clientX \n * @param {number} clientY \n */\n handleReorder: function (container, element, clientX, clientY) {\n const isVertical = container.classList.contains('vd-draggable-container-vertical');\n const siblings = [...container.querySelectorAll('.vd-draggable-item:not(.is-dragging), .vd-draggable:not(.is-dragging)')];\n\n const nextSibling = siblings.reduce((closest, child) => {\n const box = child.getBoundingClientRect();\n const offset = isVertical\n ? clientY - box.top - box.height / 2\n : clientX - box.left - box.width / 2;\n\n if (offset < 0 && offset > closest.offset) {\n return { offset: offset, element: child };\n } else {\n return closest;\n }\n }, { offset: Number.NEGATIVE_INFINITY }).element;\n\n if (nextSibling == null) {\n container.appendChild(element);\n } else {\n container.insertBefore(element, nextSibling);\n }\n },\n\n /**\n * Handle keyboard events\n * @param {KeyboardEvent} e - Keyboard event\n * @param {HTMLElement} element - Draggable element\n */\n handleKeydown: function (e, element) {\n switch (e.key) {\n case 'Enter':\n case ' ':\n e.preventDefault();\n // Trigger click or custom action\n element.click();\n break;\n case 'Escape':\n // Cancel drag if in progress\n if (element.classList.contains('is-dragging')) {\n element.classList.remove('is-dragging');\n element.setAttribute('aria-grabbed', 'false');\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n this.currentDrag = null;\n }\n break;\n case 'ArrowUp':\n case 'ArrowLeft': {\n e.preventDefault();\n const prev = element.previousElementSibling;\n if (prev && (prev.classList.contains('vd-draggable') || prev.classList.contains('vd-draggable-item'))) {\n element.parentNode.insertBefore(element, prev);\n element.focus();\n element.dispatchEvent(new CustomEvent('draggable:reorder', {\n bubbles: true,\n detail: { element: element, direction: 'up' }\n }));\n }\n break;\n }\n case 'ArrowDown':\n case 'ArrowRight': {\n e.preventDefault();\n const next = element.nextElementSibling;\n if (next && (next.classList.contains('vd-draggable') || next.classList.contains('vd-draggable-item'))) {\n element.parentNode.insertBefore(next, element);\n element.focus();\n element.dispatchEvent(new CustomEvent('draggable:reorder', {\n bubbles: true,\n detail: { element: element, direction: 'down' }\n }));\n }\n break;\n }\n }\n },\n\n /**\n * Get data from draggable element\n * @param {HTMLElement} element - Draggable element\n * @returns {string} Data associated with the element\n */\n getData: function (element) {\n return element.dataset.draggable || element.textContent.trim();\n },\n\n /**\n * Update drag feedback element\n * @param {number} x - Current X coordinate\n * @param {number} y - Current Y coordinate\n */\n updateFeedback: function (x, y) {\n if (!this.currentDrag) return;\n\n // Show feedback\n this.feedbackElement.classList.remove('hidden');\n\n // Update feedback content\n const rect = this.currentDrag.initialBounds;\n this.feedbackElement.innerHTML = '';\n const clone = this.currentDrag.element.cloneNode(true);\n this.feedbackElement.appendChild(clone);\n\n // Set styles\n const offsetX = this.currentDrag.offsetX ?? 20;\n const offsetY = this.currentDrag.offsetY ?? 20;\n Object.assign(this.feedbackElement.style, {\n left: (x - offsetX) + 'px',\n top: (y - offsetY) + 'px',\n width: rect.width + 'px',\n height: rect.height + 'px'\n });\n },\n\n /**\n * Make an element draggable programmatically\n * @param {HTMLElement|string} element - Element or selector\n * @param {Object} options - Configuration options\n */\n makeDraggable: function (element, options = {}) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n\n if (el && !this.instances.has(el)) {\n // Add classes and attributes\n el.classList.add('vd-draggable');\n el.setAttribute('draggable', 'true');\n\n // Set options\n if (options.data) {\n el.dataset.draggable = options.data;\n }\n\n // Initialize\n this.initDraggable(el);\n }\n },\n\n /**\n * Remove draggable functionality from an element\n * @param {HTMLElement|string} element - Element or selector\n */\n removeDraggable: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n\n if (el && this.instances.has(el)) {\n // Clean up\n const instance = this.instances.get(el);\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n\n // Remove classes and attributes\n el.classList.remove('vd-draggable');\n el.removeAttribute('draggable');\n el.removeAttribute('data-draggable');\n }\n },\n\n /**\n * Destroy a draggable instance and clean up event listeners\n * @param {HTMLElement} element - Draggable element\n */\n destroy: function (element) {\n this.removeDraggable(element);\n },\n\n /**\n * Destroy all draggable instances\n */\n destroyAll: function () {\n const instances = Array.from(this.instances.keys());\n instances.forEach(element => this.destroy(element));\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('draggable', Draggable);\n }\n\n // Expose globally\n window.VanduoDraggable = Draggable;\n})();\n", "/**\n * Vanduo Framework \u2013 LazyLoad Component\n * v1.2.1\n *\n * Provides two levels of API:\n *\n * LOW-LEVEL (generic IntersectionObserver wrapper)\n * VanduoLazyLoad.observe(element, callback, options?)\n * VanduoLazyLoad.unobserve(element)\n * VanduoLazyLoad.unobserveAll()\n *\n * HIGH-LEVEL (HTML-section fetcher, mirrors vanduo-docs SPA behaviour)\n * VanduoLazyLoad.loadSection(url, containerEl, options?)\n * options: { placeholder, threshold, rootMargin, onLoaded, onError }\n * placeholder: 'skeleton' | 'spinner' | HTMLString (default: 'skeleton')\n *\n * ATTRIBUTE-DRIVEN (zero-JS usage, wired by init())\n *
    \u2026
    \n *\n * EVENTS dispatched on the host element:\n * lazysection:loading \u2014 fetch started\n * lazysection:loaded \u2014 content injected\n * lazysection:error \u2014 fetch failed\n */\n\n(function () {\n 'use strict';\n\n /* \u2500\u2500 Private state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /** @type {Map} */\n const _observerMap = new Map();\n\n /* \u2500\u2500 Security helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Returns true only if `url` resolves to the same origin as the page\n * (relative paths and same-origin absolute URLs are allowed).\n * Cross-origin URLs are rejected to prevent SSRF-style fetch abuse.\n * @param {string} url\n * @returns {boolean}\n */\n function _isSafeUrl(url) {\n try {\n // Relative URLs (no origin) are always safe\n const resolved = new URL(url, window.location.href);\n return resolved.origin === window.location.origin;\n } catch (_) {\n return false;\n }\n }\n\n /**\n * Safely inject fetched HTML into a container by parsing it with\n * DOMParser (avoids script execution) and replacing children via\n * standard DOM APIs instead of raw innerHTML assignment.\n * @param {Element} containerEl\n * @param {string} html\n */\n function _safeInjectHtml(containerEl, html) {\n const parser = new DOMParser();\n const doc = parser.parseFromString(html.trim(), 'text/html'); // CodeQL [js/xss-through-dom] Intentional: parsed nodes are sanitized below before adoption into live DOM\n\n const DANGEROUS_TAGS = ['SCRIPT', 'IFRAME', 'OBJECT', 'EMBED', 'FORM', 'BASE', 'LINK', 'META', 'STYLE'];\n for (const tag of DANGEROUS_TAGS) {\n const els = doc.querySelectorAll(tag);\n for (let i = els.length - 1; i >= 0; i--) {\n els[i].parentNode.removeChild(els[i]);\n }\n }\n\n // Strip dangerous attributes (e.g. onerror, javascript: urls) from all elements\n function _sanitizeNode(node) {\n if (node.nodeType === Node.ELEMENT_NODE) {\n const attrs = node.attributes;\n for (let i = attrs.length - 1; i >= 0; i--) {\n const attrName = attrs[i].name.toLowerCase();\n const attrValue = attrs[i].value.toLowerCase();\n const trimmedValue = attrValue.trim();\n if (\n attrName.startsWith('on') ||\n trimmedValue.startsWith('javascript:') ||\n trimmedValue.startsWith('data:') ||\n trimmedValue.startsWith('vbscript:')\n ) {\n node.removeAttribute(attrs[i].name);\n }\n }\n const children = node.childNodes;\n for (let i = 0; i < children.length; i++) {\n _sanitizeNode(children[i]);\n }\n }\n }\n _sanitizeNode(doc.body);\n\n // Collect all top-level body children\n const nodes = Array.from(doc.body.childNodes);\n // Clear container and append parsed nodes\n while (containerEl.firstChild) {\n containerEl.removeChild(containerEl.firstChild);\n }\n nodes.forEach(function (node) {\n containerEl.appendChild(document.adoptNode(node));\n });\n }\n\n /* \u2500\u2500 Placeholder HTML \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n function _skeletonHtml() {\n return '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + 'Loading\u2026
    ';\n }\n\n function _spinnerHtml() {\n return '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + 'Loading\u2026
    ';\n }\n\n function _resolvePlaceholder(placeholder) {\n if (!placeholder || placeholder === 'skeleton') return _skeletonHtml();\n if (placeholder === 'spinner') return _spinnerHtml();\n // Caller-supplied HTML string\n return placeholder;\n }\n\n /* \u2500\u2500 Dispatch helper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n function _dispatch(el, eventName, detail) {\n el.dispatchEvent(new CustomEvent(eventName, { bubbles: true, detail: detail || {} }));\n }\n\n /* \u2500\u2500 VanduoLazyLoad \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const VanduoLazyLoad = {\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * LOW-LEVEL API\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Observe an element. `callback` is invoked once when the element\n * enters the viewport, then the element is automatically unobserved.\n *\n * @param {Element} element\n * @param {function(Element): void} callback\n * @param {{ threshold?: number, rootMargin?: string }} [options]\n */\n observe: function (element, callback, options) {\n if (!(element instanceof Element)) {\n console.warn('[VanduoLazyLoad] observe() requires a DOM Element.');\n return;\n }\n if (typeof callback !== 'function') {\n console.warn('[VanduoLazyLoad] observe() requires a callback function.');\n return;\n }\n // Already observed \u2014 ignore\n if (_observerMap.has(element)) return;\n\n const threshold = (options && options.threshold != null) ? options.threshold : 0;\n const rootMargin = (options && options.rootMargin) ? options.rootMargin : '0px';\n\n const observer = new IntersectionObserver(function (entries, obs) {\n entries.forEach(function (entry) {\n if (entry.isIntersecting) {\n obs.unobserve(entry.target);\n _observerMap.delete(entry.target);\n try {\n callback(entry.target);\n } catch (e) {\n console.error('[VanduoLazyLoad] Callback threw:', e);\n }\n }\n });\n }, { threshold: threshold, rootMargin: rootMargin });\n\n _observerMap.set(element, observer);\n observer.observe(element);\n },\n\n /**\n * Stop observing an element that was previously passed to observe().\n * @param {Element} element\n */\n unobserve: function (element) {\n const observer = _observerMap.get(element);\n if (observer) {\n observer.unobserve(element);\n _observerMap.delete(element);\n }\n },\n\n /**\n * Stop observing ALL currently observed elements.\n */\n unobserveAll: function () {\n _observerMap.forEach(function (observer, element) {\n observer.unobserve(element);\n });\n _observerMap.clear();\n },\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * HIGH-LEVEL API\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Fetch an HTML partial and inject it into `containerEl` when the\n * container enters the viewport. A placeholder is shown immediately.\n *\n * @param {string} url URL of the HTML partial to fetch\n * @param {Element} containerEl Target element whose content will be replaced\n * @param {{\n * placeholder?: 'skeleton'|'spinner'|string,\n * threshold?: number,\n * rootMargin?: string,\n * onLoaded?: function(Element): void,\n * onError?: function(Error): void\n * }} [options]\n */\n loadSection: function (url, containerEl, options) {\n if (typeof url !== 'string' || !url) {\n console.warn('[VanduoLazyLoad] loadSection() requires a non-empty URL string.');\n return;\n }\n if (!(containerEl instanceof Element)) {\n console.warn('[VanduoLazyLoad] loadSection() requires a DOM Element as containerEl.');\n return;\n }\n // Reject cross-origin URLs to prevent SSRF-style fetch abuse\n if (!_isSafeUrl(url)) {\n console.error('[VanduoLazyLoad] loadSection() blocked cross-origin URL:', url);\n return;\n }\n\n const opts = options || {};\n // Placeholders are known-safe static HTML strings built internally\n const placeholderHtml = _resolvePlaceholder(opts.placeholder);\n\n // Use _safeInjectHtml instead of innerHTML to prevent XSS from caller-supplied placeholders\n _safeInjectHtml(containerEl, placeholderHtml);\n _dispatch(containerEl, 'lazysection:loading', { url: url });\n\n // Fetch when visible\n this.observe(containerEl, function () {\n const controller = new window.AbortController();\n const timeoutId = setTimeout(function () { controller.abort(); }, 10000);\n\n window.fetch(url, { signal: controller.signal })\n .then(function (res) {\n clearTimeout(timeoutId);\n if (!res.ok) throw new Error('HTTP ' + res.status);\n return res.text();\n })\n .then(function (html) {\n // Use DOMParser to parse fetched content safely, avoiding\n // raw innerHTML assignment of externally-sourced strings\n _safeInjectHtml(containerEl, html);\n _dispatch(containerEl, 'lazysection:loaded', { url: url });\n // Re-init Vanduo components inside the new content\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.init();\n }\n if (typeof opts.onLoaded === 'function') {\n opts.onLoaded(containerEl);\n }\n })\n .catch(function (err) {\n // Build error node via DOM APIs \u2014 no dynamic HTML strings\n const alertEl = document.createElement('div');\n alertEl.className = 'vd-alert vd-alert-error';\n alertEl.setAttribute('role', 'alert');\n const msgEl = document.createElement('span');\n msgEl.textContent = 'Failed to load content. ';\n const detailEl = document.createElement('small');\n detailEl.style.opacity = '0.7';\n detailEl.textContent = err.message;\n alertEl.appendChild(msgEl);\n alertEl.appendChild(detailEl);\n while (containerEl.firstChild) {\n containerEl.removeChild(containerEl.firstChild);\n }\n containerEl.appendChild(alertEl);\n _dispatch(containerEl, 'lazysection:error', { url: url, error: err });\n console.error('[VanduoLazyLoad] loadSection failed:', err);\n if (typeof opts.onError === 'function') {\n opts.onError(err);\n }\n });\n }, { threshold: opts.threshold, rootMargin: opts.rootMargin });\n },\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * ATTRIBUTE-DRIVEN INIT\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Scan the DOM for [data-vd-lazy] elements and wire them up.\n * Safe to call multiple times \u2014 already-observed elements are skipped.\n */\n init: function () {\n const self = this;\n const elements = document.querySelectorAll('[data-vd-lazy]');\n elements.forEach(function (el) {\n // Skip already-observed or already-loaded elements\n if (_observerMap.has(el) || el.dataset.vdLazyState === 'loading' || el.dataset.vdLazyState === 'loaded') return;\n\n const url = el.getAttribute('data-vd-lazy');\n if (!url) return;\n\n el.dataset.vdLazyState = 'loading';\n const placeholder = el.getAttribute('data-vd-lazy-placeholder') || 'skeleton';\n\n self.loadSection(url, el, {\n placeholder: placeholder,\n onLoaded: function () {\n el.dataset.vdLazyState = 'loaded';\n },\n onError: function () {\n el.dataset.vdLazyState = 'error';\n }\n });\n });\n }\n };\n\n /* \u2500\u2500 Register with Vanduo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('LazyLoad', VanduoLazyLoad);\n }\n\n /* \u2500\u2500 Global convenience alias \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n window.VanduoLazyLoad = VanduoLazyLoad;\n\n})();\n", "/**\n * Vanduo Framework - Glass Scroll Activation\n * Generic scroll-aware glass activation via IntersectionObserver.\n *\n * Usage:\n * Add `data-glass-scroll` to any element carrying a `.vd-glass*` class.\n * By default the previous sibling is used as the sentinel.\n * Point to a custom sentinel via `data-glass-sentinel=\"\"`.\n *\n * Behaviour:\n * - Adds `.is-glass-active` when the sentinel leaves the viewport (scrolled past).\n * - Removes `.is-glass-active` when the sentinel re-enters the viewport.\n */\n(function () {\n 'use strict';\n\n const GlassScroll = {\n /** @type {Map} */\n observers: new Map(),\n\n init: function () {\n document.querySelectorAll('[data-glass-scroll]').forEach(el => {\n if (this.observers.has(el)) return;\n this.initElement(el);\n });\n },\n\n /**\n * Wire up a single scroll-activated glass element.\n * @param {HTMLElement} el\n */\n initElement: function (el) {\n const sentinelSelector = el.dataset.glassSentinel;\n let sentinel;\n\n if (sentinelSelector) {\n sentinel = document.querySelector(sentinelSelector);\n }\n\n if (!sentinel) {\n // Fall back to the previous sibling element\n sentinel = el.previousElementSibling;\n }\n\n if (!sentinel) {\n // No sentinel available \u2014 activate immediately so glass is always shown\n el.classList.add('is-glass-active');\n return;\n }\n\n const observer = new IntersectionObserver(\n (entries) => {\n entries.forEach(entry => {\n // Active when sentinel is NOT intersecting (scrolled past it)\n el.classList.toggle('is-glass-active', !entry.isIntersecting);\n });\n },\n { threshold: 0, rootMargin: '0px' }\n );\n\n observer.observe(sentinel);\n this.observers.set(el, observer);\n },\n\n /**\n * Disconnect and remove a single element's observer.\n * @param {HTMLElement} el\n */\n destroy: function (el) {\n const observer = this.observers.get(el);\n if (observer) {\n observer.disconnect();\n this.observers.delete(el);\n }\n },\n\n destroyAll: function () {\n this.observers.forEach((observer, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('glassScroll', GlassScroll);\n }\n\n window.VanduoGlassScroll = GlassScroll;\n})();\n", "/**\n * Vanduo Framework - Water Morph Effect Component\n * Liquid wave content-swap animation on click\n *\n * Usage:\n * Add .vd-morph or [data-vd-morph] to any element.\n * Provide .vd-morph-content.vd-morph-current and .vd-morph-content.vd-morph-next children.\n * Wave and shine layers are auto-created if absent.\n *\n * JS API:\n * VanduoMorph.morph(el) \u2014 trigger morph programmatically (from center)\n * VanduoMorph.destroy(el) \u2014 tear down listeners for one element\n * VanduoMorph.destroyAll() \u2014 tear down all instances\n */\n\n(function () {\n 'use strict';\n\n const MORPH_DURATION_MS = 750;\n\n const Morph = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-morph, [data-vd-morph]');\n elements.forEach(function (el) {\n if (Morph.instances.has(el)) return;\n if (el.getAttribute('data-vd-morph') === 'manual') return;\n Morph.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n Morph._ensureLayers(el);\n\n const cleanup = [];\n let morphing = false;\n\n const handleClick = function (e) {\n if (morphing) return;\n Morph._runMorph(el, e, function () { morphing = false; });\n morphing = true;\n };\n\n el.addEventListener('click', handleClick);\n cleanup.push(function () { el.removeEventListener('click', handleClick); });\n\n this.instances.set(el, { cleanup: cleanup });\n },\n\n morph: function (el) {\n if (!el) return;\n if (!this.instances.has(el)) this.initInstance(el);\n this._runMorph(el, null, null);\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(function (fn) { fn(); });\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) { Morph.destroy(el); });\n },\n\n /* \u2500\u2500 Internal helpers \u2500\u2500 */\n\n _ensureLayers: function (el) {\n if (!el.querySelector('.vd-morph-wave')) {\n const wave = document.createElement('span');\n wave.className = 'vd-morph-wave';\n wave.setAttribute('aria-hidden', 'true');\n el.insertBefore(wave, el.firstChild);\n }\n if (!el.querySelector('.vd-morph-shine')) {\n const shine = document.createElement('span');\n shine.className = 'vd-morph-shine';\n shine.setAttribute('aria-hidden', 'true');\n const waveEl = el.querySelector('.vd-morph-wave');\n if (waveEl && waveEl.nextSibling) {\n el.insertBefore(shine, waveEl.nextSibling);\n } else {\n el.insertBefore(shine, el.firstChild);\n }\n }\n },\n\n _runMorph: function (el, pointerEvent, onComplete) {\n const wave = el.querySelector('.vd-morph-wave');\n if (wave) {\n const rect = el.getBoundingClientRect();\n const cx = rect.left + rect.width / 2;\n const cy = rect.top + rect.height / 2;\n const px = pointerEvent ? (pointerEvent.clientX || cx) : cx;\n const py = pointerEvent ? (pointerEvent.clientY || cy) : cy;\n wave.style.left = (px - rect.left) + 'px';\n wave.style.top = (py - rect.top) + 'px';\n }\n\n el.classList.add('is-morphing');\n\n let duration = MORPH_DURATION_MS;\n const custom = getComputedStyle(el).getPropertyValue('--morph-duration');\n if (custom) {\n const parsed = parseFloat(custom);\n if (!isNaN(parsed)) duration = parsed * (custom.indexOf('ms') !== -1 ? 1 : 1000);\n }\n\n setTimeout(function () {\n el.classList.remove('is-morphing');\n\n const current = el.querySelector('.vd-morph-current');\n const next = el.querySelector('.vd-morph-next');\n if (current && next) {\n current.classList.remove('vd-morph-current');\n current.classList.add('vd-morph-next');\n next.classList.remove('vd-morph-next');\n next.classList.add('vd-morph-current');\n }\n\n if (typeof onComplete === 'function') onComplete();\n }, duration);\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('morph', Morph);\n }\n\n window.VanduoMorph = Morph;\n\n})();\n", "/**\n * Vanduo Framework \u2014 Expanding flex cards\n * Click / Enter / Space / Arrow keys to change the active card.\n *\n * Usage:\n *
    \n * Use data-vd-expanding-cards=\"manual\" to skip auto-init.\n */\n\n(function () {\n 'use strict';\n\n const ExpandingCards = {\n instances: new Map(),\n\n init: function () {\n document.querySelectorAll('.vd-expanding-cards').forEach(function (el) {\n if (el.getAttribute('data-vd-expanding-cards') === 'manual') return;\n if (ExpandingCards.instances.has(el)) return;\n ExpandingCards.initContainer(el);\n });\n },\n\n initContainer: function (container) {\n const cleanup = [];\n\n const getCards = function () {\n return Array.prototype.slice.call(container.querySelectorAll('.vd-expanding-card'));\n };\n\n const setActive = function (card) {\n const cards = getCards();\n if (!card || cards.indexOf(card) === -1) return;\n cards.forEach(function (c) {\n c.classList.toggle('is-active', c === card);\n });\n card.focus({ preventScroll: true });\n };\n\n const onClick = function (e) {\n const t = e.target;\n const card = t.closest ? t.closest('.vd-expanding-card') : null;\n if (!card || !container.contains(card)) return;\n setActive(card);\n };\n\n const onKeydown = function (e) {\n if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'Home' && e.key !== 'End') {\n return;\n }\n const cards = getCards().filter(function (c) {\n return c.offsetParent !== null || c.getClientRects().length > 0;\n });\n if (!cards.length) return;\n const activeEl = document.activeElement;\n let idx = cards.indexOf(activeEl);\n if (idx < 0) {\n idx = cards.findIndex(function (c) {\n return c.classList.contains('is-active');\n });\n }\n if (idx < 0) idx = 0;\n\n if (e.key === 'ArrowLeft') {\n e.preventDefault();\n setActive(cards[Math.max(0, idx - 1)]);\n } else if (e.key === 'ArrowRight') {\n e.preventDefault();\n setActive(cards[Math.min(cards.length - 1, idx + 1)]);\n } else if (e.key === 'Home') {\n e.preventDefault();\n setActive(cards[0]);\n } else if (e.key === 'End') {\n e.preventDefault();\n setActive(cards[cards.length - 1]);\n }\n };\n\n container.addEventListener('click', onClick);\n cleanup.push(function () {\n container.removeEventListener('click', onClick);\n });\n\n container.addEventListener('keydown', onKeydown);\n cleanup.push(function () {\n container.removeEventListener('keydown', onKeydown);\n });\n\n getCards().forEach(function (card) {\n if (!card.hasAttribute('tabindex')) {\n card.setAttribute('tabindex', '0');\n }\n card.setAttribute('role', 'button');\n if (!card.hasAttribute('aria-pressed')) {\n card.setAttribute('aria-pressed', card.classList.contains('is-active') ? 'true' : 'false');\n }\n });\n\n const syncAria = function () {\n getCards().forEach(function (card) {\n card.setAttribute('aria-pressed', card.classList.contains('is-active') ? 'true' : 'false');\n });\n };\n\n const mo = new MutationObserver(syncAria);\n mo.observe(container, { attributes: true, subtree: true, attributeFilter: ['class'] });\n cleanup.push(function () {\n mo.disconnect();\n });\n syncAria();\n\n ExpandingCards.instances.set(container, { cleanup: cleanup });\n },\n\n destroy: function (container) {\n const inst = this.instances.get(container);\n if (!inst) return;\n inst.cleanup.forEach(function (fn) {\n fn();\n });\n this.instances.delete(container);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) {\n ExpandingCards.destroy(el);\n });\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('expandingCards', ExpandingCards);\n }\n\n window.VanduoExpandingCards = ExpandingCards;\n})();\n", "/**\n * Vanduo Framework \u2014 Timeline animated reveal\n * Opt-in: add `.vd-timeline-animated` to a `.vd-timeline` container.\n * Uses IntersectionObserver to add `.is-revealed` per item with staggered delays.\n * Optional: `.vd-timeline-playback` with `[data-vd-timeline-prev|next|play|pause]` for stepped control.\n */\n\n(function () {\n 'use strict';\n\n const STAGGER_MS = 140;\n const MAX_STAGGER_INDEX = 7;\n const PLAY_INTERVAL_MS = 800;\n\n function countRevealedPrefix(items) {\n let count = 0;\n for (let i = 0; i < items.length; i++) {\n if (!items[i].classList.contains('is-revealed')) break;\n count++;\n }\n return count;\n }\n\n function findPlaybackControls(container) {\n return container.parentElement || document.body;\n }\n\n function initPlayback(container, items, cleanup) {\n items.forEach(function (item) {\n item.classList.remove('is-revealed');\n });\n\n const scope = findPlaybackControls(container);\n const prevBtn = scope.querySelector('[data-vd-timeline-prev]');\n const nextBtn = scope.querySelector('[data-vd-timeline-next]');\n const playBtn = scope.querySelector('[data-vd-timeline-play]');\n const pauseBtn = scope.querySelector('[data-vd-timeline-pause]');\n\n let playTimer = null;\n\n function updateNavButtons() {\n const k = countRevealedPrefix(items);\n const n = items.length;\n if (prevBtn) {\n const atStart = k === 0;\n prevBtn.disabled = atStart;\n prevBtn.setAttribute('aria-disabled', atStart ? 'true' : 'false');\n }\n if (nextBtn) {\n const atEnd = k >= n;\n nextBtn.disabled = atEnd;\n nextBtn.setAttribute('aria-disabled', atEnd ? 'true' : 'false');\n }\n if (playBtn) {\n playBtn.setAttribute('aria-pressed', playTimer ? 'true' : 'false');\n }\n if (pauseBtn) {\n pauseBtn.disabled = !playTimer;\n }\n }\n\n function stepNext() {\n const k = countRevealedPrefix(items);\n if (k < items.length) {\n items[k].classList.add('is-revealed');\n }\n updateNavButtons();\n }\n\n function stepPrev() {\n const k = countRevealedPrefix(items);\n if (k > 0) {\n items[k - 1].classList.remove('is-revealed');\n }\n updateNavButtons();\n }\n\n function play() {\n if (playTimer) return;\n playTimer = setInterval(function () {\n if (countRevealedPrefix(items) >= items.length) {\n pause();\n return;\n }\n stepNext();\n }, PLAY_INTERVAL_MS);\n updateNavButtons();\n }\n\n function pause() {\n if (playTimer) {\n clearInterval(playTimer);\n playTimer = null;\n }\n updateNavButtons();\n }\n\n function addClick(el, fn) {\n if (!el) return;\n const handler = function (e) {\n e.preventDefault();\n fn();\n };\n el.addEventListener('click', handler);\n cleanup.push(function () {\n el.removeEventListener('click', handler);\n });\n }\n\n addClick(prevBtn, stepPrev);\n addClick(nextBtn, stepNext);\n addClick(playBtn, play);\n addClick(pauseBtn, pause);\n\n cleanup.push(function () {\n pause();\n });\n\n updateNavButtons();\n\n return {\n stepNext: stepNext,\n stepPrev: stepPrev,\n play: play,\n pause: pause\n };\n }\n\n const Timeline = {\n instances: new Map(),\n\n init: function () {\n document.querySelectorAll('.vd-timeline.vd-timeline-animated').forEach(function (el) {\n if (Timeline.instances.has(el)) return;\n Timeline.initInstance(el);\n });\n },\n\n reinit: function () {\n Timeline.destroyAll();\n Timeline.init();\n },\n\n initInstance: function (container) {\n const cleanup = [];\n const items = Array.prototype.filter.call(container.children, function (child) {\n return child.classList && child.classList.contains('vd-timeline-item');\n });\n\n items.forEach(function (item, i) {\n const idx = Math.min(i, MAX_STAGGER_INDEX);\n item.style.setProperty('--vd-timeline-reveal-delay', (idx * STAGGER_MS) + 'ms');\n });\n\n const reducedMotion = typeof window.matchMedia === 'function'\n && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n if (reducedMotion) {\n items.forEach(function (item) {\n item.classList.add('is-revealed');\n });\n Timeline.instances.set(container, { cleanup: cleanup });\n return;\n }\n\n const playback = container.classList && container.classList.contains('vd-timeline-playback');\n\n if (playback) {\n const playbackApi = initPlayback(container, items, cleanup);\n Timeline.instances.set(container, { cleanup: cleanup, playback: playbackApi });\n return;\n }\n\n if (typeof IntersectionObserver === 'undefined') {\n items.forEach(function (item) {\n item.classList.add('is-revealed');\n });\n Timeline.instances.set(container, { cleanup: cleanup });\n return;\n }\n\n const observer = new IntersectionObserver(function (entries) {\n entries.forEach(function (entry) {\n if (!entry.isIntersecting) return;\n entry.target.classList.add('is-revealed');\n observer.unobserve(entry.target);\n });\n }, {\n root: null,\n rootMargin: '0px 0px -10% 0px',\n threshold: 0.15\n });\n\n items.forEach(function (item) {\n observer.observe(item);\n });\n\n cleanup.push(function () {\n observer.disconnect();\n });\n\n Timeline.instances.set(container, { cleanup: cleanup });\n },\n\n destroy: function (container) {\n const inst = this.instances.get(container);\n if (!inst) return;\n inst.cleanup.forEach(function (fn) {\n fn();\n });\n this.instances.delete(container);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) {\n Timeline.destroy(el);\n });\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('timeline', Timeline);\n }\n\n window.VanduoTimeline = Timeline;\n})();\n", "/**\n * Vanduo Framework - Flow (Carousel/Slider) Component\n * Touch-enabled carousel with slide/fade transitions, autoplay, indicators\n */\n\n(function () {\n 'use strict';\n\n const Flow = {\n instances: new Map(),\n\n init: function () {\n const carousels = document.querySelectorAll('.vd-flow, .vd-carousel');\n carousels.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const track = el.querySelector('.vd-flow-track');\n if (!track) return;\n\n const slides = Array.from(track.querySelectorAll('.vd-flow-slide'));\n if (slides.length === 0) return;\n\n const isFade = el.classList.contains('vd-flow-fade');\n const autoplay = el.hasAttribute('data-vd-autoplay');\n const interval = parseInt(el.getAttribute('data-vd-interval'), 10) || 5000;\n const loop = el.getAttribute('data-vd-loop') !== 'false';\n\n const state = {\n current: 0,\n total: slides.length,\n autoplayTimer: null,\n isFade: isFade,\n loop: loop,\n isDragging: false,\n startX: 0,\n currentX: 0,\n threshold: 50\n };\n\n const cleanup = [];\n\n // Set initial active slide\n slides.forEach((slide, i) => {\n slide.setAttribute('role', 'group');\n slide.setAttribute('aria-roledescription', 'slide');\n slide.setAttribute('aria-label', 'Slide ' + (i + 1) + ' of ' + slides.length);\n if (i === 0) slide.classList.add('is-active');\n });\n\n el.setAttribute('role', 'region');\n el.setAttribute('aria-roledescription', 'carousel');\n if (!el.getAttribute('aria-label')) {\n el.setAttribute('aria-label', 'Carousel');\n }\n\n // Live region for announcements\n const liveRegion = document.createElement('div');\n liveRegion.setAttribute('aria-live', 'polite');\n liveRegion.setAttribute('aria-atomic', 'true');\n liveRegion.className = 'sr-only';\n liveRegion.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);';\n el.appendChild(liveRegion);\n\n const goTo = (index, announce) => {\n if (announce === undefined) announce = true;\n let target = index;\n if (state.loop) {\n target = ((index % state.total) + state.total) % state.total;\n } else {\n target = Math.max(0, Math.min(index, state.total - 1));\n }\n\n const prev = state.current;\n state.current = target;\n\n if (state.isFade) {\n slides.forEach((s, i) => {\n s.classList.toggle('is-active', i === target);\n });\n } else {\n track.style.transform = 'translateX(-' + (target * 100) + '%)';\n }\n\n // Update indicators\n const indicators = el.querySelectorAll('.vd-flow-indicator');\n indicators.forEach((ind, i) => {\n ind.classList.toggle('is-active', i === target);\n ind.setAttribute('aria-selected', i === target ? 'true' : 'false');\n });\n\n // Update slide ARIA\n slides.forEach((s, i) => {\n s.setAttribute('aria-hidden', i !== target ? 'true' : 'false');\n });\n\n if (announce) {\n liveRegion.textContent = 'Slide ' + (target + 1) + ' of ' + state.total;\n }\n\n el.dispatchEvent(new CustomEvent('flow:change', {\n detail: { current: target, previous: prev, total: state.total }\n }));\n };\n\n const next = () => goTo(state.current + 1);\n const prev = () => goTo(state.current - 1);\n\n // Controls\n const prevBtn = el.querySelector('.vd-flow-prev');\n const nextBtn = el.querySelector('.vd-flow-next');\n\n if (prevBtn) {\n const h = () => prev();\n prevBtn.addEventListener('click', h);\n cleanup.push(() => prevBtn.removeEventListener('click', h));\n }\n if (nextBtn) {\n const h = () => next();\n nextBtn.addEventListener('click', h);\n cleanup.push(() => nextBtn.removeEventListener('click', h));\n }\n\n // Indicators\n const indicators = el.querySelectorAll('.vd-flow-indicator');\n indicators.forEach((ind, i) => {\n ind.setAttribute('role', 'tab');\n ind.setAttribute('aria-selected', i === 0 ? 'true' : 'false');\n ind.setAttribute('aria-label', 'Go to slide ' + (i + 1));\n const h = () => goTo(i);\n ind.addEventListener('click', h);\n cleanup.push(() => ind.removeEventListener('click', h));\n });\n\n // Keyboard navigation\n const keyHandler = (e) => {\n if (e.key === 'ArrowLeft') { prev(); e.preventDefault(); }\n if (e.key === 'ArrowRight') { next(); e.preventDefault(); }\n };\n el.setAttribute('tabindex', '0');\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n\n // Touch / pointer support\n const pointerDown = (e) => {\n state.isDragging = true;\n state.startX = e.clientX || (e.touches && e.touches[0].clientX) || 0;\n state.currentX = state.startX;\n el.classList.add('is-dragging');\n };\n\n const pointerMove = (e) => {\n if (!state.isDragging) return;\n state.currentX = e.clientX || (e.touches && e.touches[0].clientX) || 0;\n };\n\n const pointerUp = () => {\n if (!state.isDragging) return;\n state.isDragging = false;\n el.classList.remove('is-dragging');\n const diff = state.startX - state.currentX;\n if (Math.abs(diff) > state.threshold) {\n if (diff > 0) next();\n else prev();\n }\n };\n\n el.addEventListener('mousedown', pointerDown);\n el.addEventListener('mousemove', pointerMove);\n el.addEventListener('mouseup', pointerUp);\n el.addEventListener('mouseleave', pointerUp);\n el.addEventListener('touchstart', pointerDown, { passive: true });\n el.addEventListener('touchmove', pointerMove, { passive: true });\n el.addEventListener('touchend', pointerUp);\n\n cleanup.push(\n () => el.removeEventListener('mousedown', pointerDown),\n () => el.removeEventListener('mousemove', pointerMove),\n () => el.removeEventListener('mouseup', pointerUp),\n () => el.removeEventListener('mouseleave', pointerUp),\n () => el.removeEventListener('touchstart', pointerDown),\n () => el.removeEventListener('touchmove', pointerMove),\n () => el.removeEventListener('touchend', pointerUp)\n );\n\n // Autoplay\n const startAutoplay = () => {\n stopAutoplay();\n state.autoplayTimer = setInterval(next, interval);\n };\n\n const stopAutoplay = () => {\n if (state.autoplayTimer) {\n clearInterval(state.autoplayTimer);\n state.autoplayTimer = null;\n }\n };\n\n if (autoplay) {\n startAutoplay();\n const pauseHandler = () => stopAutoplay();\n const resumeHandler = () => startAutoplay();\n el.addEventListener('mouseenter', pauseHandler);\n el.addEventListener('mouseleave', resumeHandler);\n el.addEventListener('focusin', pauseHandler);\n el.addEventListener('focusout', resumeHandler);\n cleanup.push(\n () => el.removeEventListener('mouseenter', pauseHandler),\n () => el.removeEventListener('mouseleave', resumeHandler),\n () => el.removeEventListener('focusin', pauseHandler),\n () => el.removeEventListener('focusout', resumeHandler),\n () => stopAutoplay()\n );\n }\n\n // Initial ARIA state\n goTo(0, false);\n\n this.instances.set(el, {\n cleanup: cleanup,\n goTo: goTo,\n next: next,\n prev: prev,\n getState: () => ({ ...state })\n });\n },\n\n goTo: function (el, index) {\n const instance = this.instances.get(el);\n if (instance) instance.goTo(index);\n },\n\n next: function (el) {\n const instance = this.instances.get(el);\n if (instance) instance.next();\n },\n\n prev: function (el) {\n const instance = this.instances.get(el);\n if (instance) instance.prev();\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('flow', Flow);\n }\n\n window.VanduoFlow = Flow;\n\n})();\n", "/**\n * Vanduo Framework - Bubble (Popover) Component\n * Click-triggered rich HTML popover, reuses tooltip positioning concepts\n */\n\n(function () {\n 'use strict';\n\n const Bubble = {\n instances: new Map(),\n _globalCleanups: [],\n\n init: function () {\n const triggers = document.querySelectorAll('[data-vd-bubble], [data-vd-popover]');\n triggers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n\n if (this._globalCleanups.length === 0) {\n const outsideClick = (e) => {\n this.instances.forEach((inst, trigger) => {\n if (!inst.popover.contains(e.target) && !trigger.contains(e.target)) {\n this.hide(trigger);\n }\n });\n };\n const escHandler = (e) => {\n if (e.key === 'Escape') {\n this.instances.forEach((_, trigger) => this.hide(trigger));\n }\n };\n document.addEventListener('click', outsideClick, true);\n document.addEventListener('keydown', escHandler);\n this._globalCleanups.push(\n () => document.removeEventListener('click', outsideClick, true),\n () => document.removeEventListener('keydown', escHandler)\n );\n }\n },\n\n initInstance: function (trigger) {\n const cleanup = [];\n const placement = trigger.getAttribute('data-vd-bubble-placement') ||\n trigger.getAttribute('data-vd-popover-placement') || 'bottom';\n\n // Build popover element\n const popover = document.createElement('div');\n popover.className = 'vd-bubble-content';\n popover.setAttribute('role', 'dialog');\n popover.setAttribute('aria-modal', 'false');\n popover.setAttribute('data-placement', placement);\n\n const title = trigger.getAttribute('data-vd-bubble-title') ||\n trigger.getAttribute('data-vd-popover-title');\n const content = trigger.getAttribute('data-vd-bubble') ||\n trigger.getAttribute('data-vd-popover') || '';\n const htmlContent = trigger.getAttribute('data-vd-bubble-html') ||\n trigger.getAttribute('data-vd-popover-html');\n const allowSvg = trigger.hasAttribute('data-vd-bubble-allow-svg') ||\n trigger.hasAttribute('data-vd-popover-allow-svg');\n\n if (title) {\n const header = document.createElement('div');\n header.className = 'vd-bubble-header';\n const titleSpan = document.createElement('span');\n titleSpan.textContent = title;\n const closeBtn = document.createElement('button');\n closeBtn.className = 'vd-bubble-close';\n closeBtn.setAttribute('aria-label', 'Close');\n closeBtn.innerHTML = '×';\n header.appendChild(titleSpan);\n header.appendChild(closeBtn);\n popover.appendChild(header);\n\n const closeHandler = (e) => { e.stopPropagation(); this.hide(trigger); };\n closeBtn.addEventListener('click', closeHandler);\n cleanup.push(() => closeBtn.removeEventListener('click', closeHandler));\n }\n\n const body = document.createElement('div');\n body.className = 'vd-bubble-body';\n if (htmlContent) {\n if (typeof sanitizeHtml === 'function') {\n body.innerHTML = sanitizeHtml(htmlContent, { allowSvg });\n } else {\n body.textContent = htmlContent;\n }\n } else {\n body.textContent = content;\n }\n popover.appendChild(body);\n\n document.body.appendChild(popover);\n\n // ARIA on trigger\n const popId = 'vd-bubble-' + Math.random().toString(36).slice(2, 9);\n popover.id = popId;\n trigger.setAttribute('aria-haspopup', 'dialog');\n trigger.setAttribute('aria-expanded', 'false');\n trigger.setAttribute('aria-controls', popId);\n\n // Toggle on click\n const toggleHandler = (e) => {\n e.stopPropagation();\n if (popover.classList.contains('is-visible')) {\n this.hide(trigger);\n } else {\n this.hideAll();\n this.show(trigger);\n }\n };\n trigger.addEventListener('click', toggleHandler);\n cleanup.push(() => trigger.removeEventListener('click', toggleHandler));\n\n this.instances.set(trigger, { popover, cleanup, placement });\n },\n\n position: function (trigger, popover, placement) {\n const rect = trigger.getBoundingClientRect();\n const popRect = popover.getBoundingClientRect();\n const gap = 10;\n let top, left;\n\n switch (placement) {\n case 'top':\n top = rect.top - popRect.height - gap + window.scrollY;\n left = rect.left + (rect.width - popRect.width) / 2 + window.scrollX;\n break;\n case 'left':\n top = rect.top + (rect.height - popRect.height) / 2 + window.scrollY;\n left = rect.left - popRect.width - gap + window.scrollX;\n break;\n case 'right':\n top = rect.top + (rect.height - popRect.height) / 2 + window.scrollY;\n left = rect.right + gap + window.scrollX;\n break;\n default: // bottom\n top = rect.bottom + gap + window.scrollY;\n left = rect.left + (rect.width - popRect.width) / 2 + window.scrollX;\n }\n\n // Clamp to viewport\n left = Math.max(8, Math.min(left, window.innerWidth - popRect.width - 8));\n top = Math.max(8, top);\n\n popover.style.top = top + 'px';\n popover.style.left = left + 'px';\n },\n\n show: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n const { popover, placement } = instance;\n\n popover.style.display = 'block';\n popover.classList.add('is-visible');\n trigger.setAttribute('aria-expanded', 'true');\n\n requestAnimationFrame(() => {\n this.position(trigger, popover, placement);\n });\n\n trigger.dispatchEvent(new CustomEvent('bubble:show', {\n bubbles: true,\n detail: { trigger: trigger, placement: placement }\n }));\n },\n\n hide: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n instance.popover.classList.remove('is-visible');\n trigger.setAttribute('aria-expanded', 'false');\n trigger.dispatchEvent(new CustomEvent('bubble:hide', {\n bubbles: true,\n detail: { trigger: trigger }\n }));\n },\n\n hideAll: function () {\n this.instances.forEach((_, trigger) => this.hide(trigger));\n },\n\n destroy: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n if (instance.popover.parentNode) {\n instance.popover.parentNode.removeChild(instance.popover);\n }\n trigger.removeAttribute('aria-haspopup');\n trigger.removeAttribute('aria-expanded');\n trigger.removeAttribute('aria-controls');\n this.instances.delete(trigger);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, trigger) => this.destroy(trigger));\n this._globalCleanups.forEach(fn => fn());\n this._globalCleanups = [];\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('bubble', Bubble);\n }\n\n window.VanduoBubble = Bubble;\n\n})();\n", "/**\n * Vanduo Framework - Waypoint (Scrollspy) Component\n * Highlights navigation links based on scroll position using IntersectionObserver\n */\n\n(function () {\n 'use strict';\n\n const Waypoint = {\n instances: new Map(),\n\n init: function () {\n const navs = document.querySelectorAll('[data-vd-waypoint-nav], [data-vd-scrollspy-nav]');\n navs.forEach(nav => {\n if (this.instances.has(nav)) return;\n this.initInstance(nav);\n });\n },\n\n initInstance: function (nav) {\n const links = Array.from(nav.querySelectorAll('a[href^=\"#\"]'));\n if (links.length === 0) return;\n\n const cleanup = [];\n const offset = parseInt(nav.getAttribute('data-vd-waypoint-offset') || '80', 10);\n const sections = [];\n\n links.forEach(link => {\n const id = link.getAttribute('href').slice(1);\n const section = document.getElementById(id);\n if (section) {\n section.setAttribute('data-vd-waypoint-section', '');\n sections.push({ id, link, section });\n }\n });\n\n if (sections.length === 0) return;\n\n const activeSections = new Set();\n\n const setActive = (id) => {\n links.forEach(l => l.classList.remove('is-active'));\n const target = links.find(l => l.getAttribute('href') === '#' + id);\n if (target) {\n target.classList.add('is-active');\n nav.dispatchEvent(new CustomEvent('waypoint:change', {\n detail: { activeId: id, link: target }\n }));\n }\n };\n\n const rootMargin = '-' + offset + 'px 0px -40% 0px';\n\n const observer = new IntersectionObserver((entries) => {\n entries.forEach(entry => {\n if (entry.isIntersecting) {\n activeSections.add(entry.target.id);\n } else {\n activeSections.delete(entry.target.id);\n }\n });\n\n // Pick the topmost visible section\n for (let i = 0; i < sections.length; i++) {\n if (activeSections.has(sections[i].id)) {\n setActive(sections[i].id);\n return;\n }\n }\n }, {\n rootMargin: rootMargin,\n threshold: 0\n });\n\n sections.forEach(s => observer.observe(s.section));\n\n // Smooth scroll on click\n links.forEach(link => {\n const clickHandler = (e) => {\n e.preventDefault();\n const id = link.getAttribute('href').slice(1);\n const section = document.getElementById(id);\n if (section) {\n section.scrollIntoView({ behavior: 'smooth' });\n setActive(id);\n }\n };\n link.addEventListener('click', clickHandler);\n cleanup.push(() => link.removeEventListener('click', clickHandler));\n });\n\n cleanup.push(() => observer.disconnect());\n\n this.instances.set(nav, { observer, cleanup, sections, setActive });\n },\n\n refresh: function (nav) {\n this.destroy(nav);\n this.initInstance(nav);\n },\n\n destroy: function (nav) {\n const instance = this.instances.get(nav);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(nav);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, nav) => this.destroy(nav));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('waypoint', Waypoint);\n }\n\n window.VanduoWaypoint = Waypoint;\n\n})();\n", "/**\n * Vanduo Framework - Ripple (Waves Effect) Component\n * Adds expanding circle animation on click at pointer position\n */\n\n(function () {\n 'use strict';\n\n const Ripple = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-ripple, [data-vd-ripple]');\n elements.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n\n const createWave = (e) => {\n const rect = el.getBoundingClientRect();\n const size = Math.max(rect.width, rect.height);\n const x = (e.clientX || (e.touches && e.touches[0].clientX) || rect.left + rect.width / 2) - rect.left - size / 2;\n const y = (e.clientY || (e.touches && e.touches[0].clientY) || rect.top + rect.height / 2) - rect.top - size / 2;\n\n const wave = document.createElement('span');\n wave.className = 'vd-ripple-wave';\n wave.style.width = size + 'px';\n wave.style.height = size + 'px';\n wave.style.left = x + 'px';\n wave.style.top = y + 'px';\n\n el.appendChild(wave);\n\n wave.addEventListener('animationend', () => {\n if (wave.parentNode) wave.parentNode.removeChild(wave);\n });\n };\n\n el.addEventListener('mousedown', createWave);\n el.addEventListener('touchstart', createWave, { passive: true });\n\n cleanup.push(\n () => el.removeEventListener('mousedown', createWave),\n () => el.removeEventListener('touchstart', createWave)\n );\n\n this.instances.set(el, { cleanup });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n // Remove any lingering wave elements\n el.querySelectorAll('.vd-ripple-wave').forEach(w => w.remove());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('ripple', Ripple);\n }\n\n window.VanduoRipple = Ripple;\n\n})();\n", "/**\n * Vanduo Framework - Affix (Sticky) Component\n * Uses IntersectionObserver to toggle .is-stuck within the nearest scrollable parent\n */\n\n(function () {\n 'use strict';\n\n function isScrollable(element) {\n if (!element || element === document.body) return false;\n\n const style = window.getComputedStyle(element);\n const overflowY = style.overflowY;\n const overflowX = style.overflowX;\n const canScrollY = /(auto|scroll|overlay)/.test(overflowY) && element.scrollHeight > element.clientHeight;\n const canScrollX = /(auto|scroll|overlay)/.test(overflowX) && element.scrollWidth > element.clientWidth;\n\n return canScrollY || canScrollX;\n }\n\n function getScrollParent(element) {\n let parent = element.parentElement;\n\n while (parent && parent !== document.body && parent !== document.documentElement) {\n if (isScrollable(parent)) return parent;\n parent = parent.parentElement;\n }\n\n return null;\n }\n\n const Affix = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-affix, .vd-sticky, [data-vd-affix]');\n elements.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const parsedOffset = parseInt(el.getAttribute('data-vd-affix-offset') || '0', 10);\n const offset = Number.isNaN(parsedOffset) ? 0 : parsedOffset;\n const scrollParent = getScrollParent(el);\n let isStuck = false;\n\n const sentinel = document.createElement('div');\n sentinel.style.cssText = 'display:block;height:1px;margin-bottom:-1px;visibility:hidden;pointer-events:none;';\n el.parentNode.insertBefore(sentinel, el);\n\n el.style.setProperty('--affix-top-offset', offset + 'px');\n\n function stick() {\n if (isStuck) return;\n isStuck = true;\n el.classList.add('is-stuck');\n el.dispatchEvent(new CustomEvent('affix:stuck', {\n bubbles: true,\n detail: {\n offset: offset,\n root: scrollParent || window\n }\n }));\n }\n\n function unstick() {\n if (!isStuck) return;\n isStuck = false;\n el.classList.remove('is-stuck');\n el.dispatchEvent(new CustomEvent('affix:unstuck', {\n bubbles: true,\n detail: {\n offset: offset,\n root: scrollParent || window\n }\n }));\n }\n\n const observer = new IntersectionObserver(function (entries) {\n entries.forEach(entry => {\n if (!entry.isIntersecting) {\n stick();\n } else {\n unstick();\n }\n });\n }, {\n root: scrollParent,\n rootMargin: '-' + offset + 'px 0px 0px 0px',\n threshold: 0\n });\n\n observer.observe(sentinel);\n\n cleanup.push(\n () => observer.disconnect(),\n () => { if (sentinel.parentNode) sentinel.parentNode.removeChild(sentinel); },\n () => {\n el.classList.remove('is-stuck');\n el.style.removeProperty('--affix-top-offset');\n }\n );\n\n this.instances.set(el, { cleanup, observer, sentinel, scrollParent });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n el.classList.remove('is-stuck');\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('affix', Affix);\n }\n\n window.VanduoAffix = Affix;\n\n})();\n", "/**\n * Vanduo Framework - Suggest (Autocomplete) Component\n * Dropdown suggestion list with keyboard navigation, static/async data sources\n */\n\n(function () {\n 'use strict';\n\n /**\n * Escape HTML entities to prevent XSS when inserting into innerHTML\n * @param {string} text\n * @returns {string}\n */\n function _escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Allow same-origin URLs by default, or an explicit allowlist of origins.\n * @param {string} url\n * @param {string[]} allowlist\n * @returns {boolean}\n */\n function _isSafeUrl(url, allowlist) {\n try {\n const resolved = new URL(url, window.location.href);\n if (resolved.origin === window.location.origin) return true;\n return allowlist.includes(resolved.origin);\n } catch (_e) {\n return false;\n }\n }\n\n const Suggest = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-suggest], [data-vd-autocomplete]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const minChars = parseInt(input.getAttribute('data-vd-suggest-min-chars') || '1', 10);\n const url = input.getAttribute('data-vd-suggest-url') || '';\n const allowlistAttr = input.getAttribute('data-vd-suggest-allowlist') || '';\n const allowlist = allowlistAttr\n .split(',')\n .map(value => value.trim())\n .filter(Boolean);\n const staticData = input.getAttribute('data-vd-suggest') || input.getAttribute('data-vd-autocomplete') || '';\n let items = [];\n\n try { items = JSON.parse(staticData); } catch (_e) {\n items = staticData.split(',').map(s => s.trim()).filter(Boolean);\n }\n\n // Wrap input if not already wrapped\n let wrapper = input.closest('.vd-suggest-wrapper, .vd-autocomplete-wrapper');\n if (!wrapper) {\n wrapper = document.createElement('div');\n wrapper.className = 'vd-suggest-wrapper';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n }\n\n // Create dropdown list\n const list = document.createElement('ul');\n list.className = 'vd-suggest-list';\n list.setAttribute('role', 'listbox');\n const listId = 'vd-suggest-' + Math.random().toString(36).slice(2, 9);\n list.id = listId;\n wrapper.appendChild(list);\n\n // ARIA\n input.setAttribute('role', 'combobox');\n input.setAttribute('aria-autocomplete', 'list');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('aria-controls', listId);\n input.setAttribute('autocomplete', 'off');\n\n let highlighted = -1;\n let currentItems = [];\n let debounceTimer = null;\n\n const renderItems = (filtered, query) => {\n list.innerHTML = '';\n currentItems = filtered;\n highlighted = -1;\n\n if (filtered.length === 0) {\n const empty = document.createElement('li');\n empty.className = 'vd-suggest-empty';\n empty.textContent = 'No results';\n list.appendChild(empty);\n return;\n }\n\n filtered.forEach((item, i) => {\n const li = document.createElement('li');\n li.className = 'vd-suggest-item';\n li.setAttribute('role', 'option');\n li.id = listId + '-item-' + i;\n\n const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);\n if (query) {\n // Escape HTML first to prevent XSS, then highlight matches in the safe string\n const escaped = _escapeHtml(text);\n const re = new RegExp('(' + query.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi');\n li.innerHTML = escaped.replace(re, '$1');\n } else {\n li.textContent = text;\n }\n\n li.addEventListener('click', () => selectItem(i));\n list.appendChild(li);\n });\n };\n\n const open = () => {\n list.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n };\n\n const close = () => {\n list.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n highlighted = -1;\n input.removeAttribute('aria-activedescendant');\n };\n\n const selectItem = (index) => {\n const item = currentItems[index];\n const value = typeof item === 'object' ? (item.value || item.label || String(item)) : String(item);\n input.value = value;\n close();\n input.dispatchEvent(new CustomEvent('suggest:select', {\n detail: { value, item, index },\n bubbles: true\n }));\n };\n\n const highlight = (index) => {\n const listItems = list.querySelectorAll('.vd-suggest-item');\n listItems.forEach(li => li.classList.remove('is-highlighted'));\n if (index >= 0 && index < listItems.length) {\n highlighted = index;\n listItems[index].classList.add('is-highlighted');\n input.setAttribute('aria-activedescendant', listItems[index].id);\n listItems[index].scrollIntoView({ block: 'nearest' });\n }\n };\n\n const doSearch = async (query) => {\n if (query.length < minChars) { close(); return; }\n\n let filtered;\n if (url) {\n try {\n if (!_isSafeUrl(url, allowlist)) {\n console.warn('[VanduoSuggest] Blocked non-allowlisted URL:', url);\n filtered = [];\n } else {\n const separator = url.includes('?') ? '&' : '?';\n const res = await window.fetch(url + separator + 'q=' + encodeURIComponent(query));\n filtered = await res.json();\n }\n } catch (_e) {\n filtered = [];\n }\n } else {\n const lower = query.toLowerCase();\n filtered = items.filter(item => {\n const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);\n return text.toLowerCase().includes(lower);\n });\n }\n\n renderItems(filtered, query);\n if (filtered.length > 0) open();\n else open(); // show \"no results\"\n };\n\n const inputHandler = () => {\n clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => doSearch(input.value), 200);\n };\n\n const keyHandler = (e) => {\n if (!list.classList.contains('is-open')) {\n if (e.key === 'ArrowDown') { doSearch(input.value); e.preventDefault(); }\n return;\n }\n\n const total = currentItems.length;\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n highlight(highlighted < total - 1 ? highlighted + 1 : 0);\n break;\n case 'ArrowUp':\n e.preventDefault();\n highlight(highlighted > 0 ? highlighted - 1 : total - 1);\n break;\n case 'Enter':\n e.preventDefault();\n if (highlighted >= 0) selectItem(highlighted);\n break;\n case 'Escape':\n close();\n break;\n }\n };\n\n const blurHandler = () => {\n setTimeout(close, 200);\n };\n\n input.addEventListener('input', inputHandler);\n input.addEventListener('keydown', keyHandler);\n input.addEventListener('blur', blurHandler);\n input.addEventListener('focus', () => { if (input.value.length >= minChars) doSearch(input.value); });\n\n cleanup.push(\n () => input.removeEventListener('input', inputHandler),\n () => input.removeEventListener('keydown', keyHandler),\n () => input.removeEventListener('blur', blurHandler),\n () => clearTimeout(debounceTimer),\n () => { if (list.parentNode) list.parentNode.removeChild(list); }\n );\n\n this.instances.set(input, { cleanup, list, close });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('suggest', Suggest);\n }\n\n window.VanduoSuggest = Suggest;\n\n})();\n", "/**\n * Vanduo Framework - Validate (Form Validation) Component\n * Declarative validation via data attributes with real-time and on-submit modes\n */\n\n(function () {\n 'use strict';\n\n const Validate = {\n instances: new Map(),\n\n rules: {\n required: (value) => value.trim().length > 0,\n email: (value) => /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(value),\n url: (value) => { try { new URL(value); return true; } catch (_e) { return false; } },\n number: (value) => !isNaN(parseFloat(value)) && isFinite(value),\n min: (value, param) => value.length >= parseInt(param, 10),\n max: (value, param) => value.length <= parseInt(param, 10),\n minVal: (value, param) => parseFloat(value) >= parseFloat(param),\n maxVal: (value, param) => parseFloat(value) <= parseFloat(param),\n pattern: (value, param) => {\n try {\n // Cap regex length to prevent ReDoS from excessively complex patterns\n if (param.length > 100) return false;\n return new RegExp(param).test(value);\n } catch (_e) { return false; }\n },\n match: (value, param) => {\n try {\n const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(param) : param;\n const other = document.querySelector('[name=\"' + escaped + '\"]');\n return other ? value === other.value : false;\n } catch (_e) {\n return false;\n }\n }\n },\n\n messages: {\n required: 'This field is required',\n email: 'Please enter a valid email address',\n url: 'Please enter a valid URL',\n number: 'Please enter a valid number',\n min: 'Minimum {0} characters required',\n max: 'Maximum {0} characters allowed',\n minVal: 'Value must be at least {0}',\n maxVal: 'Value must be at most {0}',\n pattern: 'Invalid format',\n match: 'Fields do not match'\n },\n\n init: function () {\n const forms = document.querySelectorAll('[data-vd-validate], .vd-validate');\n forms.forEach(form => {\n if (this.instances.has(form)) return;\n this.initInstance(form);\n });\n },\n\n initInstance: function (form) {\n const cleanup = [];\n const mode = form.getAttribute('data-vd-validate-mode') || 'blur'; // blur | input | submit\n const fields = form.querySelectorAll('[data-vd-rules]');\n\n const validateField = (field) => {\n const rulesStr = field.getAttribute('data-vd-rules') || '';\n const rules = rulesStr.split('|').map(r => r.trim()).filter(Boolean);\n const value = field.value;\n const errors = [];\n\n for (const rule of rules) {\n const [name, ...params] = rule.split(':');\n const param = params.join(':');\n const validator = this.rules[name];\n\n if (validator && !validator(value, param)) {\n const customMsg = field.getAttribute('data-vd-msg-' + name);\n let msg = customMsg || this.messages[name] || 'Invalid';\n if (param) msg = msg.replace('{0}', param);\n errors.push(msg);\n break; // one error at a time\n }\n }\n\n this.setFieldState(field, errors);\n return errors.length === 0;\n };\n\n const validateAll = () => {\n let valid = true;\n fields.forEach(field => {\n if (!validateField(field)) valid = false;\n });\n return valid;\n };\n\n // Per-field listeners\n fields.forEach(field => {\n if (mode === 'input' || mode === 'blur') {\n const eventType = mode === 'input' ? 'input' : 'blur';\n const handler = () => validateField(field);\n field.addEventListener(eventType, handler);\n cleanup.push(() => field.removeEventListener(eventType, handler));\n\n if (mode === 'blur') {\n const inputClear = () => {\n if (field.classList.contains('is-invalid') || field.classList.contains('is-valid')) {\n validateField(field);\n }\n };\n field.addEventListener('input', inputClear);\n cleanup.push(() => field.removeEventListener('input', inputClear));\n }\n }\n });\n\n // Form submit\n const submitHandler = (e) => {\n const valid = validateAll();\n if (!valid) {\n e.preventDefault();\n e.stopPropagation();\n // Focus first invalid field\n const firstInvalid = form.querySelector('.is-invalid');\n if (firstInvalid) firstInvalid.focus();\n }\n form.dispatchEvent(new CustomEvent('validate:submit', {\n detail: { valid },\n bubbles: true\n }));\n };\n\n form.addEventListener('submit', submitHandler);\n cleanup.push(() => form.removeEventListener('submit', submitHandler));\n\n this.instances.set(form, { cleanup, validateAll, validateField });\n },\n\n setFieldState: function (field, errors) {\n const wrapper = field.closest('.vd-form-group') || field.parentElement;\n let errorEl = wrapper.querySelector('.vd-validate-error');\n\n field.classList.remove('is-valid', 'is-invalid');\n\n if (errors.length > 0) {\n field.classList.add('is-invalid');\n field.setAttribute('aria-invalid', 'true');\n if (!errorEl) {\n errorEl = document.createElement('div');\n errorEl.className = 'vd-validate-error';\n errorEl.id = 'vd-err-' + Math.random().toString(36).slice(2, 9);\n errorEl.setAttribute('role', 'alert');\n wrapper.appendChild(errorEl);\n }\n errorEl.textContent = errors[0];\n errorEl.style.display = '';\n field.setAttribute('aria-describedby', errorEl.id);\n } else if (field.value.trim()) {\n field.classList.add('is-valid');\n field.removeAttribute('aria-invalid');\n if (errorEl) errorEl.style.display = 'none';\n } else {\n field.removeAttribute('aria-invalid');\n if (errorEl) errorEl.style.display = 'none';\n }\n },\n\n validateForm: function (form) {\n const instance = this.instances.get(form);\n return instance ? instance.validateAll() : false;\n },\n\n addRule: function (name, validator, message) {\n this.rules[name] = validator;\n if (message) this.messages[name] = message;\n },\n\n destroy: function (form) {\n const instance = this.instances.get(form);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(form);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, form) => this.destroy(form));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('validate', Validate);\n }\n\n window.VanduoValidate = Validate;\n\n})();\n", "/**\n * Vanduo Framework - Datepicker Component\n * Calendar popup attached to input field with month/year navigation\n */\n\n(function () {\n 'use strict';\n\n const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];\n const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];\n\n function escapeRegexChar(c) {\n return c.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n }\n\n function buildParseFormat(format) {\n let regex = '^';\n const order = [];\n let i = 0;\n while (i < format.length) {\n const slice = format.slice(i);\n if (slice.toLowerCase().startsWith('yyyy')) {\n regex += '(\\\\d{4})';\n order.push('y');\n i += 4;\n } else if (slice.toLowerCase().startsWith('mm')) {\n regex += '(\\\\d{2})';\n order.push('m');\n i += 2;\n } else if (slice.toLowerCase().startsWith('dd')) {\n regex += '(\\\\d{2})';\n order.push('d');\n i += 2;\n } else {\n regex += escapeRegexChar(format[i]);\n i++;\n }\n }\n regex += '$';\n return { regex: new RegExp(regex), order };\n }\n\n function parseDateFromFormat(value, format) {\n if (!value || !format) return null;\n const { regex, order } = buildParseFormat(format);\n const m = value.trim().match(regex);\n if (!m) return null;\n let y;\n let mo;\n let d;\n let ci = 1;\n for (let k = 0; k < order.length; k++) {\n const part = order[k];\n const v = parseInt(m[ci++], 10);\n if (Number.isNaN(v)) return null;\n if (part === 'y') y = v;\n else if (part === 'm') mo = v - 1;\n else if (part === 'd') d = v;\n }\n if (y === undefined || mo === undefined || d === undefined) return null;\n const dt = new Date(y, mo, d);\n if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== d) return null;\n return dt;\n }\n\n function formatDate(d, format) {\n const yyyy = String(d.getFullYear());\n const mm = String(d.getMonth() + 1).padStart(2, '0');\n const dd = String(d.getDate()).padStart(2, '0');\n let out = '';\n let i = 0;\n while (i < format.length) {\n const slice = format.slice(i);\n if (slice.toLowerCase().startsWith('yyyy')) {\n out += yyyy;\n i += 4;\n } else if (slice.toLowerCase().startsWith('mm')) {\n out += mm;\n i += 2;\n } else if (slice.toLowerCase().startsWith('dd')) {\n out += dd;\n i += 2;\n } else {\n out += format[i];\n i++;\n }\n }\n return out;\n }\n\n function dateKey(d) {\n return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');\n }\n\n function addDays(d, n) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n x.setDate(x.getDate() + n);\n return x;\n }\n\n function addMonthsClamped(d, n) {\n return new Date(d.getFullYear(), d.getMonth() + n, d.getDate());\n }\n\n function parseYmdLocal(ymd) {\n if (!ymd || typeof ymd !== 'string') return null;\n const m = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(ymd.trim());\n if (!m) return null;\n const y = +m[1];\n const mo = +m[2] - 1;\n const day = +m[3];\n const dt = new Date(y, mo, day);\n if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== day) return null;\n return dt;\n }\n\n function startOfWeekSunday(d) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const day = x.getDay();\n x.setDate(x.getDate() - day);\n return x;\n }\n\n function endOfWeekSunday(d) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const day = x.getDay();\n x.setDate(x.getDate() + (6 - day));\n return x;\n }\n\n const Datepicker = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-datepicker]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const format = input.getAttribute('data-vd-datepicker-format') || 'YYYY-MM-DD';\n const minStr = input.getAttribute('data-vd-datepicker-min');\n const maxStr = input.getAttribute('data-vd-datepicker-max');\n const minDate = minStr ? parseYmdLocal(minStr) : null;\n const maxDate = maxStr ? parseYmdLocal(maxStr) : null;\n\n const today = new Date();\n let viewYear = today.getFullYear();\n let viewMonth = today.getMonth();\n let selectedDate = null;\n let viewMode = 'days'; // days | months | years\n let focusedDate = null;\n /** Prevents focus() after close from immediately re-opening the popup */\n let skipNextFocusOpen = false;\n\n const isDisabled = (d) => {\n if (minDate) {\n const t = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n if (t < minDate.getTime()) return true;\n }\n if (maxDate) {\n const t = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n if (t > maxDate.getTime()) return true;\n }\n return false;\n };\n\n const ensureMonthInRange = (y, m) => {\n if (!minDate && !maxDate) return { y: y, m: m };\n const first = new Date(y, m, 1);\n const last = new Date(y, m + 1, 0);\n if (minDate && last.getTime() < minDate.getTime()) {\n return { y: minDate.getFullYear(), m: minDate.getMonth() };\n }\n if (maxDate && first.getTime() > maxDate.getTime()) {\n return { y: maxDate.getFullYear(), m: maxDate.getMonth() };\n }\n return { y: y, m: m };\n };\n\n const firstSelectableInMonth = (y, m) => {\n const last = new Date(y, m + 1, 0).getDate();\n for (let day = 1; day <= last; day++) {\n const dt = new Date(y, m, day);\n if (!isDisabled(dt)) return dt;\n }\n return new Date(y, m, 1);\n };\n\n if (input.value) {\n const trimmed = input.value.trim();\n let parsed = parseDateFromFormat(trimmed, format);\n if (!parsed) {\n const fallback = new Date(trimmed);\n if (!isNaN(fallback.getTime())) parsed = fallback;\n }\n if (parsed) {\n selectedDate = parsed;\n viewYear = parsed.getFullYear();\n viewMonth = parsed.getMonth();\n }\n }\n\n const clampedInit = ensureMonthInRange(viewYear, viewMonth);\n viewYear = clampedInit.y;\n viewMonth = clampedInit.m;\n\n // Create popup\n const popup = document.createElement('div');\n popup.className = 'vd-datepicker-popup';\n popup.setAttribute('role', 'dialog');\n popup.setAttribute('aria-label', 'Choose date');\n popup.tabIndex = -1;\n\n const wrapper = document.createElement('div');\n wrapper.className = 'vd-suggest-wrapper';\n wrapper.style.position = 'relative';\n wrapper.style.display = 'inline-block';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n wrapper.appendChild(popup);\n\n const isSameDay = (a, b) => a && b &&\n a.getFullYear() === b.getFullYear() &&\n a.getMonth() === b.getMonth() &&\n a.getDate() === b.getDate();\n\n const selectDate = (date) => {\n selectedDate = date;\n viewYear = date.getFullYear();\n viewMonth = date.getMonth();\n input.value = formatDate(date, format);\n skipNextFocusOpen = true;\n close();\n input.dispatchEvent(new CustomEvent('datepicker:select', {\n detail: { date: date, formatted: input.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n input.focus();\n };\n\n const focusFocusedDay = () => {\n if (viewMode !== 'days' || !focusedDate) return;\n const key = dateKey(focusedDate);\n const btn = popup.querySelector('[data-vd-date=\"' + key + '\"]');\n if (btn && !btn.classList.contains('is-outside') && btn.getAttribute('aria-disabled') !== 'true') {\n btn.focus();\n }\n };\n\n const skipDisabled = (d, stepDir, maxSteps) => {\n let x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const step = stepDir > 0 ? 1 : -1;\n for (let i = 0; i < maxSteps; i++) {\n if (!isDisabled(x)) return x;\n x = addDays(x, step);\n }\n return d;\n };\n\n const createDayBtn = (day, outside, date) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-day';\n btn.textContent = day;\n btn.setAttribute('role', 'gridcell');\n\n if (outside) {\n btn.classList.add('is-outside');\n btn.tabIndex = -1;\n btn.setAttribute('aria-disabled', 'true');\n return btn;\n }\n\n btn.setAttribute('data-vd-date', dateKey(date));\n\n if (date && isSameDay(date, today)) btn.classList.add('is-today');\n if (date && isSameDay(date, selectedDate)) btn.classList.add('is-selected');\n if (date && isDisabled(date)) {\n btn.classList.add('is-disabled');\n btn.setAttribute('aria-disabled', 'true');\n btn.tabIndex = -1;\n return btn;\n }\n\n if (date) {\n const isFocused = focusedDate && isSameDay(date, focusedDate);\n btn.tabIndex = isFocused ? 0 : -1;\n\n btn.addEventListener('click', () => {\n selectedDate = date;\n viewYear = date.getFullYear();\n viewMonth = date.getMonth();\n focusedDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());\n input.value = formatDate(date, format);\n skipNextFocusOpen = true;\n close();\n input.dispatchEvent(new CustomEvent('datepicker:select', {\n detail: { date: date, formatted: input.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n input.focus();\n });\n }\n\n return btn;\n };\n\n const render = () => {\n popup.innerHTML = '';\n\n // Header\n const header = document.createElement('div');\n header.className = 'vd-datepicker-header';\n\n const prevBtn = document.createElement('button');\n prevBtn.type = 'button';\n prevBtn.className = 'vd-datepicker-prev';\n prevBtn.innerHTML = '‹';\n prevBtn.setAttribute('aria-label', 'Previous');\n\n const nextBtn = document.createElement('button');\n nextBtn.type = 'button';\n nextBtn.className = 'vd-datepicker-next';\n nextBtn.innerHTML = '›';\n nextBtn.setAttribute('aria-label', 'Next');\n\n const title = document.createElement('span');\n title.className = 'vd-datepicker-title';\n\n if (viewMode === 'days') {\n title.textContent = MONTHS[viewMonth] + ' ' + viewYear;\n title.addEventListener('click', () => { viewMode = 'months'; render(); });\n prevBtn.addEventListener('click', () => { viewMonth--; if (viewMonth < 0) { viewMonth = 11; viewYear--; } render(); });\n nextBtn.addEventListener('click', () => { viewMonth++; if (viewMonth > 11) { viewMonth = 0; viewYear++; } render(); });\n } else if (viewMode === 'months') {\n title.textContent = String(viewYear);\n title.addEventListener('click', () => { viewMode = 'years'; render(); });\n prevBtn.addEventListener('click', () => { viewYear--; render(); });\n nextBtn.addEventListener('click', () => { viewYear++; render(); });\n } else {\n const decadeStart = Math.floor(viewYear / 10) * 10;\n title.textContent = decadeStart + ' - ' + (decadeStart + 9);\n prevBtn.addEventListener('click', () => { viewYear -= 10; render(); });\n nextBtn.addEventListener('click', () => { viewYear += 10; render(); });\n }\n\n header.appendChild(prevBtn);\n header.appendChild(title);\n header.appendChild(nextBtn);\n popup.appendChild(header);\n\n if (viewMode === 'days') {\n const gridWrap = document.createElement('div');\n gridWrap.className = 'vd-datepicker-grid';\n gridWrap.setAttribute('role', 'grid');\n gridWrap.setAttribute('aria-label', 'Calendar');\n\n const weekdays = document.createElement('div');\n weekdays.className = 'vd-datepicker-weekdays';\n weekdays.setAttribute('role', 'row');\n DAYS.forEach(function (d) {\n const span = document.createElement('span');\n span.setAttribute('role', 'columnheader');\n span.setAttribute('aria-label', d);\n span.textContent = d;\n weekdays.appendChild(span);\n });\n gridWrap.appendChild(weekdays);\n\n const firstDay = new Date(viewYear, viewMonth, 1).getDay();\n const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();\n const daysInPrev = new Date(viewYear, viewMonth, 0).getDate();\n\n const cells = [];\n\n for (let i = firstDay - 1; i >= 0; i--) {\n const dayNum = daysInPrev - i;\n const prevMonth = viewMonth === 0 ? 11 : viewMonth - 1;\n const prevYear = viewMonth === 0 ? viewYear - 1 : viewYear;\n const date = new Date(prevYear, prevMonth, dayNum);\n cells.push({ day: dayNum, outside: true, date: date });\n }\n\n for (let d = 1; d <= daysInMonth; d++) {\n const date = new Date(viewYear, viewMonth, d);\n cells.push({ day: d, outside: false, date: date });\n }\n\n const totalCells = firstDay + daysInMonth;\n const remaining = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7);\n for (let i = 1; i <= remaining; i++) {\n const date = new Date(viewYear, viewMonth + 1, i);\n cells.push({ day: i, outside: true, date: date });\n }\n\n for (let r = 0; r < cells.length; r += 7) {\n const row = document.createElement('div');\n row.className = 'vd-datepicker-row';\n row.setAttribute('role', 'row');\n for (let c = 0; c < 7; c++) {\n const cell = cells[r + c];\n row.appendChild(createDayBtn(cell.day, cell.outside, cell.date));\n }\n gridWrap.appendChild(row);\n }\n\n popup.appendChild(gridWrap);\n } else if (viewMode === 'months') {\n const grid = document.createElement('div');\n grid.className = 'vd-datepicker-months';\n MONTHS.forEach((name, i) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-month-btn';\n btn.textContent = name.slice(0, 3);\n if (selectedDate && selectedDate.getFullYear() === viewYear && selectedDate.getMonth() === i) {\n btn.classList.add('is-selected');\n }\n btn.addEventListener('click', () => { viewMonth = i; viewMode = 'days'; render(); });\n grid.appendChild(btn);\n });\n popup.appendChild(grid);\n } else {\n const grid = document.createElement('div');\n grid.className = 'vd-datepicker-years';\n const decadeStart = Math.floor(viewYear / 10) * 10;\n for (let y = decadeStart - 1; y <= decadeStart + 10; y++) {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-year-btn';\n btn.textContent = y;\n if (selectedDate && selectedDate.getFullYear() === y) btn.classList.add('is-selected');\n if (y < decadeStart || y > decadeStart + 9) btn.style.opacity = '0.4';\n btn.addEventListener('click', () => { viewYear = y; viewMode = 'months'; render(); });\n grid.appendChild(btn);\n }\n popup.appendChild(grid);\n }\n };\n\n const handleGridKeydown = (e) => {\n if (!popup.classList.contains('is-open') || viewMode !== 'days') return;\n const grid = popup.querySelector('.vd-datepicker-grid');\n if (!grid || !grid.contains(e.target)) return;\n\n const key = e.key;\n if (key !== 'ArrowLeft' && key !== 'ArrowRight' && key !== 'ArrowUp' && key !== 'ArrowDown' &&\n key !== 'Home' && key !== 'End' && key !== 'PageUp' && key !== 'PageDown' &&\n key !== 'Enter' && key !== ' ' && key !== 'Escape') {\n return;\n }\n\n if (key === 'Escape') {\n e.preventDefault();\n e.stopPropagation();\n skipNextFocusOpen = true;\n close();\n input.focus();\n return;\n }\n\n if (!focusedDate) {\n focusedDate = firstSelectableInMonth(viewYear, viewMonth);\n }\n\n if (key === 'Enter' || key === ' ') {\n e.preventDefault();\n if (focusedDate && !isDisabled(focusedDate)) {\n selectDate(new Date(focusedDate.getFullYear(), focusedDate.getMonth(), focusedDate.getDate()));\n }\n return;\n }\n\n e.preventDefault();\n\n let next = new Date(focusedDate.getFullYear(), focusedDate.getMonth(), focusedDate.getDate());\n let skipDir = 1;\n\n if (key === 'ArrowLeft') {\n next = addDays(next, -1);\n skipDir = -1;\n } else if (key === 'ArrowRight') {\n next = addDays(next, 1);\n skipDir = 1;\n } else if (key === 'ArrowUp') {\n next = addDays(next, -7);\n skipDir = -1;\n } else if (key === 'ArrowDown') {\n next = addDays(next, 7);\n skipDir = 1;\n } else if (key === 'Home') {\n next = startOfWeekSunday(next);\n skipDir = 1;\n } else if (key === 'End') {\n next = endOfWeekSunday(next);\n skipDir = -1;\n } else if (key === 'PageUp') {\n next = addMonthsClamped(next, -1);\n skipDir = -1;\n } else if (key === 'PageDown') {\n next = addMonthsClamped(next, 1);\n skipDir = 1;\n }\n\n next = skipDisabled(next, skipDir, 400);\n\n if (next.getMonth() !== viewMonth || next.getFullYear() !== viewYear) {\n viewYear = next.getFullYear();\n viewMonth = next.getMonth();\n const cl = ensureMonthInRange(viewYear, viewMonth);\n viewYear = cl.y;\n viewMonth = cl.m;\n }\n\n focusedDate = next;\n render();\n requestAnimationFrame(focusFocusedDay);\n };\n\n const open = () => {\n viewMode = 'days';\n if (selectedDate) {\n viewYear = selectedDate.getFullYear();\n viewMonth = selectedDate.getMonth();\n }\n const cl = ensureMonthInRange(viewYear, viewMonth);\n viewYear = cl.y;\n viewMonth = cl.m;\n\n if (selectedDate) {\n focusedDate = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate());\n } else {\n focusedDate = firstSelectableInMonth(viewYear, viewMonth);\n }\n\n render();\n popup.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n requestAnimationFrame(focusFocusedDay);\n };\n\n const close = () => {\n popup.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n viewMode = 'days';\n };\n\n const focusHandler = () => {\n if (skipNextFocusOpen) {\n skipNextFocusOpen = false;\n return;\n }\n open();\n };\n const outsideHandler = (e) => {\n if (!wrapper.contains(e.target)) close();\n };\n const escHandler = (e) => {\n if (e.key === 'Escape' && popup.classList.contains('is-open')) {\n skipNextFocusOpen = true;\n close();\n input.focus();\n }\n };\n\n input.addEventListener('focus', focusHandler);\n document.addEventListener('click', outsideHandler, true);\n document.addEventListener('keydown', escHandler);\n popup.addEventListener('keydown', handleGridKeydown);\n\n input.setAttribute('aria-haspopup', 'dialog');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('autocomplete', 'off');\n\n cleanup.push(\n () => input.removeEventListener('focus', focusHandler),\n () => document.removeEventListener('click', outsideHandler, true),\n () => document.removeEventListener('keydown', escHandler),\n () => popup.removeEventListener('keydown', handleGridKeydown)\n );\n\n this.instances.set(input, { cleanup: cleanup, open: open, close: close, popup: popup });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('datepicker', Datepicker);\n }\n\n window.VanduoDatepicker = Datepicker;\n\n})();\n", "/**\n * Vanduo Framework - Timepicker Component\n * Dropdown time selection with 12h/24h format and configurable step intervals\n */\n\n(function () {\n 'use strict';\n\n const Timepicker = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-timepicker]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const is24h = input.getAttribute('data-vd-timepicker-format') === '24h';\n const step = parseInt(input.getAttribute('data-vd-timepicker-step') || '30', 10);\n\n // Create wrapper\n let wrapper = input.closest('.vd-suggest-wrapper');\n if (!wrapper) {\n wrapper = document.createElement('div');\n wrapper.style.position = 'relative';\n wrapper.style.display = 'inline-block';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n }\n\n // Create popup\n const popup = document.createElement('div');\n popup.className = 'vd-timepicker-popup';\n popup.setAttribute('role', 'listbox');\n wrapper.appendChild(popup);\n\n // Generate time slots\n const times = [];\n for (let h = 0; h < 24; h++) {\n for (let m = 0; m < 60; m += step) {\n const hh24 = String(h).padStart(2, '0');\n const mm = String(m).padStart(2, '0');\n\n if (is24h) {\n times.push({ display: hh24 + ':' + mm, value: hh24 + ':' + mm });\n } else {\n const period = h < 12 ? 'AM' : 'PM';\n const h12 = h === 0 ? 12 : (h > 12 ? h - 12 : h);\n const display = h12 + ':' + mm + ' ' + period;\n times.push({ display, value: hh24 + ':' + mm });\n }\n }\n }\n\n const render = () => {\n popup.innerHTML = '';\n times.forEach(t => {\n const item = document.createElement('div');\n item.className = 'vd-timepicker-item';\n item.setAttribute('role', 'option');\n item.textContent = t.display;\n\n if (input.value === t.value || input.value === t.display) {\n item.classList.add('is-selected');\n }\n\n item.addEventListener('click', () => {\n input.value = t.display;\n popup.querySelectorAll('.vd-timepicker-item').forEach(i => i.classList.remove('is-selected'));\n item.classList.add('is-selected');\n close();\n input.dispatchEvent(new CustomEvent('timepicker:select', {\n detail: { display: t.display, value: t.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n });\n\n popup.appendChild(item);\n });\n };\n\n const open = () => {\n render();\n popup.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n // Scroll to selected\n const selected = popup.querySelector('.is-selected');\n if (selected) selected.scrollIntoView({ block: 'center' });\n };\n\n const close = () => {\n popup.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n };\n\n const focusHandler = () => open();\n const outsideHandler = (e) => {\n if (!wrapper.contains(e.target)) close();\n };\n const escHandler = (e) => { if (e.key === 'Escape') close(); };\n\n input.addEventListener('focus', focusHandler);\n document.addEventListener('click', outsideHandler, true);\n document.addEventListener('keydown', escHandler);\n input.setAttribute('aria-haspopup', 'listbox');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('autocomplete', 'off');\n input.readOnly = true;\n\n cleanup.push(\n () => input.removeEventListener('focus', focusHandler),\n () => document.removeEventListener('click', outsideHandler, true),\n () => document.removeEventListener('keydown', escHandler)\n );\n\n this.instances.set(input, { cleanup, open, close });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('timepicker', Timepicker);\n }\n\n window.VanduoTimepicker = Timepicker;\n\n})();\n", "/**\n * Vanduo Framework - Stepper Component\n * Multi-step progress indicator with state management\n */\n\n(function () {\n 'use strict';\n\n const Stepper = {\n instances: new Map(),\n\n init: function () {\n const steppers = document.querySelectorAll('.vd-stepper');\n steppers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const items = Array.from(el.querySelectorAll('.vd-stepper-item'));\n const isClickable = el.classList.contains('vd-stepper-clickable');\n let currentIndex = items.findIndex(i => i.classList.contains('is-active'));\n if (currentIndex === -1) currentIndex = 0;\n\n const setStep = (index) => {\n if (index < 0 || index >= items.length) return;\n const prev = currentIndex;\n currentIndex = index;\n\n items.forEach((item, i) => {\n item.classList.remove('is-active', 'is-completed');\n if (i < index) item.classList.add('is-completed');\n else if (i === index) item.classList.add('is-active');\n });\n\n el.dispatchEvent(new CustomEvent('stepper:change', {\n detail: { current: index, previous: prev, total: items.length },\n bubbles: true\n }));\n };\n\n if (isClickable) {\n items.forEach((item, i) => {\n const handler = () => setStep(i);\n item.addEventListener('click', handler);\n cleanup.push(() => item.removeEventListener('click', handler));\n });\n }\n\n // Initialize current state\n setStep(currentIndex);\n\n this.instances.set(el, {\n cleanup,\n setStep,\n next: () => setStep(currentIndex + 1),\n prev: () => setStep(currentIndex - 1),\n getCurrent: () => currentIndex\n });\n },\n\n setStep: function (el, index) {\n const inst = this.instances.get(el);\n if (inst) inst.setStep(index);\n },\n\n next: function (el) {\n const inst = this.instances.get(el);\n if (inst) inst.next();\n },\n\n prev: function (el) {\n const inst = this.instances.get(el);\n if (inst) inst.prev();\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('stepper', Stepper);\n }\n\n window.VanduoStepper = Stepper;\n\n})();\n", "/**\n * Vanduo Framework - Rating Component\n * Star-based rating input with hover preview and read-only mode\n */\n\n(function () {\n 'use strict';\n\n const Rating = {\n instances: new Map(),\n\n init: function () {\n const ratings = document.querySelectorAll('[data-vd-rating]');\n ratings.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const max = parseInt(el.getAttribute('data-vd-rating-max') || '5', 10);\n const initialValue = parseFloat(el.getAttribute('data-vd-rating-value') || '0');\n const readonly = el.classList.contains('vd-rating-readonly') || el.hasAttribute('data-vd-rating-readonly');\n let currentValue = initialValue;\n\n el.classList.add('vd-rating');\n el.setAttribute('role', 'radiogroup');\n el.setAttribute('aria-label', el.getAttribute('aria-label') || 'Rating');\n\n // Clear existing stars\n el.innerHTML = '';\n\n // Create stars\n const stars = [];\n for (let i = 1; i <= max; i++) {\n const star = document.createElement('button');\n star.type = 'button';\n star.className = 'vd-rating-star';\n star.setAttribute('role', 'radio');\n star.setAttribute('aria-label', i + ' star' + (i > 1 ? 's' : ''));\n star.setAttribute('aria-checked', i <= currentValue ? 'true' : 'false');\n if (readonly) star.tabIndex = -1;\n stars.push(star);\n el.appendChild(star);\n }\n\n // Value display\n const valueDisplay = document.createElement('span');\n valueDisplay.className = 'vd-rating-value';\n valueDisplay.textContent = currentValue > 0 ? currentValue.toString() : '';\n el.appendChild(valueDisplay);\n\n const updateStars = (value) => {\n stars.forEach((star, i) => {\n star.classList.remove('is-active', 'is-half');\n const starNum = i + 1;\n if (starNum <= Math.floor(value)) {\n star.classList.add('is-active');\n } else if (starNum - 0.5 <= value) {\n star.classList.add('is-half');\n }\n star.setAttribute('aria-checked', starNum <= value ? 'true' : 'false');\n });\n valueDisplay.textContent = value > 0 ? value.toString() : '';\n };\n\n updateStars(currentValue);\n\n if (!readonly) {\n stars.forEach((star, i) => {\n const enterHandler = () => {\n stars.forEach((s, j) => {\n s.classList.toggle('is-hovered', j <= i);\n });\n };\n const leaveHandler = () => {\n stars.forEach(s => s.classList.remove('is-hovered'));\n };\n const clickHandler = () => {\n currentValue = i + 1;\n el.setAttribute('data-vd-rating-value', currentValue);\n updateStars(currentValue);\n el.dispatchEvent(new CustomEvent('rating:change', {\n detail: { value: currentValue, max },\n bubbles: true\n }));\n };\n\n star.addEventListener('mouseenter', enterHandler);\n star.addEventListener('mouseleave', leaveHandler);\n star.addEventListener('click', clickHandler);\n\n cleanup.push(\n () => star.removeEventListener('mouseenter', enterHandler),\n () => star.removeEventListener('mouseleave', leaveHandler),\n () => star.removeEventListener('click', clickHandler)\n );\n });\n\n // Keyboard\n const keyHandler = (e) => {\n if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {\n e.preventDefault();\n if (currentValue < max) {\n currentValue++;\n updateStars(currentValue);\n stars[currentValue - 1].focus();\n el.dispatchEvent(new CustomEvent('rating:change', { detail: { value: currentValue, max }, bubbles: true }));\n }\n } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {\n e.preventDefault();\n if (currentValue > 1) {\n currentValue--;\n updateStars(currentValue);\n stars[currentValue - 1].focus();\n el.dispatchEvent(new CustomEvent('rating:change', { detail: { value: currentValue, max }, bubbles: true }));\n }\n }\n };\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n }\n\n this.instances.set(el, {\n cleanup,\n getValue: () => currentValue,\n setValue: (v) => { currentValue = v; updateStars(v); }\n });\n },\n\n getValue: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getValue() : 0;\n },\n\n setValue: function (el, value) {\n const inst = this.instances.get(el);\n if (inst) inst.setValue(value);\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('rating', Rating);\n }\n\n window.VanduoRating = Rating;\n\n})();\n", "/**\n * Vanduo Framework - Transfer (Dual-list) Component\n * Source-to-target list transfer with search, select-all, and move actions\n */\n\n(function () {\n 'use strict';\n\n const Transfer = {\n instances: new Map(),\n\n init: function () {\n const transfers = document.querySelectorAll('[data-vd-transfer]');\n transfers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n el.classList.add('vd-transfer');\n\n let sourceData, targetData;\n try {\n const raw = JSON.parse(el.getAttribute('data-vd-transfer') || '[]');\n sourceData = raw.map((item, i) => ({\n id: item.id || 'item-' + i,\n label: item.label || item.text || String(item),\n selected: false\n }));\n } catch (_e) {\n sourceData = [];\n }\n targetData = [];\n\n const sourceSelected = new Set();\n const targetSelected = new Set();\n\n const render = () => {\n el.innerHTML = '';\n\n // Source panel\n const sourcePanel = createPanel('Source', sourceData, sourceSelected, 'source');\n // Actions\n const actions = document.createElement('div');\n actions.className = 'vd-transfer-actions';\n\n const moveRightBtn = document.createElement('button');\n moveRightBtn.type = 'button';\n moveRightBtn.className = 'vd-transfer-btn';\n moveRightBtn.innerHTML = '›';\n moveRightBtn.setAttribute('aria-label', 'Move to target');\n moveRightBtn.disabled = sourceSelected.size === 0;\n moveRightBtn.addEventListener('click', () => moveRight());\n\n const moveLeftBtn = document.createElement('button');\n moveLeftBtn.type = 'button';\n moveLeftBtn.className = 'vd-transfer-btn';\n moveLeftBtn.innerHTML = '‹';\n moveLeftBtn.setAttribute('aria-label', 'Move to source');\n moveLeftBtn.disabled = targetSelected.size === 0;\n moveLeftBtn.addEventListener('click', () => moveLeft());\n\n actions.appendChild(moveRightBtn);\n actions.appendChild(moveLeftBtn);\n\n // Target panel\n const targetPanel = createPanel('Target', targetData, targetSelected, 'target');\n\n el.appendChild(sourcePanel);\n el.appendChild(actions);\n el.appendChild(targetPanel);\n };\n\n const createPanel = (title, data, selected, _side) => {\n const panel = document.createElement('div');\n panel.className = 'vd-transfer-panel';\n\n const header = document.createElement('div');\n header.className = 'vd-transfer-header';\n const titleSpan = document.createElement('span');\n titleSpan.textContent = title;\n const count = document.createElement('span');\n count.className = 'vd-transfer-count';\n count.textContent = selected.size + '/' + data.length;\n header.appendChild(titleSpan);\n header.appendChild(count);\n panel.appendChild(header);\n\n // Search\n const searchDiv = document.createElement('div');\n searchDiv.className = 'vd-transfer-search';\n const searchInput = document.createElement('input');\n searchInput.type = 'text';\n searchInput.placeholder = 'Search...';\n searchInput.setAttribute('aria-label', 'Search ' + title.toLowerCase());\n searchDiv.appendChild(searchInput);\n panel.appendChild(searchDiv);\n\n // List\n const list = document.createElement('ul');\n list.className = 'vd-transfer-list';\n list.setAttribute('role', 'listbox');\n\n const renderList = (filter) => {\n list.innerHTML = '';\n const filtered = filter ? data.filter(d => {\n const label = (d.label || d.text || String(d)).toLowerCase();\n return label.includes(filter.toLowerCase());\n }) : data;\n filtered.forEach(item => {\n const li = document.createElement('li');\n li.className = 'vd-transfer-item';\n li.setAttribute('role', 'option');\n if (selected.has(item.id)) li.classList.add('is-selected');\n\n const checkbox = document.createElement('input');\n checkbox.type = 'checkbox';\n checkbox.checked = selected.has(item.id);\n checkbox.setAttribute('aria-label', item.label);\n\n const label = document.createElement('span');\n label.textContent = item.label;\n\n li.addEventListener('click', () => {\n if (selected.has(item.id)) selected.delete(item.id);\n else selected.add(item.id);\n render();\n });\n\n li.appendChild(checkbox);\n li.appendChild(label);\n list.appendChild(li);\n });\n };\n\n searchInput.addEventListener('input', () => renderList(searchInput.value));\n renderList('');\n\n panel.appendChild(list);\n return panel;\n };\n\n const moveRight = () => {\n const toMove = sourceData.filter(d => sourceSelected.has(d.id));\n sourceData = sourceData.filter(d => !sourceSelected.has(d.id));\n targetData = targetData.concat(toMove);\n sourceSelected.clear();\n render();\n fireChange();\n };\n\n const moveLeft = () => {\n const toMove = targetData.filter(d => targetSelected.has(d.id));\n targetData = targetData.filter(d => !targetSelected.has(d.id));\n sourceData = sourceData.concat(toMove);\n targetSelected.clear();\n render();\n fireChange();\n };\n\n const fireChange = () => {\n el.dispatchEvent(new CustomEvent('transfer:change', {\n detail: {\n source: sourceData.map(d => d.id),\n target: targetData.map(d => d.id)\n },\n bubbles: true\n }));\n };\n\n render();\n\n this.instances.set(el, {\n cleanup,\n getTarget: () => targetData.map(d => d.id),\n getSource: () => sourceData.map(d => d.id)\n });\n },\n\n getSelected: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getTarget() : [];\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n el.innerHTML = '';\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('transfer', Transfer);\n }\n\n window.VanduoTransfer = Transfer;\n\n})();\n", "/**\n * Vanduo Framework - Tree View Component\n * Hierarchical collapsible tree with checkbox selection and keyboard navigation\n */\n\n(function () {\n 'use strict';\n\n const Tree = {\n instances: new Map(),\n\n init: function () {\n const trees = document.querySelectorAll('[data-vd-tree]');\n trees.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const cascade = el.getAttribute('data-vd-tree-cascade') !== 'false';\n\n let data;\n try { data = JSON.parse(el.getAttribute('data-vd-tree') || '[]'); } catch (_e) { data = []; }\n\n el.classList.add('vd-tree');\n el.setAttribute('role', 'tree');\n\n const render = (items, parent) => {\n parent.innerHTML = '';\n items.forEach(item => {\n const node = document.createElement('li');\n node.className = 'vd-tree-node';\n node.setAttribute('role', 'treeitem');\n node.setAttribute('aria-expanded', item.open ? 'true' : 'false');\n if (item.open) node.classList.add('is-open');\n\n const content = document.createElement('div');\n content.className = 'vd-tree-node-content';\n\n // Toggle\n if (item.children && item.children.length > 0) {\n const toggle = document.createElement('button');\n toggle.type = 'button';\n toggle.className = 'vd-tree-toggle';\n toggle.setAttribute('aria-label', 'Toggle');\n toggle.addEventListener('click', (e) => {\n e.stopPropagation();\n item.open = !item.open;\n node.classList.toggle('is-open');\n node.setAttribute('aria-expanded', item.open ? 'true' : 'false');\n });\n content.appendChild(toggle);\n } else {\n const ph = document.createElement('span');\n ph.className = 'vd-tree-toggle-placeholder';\n content.appendChild(ph);\n }\n\n // Checkbox\n if (el.hasAttribute('data-vd-tree-checkbox')) {\n const cb = document.createElement('input');\n cb.type = 'checkbox';\n cb.className = 'vd-tree-checkbox';\n cb.checked = !!item.checked;\n cb.setAttribute('aria-label', item.label);\n cb.addEventListener('change', (e) => {\n e.stopPropagation();\n item.checked = cb.checked;\n if (cascade && item.children) {\n setChildChecked(item.children, cb.checked);\n render(data, el);\n }\n el.dispatchEvent(new CustomEvent('tree:check', {\n detail: { id: item.id, checked: cb.checked, label: item.label },\n bubbles: true\n }));\n });\n content.appendChild(cb);\n }\n\n // Icon\n if (item.icon) {\n const icon = document.createElement('span');\n icon.className = 'vd-tree-icon ' + item.icon;\n content.appendChild(icon);\n }\n\n // Label\n const label = document.createElement('span');\n label.className = 'vd-tree-label';\n label.textContent = item.label || '';\n content.appendChild(label);\n\n node.appendChild(content);\n\n // Children\n if (item.children && item.children.length > 0) {\n const childList = document.createElement('ul');\n childList.className = 'vd-tree-children';\n childList.setAttribute('role', 'group');\n render(item.children, childList);\n node.appendChild(childList);\n }\n\n parent.appendChild(node);\n });\n };\n\n const setChildChecked = (items, checked) => {\n items.forEach(item => {\n item.checked = checked;\n if (item.children) setChildChecked(item.children, checked);\n });\n };\n\n // Keyboard\n const keyHandler = (e) => {\n const focused = document.activeElement;\n if (!el.contains(focused)) return;\n\n const nodes = Array.from(el.querySelectorAll('.vd-tree-node-content'));\n const idx = nodes.indexOf(focused.closest('.vd-tree-node-content'));\n if (idx === -1) return;\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n if (idx < nodes.length - 1) {\n const next = nodes[idx + 1].querySelector('.vd-tree-toggle, .vd-tree-label');\n if (next) next.focus();\n }\n break;\n case 'ArrowUp':\n e.preventDefault();\n if (idx > 0) {\n const prev = nodes[idx - 1].querySelector('.vd-tree-toggle, .vd-tree-label');\n if (prev) prev.focus();\n }\n break;\n }\n };\n\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n\n render(data, el);\n\n this.instances.set(el, {\n cleanup,\n getData: () => data,\n getChecked: () => {\n const checked = [];\n const collect = (items) => {\n items.forEach(i => {\n if (i.checked) checked.push(i.id || i.label);\n if (i.children) collect(i.children);\n });\n };\n collect(data);\n return checked;\n }\n });\n },\n\n getChecked: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getChecked() : [];\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n el.innerHTML = '';\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('tree', Tree);\n }\n\n window.VanduoTree = Tree;\n\n})();\n", "/**\n * Vanduo Framework - Spotlight (Feature Discovery) Component\n * Guided tour with overlay highlight and step-through tooltip\n */\n\n(function () {\n 'use strict';\n\n const Spotlight = {\n _active: false,\n _steps: [],\n _currentStep: 0,\n _elements: {},\n _cleanup: [],\n _boundTriggers: new WeakMap(),\n _triggerElement: null,\n\n init: function () {\n const triggers = document.querySelectorAll('[data-vd-spotlight]');\n\n triggers.forEach(trigger => {\n if (this._boundTriggers.has(trigger)) return;\n\n const clickHandler = (event) => {\n event.preventDefault();\n\n const steps = this._parseSteps(trigger.getAttribute('data-vd-spotlight'));\n if (steps.length === 0) return;\n\n this.start(steps, { trigger });\n };\n\n trigger.addEventListener('click', clickHandler);\n this._boundTriggers.set(trigger, clickHandler);\n });\n },\n\n _parseSteps: function (raw) {\n if (typeof raw !== 'string' || raw.trim() === '') return [];\n\n try {\n const parsed = JSON.parse(raw);\n return this._normalizeSteps(parsed);\n } catch (error) {\n console.error('VanduoSpotlight: invalid data-vd-spotlight payload.', error);\n return [];\n }\n },\n\n _normalizeStep: function (step) {\n if (!step || typeof step !== 'object') return null;\n\n const target = step.target;\n const hasSelectorTarget = typeof target === 'string' && target.trim() !== '';\n const hasElementTarget = typeof Element !== 'undefined' && target instanceof Element;\n\n if (!hasSelectorTarget && !hasElementTarget) return null;\n\n const title = typeof step.title === 'string' ? step.title : '';\n const description = typeof step.description === 'string'\n ? step.description\n : (typeof step.content === 'string' ? step.content : '');\n\n return {\n target,\n title,\n description\n };\n },\n\n _normalizeSteps: function (steps) {\n if (!Array.isArray(steps)) return [];\n\n return steps\n .map(step => this._normalizeStep(step))\n .filter(Boolean);\n },\n\n start: function (steps, options) {\n if (this._active) this.stop();\n\n const normalizedSteps = this._normalizeSteps(steps);\n if (normalizedSteps.length === 0) return;\n\n const startOptions = options || {};\n\n this._steps = normalizedSteps;\n this._currentStep = 0;\n this._active = true;\n this._triggerElement = startOptions.trigger || (document.activeElement instanceof HTMLElement ? document.activeElement : null);\n\n // Create overlay\n const overlay = document.createElement('div');\n overlay.className = 'vd-spotlight-overlay';\n overlay.setAttribute('aria-hidden', 'true');\n document.body.appendChild(overlay);\n\n // Create tooltip\n const tooltip = document.createElement('div');\n tooltip.className = 'vd-spotlight-tooltip';\n tooltip.setAttribute('role', 'dialog');\n tooltip.setAttribute('aria-modal', 'true');\n tooltip.tabIndex = -1;\n document.body.appendChild(tooltip);\n\n this._elements = { overlay, tooltip };\n\n // ESC to close\n const escHandler = (e) => { if (e.key === 'Escape') this.stop(); };\n document.addEventListener('keydown', escHandler);\n this._cleanup.push(() => document.removeEventListener('keydown', escHandler));\n\n // Overlay click to close\n overlay.addEventListener('click', () => this.stop());\n\n this._showStep(this._currentStep);\n },\n\n _showStep: function (index) {\n const step = this._steps[index];\n if (!step) return;\n\n const target = typeof step.target === 'string' ? document.querySelector(step.target) : step.target;\n const { tooltip } = this._elements;\n\n // Remove previous highlight\n document.querySelectorAll('.vd-spotlight-target').forEach(el => {\n el.classList.remove('vd-spotlight-target');\n });\n\n // Highlight target\n if (target) {\n target.classList.add('vd-spotlight-target');\n target.scrollIntoView({ behavior: 'smooth', block: 'center' });\n }\n\n // Build tooltip content\n const total = this._steps.length;\n tooltip.innerHTML = '';\n tooltip.removeAttribute('aria-labelledby');\n tooltip.removeAttribute('aria-describedby');\n\n if (step.title) {\n const title = document.createElement('h4');\n title.className = 'vd-spotlight-title';\n title.id = 'vd-spotlight-title-' + index + '-' + Date.now();\n title.textContent = step.title;\n tooltip.appendChild(title);\n tooltip.setAttribute('aria-labelledby', title.id);\n }\n\n if (step.description) {\n const desc = document.createElement('p');\n desc.className = 'vd-spotlight-description';\n desc.id = 'vd-spotlight-description-' + index + '-' + Date.now();\n desc.textContent = step.description;\n tooltip.appendChild(desc);\n tooltip.setAttribute('aria-describedby', desc.id);\n }\n\n // Footer\n const footer = document.createElement('div');\n footer.className = 'vd-spotlight-footer';\n footer.setAttribute('aria-label', 'Step ' + (index + 1) + ' of ' + total);\n\n const counter = document.createElement('span');\n counter.className = 'vd-spotlight-counter';\n counter.textContent = (index + 1) + ' / ' + total;\n\n const actions = document.createElement('div');\n actions.className = 'vd-spotlight-actions';\n\n if (index > 0) {\n const prevBtn = document.createElement('button');\n prevBtn.type = 'button';\n prevBtn.className = 'vd-spotlight-btn';\n prevBtn.textContent = 'Back';\n prevBtn.addEventListener('click', () => this.prev());\n actions.appendChild(prevBtn);\n }\n\n const skipBtn = document.createElement('button');\n skipBtn.type = 'button';\n skipBtn.className = 'vd-spotlight-btn';\n skipBtn.textContent = 'Skip';\n skipBtn.addEventListener('click', () => this.stop());\n actions.appendChild(skipBtn);\n\n if (index < total - 1) {\n const nextBtn = document.createElement('button');\n nextBtn.type = 'button';\n nextBtn.className = 'vd-spotlight-btn vd-spotlight-btn-primary';\n nextBtn.textContent = 'Next';\n nextBtn.addEventListener('click', () => this.next());\n actions.appendChild(nextBtn);\n } else {\n const doneBtn = document.createElement('button');\n doneBtn.type = 'button';\n doneBtn.className = 'vd-spotlight-btn vd-spotlight-btn-primary';\n doneBtn.textContent = 'Done';\n doneBtn.addEventListener('click', () => this.stop());\n actions.appendChild(doneBtn);\n }\n\n footer.appendChild(counter);\n footer.appendChild(actions);\n tooltip.appendChild(footer);\n\n // Position tooltip near target\n if (target) {\n requestAnimationFrame(() => {\n const rect = target.getBoundingClientRect();\n const tRect = tooltip.getBoundingClientRect();\n let top = rect.bottom + 12 + window.scrollY;\n let left = rect.left + (rect.width - tRect.width) / 2 + window.scrollX;\n\n // Keep in viewport\n left = Math.max(8, Math.min(left, window.innerWidth - tRect.width - 8));\n if (top + tRect.height > window.innerHeight + window.scrollY) {\n top = rect.top - tRect.height - 12 + window.scrollY;\n }\n\n tooltip.style.top = top + 'px';\n tooltip.style.left = left + 'px';\n });\n }\n\n document.dispatchEvent(new CustomEvent('spotlight:step', {\n detail: { index, step: index, total, data: step }\n }));\n },\n\n next: function () {\n if (this._currentStep < this._steps.length - 1) {\n this._currentStep++;\n this._showStep(this._currentStep);\n }\n },\n\n prev: function () {\n if (this._currentStep > 0) {\n this._currentStep--;\n this._showStep(this._currentStep);\n }\n },\n\n stop: function () {\n if (!this._active) return;\n\n const total = this._steps.length;\n const detail = {\n completedSteps: total === 0 ? 0 : Math.min(this._currentStep + 1, total),\n total,\n completed: total > 0 && this._currentStep >= total - 1\n };\n\n this._active = false;\n\n document.querySelectorAll('.vd-spotlight-target').forEach(el => {\n el.classList.remove('vd-spotlight-target');\n });\n\n if (this._elements.overlay && this._elements.overlay.parentNode) {\n this._elements.overlay.parentNode.removeChild(this._elements.overlay);\n }\n if (this._elements.tooltip && this._elements.tooltip.parentNode) {\n this._elements.tooltip.parentNode.removeChild(this._elements.tooltip);\n }\n\n this._cleanup.forEach(fn => fn());\n this._cleanup = [];\n this._elements = {};\n this._steps = [];\n this._currentStep = 0;\n\n if (this._triggerElement && this._triggerElement.isConnected && typeof this._triggerElement.focus === 'function') {\n this._triggerElement.focus();\n }\n this._triggerElement = null;\n\n document.dispatchEvent(new CustomEvent('spotlight:end', { detail }));\n },\n\n destroyAll: function () {\n this.stop();\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('spotlight', Spotlight);\n }\n\n window.VanduoSpotlight = Spotlight;\n\n})();\n", "/**\n * Vanduo Framework - Music Player Component\n * HTML5 Audio-based music player with transport controls, volume,\n * and optional shuffle, seek bar, and playlist features.\n *\n * Options (passed to MusicPlayer.init or data-music-player-options):\n * tracks {Array} - [{name, url}] \u2014 required\n * volume {number} - Initial volume 0\u20131 (default 0.5)\n * shuffle {boolean} - Shuffle on init (default false)\n * showProgress {boolean} - Show seek/progress bar (default false)\n * showPlaylist {boolean} - Show expandable playlist panel (default false)\n * autoAdvance {boolean} - Auto-play next track on end (default true)\n * glass {boolean} - Frosted-glass surface styling (default false)\n * detachable {boolean} - Show detach/attach; float above page when detached (default false)\n * floatingPosition {string|null} - After detach: 'bottom-left' or 'bottom-right' (default null = bottom-right)\n * draggable {boolean} - When detached+detachable, drag by handle (default false)\n * minimizable {boolean} - Minimize/expand control (default false)\n * startMinimized {boolean} - On first detach, start minimized (default false)\n * persistPosition {boolean} - Save floating x/y in localStorage (default false)\n * persistKey {string} - Storage key for persist (default from element id)\n *\n * Custom events (all bubble, dispatched on container):\n * musicplayer:play \u2014 playback started\n * musicplayer:pause \u2014 playback paused\n * musicplayer:trackchange \u2014 detail: { index, name, url }\n * musicplayer:volumechange \u2014 detail: { volume }\n * musicplayer:ended \u2014 last track ended (autoAdvance=false only)\n * musicplayer:detach \u2014 after floating player is created\n * musicplayer:attach \u2014 after returned to document flow\n * musicplayer:minimize \u2014 collapsed to minimal controls\n * musicplayer:expand \u2014 full controls visible again\n *\n * Programmatic API:\n * MusicPlayer.init(container?, options?)\n * MusicPlayer.play(container)\n * MusicPlayer.pause(container)\n * MusicPlayer.toggle(container)\n * MusicPlayer.next(container)\n * MusicPlayer.previous(container)\n * MusicPlayer.setVolume(container, value)\n * MusicPlayer.setTrack(container, index)\n * MusicPlayer.shuffle(container)\n * MusicPlayer.detach(container, position?)\n * MusicPlayer.attach(container)\n * MusicPlayer.minimize(container)\n * MusicPlayer.expand(container)\n * MusicPlayer.toggleMinimize(container)\n * MusicPlayer.setPosition(container, 'bottom-left'|'bottom-right'|{x,y})\n * MusicPlayer.getState(container)\n * MusicPlayer.destroy(container)\n * MusicPlayer.destroyAll()\n */\n\n(function () {\n 'use strict';\n\n /* \u2500\u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Fisher-Yates shuffle (returns new array).\n * @param {Array} arr\n * @returns {Array}\n */\n function shuffleArray(arr) {\n const shuffled = arr.slice();\n for (let i = shuffled.length - 1; i > 0; i--) {\n const j = Math.floor(Math.random() * (i + 1));\n const tmp = shuffled[i];\n shuffled[i] = shuffled[j];\n shuffled[j] = tmp;\n }\n return shuffled;\n }\n\n /**\n * Format seconds as m:ss.\n * @param {number} seconds\n * @returns {string}\n */\n function formatTime(seconds) {\n if (!isFinite(seconds) || seconds < 0) return '0:00';\n const m = Math.floor(seconds / 60);\n const s = Math.floor(seconds % 60);\n return m + ':' + (s < 10 ? '0' : '') + s;\n }\n\n /**\n * Set CSS background-size on a range input to visually fill the track.\n * @param {HTMLInputElement} input\n */\n /**\n * @param {string|undefined} id\n * @returns {string}\n */\n function persistStorageKey(id) {\n return 'vanduo:music-player:' + (id && id.trim() ? id.trim() : 'default') + ':pos';\n }\n\n function updateRangeFill(input) {\n const min = parseFloat(input.min) || 0;\n const max = parseFloat(input.max) || 1;\n const val = parseFloat(input.value) || 0;\n const pct = ((val - min) / (max - min)) * 100;\n input.style.setProperty('--fill', pct + '%');\n // Fallback inline gradient for browsers without ::-moz-range-progress\n input.style.backgroundImage =\n 'linear-gradient(to right, var(--music-player-track-fill, currentColor) 0%, ' +\n 'var(--music-player-track-fill, currentColor) ' + pct + '%, ' +\n 'var(--music-player-track-bg, #ccc) ' + pct + '%, ' +\n 'var(--music-player-track-bg, #ccc) 100%)';\n }\n\n /* \u2500\u2500\u2500 Phosphor icon helper (matches framework icon style) \u2500 */\n\n /**\n * Return an element (Phosphor icon).\n * @param {string} name\n * @returns {HTMLElement}\n */\n function icon(name) {\n const el = document.createElement('i');\n el.className = 'ph ph-' + name;\n el.setAttribute('aria-hidden', 'true');\n return el;\n }\n\n /* \u2500\u2500\u2500 Component \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const MusicPlayer = {\n /** @type {Map} */\n instances: new Map(),\n\n /**\n * Default options.\n */\n defaults: {\n tracks: [],\n volume: 0.5,\n shuffle: false,\n showProgress: false,\n showPlaylist: false,\n autoAdvance: true,\n glass: false,\n detachable: false,\n /** @type {null|string} */\n floatingPosition: null,\n draggable: false,\n minimizable: false,\n startMinimized: false,\n persistPosition: false,\n persistKey: '',\n },\n\n /**\n * Auto-initialize all .vd-music-player / [data-music-player] elements.\n * Options can be provided via data-music-player-options (JSON string).\n */\n init: function () {\n document.querySelectorAll('.vd-music-player, [data-music-player]').forEach((el) => {\n if (this.instances.has(el)) return;\n\n let opts = {};\n const attr = el.getAttribute('data-music-player-options');\n if (attr) {\n try { opts = JSON.parse(attr); } catch (_) { /* ignore malformed JSON */ }\n }\n this.initPlayer(el, opts);\n });\n },\n\n /**\n * Initialize a single player element.\n * @param {HTMLElement} container\n * @param {Object} [options]\n */\n initPlayer: function (container, options) {\n const opts = Object.assign({}, this.defaults, options || {});\n\n // Validate and normalise tracks\n const rawTracks = Array.isArray(opts.tracks) ? opts.tracks : [];\n const tracks = rawTracks.filter((t) => t && typeof t.url === 'string' && t.url.trim());\n\n // Build shuffled working copy without mutating opts\n const trackList = opts.shuffle ? shuffleArray(tracks) : tracks.slice();\n\n /* \u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const state = {\n tracks: trackList,\n originalTracks: tracks.slice(),\n currentIndex: 0,\n isPlaying: false,\n volume: Math.max(0, Math.min(1, opts.volume)),\n shuffle: opts.shuffle,\n showProgress: opts.showProgress,\n showPlaylist: opts.showPlaylist,\n autoAdvance: opts.autoAdvance,\n audio: null,\n glass: Boolean(opts.glass),\n detachable: Boolean(opts.detachable),\n floatingPosition: opts.floatingPosition || 'bottom-right',\n draggable: Boolean(opts.draggable) && Boolean(opts.detachable),\n minimizable: Boolean(opts.minimizable),\n startMinimized: Boolean(opts.startMinimized),\n persistPosition: Boolean(opts.persistPosition),\n persistKey: typeof opts.persistKey === 'string' ? opts.persistKey : '',\n isDetached: false,\n isMinimized: false,\n _startMinimizeApplied: false,\n };\n\n /* \u2500\u2500 Audio element \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const audio = new Audio();\n audio.volume = state.volume;\n audio.preload = 'metadata';\n state.audio = audio;\n\n /* \u2500\u2500 Build DOM \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n this._buildDOM(container, state);\n\n // Grab references after DOM build\n const refs = {\n btnPlay: container.querySelector('.vd-music-player-btn-play'),\n btnPrev: container.querySelector('.vd-music-player-btn-prev'),\n btnNext: container.querySelector('.vd-music-player-btn-next'),\n btnShuffle: container.querySelector('.vd-music-player-btn-shuffle'),\n btnPlaylist: container.querySelector('.vd-music-player-btn-playlist'),\n btnDetach: container.querySelector('.vd-music-player-btn-detach'),\n btnAttach: container.querySelector('.vd-music-player-btn-attach'),\n btnMinimize: container.querySelector('.vd-music-player-btn-minimize'),\n dragHandle: container.querySelector('.vd-music-player-drag-handle'),\n trackName: container.querySelector('.vd-music-player-track-name'),\n volumeSlider: container.querySelector('.vd-music-player-volume-slider'),\n volumeIcon: container.querySelector('.vd-music-player-volume-icon'),\n progressBar: container.querySelector('.vd-music-player-progress-bar'),\n timeElapsed: container.querySelector('.vd-music-player-time-elapsed'),\n timeDuration: container.querySelector('.vd-music-player-time-duration'),\n playlistPanel: container.querySelector('.vd-music-player-playlist'),\n };\n\n /* \u2500\u2500 Internal render helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const renderPlayIcon = () => {\n const btn = refs.btnPlay;\n if (!btn) return;\n btn.innerHTML = '';\n btn.appendChild(icon(state.isPlaying ? 'pause' : 'play'));\n btn.setAttribute('aria-label', state.isPlaying ? 'Pause' : 'Play');\n btn.classList.toggle('is-active', state.isPlaying);\n };\n\n const renderTrackName = () => {\n const el = refs.trackName;\n if (!el) return;\n const track = state.tracks[state.currentIndex];\n if (track) {\n el.textContent = track.name || 'Unknown Track';\n el.classList.remove('is-idle');\n } else {\n el.textContent = 'No tracks loaded';\n el.classList.add('is-idle');\n }\n };\n\n const renderVolumeIcon = () => {\n const el = refs.volumeIcon;\n if (!el) return;\n el.innerHTML = '';\n const v = state.volume;\n const name = v === 0 ? 'speaker-none' : v < 0.5 ? 'speaker-low' : 'speaker-high';\n el.appendChild(icon(name));\n };\n\n const renderShuffleBtn = () => {\n const btn = refs.btnShuffle;\n if (!btn) return;\n btn.classList.toggle('is-active', state.shuffle);\n btn.setAttribute('aria-pressed', state.shuffle ? 'true' : 'false');\n };\n\n const renderPlaylistItems = () => {\n const panel = refs.playlistPanel;\n if (!panel) return;\n panel.innerHTML = '';\n state.tracks.forEach((track, i) => {\n const item = document.createElement('button');\n item.className =\n 'vd-music-player-playlist-item' + (i === state.currentIndex ? ' is-active' : '');\n item.type = 'button';\n item.setAttribute('data-index', String(i));\n item.setAttribute('aria-current', i === state.currentIndex ? 'true' : 'false');\n\n const num = document.createElement('span');\n num.className = 'vd-music-player-playlist-num';\n num.textContent = String(i + 1);\n\n const name = document.createElement('span');\n name.className = 'vd-music-player-playlist-name';\n name.textContent = track.name || 'Track ' + (i + 1);\n\n item.appendChild(num);\n item.appendChild(name);\n panel.appendChild(item);\n });\n };\n\n const renderProgress = () => {\n const bar = refs.progressBar;\n if (!bar || !audio.duration) return;\n const pct = (audio.currentTime / audio.duration) * 100;\n bar.value = String(pct);\n updateRangeFill(bar);\n if (refs.timeElapsed) refs.timeElapsed.textContent = formatTime(audio.currentTime);\n if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);\n };\n\n /* \u2500\u2500 Load track \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const loadTrack = (index, autoPlay) => {\n const track = state.tracks[index];\n if (!track) return;\n state.currentIndex = index;\n audio.src = track.url;\n renderTrackName();\n renderPlaylistItems();\n\n // Reset progress\n if (refs.progressBar) {\n refs.progressBar.value = '0';\n updateRangeFill(refs.progressBar);\n }\n if (refs.timeElapsed) refs.timeElapsed.textContent = '0:00';\n if (refs.timeDuration) refs.timeDuration.textContent = '0:00';\n\n container.dispatchEvent(\n new CustomEvent('musicplayer:trackchange', {\n bubbles: true,\n detail: { index, name: track.name, url: track.url },\n })\n );\n\n if (autoPlay) {\n audio.play().catch(() => { /* browser may block autoplay */ });\n }\n };\n\n /* \u2500\u2500 Audio event listeners \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const cleanupFunctions = [];\n\n const onPlay = () => {\n state.isPlaying = true;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:play', { bubbles: true }));\n };\n\n const onPause = () => {\n state.isPlaying = false;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:pause', { bubbles: true }));\n };\n\n const onEnded = () => {\n if (state.autoAdvance && state.tracks.length > 1) {\n const next = (state.currentIndex + 1) % state.tracks.length;\n loadTrack(next, true);\n } else {\n state.isPlaying = false;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:ended', { bubbles: true }));\n }\n };\n\n const onTimeUpdate = () => {\n if (state.showProgress) renderProgress();\n };\n\n const onLoadedMetadata = () => {\n if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);\n if (refs.progressBar) {\n refs.progressBar.max = '100';\n updateRangeFill(refs.progressBar);\n }\n };\n\n audio.addEventListener('play', onPlay);\n audio.addEventListener('pause', onPause);\n audio.addEventListener('ended', onEnded);\n audio.addEventListener('timeupdate', onTimeUpdate);\n audio.addEventListener('loadedmetadata', onLoadedMetadata);\n cleanupFunctions.push(() => {\n audio.removeEventListener('play', onPlay);\n audio.removeEventListener('pause', onPause);\n audio.removeEventListener('ended', onEnded);\n audio.removeEventListener('timeupdate', onTimeUpdate);\n audio.removeEventListener('loadedmetadata', onLoadedMetadata);\n audio.pause();\n audio.src = '';\n });\n\n /* \u2500\u2500 Control button listeners \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n if (refs.btnPlay) {\n const handler = () => {\n if (!audio.src && state.tracks.length) loadTrack(state.currentIndex, false);\n if (state.isPlaying) {\n audio.pause();\n } else {\n audio.play().catch(() => {});\n }\n };\n refs.btnPlay.addEventListener('click', handler);\n cleanupFunctions.push(() => refs.btnPlay.removeEventListener('click', handler));\n\n // Keyboard: Space / Enter (already native for `;let s="";for(let[a,r]of Object.entries(this.NEUTRAL_COLORS))s+=``;let o="";this.RADIUS_OPTIONS.forEach(a=>{o+=``});let i="";for(let[a,r]of Object.entries(this.FONT_OPTIONS))i+=``;return` +`).length,o=document.createElement("div");o.className="vd-code-snippet-line-numbers",o.setAttribute("aria-hidden","true");for(let a=1;a<=s;a++){let r=document.createElement("span");r.textContent=a,o.appendChild(r)}let i=document.createElement("div");i.className="vd-code-snippet-code",i.appendChild(t.cloneNode(!0)),t.parentNode.removeChild(t),e.appendChild(o),e.appendChild(i)},expand:function(e){if(typeof e=="string"&&(e=document.querySelector(e)),!e)return;e.dataset.expanded="true";let t=e.querySelector(".vd-code-snippet-toggle"),n=e.querySelector(".vd-code-snippet-content");t&&t.setAttribute("aria-expanded","true"),n&&(n.dataset.visible="true")},collapse:function(e){if(typeof e=="string"&&(e=document.querySelector(e)),!e)return;e.dataset.expanded="false";let t=e.querySelector(".vd-code-snippet-toggle"),n=e.querySelector(".vd-code-snippet-content");t&&t.setAttribute("aria-expanded","false"),n&&(n.dataset.visible="false")},showLang:function(e,t){if(typeof e=="string"&&(e=document.querySelector(e)),!e)return;let n=e.querySelector(`.vd-code-snippet-tab[data-lang="${t}"]`),s=e.querySelectorAll(".vd-code-snippet-tab"),o=e.querySelectorAll(".vd-code-snippet-pane");n&&this.switchTab(e,n,s,o)},destroy:function(e){typeof e=="string"&&(e=document.querySelector(e)),e&&(e._codeSnippetCleanup&&(e._codeSnippetCleanup.forEach(t=>t()),delete e._codeSnippetCleanup),delete e.dataset.initialized)},destroyAll:function(){document.querySelectorAll('.vd-code-snippet[data-initialized="true"]').forEach(t=>this.destroy(t))}};typeof window.Vanduo<"u"&&window.Vanduo.register("codeSnippet",p),window.CodeSnippet=p})();(function(){"use strict";let p={instances:new Map,init:function(){document.querySelectorAll(".vd-collapsible, .accordion").forEach(t=>{this.instances.has(t)||this.initCollapsible(t)})},initCollapsible:function(e){let t=e.classList.contains("accordion"),n=e.querySelectorAll(".vd-collapsible-item, .accordion-item"),s=[];n.forEach(o=>{let i=o.querySelector(".vd-collapsible-header, .accordion-header"),a=o.querySelector(".vd-collapsible-body, .accordion-body"),r=o.querySelector(".vd-collapsible-trigger, .accordion-trigger")||i;if(!i||!a)return;o.classList.contains("is-open")?this.openItem(o,a,!1):this.closeItem(o,a,!1);let c=l=>{l.preventDefault(),this.toggleItem(o,a,e,t)};r.addEventListener("click",c),s.push(()=>r.removeEventListener("click",c))}),this.instances.set(e,{cleanup:s})},toggleItem:function(e,t,n,s){e.classList.contains("is-open")?this.closeItem(e,t):(s&&n.querySelectorAll(".vd-collapsible-item.is-open, .accordion-item.is-open").forEach(a=>{if(a!==e){let r=a.querySelector(".vd-collapsible-body, .accordion-body");this.closeItem(a,r)}}),this.openItem(e,t))},openItem:function(e,t,n=!0){n||(t.style.transition="none"),e.classList.add("is-open"),e.setAttribute("aria-expanded","true");let s=t.scrollHeight;t.style.maxHeight=`${s}px`,n||setTimeout(()=>{t.style.transition=""},0),e.dispatchEvent(new CustomEvent("collapsible:open",{bubbles:!0}))},closeItem:function(e,t,n=!0){n||(t.style.transition="none"),e.classList.remove("is-open"),e.setAttribute("aria-expanded","false"),t.style.maxHeight="0",n||setTimeout(()=>{t.style.transition=""},0),e.dispatchEvent(new CustomEvent("collapsible:close",{bubbles:!0}))},open:function(e){let t=typeof e=="string"?document.querySelector(e):e;if(t){let n=t.querySelector(".vd-collapsible-body, .accordion-body");n&&this.openItem(t,n)}},close:function(e){let t=typeof e=="string"?document.querySelector(e):e;if(t){let n=t.querySelector(".vd-collapsible-body, .accordion-body");n&&this.closeItem(t,n)}},toggle:function(e){let t=typeof e=="string"?document.querySelector(e):e;if(t){let n=t.querySelector(".vd-collapsible-body, .accordion-body"),s=t.closest(".vd-collapsible, .accordion"),o=s&&s.classList.contains("accordion");n&&this.toggleItem(t,n,s,o)}},destroy:function(e){let t=this.instances.get(e);t&&(t.cleanup.forEach(n=>n()),this.instances.delete(e))},destroyAll:function(){this.instances.forEach((e,t)=>{this.destroy(t)})}};typeof window.Vanduo<"u"&&window.Vanduo.register("collapsible",p),window.VanduoCollapsible=p})();(function(){"use strict";let p={instances:new Map,init:function(){document.querySelectorAll(".vd-dropdown").forEach(t=>{this.instances.has(t)||this.initDropdown(t)})},initDropdown:function(e){let t=e.querySelector(".vd-dropdown-toggle"),n=e.querySelector(".vd-dropdown-menu");if(!t||!n)return;let s=[];t.setAttribute("aria-haspopup","true"),t.setAttribute("aria-expanded","false"),n.setAttribute("role","menu"),n.setAttribute("aria-hidden","true");let o=c=>{c.preventDefault(),c.stopPropagation(),this.toggleDropdown(e,t,n)};t.addEventListener("click",o),s.push(()=>t.removeEventListener("click",o));let i=c=>{!e.contains(c.target)&&n.classList.contains("is-open")&&this.closeDropdown(e,t,n)};document.addEventListener("click",i),s.push(()=>document.removeEventListener("click",i));let a=c=>{this.handleKeydown(c,e,t,n)};t.addEventListener("keydown",a),s.push(()=>t.removeEventListener("keydown",a)),n.querySelectorAll(".vd-dropdown-item:not(.disabled):not(.is-disabled)").forEach(c=>{let l=h=>{h.preventDefault(),this.selectItem(c,e,t,n)};c.addEventListener("click",l),s.push(()=>c.removeEventListener("click",l));let f=h=>{(h.key==="Enter"||h.key===" ")&&(h.preventDefault(),this.selectItem(c,e,t,n))};c.addEventListener("keydown",f),s.push(()=>c.removeEventListener("keydown",f))}),this.instances.set(e,{toggle:t,menu:n,cleanup:s,typeaheadBuffer:"",typeaheadTimer:null})},toggleDropdown:function(e,t,n){n.classList.contains("is-open")?this.closeDropdown(e,t,n):this.openDropdown(e,t,n)},openDropdown:function(e,t,n){document.querySelectorAll(".vd-dropdown-menu.is-open").forEach(i=>{if(i!==n){let a=i.closest(".vd-dropdown"),r=a.querySelector(".vd-dropdown-toggle");this.closeDropdown(a,r,i)}}),e.classList.add("is-open"),n.classList.add("is-open"),t.setAttribute("aria-expanded","true"),n.setAttribute("aria-hidden","false"),this.positionMenu(e,n);let o=n.querySelector(".vd-dropdown-item:not(.disabled):not(.is-disabled)");o&&setTimeout(()=>o.focus(),0)},closeDropdown:function(e,t,n){e.classList.remove("is-open"),n.classList.remove("is-open"),t.setAttribute("aria-expanded","false"),n.setAttribute("aria-hidden","true"),t.focus()},positionMenu:function(e,t){let n=e.getBoundingClientRect(),s=t.getBoundingClientRect(),o=window.innerWidth,i=window.innerHeight,a=8;if(t.classList.remove("vd-dropdown-menu-end","vd-dropdown-menu-start","vd-dropdown-menu-top"),e.classList.contains("vd-dropdown-dropup")){t.classList.add("vd-dropdown-menu-top");return}e.classList.contains("vd-dropdown-dropright")||e.classList.contains("vd-dropdown-dropleft")||(n.left+s.width>o-a?t.classList.add("vd-dropdown-menu-end"):t.classList.add("vd-dropdown-menu-start"),n.bottom+s.height>i-a&&n.top-s.height>a&&t.classList.add("vd-dropdown-menu-top"))},handleKeydown:function(e,t,n,s){let o=s.classList.contains("is-open"),i=Array.from(s.querySelectorAll(".vd-dropdown-item:not(.disabled):not(.is-disabled)")),a=i.findIndex(r=>r===document.activeElement);switch(e.key){case"Enter":case" ":case"ArrowDown":if(e.preventDefault(),!o)this.openDropdown(t,n,s);else if(e.key==="ArrowDown"){let r=a0?a-1:i.length-1;i[r].focus()}break;case"Escape":o&&(e.preventDefault(),this.closeDropdown(t,n,s));break;case"Home":o&&(e.preventDefault(),i[0].focus());break;case"End":o&&(e.preventDefault(),i[i.length-1].focus());break;default:if(o&&e.key.length===1&&!e.ctrlKey&&!e.metaKey&&!e.altKey){let r=this.instances.get(t);if(!r)break;clearTimeout(r.typeaheadTimer),r.typeaheadBuffer+=e.key.toLowerCase();let c=i.find(l=>l.textContent.trim().toLowerCase().startsWith(r.typeaheadBuffer));c&&c.focus(),r.typeaheadTimer=setTimeout(()=>{r.typeaheadBuffer=""},500)}break}},selectItem:function(e,t,n,s){s.querySelectorAll(".vd-dropdown-item").forEach(o=>{o.classList.remove("active","is-active")}),e.classList.add("active","is-active"),(n.tagName==="BUTTON"||n.classList.contains("btn"))&&(n.textContent=e.textContent.trim()),this.closeDropdown(t,n,s),e.dispatchEvent(new CustomEvent("dropdown:select",{bubbles:!0,detail:{item:e,value:e.dataset.value||e.textContent}}))},open:function(e){let t=typeof e=="string"?document.querySelector(e):e;if(t){let n=t.querySelector(".vd-dropdown-toggle"),s=t.querySelector(".vd-dropdown-menu");n&&s&&this.openDropdown(t,n,s)}},close:function(e){let t=typeof e=="string"?document.querySelector(e):e;if(t){let n=t.querySelector(".vd-dropdown-toggle"),s=t.querySelector(".vd-dropdown-menu");n&&s&&this.closeDropdown(t,n,s)}},destroy:function(e){let t=this.instances.get(e);t&&(t.cleanup.forEach(n=>n()),this.instances.delete(e))},destroyAll:function(){this.instances.forEach((e,t)=>{this.destroy(t)})}};typeof window.Vanduo<"u"&&window.Vanduo.register("dropdown",p),window.VanduoDropdown=p})();(function(){"use strict";let p={STORAGE_KEY:"vanduo-font-preference",isInitialized:!1,fonts:{system:{name:"System Default",family:null},"jetbrains-mono":{name:"JetBrains Mono",family:"'JetBrains Mono', monospace"},ubuntu:{name:"Ubuntu",family:"'Ubuntu', sans-serif",category:"sans-serif",description:"Friendly, humanist sans-serif"},"open-sans":{name:"Open Sans",family:"'Open Sans', sans-serif",category:"sans-serif",description:"Neutral, highly readable"},lato:{name:"Lato",family:"'Lato', sans-serif",category:"sans-serif",description:"Friendly, rounded sans-serif"}},init:function(){if(this.state={preference:this.getPreference()},this.fonts[this.state.preference]||(this.state.preference="ubuntu",this.setStorageValue(this.STORAGE_KEY,this.state.preference)),this.isInitialized){this.applyFont(),this.renderUI(),this.updateUI();return}this.isInitialized=!0,this.applyFont(),this.renderUI(),console.log("Vanduo Font Switcher initialized")},getPreference:function(){return this.getStorageValue(this.STORAGE_KEY,"ubuntu")},setPreference:function(e){if(!this.fonts[e]){console.warn("Unknown font:",e);return}this.state.preference=e,this.setStorageValue(this.STORAGE_KEY,e),this.applyFont(),this.updateUI();let t=new CustomEvent("font:change",{bubbles:!0,detail:{font:e,fontData:this.fonts[e]}});document.dispatchEvent(t)},applyFont:function(){let e=this.state.preference;e==="system"?document.documentElement.removeAttribute("data-font"):document.documentElement.setAttribute("data-font",e)},renderUI:function(){document.querySelectorAll('[data-toggle="font"]').forEach(t=>{if(t.getAttribute("data-font-initialized")==="true"){t.tagName==="SELECT"&&(t.value=this.state.preference);return}if(t.tagName==="SELECT"){t.value=this.state.preference;let n=s=>{this.setPreference(s.target.value)};t.addEventListener("change",n),t._fontToggleHandler=n}else{let n=()=>{let s=Object.keys(this.fonts),i=(s.indexOf(this.state.preference)+1)%s.length;this.setPreference(s[i])};t.addEventListener("click",n),t._fontToggleHandler=n}t.setAttribute("data-font-initialized","true")})},updateUI:function(){document.querySelectorAll('[data-toggle="font"]').forEach(t=>{if(t.tagName==="SELECT")t.value=this.state.preference;else{let n=t.querySelector(".font-current-label");n&&(n.textContent=this.fonts[this.state.preference].name)}})},getCurrentFont:function(){return this.state.preference},getFontData:function(e){return this.fonts[e]||null},destroyAll:function(){document.querySelectorAll('[data-toggle="font"][data-font-initialized="true"]').forEach(t=>{if(t._fontToggleHandler){let n=t.tagName==="SELECT"?"change":"click";t.removeEventListener(n,t._fontToggleHandler),delete t._fontToggleHandler}t.removeAttribute("data-font-initialized")}),this.isInitialized=!1},getStorageValue:function(e,t){if(typeof window.safeStorageGet=="function")return window.safeStorageGet(e,t);try{let n=localStorage.getItem(e);return n!==null?n:t}catch{return t}},setStorageValue:function(e,t){if(typeof window.safeStorageSet=="function")return window.safeStorageSet(e,t);try{return localStorage.setItem(e,t),!0}catch{return!1}}};window.Vanduo&&window.Vanduo.register("fontSwitcher",p),window.FontSwitcher=p})();(function(){"use strict";let p=(function(){try{return CSS.supports("selector(:has(*))")}catch{return!1}})(),e={instances:new Map,init:function(){document.querySelectorAll("[data-layout-mode]").forEach(function(n){this.instances.has(n)||this.initContainer(n)}.bind(this)),this.initToggleButtons()},initContainer:function(t){let n=t.getAttribute("data-layout-mode")||"standard",s=[];this.applyMode(t,n),t.setAttribute("role","region"),t.setAttribute("aria-label","Grid layout: "+n+" mode"),this.instances.set(t,{cleanup:s,mode:n})},initToggleButtons:function(){document.querySelectorAll("[data-grid-toggle]").forEach(function(n){if(n.getAttribute("data-grid-initialized")==="true")return;let s=function(o){o.preventDefault();let i=n.getAttribute("data-grid-toggle"),a;i?a=document.querySelector(i):a=n.closest("[data-layout-mode]"),a&&this.toggle(a)}.bind(this);n.addEventListener("click",s),n.setAttribute("data-grid-initialized","true"),n.setAttribute("aria-pressed","false"),n._gridCleanup=function(){n.removeEventListener("click",s),n.removeAttribute("data-grid-initialized"),n.removeAttribute("aria-pressed")}}.bind(this))},applyFibFallback:function(t){if(p)return;t.querySelectorAll(".vd-row, .row").forEach(function(s){let i=s.querySelectorAll(':scope > [class*="vd-col-"], :scope > [class*="col-"]').length;i===1?s.style.gridTemplateColumns="1fr":i===2?s.style.gridTemplateColumns="1fr 1.618fr":i===3?s.style.gridTemplateColumns="2fr 3fr 5fr":i===4?s.style.gridTemplateColumns="1fr 2fr 3fr 5fr":s.style.gridTemplateColumns="repeat("+i+", 1fr)"})},removeFibFallback:function(t){t.querySelectorAll(".vd-row, .row").forEach(function(s){s.style.gridTemplateColumns=""})},applyMode:function(t,n){t.classList.remove("vd-grid-standard","vd-grid-fibonacci"),n==="fibonacci"?(t.classList.add("vd-grid-fibonacci"),this.applyFibFallback(t)):(t.classList.add("vd-grid-standard"),this.removeFibFallback(t)),t.setAttribute("data-layout-mode",n),t.setAttribute("aria-label","Grid layout: "+n+" mode"),document.querySelectorAll("[data-grid-toggle]").forEach(function(a){let r=a.getAttribute("data-grid-toggle");if(r&&t.matches(r)){let c=n==="fibonacci";c?a.classList.add("is-active"):a.classList.remove("is-active"),a.setAttribute("aria-pressed",c?"true":"false")}});let o=this.instances.get(t);o&&(o.mode=n);let i;try{i=new CustomEvent("grid:modechange",{bubbles:!0,detail:{container:t,mode:n}})}catch{i=document.createEvent("CustomEvent"),i.initCustomEvent("grid:modechange",!0,!0,{container:t,mode:n})}t.dispatchEvent(i)},toggle:function(t){if(typeof t=="string"&&(t=document.querySelector(t)),!t)return;let s=(t.getAttribute("data-layout-mode")||"standard")==="fibonacci"?"standard":"fibonacci";this.applyMode(t,s)},setMode:function(t,n){typeof t=="string"&&(t=document.querySelector(t)),t&&(n!=="fibonacci"&&n!=="standard"||this.applyMode(t,n))},getMode:function(t){return typeof t=="string"&&(t=document.querySelector(t)),t?t.getAttribute("data-layout-mode")||"standard":null},destroy:function(t){let n=this.instances.get(t);n&&(n.cleanup.forEach(function(s){s()}),t.classList.remove("vd-grid-standard","vd-grid-fibonacci"),t.removeAttribute("aria-label"),this.removeFibFallback(t),this.instances.delete(t))},destroyAll:function(){this.instances.forEach(function(n,s){this.destroy(s)}.bind(this)),document.querySelectorAll('[data-grid-initialized="true"]').forEach(function(n){n._gridCleanup&&(n._gridCleanup(),delete n._gridCleanup)})}};typeof window.Vanduo<"u"&&window.Vanduo.register("gridLayout",e),window.VanduoGridLayout=e})();(function(){"use strict";let p={backdrop:null,container:null,img:null,closeBtn:null,caption:null,currentTrigger:null,scrollThreshold:50,initialScrollY:0,isOpen:!1,_cleanupFunctions:[],init:function(){this.createBackdrop(),this.bindTriggers()},createBackdrop:function(){if(this.backdrop||document.querySelector(".vd-image-box-backdrop")){this.backdrop||(this.backdrop=document.querySelector(".vd-image-box-backdrop"),this.container=this.backdrop.querySelector(".vd-image-box-container"),this.img=this.backdrop.querySelector(".vd-image-box-img"),this.closeBtn=this.backdrop.querySelector(".vd-image-box-close"),this.caption=this.backdrop.querySelector(".vd-image-box-caption"),this.bindBackdropEvents());return}this.backdrop=document.createElement("div"),this.backdrop.className="vd-image-box-backdrop",this.backdrop.setAttribute("role","dialog"),this.backdrop.setAttribute("aria-modal","true"),this.backdrop.setAttribute("aria-label","Image viewer"),this.backdrop.setAttribute("tabindex","-1"),this.container=document.createElement("div"),this.container.className="vd-image-box-container",this.img=document.createElement("img"),this.img.className="vd-image-box-img",this.img.alt="",this.closeBtn=document.createElement("button"),this.closeBtn.className="vd-image-box-close",this.closeBtn.setAttribute("aria-label","Close image viewer"),this.closeBtn.innerHTML="×",this.caption=document.createElement("div"),this.caption.className="vd-image-box-caption",this.container.appendChild(this.img),this.backdrop.appendChild(this.closeBtn),this.backdrop.appendChild(this.container),this.backdrop.appendChild(this.caption),document.body.appendChild(this.backdrop),this.bindBackdropEvents()},bindBackdropEvents:function(){let e=this,t=function(a){(a.target===e.backdrop||a.target===e.container)&&e.close()};this.backdrop.addEventListener("click",t),this._cleanupFunctions.push(()=>this.backdrop.removeEventListener("click",t));let n=function(){e.close()};this.img.addEventListener("click",n),this._cleanupFunctions.push(()=>this.img.removeEventListener("click",n));let s=function(){e.close()};this.closeBtn.addEventListener("click",s),this._cleanupFunctions.push(()=>this.closeBtn.removeEventListener("click",s));let o=function(a){a.key==="Escape"&&e.isOpen&&e.close()};document.addEventListener("keydown",o),this._cleanupFunctions.push(()=>document.removeEventListener("keydown",o));let i=function(){if(!e.isOpen)return;let a=window.scrollY;Math.abs(a-e.initialScrollY)>e.scrollThreshold&&e.close()};window.addEventListener("scroll",i,{passive:!0}),this._cleanupFunctions.push(()=>window.removeEventListener("scroll",i))},bindTriggers:function(){let e=this;document.querySelectorAll("[data-image-box]").forEach(function(n){if(n.dataset.imageBoxInitialized)return;if(n.dataset.imageBoxInitialized="true",n.classList.add("vd-image-box-trigger"),n.tagName==="IMG"){n.complete&&n.naturalWidth===0&&n.classList.add("is-broken");let o=function(){n.classList.add("is-broken")};n.addEventListener("error",o);let i=function(){n.classList.remove("is-broken")};n.addEventListener("load",i)}let s=function(o){o.preventDefault(),e.open(n)};if(n.addEventListener("click",s),n._imageBoxCleanup=()=>n.removeEventListener("click",s),n.tagName!=="BUTTON"&&n.tagName!=="A"){n.setAttribute("role","button"),n.setAttribute("tabindex","0"),n.setAttribute("aria-label","View enlarged image");let o=function(a){(a.key==="Enter"||a.key===" ")&&(a.preventDefault(),e.open(n))};n.addEventListener("keydown",o);let i=n._imageBoxCleanup;n._imageBoxCleanup=()=>{i(),n.removeEventListener("keydown",o)}}})},open:function(e){if(this.isOpen)return;this.currentTrigger=e,this.isOpen=!0,this.initialScrollY=window.scrollY;let t=e.dataset.imageBoxFullSrc||e.dataset.imageBoxSrc||e.src||e.href;if(!t){console.warn("[Vanduo ImageBox] No image source found for trigger:",e);return}let n=e.dataset.imageBoxCaption||e.alt||"";this.img.src=t,this.img.alt=e.alt||"",n?(this.caption.textContent=n,this.caption.style.display="block"):this.caption.style.display="none";let s=window.innerWidth-document.documentElement.clientWidth;document.body.style.setProperty("--scrollbar-width",`${s}px`),document.body.classList.add("body-image-box-open"),this.backdrop.classList.add("is-visible"),this.backdrop.focus(),e.dispatchEvent(new CustomEvent("imageBox:open",{bubbles:!0,detail:{src:t}})),this.img.complete||(this.img.style.opacity="0",this._imgLoadHandler=()=>{this.img.style.opacity=""},this.img.addEventListener("load",this._imgLoadHandler,{once:!0}))},close:function(){this.isOpen&&(this.isOpen=!1,this.backdrop.classList.remove("is-visible"),document.body.classList.remove("body-image-box-open"),document.body.style.removeProperty("--scrollbar-width"),this.currentTrigger&&(this.currentTrigger.focus(),this.currentTrigger.dispatchEvent(new CustomEvent("imageBox:close",{bubbles:!0})),this.currentTrigger=null),setTimeout(()=>{this.isOpen||(this._imgLoadHandler&&(this.img.removeEventListener("load",this._imgLoadHandler),this._imgLoadHandler=null),this.img.src="",this.img.alt="")},300))},reinit:function(){this.bindTriggers()},destroy:function(){this.isOpen&&this.close(),this.backdrop&&this.backdrop.parentNode&&this.backdrop.parentNode.removeChild(this.backdrop),this._cleanupFunctions.forEach(t=>t()),this._cleanupFunctions=[],document.querySelectorAll("[data-image-box-initialized]").forEach(t=>{t.classList.remove("vd-image-box-trigger"),t._imageBoxCleanup&&(t._imageBoxCleanup(),delete t._imageBoxCleanup),delete t.dataset.imageBoxInitialized}),this.backdrop=null,this.container=null,this.img=null,this.closeBtn=null,this.caption=null,this.currentTrigger=null,this.isOpen=!1},destroyAll:function(){this.destroy()}};typeof window.Vanduo<"u"&&window.Vanduo.register("imageBox",p),window.VanduoImageBox=p})();(function(){"use strict";let p={modals:new Map,openModals:[],zIndexCounter:1050,_triggerCleanups:[],_sharedEscHandler:null,getPortalState:function(e){return e._vdPortalState||(e._vdPortalState={originalParent:null,originalNextSibling:null,placeholder:null}),e._vdPortalState},portalToBody:function(e){if(!e||e.parentNode===document.body)return;let t=this.getPortalState(e);t.originalParent=e.parentNode,t.originalNextSibling=e.nextSibling,t.placeholder||(t.placeholder=document.createComment("vd-modal-placeholder")),t.originalParent.insertBefore(t.placeholder,e),document.body.appendChild(e),e.dataset.vdPortaled="true"},restoreFromPortal:function(e){if(!e)return;let t=this.getPortalState(e);if(!t.placeholder){delete e.dataset.vdPortaled;return}t.placeholder.parentNode?(t.placeholder.parentNode.insertBefore(e,t.placeholder),t.placeholder.parentNode.removeChild(t.placeholder)):t.originalParent&&t.originalParent.isConnected&&(t.originalNextSibling&&t.originalNextSibling.parentNode===t.originalParent?t.originalParent.insertBefore(e,t.originalNextSibling):t.originalParent.appendChild(e)),t.originalParent=null,t.originalNextSibling=null,t.placeholder=null,delete e.dataset.vdPortaled},init:function(){document.querySelectorAll(".vd-modal").forEach(n=>{this.modals.has(n)||this.initModal(n)}),document.querySelectorAll("[data-modal]").forEach(n=>{if(n.dataset.modalTriggerInitialized)return;n.dataset.modalTriggerInitialized="true";let s=o=>{o.preventDefault();let i=n.dataset.modal,a=document.querySelector(i);a&&this.open(a)};n.addEventListener("click",s),this._triggerCleanups.push(()=>n.removeEventListener("click",s))})},initModal:function(e){let t=this.createBackdrop(e),n=e.querySelectorAll('.vd-modal-close, [data-dismiss="modal"]'),s=e.querySelector(".vd-modal-dialog");if(!s)return;let o=[];e.setAttribute("role","dialog"),e.setAttribute("aria-modal","true"),e.setAttribute("aria-hidden","true"),e.id||(e.id="modal-"+Math.random().toString(36).substr(2,9));let i=e.querySelector(".vd-modal-title");i&&!i.id&&(i.id=e.id+"-title",e.setAttribute("aria-labelledby",i.id)),n.forEach(r=>{let c=()=>{this.close(e)};r.addEventListener("click",c),o.push(()=>r.removeEventListener("click",c))});let a=r=>{r.target===t&&e.dataset.backdrop!=="static"&&this.close(e)};t.addEventListener("click",a),o.push(()=>t.removeEventListener("click",a)),this._sharedEscHandler||(this._sharedEscHandler=r=>{if(r.key==="Escape"&&this.openModals.length>0){let c=this.openModals[this.openModals.length-1];c.dataset.keyboard!=="false"&&this.close(c)}},document.addEventListener("keydown",this._sharedEscHandler)),this.modals.set(e,{backdrop:t,dialog:s,trapHandler:null,cleanup:o})},createBackdrop:function(e){let t=e.querySelector(".vd-modal-backdrop");return t||(t=document.createElement("div"),t.className="vd-modal-backdrop",document.body.appendChild(t)),t},open:function(e){let t=typeof e=="string"?document.querySelector(e):e;if(!t){console.warn("[Vanduo Modals] Modal element not found:",e);return}if(!this.modals.has(t)){console.warn("[Vanduo Modals] Modal not initialized:",t);return}let n=this.modals.get(t),{backdrop:s,dialog:o}=n;if(this.portalToBody(t),this.zIndexCounter+=10,t.style.zIndex=this.zIndexCounter,s.style.zIndex=this.zIndexCounter-1,this.openModals.push(t),s.classList.add("is-visible"),t.classList.add("is-open"),t.setAttribute("aria-hidden","false"),this.openModals.length===1){document.body.classList.add("body-modal-open");let a=window.innerWidth-document.documentElement.clientWidth;a>0&&(document.body.style.paddingRight=`${a}px`)}let i=this.trapFocus(t);n.trapHandler=i,setTimeout(()=>{let a=this.getFocusableElements(t)[0];a&&a.focus()},100),t.dispatchEvent(new CustomEvent("modal:open",{bubbles:!0}))},close:function(e){let t=typeof e=="string"?document.querySelector(e):e;if(!t){console.warn("[Vanduo Modals] Modal element not found:",e);return}if(!this.modals.has(t)){console.warn("[Vanduo Modals] Modal not initialized:",t);return}let n=this.modals.get(t),{backdrop:s,trapHandler:o}=n;o&&(t.removeEventListener("keydown",o),n.trapHandler=null);let i=this.openModals.indexOf(t);if(i>-1&&this.openModals.splice(i,1),t.classList.remove("is-open"),t.setAttribute("aria-hidden","true"),this.openModals.length===0)s.classList.remove("is-visible"),document.body.classList.remove("body-modal-open"),document.body.style.paddingRight="",this.zIndexCounter=1050;else{let r=this.openModals[this.openModals.length-1];this.modals.get(r).backdrop.classList.add("is-visible")}let a=document.querySelector(`[data-modal="#${t.id}"]`);a&&a.focus(),t.dispatchEvent(new CustomEvent("modal:close",{bubbles:!0})),this.restoreFromPortal(t)},trapFocus:function(e){let t=this,n=function(s){if(s.key!=="Tab")return;let o=t.getFocusableElements(e),i=o[0],a=o[o.length-1];s.shiftKey?document.activeElement===i&&(s.preventDefault(),a.focus()):document.activeElement===a&&(s.preventDefault(),i.focus())};return e.addEventListener("keydown",n),n},getFocusableElements:function(e){return Array.from(e.querySelectorAll('a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter(n=>!n.hasAttribute("disabled")&&n.offsetWidth>0&&n.offsetHeight>0)},toggle:function(e){let t=typeof e=="string"?document.querySelector(e):e;t&&(t.classList.contains("is-open")?this.close(t):this.open(t))},destroy:function(e){let t=this.modals.get(e);if(t){if(e.classList.contains("is-open")){let n=this.openModals.indexOf(e);n>-1&&this.openModals.splice(n,1),t.backdrop.classList.remove("is-visible"),e.classList.remove("is-open"),e.setAttribute("aria-hidden","true"),this.openModals.length===0&&(document.body.classList.remove("body-modal-open"),document.body.style.paddingRight="",this.zIndexCounter=1050)}this.restoreFromPortal(e),t.cleanup&&t.cleanup.forEach(n=>n()),t.backdrop&&t.backdrop.parentNode&&t.backdrop.parentNode.removeChild(t.backdrop),this.modals.delete(e)}},destroyAll:function(){this.modals.forEach((e,t)=>{this.destroy(t)}),this._triggerCleanups.forEach(e=>e()),this._triggerCleanups=[],this._sharedEscHandler&&(document.removeEventListener("keydown",this._sharedEscHandler),this._sharedEscHandler=null)}};typeof window.Vanduo<"u"&&window.Vanduo.register("modals",p),window.VanduoModals=p})();(function(){"use strict";let p={instances:new Map,getBreakpoint:function(){let t=getComputedStyle(document.documentElement).getPropertyValue("--breakpoint-lg").trim(),n=parseInt(t,10);return isNaN(n)?992:n},init:function(){document.querySelectorAll(".vd-navbar").forEach(t=>{this.instances.has(t)||this.initNavbar(t)})},initScrollWatcher:function(e){let t=e.classList.contains("vd-navbar-glass"),n=e.classList.contains("vd-navbar-transparent");if(!t&&!n)return null;let s=()=>{let i=parseInt(e.dataset.scrollThreshold,10);return isNaN(i)?e.offsetHeight||60:i},o=()=>{let i=window.scrollY>s();e.classList.toggle("vd-navbar-scrolled",i)};return o(),window.addEventListener("scroll",o,{passive:!0}),()=>window.removeEventListener("scroll",o)},initNavbar:function(e){let t=e.querySelector(".vd-navbar-toggle, .vd-navbar-burger"),n=e.querySelector(".vd-navbar-menu"),s=e.querySelector(".vd-navbar-overlay")||this.createOverlay(e),o=[],i=this.initScrollWatcher(e);if(i&&o.push(i),!t||!n){o.length&&this.instances.set(e,{toggle:null,menu:null,overlay:null,cleanup:o});return}let a=u=>{u.preventDefault(),u.stopPropagation(),this.toggleMenu(e,t,n,s)};if(t.addEventListener("click",a),o.push(()=>t.removeEventListener("click",a)),s){let u=()=>{this.closeMenu(e,t,n,s)};s.addEventListener("click",u),o.push(()=>s.removeEventListener("click",u))}let r=u=>{u.key==="Escape"&&n.classList.contains("is-open")&&this.closeMenu(e,t,n,s)};document.addEventListener("keydown",r),o.push(()=>document.removeEventListener("keydown",r));let c,l=()=>{clearTimeout(c),c=setTimeout(()=>{let u=this.getBreakpoint();window.innerWidth>=u&&n.classList.contains("is-open")&&this.closeMenu(e,t,n,s)},250)};window.addEventListener("resize",l),o.push(()=>{clearTimeout(c),window.removeEventListener("resize",l)});let f=u=>{n.classList.contains("is-open")&&!e.contains(u.target)&&!n.contains(u.target)&&this.closeMenu(e,t,n,s)};document.addEventListener("click",f),o.push(()=>document.removeEventListener("click",f)),n.querySelectorAll(".vd-navbar-dropdown > .vd-nav-link, .vd-navbar-dropdown > .nav-link").forEach(u=>{let d=m=>{let b=this.getBreakpoint();if(window.innerWidthu.removeEventListener("click",d))}),this.instances.set(e,{toggle:t,menu:n,overlay:s,cleanup:o})},destroy:function(e){let t=this.instances.get(e);t&&(t.cleanup.forEach(n=>n()),t.overlay&&t.overlay.parentNode&&t.overlay.parentNode.removeChild(t.overlay),this.instances.delete(e))},destroyAll:function(){this.instances.forEach((e,t)=>{this.destroy(t)})},toggleMenu:function(e,t,n,s){n.classList.contains("is-open")?this.closeMenu(e,t,n,s):this.openMenu(e,t,n,s)},openMenu:function(e,t,n,s){n.classList.add("is-open"),t.classList.add("is-active"),s&&s.classList.add("is-active"),document.body.classList.add("body-navbar-open"),t.setAttribute("aria-expanded","true"),n.setAttribute("aria-hidden","false")},closeMenu:function(e,t,n,s){n.classList.remove("is-open"),t.classList.remove("is-active"),s&&s.classList.remove("is-active"),document.body.classList.remove("body-navbar-open"),n.querySelectorAll(".vd-navbar-dropdown-menu.is-open").forEach(i=>{i.classList.remove("is-open")}),t.setAttribute("aria-expanded","false"),n.setAttribute("aria-hidden","true")},createOverlay:function(e){let t=document.createElement("div");return t.className="vd-navbar-overlay",document.body.appendChild(t),t}};typeof window.Vanduo<"u"&&window.Vanduo.register("navbar",p),window.VanduoNavbar=p})();(function(){"use strict";let p={instances:new Map,init:function(){document.querySelectorAll(".vd-pagination[data-pagination]").forEach(t=>{this.instances.has(t)||this.initPagination(t)})},initPagination:function(e){let t=parseInt(e.dataset.totalPages)||1,n=parseInt(e.dataset.currentPage)||1,s=parseInt(e.dataset.maxVisible)||7;this.render(e,{totalPages:t,currentPage:n,maxVisible:s});let o=i=>{let a=i.target.closest(".vd-pagination-link");if(!a||a.closest(".vd-pagination-item.disabled")||a.closest(".vd-pagination-item.active"))return;i.preventDefault();let r=a.closest(".vd-pagination-item"),c=r.dataset.page;c?this.goToPage(e,parseInt(c)):r.classList.contains("pagination-prev")?this.prevPage(e):r.classList.contains("pagination-next")&&this.nextPage(e)};e.addEventListener("click",o),this.instances.set(e,{cleanup:[()=>e.removeEventListener("click",o)]})},render:function(e,t){let{totalPages:n,currentPage:s,maxVisible:o}=t;if(n<=1){e.innerHTML="";return}let i="";i+=`
  • `,i+='Previous',i+="
  • ";let a=this.calculatePages(s,n,o),r=0;a.forEach(c=>{if(c==="ellipsis")i+='
  • \u2026
  • ';else{c!==r+1&&r>0&&(i+='
  • \u2026
  • ');let l=Number(c);i+=`
  • `,i+=`${l}`,i+="
  • ",r=c}}),i+=`
  • `,i+='Next',i+="
  • ",e.innerHTML=i,e.dataset.currentPage=s},calculatePages:function(e,t,n){let s=[],o=Math.floor(n/2);if(t<=n)for(let i=1;i<=t;i++)s.push(i);else{s.push(1);let i=Math.max(2,e-o),a=Math.min(t-1,e+o);e<=o+1&&(a=Math.min(t-1,n-1)),e>=t-o&&(i=Math.max(2,t-n+2)),i>2&&s.push("ellipsis");for(let r=i;r<=a;r++)s.push(r);a1&&s.push(t)}return s},goToPage:function(e,t){let n=parseInt(e.dataset.totalPages)||1,s=parseInt(e.dataset.maxVisible)||7;t<1||t>n||(this.render(e,{totalPages:n,currentPage:t,maxVisible:s}),e.dispatchEvent(new CustomEvent("pagination:change",{bubbles:!0,detail:{page:t,totalPages:n}})))},prevPage:function(e){let t=parseInt(e.dataset.currentPage)||1;t>1&&this.goToPage(e,t-1)},nextPage:function(e){let t=parseInt(e.dataset.currentPage)||1,n=parseInt(e.dataset.totalPages)||1;tn()),this.instances.delete(e))},destroyAll:function(){this.instances.forEach((e,t)=>{this.destroy(t)})}};typeof window.Vanduo<"u"&&window.Vanduo.register("pagination",p),window.VanduoPagination=p})();(function(){"use strict";let p={parallaxElements:new Map,ticking:!1,isMobile:window.innerWidth<768,reducedMotion:window.matchMedia("(prefers-reduced-motion: reduce)").matches,isInitialized:!1,_onScroll:null,_onResize:null,init:function(){if(this.isInitialized){this.refresh();return}if(this.isInitialized=!0,this.reducedMotion)return;document.querySelectorAll(".vd-parallax").forEach(t=>{t.dataset.parallaxInitialized||this.initParallax(t)}),this.handleScroll(),this._onScroll=()=>{this.handleScroll()},window.addEventListener("scroll",this._onScroll,{passive:!0}),this._onResize=()=>{this.isMobile=window.innerWidth<768,this.updateAll()},window.addEventListener("resize",this._onResize)},initParallax:function(e){e.dataset.parallaxInitialized="true";let t=e.classList.contains("parallax-disable-mobile");if(t&&this.isMobile)return;let n=e.querySelectorAll(".vd-parallax-layer, .vd-parallax-bg"),s=this.getSpeed(e),o=e.classList.contains("parallax-horizontal")?"horizontal":"vertical";this.parallaxElements.set(e,{layers:Array.from(n),speed:s,direction:o,disableMobile:t}),this.updateParallax(e)},getSpeed:function(e){return e.classList.contains("parallax-slow")?.5:e.classList.contains("parallax-fast")?1.5:1},handleScroll:function(){this.ticking||(window.requestAnimationFrame(()=>{this.updateAll(),this.ticking=!1}),this.ticking=!0)},updateAll:function(){this.parallaxElements.forEach((e,t)=>{e.disableMobile&&this.isMobile||this.updateParallax(t)})},updateParallax:function(e){let t=this.parallaxElements.get(e);if(!t)return;let n=e.getBoundingClientRect(),s=window.innerHeight,o=n.top,i=n.height,r=(Math.max(0,Math.min(1,(s-o)/(s+i)))-.5)*t.speed*100;t.layers.forEach((c,l)=>{let f=c.dataset.parallaxSpeed?parseFloat(c.dataset.parallaxSpeed):1,h=r*f;t.direction==="horizontal"?c.style.transform=`translateX(${h}px)`:c.style.transform=`translateY(${h}px)`})},destroy:function(e){let t=typeof e=="string"?document.querySelector(e):e;t&&this.parallaxElements.has(t)&&(this.parallaxElements.get(t).layers.forEach(s=>{s.style.transform=""}),this.parallaxElements.delete(t))},refresh:function(){this.updateAll()},destroyAll:function(){this.parallaxElements.forEach((e,t)=>{this.destroy(t)}),this.parallaxElements.clear(),this._onScroll&&(window.removeEventListener("scroll",this._onScroll),this._onScroll=null),this._onResize&&(window.removeEventListener("resize",this._onResize),this._onResize=null),this.isInitialized=!1}};typeof window.Vanduo<"u"&&window.Vanduo.register("parallax",p),window.VanduoParallax=p})();(function(){"use strict";let p={init:function(){document.querySelectorAll(".vd-progress-bar[data-progress], .progress-bar[data-progress]").forEach(t=>{t.dataset.progressInitialized||this.initProgressBar(t)})},initProgressBar:function(e){e.dataset.progressInitialized="true";let t=parseInt(e.dataset.progress)||0;this.setProgress(e,t,!1)},setProgress:function(e,t,n=!0){let s=typeof e=="string"?document.querySelector(e):e;if(!s)return;t=Math.max(0,Math.min(100,t)),n?s.style.transition="width var(--transition-duration-slow) var(--transition-ease)":(s.style.transition="none",setTimeout(()=>{s.style.transition=""},0)),s.style.width=t+"%",s.setAttribute("aria-valuenow",t),s.setAttribute("aria-valuemin",0),s.setAttribute("aria-valuemax",100);let o=s.querySelector(".vd-progress-text, .progress-text");o&&(o.textContent=t+"%"),s.dispatchEvent(new CustomEvent("progress:update",{bubbles:!0,detail:{value:t,max:100}})),t>=100&&s.dispatchEvent(new CustomEvent("progress:complete",{bubbles:!0,detail:{value:t,max:100}}))},animateProgress:function(e,t,n=1e3){let s=typeof e=="string"?document.querySelector(e):e;if(!s)return;let o=parseInt(s.style.width)||0,i=t-o,a=performance.now(),r=c=>{let l=c-a,f=Math.min(l/n,1),h=1-Math.pow(1-f,3),u=o+i*h;this.setProgress(s,u,!1),f<1&&requestAnimationFrame(r)};requestAnimationFrame(r)},show:function(e){let t=typeof e=="string"?document.querySelector(e):e;t&&(t.style.display="inline-block",t.setAttribute("aria-hidden","false"))},hide:function(e){let t=typeof e=="string"?document.querySelector(e):e;t&&(t.style.display="none",t.setAttribute("aria-hidden","true"))},toggle:function(e){let t=typeof e=="string"?document.querySelector(e):e;t&&(t.style.display==="none"||t.getAttribute("aria-hidden")==="true"?this.show(t):this.hide(t))},destroyAll:function(){document.querySelectorAll('.vd-progress-bar[data-progress-initialized="true"], .progress-bar[data-progress-initialized="true"]').forEach(t=>{delete t.dataset.progressInitialized})}};typeof window.Vanduo<"u"&&window.Vanduo.register("preloader",p),window.VanduoPreloader=p})();(function(){"use strict";let p={instances:new Map,init:function(){document.querySelectorAll("select.vd-custom-select-input, select[data-custom-select]").forEach(t=>{this.instances.has(t)||this.initSelect(t)})},initSelect:function(e){if(e.closest(".vd-custom-select-wrapper"))return;let t=[],n=document.createElement("div");n.className="custom-select-wrapper",e.parentNode.insertBefore(n,e),n.appendChild(e);let s=document.createElement("button");s.type="button",s.className="custom-select-button",s.setAttribute("aria-haspopup","listbox"),s.setAttribute("aria-expanded","false"),s.setAttribute("aria-labelledby",e.id||this.generateId(e));let o=document.createElement("div");if(o.className="custom-select-dropdown",o.setAttribute("role","listbox"),e.dataset.searchable==="true"){let l=document.createElement("div");l.className="custom-select-search";let f=document.createElement("input");f.type="text",f.className="input input-sm",f.placeholder="Search...",f.setAttribute("aria-label","Search options"),l.appendChild(f),o.appendChild(l);let h=d=>{this.filterOptions(o,d.target.value)},u=typeof debounce=="function"?debounce(h,150):h;f.addEventListener("input",u),t.push(()=>f.removeEventListener("input",u))}this.buildOptions(e,o,s),n.appendChild(s),n.appendChild(o),this.updateButtonText(e,s);let i=l=>{l.preventDefault(),l.stopPropagation(),this.toggleDropdown(s,o)};s.addEventListener("click",i),t.push(()=>s.removeEventListener("click",i));let a=l=>{!n.contains(l.target)&&o.classList.contains("is-open")&&this.closeDropdown(s,o)};document.addEventListener("click",a),t.push(()=>document.removeEventListener("click",a));let r=l=>{this.handleKeydown(l,e,s,o)};s.addEventListener("keydown",r),t.push(()=>s.removeEventListener("keydown",r));let c=()=>{this.updateButtonText(e,s),this.updateSelectedOptions(e,o)};e.addEventListener("change",c),t.push(()=>e.removeEventListener("change",c)),this.instances.set(e,{wrapper:n,button:s,dropdown:o,cleanup:t,typeaheadBuffer:"",typeaheadTimer:null})},buildOptions:function(e,t,n){let s=e.querySelectorAll("option"),o=document.createDocumentFragment();s.forEach((i,a)=>{if(i.parentElement.tagName==="OPTGROUP"){let c=i.parentElement;if(!t.querySelector(`[data-group="${c.label}"]`)){let l=document.createElement("div");l.className="custom-select-option-group",l.textContent=c.label,l.dataset.group=c.label,o.appendChild(l)}}if(i.value===""&&!i.textContent.trim())return;let r=document.createElement("div");r.className="custom-select-option",r.textContent=i.textContent,r.setAttribute("role","option"),r.setAttribute("data-value",i.value),r.setAttribute("data-index",a),i.selected&&(r.classList.add("is-selected"),r.setAttribute("aria-selected","true")),i.disabled&&(r.classList.add("is-disabled"),r.setAttribute("aria-disabled","true")),r.addEventListener("click",c=>{i.disabled||this.selectOption(e,i,r,n,t)}),o.appendChild(r)}),t.appendChild(o)},selectOption:function(e,t,n,s,o){e.multiple?(t.selected=!t.selected,n.classList.toggle("is-selected"),n.setAttribute("aria-selected",t.selected)):(e.value=t.value,e.dispatchEvent(new Event("change",{bubbles:!0})),this.closeDropdown(s,o)),this.updateButtonText(e,s)},updateButtonText:function(e,t){if(e.multiple){let n=Array.from(e.selectedOptions);n.length===0?t.textContent=e.dataset.placeholder||"Select options...":n.length===1?t.textContent=n[0].textContent:t.textContent=`${n.length} selected`}else{let n=e.options[e.selectedIndex];t.textContent=n?n.textContent:e.dataset.placeholder||"Select..."}},updateSelectedOptions:function(e,t){let n=t.querySelectorAll(".custom-select-option"),s=Array.from(e.selectedOptions).map(o=>o.value);n.forEach(o=>{let i=o.dataset.value;s.includes(i)?(o.classList.add("is-selected"),o.setAttribute("aria-selected","true")):(o.classList.remove("is-selected"),o.setAttribute("aria-selected","false"))})},toggleDropdown:function(e,t){t.classList.contains("is-open")?this.closeDropdown(e,t):this.openDropdown(e,t)},openDropdown:function(e,t){t.classList.add("is-open"),e.setAttribute("aria-expanded","true");let n=t.querySelector(".custom-select-option:not(.is-disabled)");n&&n.focus()},closeDropdown:function(e,t){t.classList.remove("is-open"),e.setAttribute("aria-expanded","false")},handleKeydown:function(e,t,n,s){let o=s.classList.contains("is-open"),i=Array.from(s.querySelectorAll(".custom-select-option:not(.is-disabled)")),a=i.findIndex(r=>r===document.activeElement);switch(e.key){case"Enter":case" ":if(e.preventDefault(),o&&a>=0){let r=i[a],c=t.options[parseInt(r.dataset.index)];this.selectOption(t,c,r,n,s)}else this.openDropdown(n,s);break;case"Escape":o&&(e.preventDefault(),this.closeDropdown(n,s),n.focus());break;case"ArrowDown":if(e.preventDefault(),!o)this.openDropdown(n,s);else{let r=a0?a-1:i.length-1;i[r].focus()}break;case"Home":o&&(e.preventDefault(),i[0].focus());break;case"End":o&&(e.preventDefault(),i[i.length-1].focus());break;default:if(o&&e.key.length===1&&!e.ctrlKey&&!e.metaKey&&!e.altKey){let r=this.instances.get(t);if(!r)break;clearTimeout(r.typeaheadTimer),r.typeaheadBuffer+=e.key.toLowerCase();let c=i.find(l=>l.textContent.trim().toLowerCase().startsWith(r.typeaheadBuffer));c&&c.focus(),r.typeaheadTimer=setTimeout(()=>{r.typeaheadBuffer=""},500)}break}},filterOptions:function(e,t){let n=e.querySelectorAll(".vd-custom-select-option"),s=t.toLowerCase();n.forEach(o=>{o.textContent.toLowerCase().includes(s)?o.style.display="block":o.style.display="none"})},generateId:function(e){if(e.id)return e.id;let t="select-"+Math.random().toString(36).substr(2,9);return e.id=t,t},destroy:function(e){let t=this.instances.get(e);t&&(t.cleanup.forEach(n=>n()),t.wrapper&&t.wrapper.parentNode&&(t.wrapper.parentNode.insertBefore(e,t.wrapper),t.wrapper.parentNode.removeChild(t.wrapper)),this.instances.delete(e))},destroyAll:function(){this.instances.forEach((e,t)=>{this.destroy(t)})}};typeof window.Vanduo<"u"&&window.Vanduo.register("select",p)})();(function(){"use strict";let p={sidenavs:new Map,breakpoint:992,restoreDelayMs:450,_globalCleanups:[],isFixedVariant:function(e){return e.classList.contains("vd-sidenav-fixed")||e.classList.contains("sidenav-fixed")},isPushVariant:function(e){return e.classList.contains("vd-sidenav-push")||e.classList.contains("sidenav-push")},isRightVariant:function(e){return e.classList.contains("vd-sidenav-right")||e.classList.contains("sidenav-right")},getPortalState:function(e){return e._vdPortalState||(e._vdPortalState={originalParent:null,originalNextSibling:null,placeholder:null,restoreTimer:null,restoreHandler:null}),e._vdPortalState},cancelScheduledRestore:function(e){let t=this.getPortalState(e);t.restoreHandler&&(e.removeEventListener("transitionend",t.restoreHandler),t.restoreHandler=null),t.restoreTimer&&(window.clearTimeout(t.restoreTimer),t.restoreTimer=null)},portalToBody:function(e){if(!e)return;if(e.parentNode===document.body){this.cancelScheduledRestore(e);return}let t=this.getPortalState(e);this.cancelScheduledRestore(e),t.originalParent=e.parentNode,t.originalNextSibling=e.nextSibling,t.placeholder||(t.placeholder=document.createComment("vd-sidenav-placeholder")),t.originalParent.insertBefore(t.placeholder,e),document.body.appendChild(e),e.dataset.vdPortaled="true"},restoreFromPortal:function(e){if(!e)return;let t=this.getPortalState(e);if(this.cancelScheduledRestore(e),!t.placeholder){delete e.dataset.vdPortaled;return}t.placeholder.parentNode?(t.placeholder.parentNode.insertBefore(e,t.placeholder),t.placeholder.parentNode.removeChild(t.placeholder)):t.originalParent&&t.originalParent.isConnected&&(t.originalNextSibling&&t.originalNextSibling.parentNode===t.originalParent?t.originalParent.insertBefore(e,t.originalNextSibling):t.originalParent.appendChild(e)),t.originalParent=null,t.originalNextSibling=null,t.placeholder=null,delete e.dataset.vdPortaled},scheduleRestoreFromPortal:function(e){if(!e||e.parentNode!==document.body)return;let t=this.getPortalState(e);this.cancelScheduledRestore(e);let n=()=>{this.restoreFromPortal(e)},s=o=>{o.target!==e||o.propertyName!=="transform"||n()};t.restoreHandler=s,e.addEventListener("transitionend",s),t.restoreTimer=window.setTimeout(()=>{n()},this.restoreDelayMs)},init:function(){document.querySelectorAll(".vd-sidenav, .vd-offcanvas").forEach(s=>{this.sidenavs.has(s)||this.initSidenav(s)}),document.querySelectorAll("[data-sidenav-toggle]").forEach(s=>{if(s.dataset.sidenavToggleInitialized)return;s.dataset.sidenavToggleInitialized="true";let o=i=>{i.preventDefault();let a=s.dataset.sidenavToggle,r=document.querySelector(a);r&&this.toggle(r)};s.addEventListener("click",o),this._globalCleanups.push(()=>s.removeEventListener("click",o))}),this.handleResize();let n=()=>{this.handleResize()};window.addEventListener("resize",n),this._globalCleanups.push(()=>window.removeEventListener("resize",n))},initSidenav:function(e){let t=e.getAttribute("data-vd-position");if(t){let r=e.classList.contains("vd-offcanvas")?"vd-offcanvas":"vd-sidenav";e.classList.add(r+"-"+t)}let n=this.createOverlay(e),s=e.querySelector(".vd-sidenav-close, .vd-offcanvas-close"),o=[];if(e.setAttribute("role","navigation"),e.setAttribute("aria-hidden","true"),s){let r=()=>{this.close(e)};s.addEventListener("click",r),o.push(()=>s.removeEventListener("click",r))}let i=()=>{e.dataset.backdrop!=="static"&&this.close(e)};n.addEventListener("click",i),o.push(()=>n.removeEventListener("click",i));let a=r=>{r.key==="Escape"&&e.classList.contains("is-open")&&e.dataset.keyboard!=="false"&&this.close(e)};document.addEventListener("keydown",a),o.push(()=>document.removeEventListener("keydown",a)),this.sidenavs.set(e,{overlay:n,cleanup:o})},createOverlay:function(e){let t=e.querySelector(".vd-sidenav-overlay");return t||(t=document.createElement("div"),t.className="vd-sidenav-overlay",document.body.appendChild(t)),t},open:function(e){let t=typeof e=="string"?document.querySelector(e):e;if(!t||!this.sidenavs.has(t))return;let{overlay:n}=this.sidenavs.get(t);this.portalToBody(t),this.isFixedVariant(t)||n.classList.add("is-visible"),t.classList.add("is-open"),t.setAttribute("aria-hidden","false"),document.body.classList.add("body-sidenav-open"),this.isPushVariant(t)&&this.handlePushVariant(t,!0),t.dispatchEvent(new CustomEvent("sidenav:open",{bubbles:!0}))},close:function(e){let t=typeof e=="string"?document.querySelector(e):e;if(!t||!this.sidenavs.has(t))return;let{overlay:n}=this.sidenavs.get(t);n.classList.remove("is-visible"),t.classList.remove("is-open"),t.setAttribute("aria-hidden","true"),document.body.classList.remove("body-sidenav-open"),this.isPushVariant(t)&&this.handlePushVariant(t,!1),t.dispatchEvent(new CustomEvent("sidenav:close",{bubbles:!0})),this.scheduleRestoreFromPortal(t)},toggle:function(e){let t=typeof e=="string"?document.querySelector(e):e;t&&(t.classList.contains("is-open")?this.close(t):this.open(t))},handlePushVariant:function(e,t){let n=document.querySelector('main, .main-content, .content, [role="main"]')||document.body;t?window.innerWidth>=this.breakpoint&&(this.isRightVariant(e)?n.style.marginRight=e.offsetWidth+"px":n.style.marginLeft=e.offsetWidth+"px"):(n.style.marginLeft="",n.style.marginRight="")},handleResize:function(){this.sidenavs.forEach(({overlay:e},t)=>{window.innerWidth>=this.breakpoint?this.isFixedVariant(t)&&!t.classList.contains("is-open")&&(t.classList.add("is-open"),t.setAttribute("aria-hidden","false"),e.classList.remove("is-visible")):this.isFixedVariant(t)&&t.classList.contains("is-open")&&this.close(t)})},destroy:function(e){let t=this.sidenavs.get(e);t&&(e.classList.contains("is-open")&&(t.overlay.classList.remove("is-visible"),e.classList.remove("is-open"),e.setAttribute("aria-hidden","true"),document.body.classList.remove("body-sidenav-open")),this.restoreFromPortal(e),t.cleanup.forEach(n=>n()),t.overlay&&t.overlay.parentNode&&t.overlay.parentNode.removeChild(t.overlay),this.sidenavs.delete(e))},destroyAll:function(){this.sidenavs.forEach((e,t)=>{this.destroy(t)}),this._globalCleanups.forEach(e=>e()),this._globalCleanups=[]}};typeof window.Vanduo<"u"&&window.Vanduo.register("sidenav",p),window.VanduoSidenav=p})();(function(){"use strict";let p={instances:new Map,init:function(){document.querySelectorAll(".vd-tabs, [data-tabs]").forEach(t=>{this.instances.has(t)||this.initTabs(t)})},initTabs:function(e){let t=e.querySelector('.vd-tab-list, [role="tablist"]'),n=e.querySelectorAll(".vd-tab-link, [data-tab]"),s=e.querySelectorAll(".vd-tab-pane, [data-tab-pane]");if(!t||n.length===0)return;let o=[];t.setAttribute("role","tablist"),n.forEach((a,r)=>{let c=this.getTabId(a,r),l=this.findPane(e,c,s);a.setAttribute("role","tab"),a.setAttribute("aria-selected",a.classList.contains("is-active")?"true":"false"),a.setAttribute("tabindex",a.classList.contains("is-active")?"0":"-1"),a.id||(a.id=`tab-btn-${c}`),l&&(l.setAttribute("role","tabpanel"),l.setAttribute("aria-labelledby",a.id),l.id||(l.id=`tab-pane-${c}`),a.setAttribute("aria-controls",l.id));let f=u=>{u.preventDefault(),!a.classList.contains("disabled")&&!a.disabled&&this.activateTab(e,a,n,s)};a.addEventListener("click",f),o.push(()=>a.removeEventListener("click",f));let h=u=>{this.handleKeydown(u,e,a,n,s)};a.addEventListener("keydown",h),o.push(()=>a.removeEventListener("keydown",h))}),!e.querySelector(".vd-tab-link.is-active, [data-tab].is-active")&&n.length>0&&this.activateTab(e,n[0],n,s),this.instances.set(e,{cleanup:o})},getTabId:function(e,t){return e.dataset.tabTarget||e.dataset.tab||e.getAttribute("href")?.replace("#","")||(typeof t=="number"?`tab-${t}`:e.id)},findPane:function(e,t,n){let s=e.querySelector(`[data-tab-pane="${t}"]`);return s||(s=e.querySelector(`#${t}`)),s||e.querySelectorAll(".vd-tab-link, [data-tab]").forEach((i,a)=>{this.getTabId(i,a)===t&&n[a]&&(s=n[a])}),s},activateTab:function(e,t,n,s){let o=Array.from(n).indexOf(t),i=this.getTabId(t,o);n.forEach(c=>{c.classList.remove("is-active"),c.setAttribute("aria-selected","false"),c.setAttribute("tabindex","-1"),c.parentElement&&c.parentElement.classList.contains("tab-item")&&c.parentElement.classList.remove("is-active")}),s.forEach(c=>{c.classList.remove("is-active")}),t.classList.add("is-active"),t.setAttribute("aria-selected","true"),t.setAttribute("tabindex","0"),t.parentElement&&t.parentElement.classList.contains("tab-item")&&t.parentElement.classList.add("is-active");let a=this.findPane(e,i,s);a&&a.classList.add("is-active");let r=new CustomEvent("tab:change",{bubbles:!0,detail:{tab:t,pane:a,tabId:i}});e.dispatchEvent(r)},handleKeydown:function(e,t,n,s,o){let i=t.classList.contains("vd-tabs-vertical")||t.classList.contains("tabs-vertical"),a=Array.from(s).filter(l=>!l.classList.contains("disabled")&&!l.disabled),r=a.indexOf(n),c=r;switch(e.key){case"ArrowLeft":i||(e.preventDefault(),c=r>0?r-1:a.length-1);break;case"ArrowRight":i||(e.preventDefault(),c=r0?r-1:a.length-1);break;case"ArrowDown":i&&(e.preventDefault(),c=rn()),this.instances.delete(e))},destroyAll:function(){this.instances.forEach((e,t)=>{this.destroy(t)})}};typeof window.Vanduo<"u"&&window.Vanduo.register("tabs",p)})();(function(){"use strict";let p={STORAGE_KEYS:{PRIMARY:"vanduo-primary-color",NEUTRAL:"vanduo-neutral-color",RADIUS:"vanduo-radius",FONT:"vanduo-font-preference",THEME:"vanduo-theme-preference"},DEFAULTS:{PRIMARY_LIGHT:"black",PRIMARY_DARK:"amber",NEUTRAL:"charcoal",RADIUS:"0.5",FONT:"ubuntu",THEME:"system"},PRIMARY_COLORS:{black:{name:"Black",color:"#000000"},red:{name:"Red",color:"#fa5252"},orange:{name:"Orange",color:"#fd7e14"},amber:{name:"Amber",color:"#f59f00"},yellow:{name:"Yellow",color:"#fcc419"},lime:{name:"Lime",color:"#82c91e"},green:{name:"Green",color:"#40c057"},emerald:{name:"Emerald",color:"#20c997"},teal:{name:"Teal",color:"#12b886"},cyan:{name:"Cyan",color:"#22b8cf"},sky:{name:"Sky",color:"#3bc9db"},blue:{name:"Blue",color:"#228be6"},indigo:{name:"Indigo",color:"#4c6ef5"},violet:{name:"Violet",color:"#7950f2"},purple:{name:"Purple",color:"#be4bdb"},fuchsia:{name:"Fuchsia",color:"#f06595"},pink:{name:"Pink",color:"#e64980"},rose:{name:"Rose",color:"#ff8787"}},NEUTRAL_COLORS:{charcoal:{name:"Charcoal",color:"#2d333b"},slate:{name:"Slate",color:"#64748b"},gray:{name:"Gray",color:"#6b7280"},zinc:{name:"Zinc",color:"#71717a"},neutral:{name:"Neutral",color:"#737373"},stone:{name:"Stone",color:"#78716c"}},RADIUS_OPTIONS:["0","0.125","0.25","0.375","0.5"],FONT_OPTIONS:{"jetbrains-mono":{name:"JetBrains Mono",family:"'JetBrains Mono', monospace"},system:{name:"System Default",family:null},ubuntu:{name:"Ubuntu",family:"'Ubuntu', sans-serif"},lato:{name:"Lato",family:"'Lato', sans-serif"},"open-sans":{name:"Open Sans",family:"'Open Sans', sans-serif"}},THEME_MODES:["system","dark","light"],state:{primary:null,neutral:null,radius:null,font:null,theme:null,isOpen:!1},isInitialized:!1,_cleanup:[],elements:{customizer:null,trigger:null,triggers:[],panel:null,overlay:null},init:function(){if(this.isInitialized){this.bindExistingElements(),this.bindTriggerEvents(),this.bindPanelEvents(),this.updateUI();return}this.isInitialized=!0,this._cleanup=[],this.loadPreferences(),this.applyAllPreferences(),this.bindExistingElements(),this.bindEvents(),console.log("Vanduo Theme Customizer initialized")},addListener:function(e,t,n,s){e&&(e.addEventListener(t,n,s),this._cleanup.push(()=>e.removeEventListener(t,n,s)))},getDefaultPrimary:function(e){return e==="system"?window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?this.DEFAULTS.PRIMARY_DARK:this.DEFAULTS.PRIMARY_LIGHT:e==="dark"?this.DEFAULTS.PRIMARY_DARK:this.DEFAULTS.PRIMARY_LIGHT},loadPreferences:function(){this.state.theme=this.getStorageValue(this.STORAGE_KEYS.THEME,this.DEFAULTS.THEME),this.state.primary=this.getStorageValue(this.STORAGE_KEYS.PRIMARY,this.getDefaultPrimary(this.state.theme)),this._normalizeDefaultPrimaryIfStaleWithStoredTheme(),this.state.neutral=this.getStorageValue(this.STORAGE_KEYS.NEUTRAL,this.DEFAULTS.NEUTRAL),this.state.radius=this.getStorageValue(this.STORAGE_KEYS.RADIUS,this.DEFAULTS.RADIUS),this.state.font=this.getStorageValue(this.STORAGE_KEYS.FONT,this.DEFAULTS.FONT)},savePreference:function(e,t){this.setStorageValue(e,t)},applyAllPreferences:function(){this.applyPrimary(this.state.primary),this.applyNeutral(this.state.neutral),this.applyRadius(this.state.radius),this.applyFont(this.state.font),this.applyTheme(this.state.theme)},applyPrimary:function(e){this.PRIMARY_COLORS[e]||(e=this.getDefaultPrimary(this.state.theme)),this.state.primary=e,document.documentElement.setAttribute("data-primary",e),this.savePreference(this.STORAGE_KEYS.PRIMARY,e),this.dispatchEvent("primary-change",{color:e})},applyNeutral:function(e){this.NEUTRAL_COLORS[e]||(e=this.DEFAULTS.NEUTRAL),this.state.neutral=e,document.documentElement.setAttribute("data-neutral",e),this.savePreference(this.STORAGE_KEYS.NEUTRAL,e),this.dispatchEvent("neutral-change",{neutral:e})},applyRadius:function(e){this.RADIUS_OPTIONS.includes(e)||(e=this.DEFAULTS.RADIUS),this.state.radius=e,document.documentElement.setAttribute("data-radius",e),document.documentElement.style.setProperty("--radius-scale",e),this.savePreference(this.STORAGE_KEYS.RADIUS,e),this.dispatchEvent("radius-change",{radius:e})},applyFont:function(e){this.FONT_OPTIONS[e]||(e=this.DEFAULTS.FONT),this.state.font=e,e==="system"?document.documentElement.removeAttribute("data-font"):document.documentElement.setAttribute("data-font",e),this.savePreference(this.STORAGE_KEYS.FONT,e),window.FontSwitcher&&window.FontSwitcher.setPreference&&(window.FontSwitcher.state.preference=e,window.FontSwitcher.applyFont()),this.dispatchEvent("font-change",{font:e})},applyTheme:function(e){if(this.THEME_MODES.includes(e)||(e=this.DEFAULTS.THEME),this._isApplying=!0,this.isUsingDefaultPrimary()){let t=this.getDefaultPrimary(e);this.state.primary!==t&&this.applyPrimary(t)}if(this.state.theme=e,e==="system"?document.documentElement.removeAttribute("data-theme"):document.documentElement.setAttribute("data-theme",e),this.savePreference(this.STORAGE_KEYS.THEME,e),window.Vanduo&&window.Vanduo.components.themeSwitcher){let t=window.Vanduo.components.themeSwitcher;t.state&&t.state.preference!==e&&(t.state.preference=e,typeof t.setStorageValue=="function"&&t.setStorageValue(t.STORAGE_KEY,e),typeof t.updateUI=="function"&&t.updateUI())}this._isApplying=!1,this.dispatchEvent("mode-change",{mode:e})},dispatchEvent:function(e,t){let n=new CustomEvent("theme:"+e,{bubbles:!0,detail:t});document.dispatchEvent(n);let s=new CustomEvent("theme:change",{bubbles:!0,detail:{type:e,value:t[Object.keys(t)[0]],state:{...this.state}}});document.dispatchEvent(s)},bindExistingElements:function(){this.elements.customizer=document.querySelector(".vd-theme-customizer"),this.elements.triggers=Array.from(document.querySelectorAll("[data-theme-customizer-trigger]")),!this.elements.trigger&&this.elements.triggers.length&&(this.elements.trigger=this.elements.triggers[0]),this.elements.customizer?(this.elements.trigger=this.elements.customizer.querySelector(".vd-theme-customizer-trigger")||this.elements.trigger,this.elements.panel=this.elements.customizer.querySelector(".vd-theme-customizer-panel"),this.elements.overlay=this.elements.customizer.querySelector(".vd-theme-customizer-overlay")):this.elements.triggers.length&&this.createDynamicPanel(),this.updateUI()},createDynamicPanel:function(){if(!this.elements.triggers.length)return;this.elements.trigger=this.elements.triggers[0];let e=document.createElement("div");e.className="vd-theme-customizer-overlay";let t=document.createElement("div");t.className="vd-theme-customizer-panel",t.innerHTML=this.getPanelHTML(),document.body.appendChild(e),document.body.appendChild(t),this.elements.panel=t,this.elements.overlay=e,this.elements.customizer={contains:n=>t.contains(n)||this.elements.triggers.some(s=>s.contains(n))},this.positionPanel(),this.bindPanelEvents(),this.addListener(window,"resize",()=>this.positionPanel())},positionPanel:function(){if(!this.elements.panel||!this.elements.trigger)return;let e=this.elements.activeTrigger||this.elements.trigger;if(window.innerWidth<768)this.elements.panel.style.top="",this.elements.panel.style.right="",this.elements.panel.style.left="",this.elements.panel.style.height="",this.elements.panel.style.maxHeight="";else{let n=e.getBoundingClientRect(),s=320,o=n.bottom+8,i=window.innerWidth,a=i-n.right;i-a-s<8&&(a=i-s-8),this.elements.panel.style.top=o+"px",this.elements.panel.style.right=a+"px",this.elements.panel.style.left="",this.elements.panel.style.height="auto",this.elements.panel.style.maxHeight="calc(100vh - "+o+"px)"}},bindPanelEvents:function(){if(!this.elements.panel||this.elements.panel.getAttribute("data-customizer-initialized")==="true")return;this.elements.panel.setAttribute("data-customizer-initialized","true"),this.elements.panel.querySelectorAll("[data-color]").forEach(s=>{this.addListener(s,"click",()=>{this.applyPrimary(s.dataset.color),this.updateUI()})}),this.elements.panel.querySelectorAll("[data-neutral]").forEach(s=>{this.addListener(s,"click",()=>{this.applyNeutral(s.dataset.neutral),this.updateUI()})}),this.elements.panel.querySelectorAll("[data-radius]").forEach(s=>{this.addListener(s,"click",()=>{this.applyRadius(s.dataset.radius),this.updateUI()})});let e=this.elements.panel.querySelector("[data-customizer-font]");e&&this.addListener(e,"change",s=>{this.applyFont(s.target.value),this.updateUI()});let t=this.elements.panel.querySelector(".customizer-reset");t&&this.addListener(t,"click",()=>{this.reset()});let n=this.elements.panel.querySelector(".customizer-mobile-close");n&&this.addListener(n,"click",()=>{this.close()}),this.elements.overlay&&this.addListener(this.elements.overlay,"click",()=>{this.close()})},getPanelHTML:function(){let e=typeof escapeHtml=="function"?escapeHtml:function(a){let r=document.createElement("div");return r.textContent=String(a??""),r.innerHTML},t=function(a){let r=String(a??"").trim();return/^(#[0-9a-fA-F]{3,8}|rgb[a]?\([^)]{1,60}\)|hsl[a]?\([^)]{1,60}\)|var\(--[a-zA-Z0-9_-]{1,40}\))$/.test(r)?r:"#000000"},n="";for(let[a,r]of Object.entries(this.PRIMARY_COLORS))n+=``;let s="";for(let[a,r]of Object.entries(this.NEUTRAL_COLORS))s+=``;let o="";this.RADIUS_OPTIONS.forEach(a=>{o+=``});let i="";for(let[a,r]of Object.entries(this.FONT_OPTIONS))i+=``;return`

    Customize Theme

    `;\n }\n\n // Generate neutral color swatches\n let neutralSwatches = '';\n for (const [key, value] of Object.entries(this.NEUTRAL_COLORS)) {\n neutralSwatches += ``;\n }\n\n // Generate radius buttons\n let radiusButtons = '';\n this.RADIUS_OPTIONS.forEach(r => {\n radiusButtons += ``;\n });\n\n // Generate font options\n let fontOptions = '';\n for (const [key, value] of Object.entries(this.FONT_OPTIONS)) {\n fontOptions += ``;\n }\n\n return `\n
    \n

    Customize Theme

    \n \n
    \n
    \n\n
    \n \n
    \n ${primarySwatches}\n
    \n
    \n
    \n \n
    \n ${neutralSwatches}\n
    \n
    \n
    \n \n
    \n ${radiusButtons}\n
    \n
    \n
    \n \n \n
    \n
    \n
    \n \n
    \n `;\n },\n\n /**\n * Bind event listeners\n */\n /**\n * Check whether the current primary color is one of the auto-defaults\n * (i.e. the user hasn't explicitly picked a non-default color).\n */\n isUsingDefaultPrimary: function () {\n return this.state.primary === this.DEFAULTS.PRIMARY_LIGHT ||\n this.state.primary === this.DEFAULTS.PRIMARY_DARK;\n },\n\n /**\n * When primary is still one of the auto-default palette keys (black/amber) but\n * localStorage was written under a different theme (or OS changed in system mode),\n * align in-memory state before applyAllPreferences runs \u2014 avoids amber+light / black+dark drift.\n */\n _normalizeDefaultPrimaryIfStaleWithStoredTheme: function () {\n if (!this.isUsingDefaultPrimary()) {\n return;\n }\n const expected = this.getDefaultPrimary(this.state.theme);\n if (this.state.primary !== expected) {\n this.state.primary = expected;\n }\n },\n\n bindEvents: function () {\n this.bindTriggerEvents();\n\n this.bindPanelEvents();\n\n // Listen for OS dark/light changes so we can swap the default primary\n if (window.matchMedia) {\n const mq = window.matchMedia('(prefers-color-scheme: dark)');\n const handler = () => {\n if (this.state.theme === 'system' && this.isUsingDefaultPrimary()) {\n const newDefault = this.getDefaultPrimary('system');\n if (newDefault !== this.state.primary) {\n this.applyPrimary(newDefault);\n this.updateUI();\n }\n }\n };\n mq.addEventListener('change', handler);\n this._cleanup.push(() => mq.removeEventListener('change', handler));\n }\n\n // Close on outside click\n this.addListener(document, 'click', (e) => {\n if (this.state.isOpen && this.elements.customizer && !this.elements.customizer.contains(e.target)) {\n this.close();\n }\n });\n\n // Close on Escape key\n this.addListener(document, 'keydown', (e) => {\n if (e.key === 'Escape' && this.state.isOpen) {\n this.close();\n }\n });\n },\n\n bindTriggerEvents: function () {\n this.elements.triggers.forEach((trigger) => {\n if (trigger.getAttribute('data-customizer-trigger-initialized') === 'true') {\n return;\n }\n this.addListener(trigger, 'click', (e) => {\n e.preventDefault();\n e.stopPropagation();\n this.elements.activeTrigger = trigger;\n this.elements.trigger = trigger;\n this.toggle();\n });\n trigger.setAttribute('data-customizer-trigger-initialized', 'true');\n });\n },\n\n /**\n * Toggle panel open/close\n */\n toggle: function () {\n if (this.state.isOpen) {\n this.close();\n } else {\n this.open();\n }\n },\n\n /**\n * Open the panel\n */\n open: function () {\n this.state.isOpen = true;\n\n // Ensure panel is positioned correctly before opening\n this.positionPanel();\n\n if (this.elements.panel) {\n this.elements.panel.classList.add('is-open');\n }\n this.elements.triggers.forEach((trigger) => trigger.setAttribute('aria-expanded', 'false'));\n if (this.elements.trigger) this.elements.trigger.setAttribute('aria-expanded', 'true');\n if (this.elements.overlay) {\n this.elements.overlay.classList.add('is-active');\n }\n\n this.dispatchEvent('panel-open', { isOpen: true });\n },\n\n /**\n * Close the panel\n */\n close: function () {\n this.state.isOpen = false;\n\n if (this.elements.panel) {\n this.elements.panel.classList.remove('is-open');\n }\n this.elements.triggers.forEach((trigger) => trigger.setAttribute('aria-expanded', 'false'));\n if (this.elements.overlay) {\n this.elements.overlay.classList.remove('is-active');\n }\n\n this.dispatchEvent('panel-close', { isOpen: false });\n },\n\n /**\n * Update UI to reflect current state\n */\n updateUI: function () {\n if (!this.elements.panel) return;\n\n // Update primary color swatches\n this.elements.panel.querySelectorAll('[data-color]').forEach(swatch => {\n swatch.classList.toggle('is-active', swatch.dataset.color === this.state.primary);\n });\n\n // Update neutral color swatches\n this.elements.panel.querySelectorAll('[data-neutral]').forEach(swatch => {\n swatch.classList.toggle('is-active', swatch.dataset.neutral === this.state.neutral);\n });\n\n // Update radius buttons\n this.elements.panel.querySelectorAll('[data-radius]').forEach(btn => {\n btn.classList.toggle('is-active', btn.dataset.radius === this.state.radius);\n });\n\n // Update font selector\n const fontSelect = this.elements.panel.querySelector('[data-customizer-font]');\n if (fontSelect) {\n fontSelect.value = this.state.font;\n }\n\n },\n\n /**\n * Reset all preferences to defaults\n */\n reset: function () {\n this.applyTheme(this.DEFAULTS.THEME);\n this.applyPrimary(this.getDefaultPrimary(this.DEFAULTS.THEME));\n this.applyNeutral(this.DEFAULTS.NEUTRAL);\n this.applyRadius(this.DEFAULTS.RADIUS);\n this.applyFont(this.DEFAULTS.FONT);\n this.updateUI();\n\n this.dispatchEvent('reset', { state: { ...this.state } });\n },\n\n /**\n * Get current state\n */\n getState: function () {\n return { ...this.state };\n },\n\n /**\n * Programmatically set preferences\n */\n setPreferences: function (prefs) {\n if (prefs.primary) this.applyPrimary(prefs.primary);\n if (prefs.neutral) this.applyNeutral(prefs.neutral);\n if (prefs.radius) this.applyRadius(prefs.radius);\n if (prefs.font) this.applyFont(prefs.font);\n if (prefs.theme) this.applyTheme(prefs.theme);\n this.updateUI();\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 destroyAll: function () {\n this._cleanup.forEach(fn => fn());\n this._cleanup = [];\n\n if (this.elements.panel) {\n this.elements.panel.removeAttribute('data-customizer-initialized');\n }\n\n this.close();\n this.isInitialized = false;\n }\n };\n\n // Register component\n if (window.Vanduo) {\n window.Vanduo.register('themeCustomizer', ThemeCustomizer);\n }\n\n // Expose globally for convenience\n window.ThemeCustomizer = ThemeCustomizer;\n})();\n", "/**\n * Vanduo Framework - Theme Switcher\n * Handles light/dark/system theme toggling and persistence\n */\n\n(function () {\n 'use strict';\n\n const ThemeSwitcher = {\n isInitialized: false,\n _mediaQuery: null,\n _onMediaChange: null,\n\n init: function () {\n this.STORAGE_KEY = 'vanduo-theme-preference';\n this.state = {\n preference: this.getPreference() // 'light', 'dark', or 'system'\n };\n\n if (this.isInitialized) {\n this.applyTheme();\n this.renderUI();\n this.updateUI();\n return;\n }\n\n this.isInitialized = true;\n\n this.applyTheme();\n this.listenForSystemChanges();\n this.renderUI();\n\n console.log('Vanduo Theme Switcher initialized');\n },\n\n getPreference: function () {\n return this.getStorageValue(this.STORAGE_KEY, 'system');\n },\n\n setPreference: function (pref) {\n this.state.preference = pref;\n this.setStorageValue(this.STORAGE_KEY, pref);\n this.applyTheme();\n \n // Coordinate with ThemeCustomizer for primary color swap if available\n // Check _isApplying flag to prevent circular updates\n if (window.ThemeCustomizer && window.ThemeCustomizer.applyTheme && !window.ThemeCustomizer._isApplying) {\n window.ThemeCustomizer.applyTheme(pref);\n }\n \n this.updateUI();\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 applyTheme: function () {\n const pref = this.state.preference;\n\n if (pref === 'system') {\n // When in system mode, we remove the data attribute to let the media query take over\n // or we can explicitly set it. Explicitly setting it ensures consistency if we rely on [data-theme]\n // But if we rely on @media in CSS, we might want to remove attributes.\n // However, our CSS strategy uses :root:not([data-theme=\"light\"]) inside media query for system dark fallback\n // which is a bit complex.\n\n // Simpler approach: \n // If preference is system, REMOVE data-theme attribute. Let CSS media queries handle it.\n document.documentElement.removeAttribute('data-theme');\n } else {\n document.documentElement.setAttribute('data-theme', pref);\n }\n },\n\n listenForSystemChanges: function () {\n if (this._mediaQuery && this._onMediaChange) {\n return;\n }\n\n this._mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n this._onMediaChange = _e => {\n if (this.state.preference === 'system') {\n // Re-apply (effectively just to ensure consistency, though removing attribute usually suffices)\n this.applyTheme();\n // Keep default primary (black/amber) aligned when OS scheme changes while in system mode\n if (window.ThemeCustomizer && typeof window.ThemeCustomizer.applyTheme === 'function' && !window.ThemeCustomizer._isApplying) {\n window.ThemeCustomizer.applyTheme('system');\n }\n }\n };\n this._mediaQuery.addEventListener('change', this._onMediaChange);\n },\n\n // Helper to facilitate UI creation if needed, though often UI is in HTML\n renderUI: function () {\n // Look for any uninitialized theme toggles\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"]');\n toggles.forEach(toggle => {\n if (toggle.getAttribute('data-theme-initialized') === 'true') {\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n }\n return;\n }\n\n // Simplified UI Binding - assumes a select or a button cycle\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n const onChange = (e) => {\n this.setPreference(e.target.value);\n };\n toggle.addEventListener('change', onChange);\n toggle._themeToggleHandler = onChange;\n } else {\n // Button implementation (cycle)\n const onClick = () => {\n const modes = ['system', 'light', 'dark'];\n const nextIndex = (modes.indexOf(this.state.preference) + 1) % modes.length;\n this.setPreference(modes[nextIndex]);\n };\n toggle.addEventListener('click', onClick);\n toggle._themeToggleHandler = onClick;\n }\n // Mark as initialized\n toggle.setAttribute('data-theme-initialized', 'true');\n });\n },\n\n updateUI: function () {\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"]');\n toggles.forEach(toggle => {\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n } else {\n // Update button text/icon if needed\n // e.g. toggle.textContent = this.state.preference;\n // For now, assume the user handles visual state or generic text\n\n // If there is an icon or text span inside, update it\n const span = toggle.querySelector('.theme-current-label');\n if (span) {\n span.textContent = this.state.preference.charAt(0).toUpperCase() + this.state.preference.slice(1);\n }\n }\n });\n },\n\n destroyAll: function () {\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"][data-theme-initialized=\"true\"]');\n toggles.forEach(toggle => {\n if (toggle._themeToggleHandler) {\n const eventName = toggle.tagName === 'SELECT' ? 'change' : 'click';\n toggle.removeEventListener(eventName, toggle._themeToggleHandler);\n delete toggle._themeToggleHandler;\n }\n toggle.removeAttribute('data-theme-initialized');\n });\n\n if (this._mediaQuery && this._onMediaChange) {\n this._mediaQuery.removeEventListener('change', this._onMediaChange);\n }\n\n this._mediaQuery = null;\n this._onMediaChange = null;\n this.isInitialized = false;\n }\n };\n\n // Register component\n if (window.Vanduo) {\n window.Vanduo.register('themeSwitcher', ThemeSwitcher);\n }\n})();\n", "/**\n * Vanduo Framework - Toast Component\n * Popup notifications for user feedback\n */\n\n(function() {\n 'use strict';\n\n /**\n * Toast Component\n */\n const Toast = {\n // Default options\n defaults: {\n position: 'top-right',\n duration: 5000,\n dismissible: true,\n showProgress: true,\n pauseOnHover: true\n },\n\n // Container cache\n containers: {},\n\n /**\n * Get or create a toast container for a position\n * @param {string} position - Container position\n * @returns {HTMLElement} Toast container element\n */\n getContainer: function(position) {\n if (this.containers[position]) {\n return this.containers[position];\n }\n\n const container = document.createElement('div');\n container.className = `vd-toast-container vd-toast-container-${position}`;\n container.setAttribute('role', 'status');\n container.setAttribute('aria-live', 'polite');\n container.setAttribute('aria-atomic', 'false');\n document.body.appendChild(container);\n this.containers[position] = container;\n\n return container;\n },\n\n /**\n * Show a toast notification\n * @param {Object|string} options - Toast options or message string\n * @param {string} [type] - Toast type (success, error, warning, info)\n * @param {number} [duration] - Auto-dismiss duration in ms\n * @returns {HTMLElement} Toast element\n */\n show: function(options, type, duration) {\n // Support simple API:\n // - Toast.show('Message', 'success', 3000)\n // - Toast.show('Message', { type: 'success', duration: 3000 })\n if (typeof options === 'string') {\n if (type && typeof type === 'object') {\n options = Object.assign({}, type, {\n message: options\n });\n } else {\n options = {\n message: options,\n type: type,\n duration: duration\n };\n }\n }\n\n const config = Object.assign({}, this.defaults, options);\n const container = this.getContainer(config.position);\n\n // Create toast element\n const toast = document.createElement('div');\n toast.className = 'vd-toast';\n\n if (config.type) {\n toast.classList.add(`vd-toast-${config.type}`);\n }\n\n if (config.solid) {\n toast.classList.add('vd-toast-solid');\n }\n\n if (config.showProgress && config.duration > 0) {\n toast.classList.add('vd-toast-with-progress');\n }\n\n // Build toast content\n let html = '';\n\n // Icon (sanitize custom icons, default icons are trusted SVG)\n if (config.icon) {\n const allowSvg = config.iconAllowSvg === true;\n const safeIcon = typeof sanitizeHtml === 'function'\n ? sanitizeHtml(config.icon, { allowSvg })\n : escapeHtml(config.icon);\n html += `${safeIcon}`;\n } else if (config.type) {\n html += `${this.getDefaultIcon(config.type)}`;\n }\n\n // Local escape helper \u2014 guarantees HTML-safe output even if the\n // global escapeHtml utility is not loaded in the current bundle.\n const _esc = typeof escapeHtml === 'function'\n ? escapeHtml\n : function (s) {\n const d = document.createElement('div');\n d.appendChild(document.createTextNode(s));\n return d.innerHTML;\n };\n\n // Content (escape text to prevent injection)\n html += '
    ';\n if (config.title) {\n html += `
    ${_esc(String(config.title))}
    `;\n }\n if (config.message) {\n html += `
    ${_esc(String(config.message))}
    `;\n }\n html += '
    ';\n\n // Close button\n if (config.dismissible) {\n html += '';\n }\n\n // Progress bar\n if (config.showProgress && config.duration > 0) {\n const safeDuration = parseInt(config.duration, 10) || 0;\n html += `
    `;\n }\n\n toast.innerHTML = html;\n\n // Add to container\n container.appendChild(toast);\n\n toast._toastCleanup = [];\n\n // Set up close button handler\n if (config.dismissible) {\n const closeBtn = toast.querySelector('.vd-toast-close');\n const onClose = () => {\n this.dismiss(toast);\n };\n closeBtn.addEventListener('click', onClose);\n toast._toastCleanup.push(() => closeBtn.removeEventListener('click', onClose));\n }\n\n // Pause on hover\n let timeoutId = null;\n let remainingTime = config.duration;\n let startTime = null;\n\n const startTimer = () => {\n if (config.duration > 0) {\n startTime = Date.now();\n timeoutId = setTimeout(() => {\n this.dismiss(toast);\n }, remainingTime);\n toast._toastTimeoutId = timeoutId;\n\n // Resume progress animation\n const progress = toast.querySelector('.vd-toast-progress');\n if (progress) {\n progress.style.animationPlayState = 'running';\n }\n }\n };\n\n const pauseTimer = () => {\n if (timeoutId) {\n clearTimeout(timeoutId);\n timeoutId = null;\n toast._toastTimeoutId = null;\n remainingTime -= Date.now() - startTime;\n\n // Pause progress animation\n const progress = toast.querySelector('.vd-toast-progress');\n if (progress) {\n progress.style.animationPlayState = 'paused';\n }\n }\n };\n\n if (config.pauseOnHover) {\n toast.addEventListener('mouseenter', pauseTimer);\n toast.addEventListener('mouseleave', startTimer);\n toast._toastCleanup.push(\n () => toast.removeEventListener('mouseenter', pauseTimer),\n () => toast.removeEventListener('mouseleave', startTimer)\n );\n }\n\n // Trigger enter animation\n requestAnimationFrame(() => {\n toast.classList.add('is-visible');\n startTimer();\n });\n\n // Store config on element for later access\n toast._toastConfig = config;\n\n // Dispatch show event\n const showEvent = new CustomEvent('toast:show', {\n bubbles: true,\n detail: { toast, config }\n });\n toast.dispatchEvent(showEvent);\n\n return toast;\n },\n\n /**\n * Dismiss a toast\n * @param {HTMLElement} toast - Toast element to dismiss\n */\n dismiss: function(toast) {\n if (!toast || toast.classList.contains('is-exiting')) return;\n\n if (toast._toastTimeoutId) {\n clearTimeout(toast._toastTimeoutId);\n toast._toastTimeoutId = null;\n }\n\n toast.classList.remove('is-visible');\n toast.classList.add('is-exiting');\n\n // Dispatch dismiss event\n const dismissEvent = new CustomEvent('toast:dismiss', {\n bubbles: true,\n detail: { toast }\n });\n toast.dispatchEvent(dismissEvent);\n\n // Remove after animation\n const handleTransitionEnd = () => {\n toast.removeEventListener('transitionend', handleTransitionEnd);\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n };\n\n toast.addEventListener('transitionend', handleTransitionEnd);\n\n // Fallback removal if transition doesn't fire\n setTimeout(() => {\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n }, 400);\n },\n\n /**\n * Destroy all toasts and containers\n */\n destroyAll: function() {\n Object.keys(this.containers).forEach(position => {\n const container = this.containers[position];\n if (!container) return;\n\n const toasts = container.querySelectorAll('.vd-toast');\n toasts.forEach(toast => {\n if (toast._toastTimeoutId) {\n clearTimeout(toast._toastTimeoutId);\n }\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n });\n\n if (container.parentElement) {\n container.parentElement.removeChild(container);\n }\n });\n\n this.containers = {};\n },\n\n /**\n * Dismiss all toasts\n * @param {string} [position] - Optional position to clear (clears all if not specified)\n */\n dismissAll: function(position) {\n if (position && this.containers[position]) {\n const toasts = this.containers[position].querySelectorAll('.vd-toast');\n toasts.forEach(toast => this.dismiss(toast));\n } else {\n Object.values(this.containers).forEach(container => {\n const toasts = container.querySelectorAll('.vd-toast');\n toasts.forEach(toast => this.dismiss(toast));\n });\n }\n },\n\n /**\n * Get default icon SVG for a type\n * @param {string} type - Toast type\n * @returns {string} SVG icon markup\n */\n getDefaultIcon: function(type) {\n const icons = {\n success: '',\n error: '',\n warning: '',\n info: ''\n };\n\n return icons[type] || '';\n },\n\n /**\n * Convenience methods for common toast types\n */\n success: function(message, options) {\n return this.show(Object.assign({ message, type: 'success' }, options));\n },\n\n error: function(message, options) {\n return this.show(Object.assign({ message, type: 'error' }, options));\n },\n\n warning: function(message, options) {\n return this.show(Object.assign({ message, type: 'warning' }, options));\n },\n\n info: function(message, options) {\n return this.show(Object.assign({ message, type: 'info' }, options));\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('toast', Toast);\n }\n\n // Also expose globally for convenience\n window.Toast = Toast;\n\n})();\n", "/**\n * Vanduo Framework - Tooltips Component\n * JavaScript functionality for tooltips\n */\n\n(function () {\n 'use strict';\n\n /**\n * Tooltips Component\n */\n const Tooltips = {\n tooltips: new Map(),\n delayTimers: new Map(),\n\n /**\n * Sanitize HTML \u2014 delegates to shared sanitizeHtml from helpers.js\n * @param {string} input\n * @param {{ allowSvg?: boolean }} [options]\n * @returns {string} sanitized HTML\n */\n sanitizeHtml: function (input, options) {\n if (typeof sanitizeHtml === 'function') {\n return sanitizeHtml(input, options);\n }\n // Fallback: content originates from developer-authored data attributes,\n // so returning it as-is is no worse than any other inline HTML.\n return input || '';\n },\n\n /**\n * Initialize tooltips\n */\n init: function () {\n const elements = document.querySelectorAll('[data-tooltip], [data-tooltip-html]');\n\n elements.forEach(element => {\n if (this.tooltips.has(element)) {\n return;\n }\n this.initTooltip(element);\n });\n },\n\n /**\n * Initialize a single tooltip\n * @param {HTMLElement} element - Element with tooltip\n */\n initTooltip: function (element) {\n const tooltip = this.createTooltip(element);\n const cleanupFunctions = [];\n\n // Show on hover/focus\n const enterHandler = () => { this.showTooltip(element, tooltip); };\n const leaveHandler = () => { this.hideTooltip(element, tooltip); };\n const focusHandler = () => { this.showTooltip(element, tooltip); };\n const blurHandler = () => { this.hideTooltip(element, tooltip); };\n\n element.addEventListener('mouseenter', enterHandler);\n element.addEventListener('mouseleave', leaveHandler);\n element.addEventListener('focus', focusHandler);\n element.addEventListener('blur', blurHandler);\n\n cleanupFunctions.push(\n () => element.removeEventListener('mouseenter', enterHandler),\n () => element.removeEventListener('mouseleave', leaveHandler),\n () => element.removeEventListener('focus', focusHandler),\n () => element.removeEventListener('blur', blurHandler)\n );\n\n this.tooltips.set(element, { tooltip, cleanup: cleanupFunctions });\n },\n\n /**\n * Create tooltip element\n * @param {HTMLElement} element - Target element\n * @returns {HTMLElement} Tooltip element\n */\n createTooltip: function (element) {\n const tooltip = document.createElement('div');\n tooltip.className = 'vd-tooltip';\n tooltip.setAttribute('role', 'tooltip');\n tooltip.setAttribute('aria-hidden', 'true');\n\n // Generate unique ID and link via aria-describedby\n const tooltipId = 'tooltip-' + Math.random().toString(36).substr(2, 9);\n tooltip.id = tooltipId;\n element.setAttribute('aria-describedby', tooltipId);\n\n // Get content\n const htmlContent = element.dataset.tooltipHtml;\n const textContent = element.dataset.tooltip;\n\n if (htmlContent) {\n const allowSvg = element.hasAttribute('data-tooltip-allow-svg');\n tooltip.innerHTML = this.sanitizeHtml(htmlContent, { allowSvg });\n tooltip.classList.add('vd-tooltip-html');\n } else if (textContent) {\n tooltip.textContent = textContent;\n }\n\n // Get placement\n const placement = element.dataset.tooltipPlacement || element.dataset.placement || 'top';\n tooltip.setAttribute('data-placement', placement);\n tooltip.classList.add(`vd-tooltip-${placement}`);\n\n // Get variant\n if (element.dataset.tooltipVariant) {\n tooltip.classList.add(`vd-tooltip-${element.dataset.tooltipVariant}`);\n }\n\n // Get size\n if (element.dataset.tooltipSize) {\n tooltip.classList.add(`vd-tooltip-${element.dataset.tooltipSize}`);\n }\n\n // Get delay\n const delay = parseInt(element.dataset.tooltipDelay) || 0;\n tooltip.dataset.delay = delay;\n\n document.body.appendChild(tooltip);\n\n return tooltip;\n },\n\n /**\n * Show tooltip\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n showTooltip: function (element, tooltip) {\n const delay = parseInt(tooltip.dataset.delay) || 0;\n\n if (delay > 0) {\n const timer = setTimeout(() => {\n this.positionTooltip(element, tooltip);\n tooltip.classList.add('is-visible');\n tooltip.setAttribute('aria-hidden', 'false');\n }, delay);\n this.delayTimers.set(element, timer);\n } else {\n this.positionTooltip(element, tooltip);\n tooltip.classList.add('is-visible');\n tooltip.setAttribute('aria-hidden', 'false');\n }\n },\n\n /**\n * Hide tooltip\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n hideTooltip: function (element, tooltip) {\n // Clear delay timer if exists\n const timer = this.delayTimers.get(element);\n if (timer) {\n clearTimeout(timer);\n this.delayTimers.delete(element);\n }\n\n tooltip.classList.remove('is-visible');\n tooltip.setAttribute('aria-hidden', 'true');\n },\n\n /**\n * Position tooltip relative to element\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n positionTooltip: function (element, tooltip) {\n const placement = tooltip.dataset.placement || 'top';\n const rect = element.getBoundingClientRect();\n const tooltipRect = tooltip.getBoundingClientRect();\n const scrollTop = window.pageYOffset || document.documentElement.scrollTop;\n const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;\n\n let top = 0;\n let left = 0;\n\n switch (placement) {\n case 'top':\n top = rect.top + scrollTop - tooltipRect.height - 8;\n left = rect.left + scrollLeft + (rect.width / 2) - (tooltipRect.width / 2);\n break;\n case 'bottom':\n top = rect.bottom + scrollTop + 8;\n left = rect.left + scrollLeft + (rect.width / 2) - (tooltipRect.width / 2);\n break;\n case 'left':\n top = rect.top + scrollTop + (rect.height / 2) - (tooltipRect.height / 2);\n left = rect.left + scrollLeft - tooltipRect.width - 8;\n break;\n case 'right':\n top = rect.top + scrollTop + (rect.height / 2) - (tooltipRect.height / 2);\n left = rect.right + scrollLeft + 8;\n break;\n }\n\n // Prevent overflow\n const viewportWidth = window.innerWidth;\n const viewportHeight = window.innerHeight;\n const padding = 8;\n\n if (left < padding) {\n left = padding;\n } else if (left + tooltipRect.width > viewportWidth - padding) {\n left = viewportWidth - tooltipRect.width - padding;\n }\n\n if (top < scrollTop + padding) {\n top = scrollTop + padding;\n } else if (top + tooltipRect.height > scrollTop + viewportHeight - padding) {\n top = scrollTop + viewportHeight - tooltipRect.height - padding;\n }\n\n // Use single style assignment with transform for better performance\n tooltip.style.cssText = `position: absolute; top: 0; left: 0; transform: translate(${left}px, ${top}px);`;\n },\n\n /**\n * Show tooltip programmatically\n * @param {HTMLElement|string} element - Target element or selector\n */\n show: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n this.showTooltip(el, tooltip);\n }\n },\n\n /**\n * Hide tooltip programmatically\n * @param {HTMLElement|string} element - Target element or selector\n */\n hide: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n this.hideTooltip(el, tooltip);\n }\n },\n\n /**\n * Update tooltip content\n * @param {HTMLElement|string} element - Target element or selector\n * @param {string} content - New content\n * @param {boolean} isHtml - Whether content is HTML\n */\n update: function (element, content, isHtml = false) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n if (isHtml) {\n const allowSvg = el.hasAttribute('data-tooltip-allow-svg');\n tooltip.innerHTML = this.sanitizeHtml(content, { allowSvg });\n tooltip.classList.add('vd-tooltip-html');\n } else {\n tooltip.textContent = content;\n tooltip.classList.remove('vd-tooltip-html');\n }\n }\n },\n\n /**\n * Destroy a tooltip instance and clean up\n * @param {HTMLElement} element - Element with tooltip\n */\n destroy: function (element) {\n const data = this.tooltips.get(element);\n if (!data) return;\n\n // Clear any pending timer\n const timer = this.delayTimers.get(element);\n if (timer) {\n clearTimeout(timer);\n this.delayTimers.delete(element);\n }\n\n data.cleanup.forEach(fn => fn());\n\n // Remove tooltip element from DOM\n if (data.tooltip && data.tooltip.parentNode) {\n data.tooltip.parentNode.removeChild(data.tooltip);\n }\n\n this.tooltips.delete(element);\n },\n\n /**\n * Destroy all tooltip instances\n */\n destroyAll: function () {\n this.tooltips.forEach((data, element) => {\n this.destroy(element);\n });\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('tooltips', Tooltips);\n }\n\n // Expose globally\n window.VanduoTooltips = Tooltips;\n\n})();\n", "/**\n * Vanduo Framework - Search Component\n * Client-side search functionality for content pages\n * \n * @example Basic usage (initialize with defaults)\n * // HTML:\n * //
    \n * // \n * //
    \n * //
    \n * \n * @example Custom configuration\n * var search = Search.create({\n * containerSelector: '.my-search',\n * contentSelector: 'article[id]',\n * titleSelector: 'h2, h3',\n * maxResults: 5,\n * onSelect: function(result) {\n * console.log('Selected:', result.title);\n * }\n * });\n * \n * @example With custom data source\n * var search = Search.create({\n * containerSelector: '.my-search',\n * data: [\n * { id: 'item1', title: 'First Item', content: 'Description...', category: 'Category A' },\n * { id: 'item2', title: 'Second Item', content: 'Description...', category: 'Category B' }\n * ]\n * });\n */\n\n(function() {\n 'use strict';\n\n /**\n * Default configuration\n */\n const DEFAULTS = {\n // Behavior\n minQueryLength: 2,\n maxResults: 10,\n debounceMs: 150,\n highlightTag: 'mark',\n keyboardShortcut: true, // Enable Cmd/Ctrl+K shortcut\n \n // Selectors (for DOM-based indexing)\n containerSelector: '.vd-doc-search',\n inputSelector: '.vd-doc-search-input',\n resultsSelector: '.vd-doc-search-results',\n contentSelector: '.doc-content section[id]',\n titleSelector: '.demo-title, h2, h3',\n navSelector: '.doc-nav-link',\n sectionSelector: '.doc-nav-section',\n \n // Content extraction\n excludeFromContent: 'pre, code, script, style',\n maxContentLength: 500,\n \n // Custom data source (alternative to DOM indexing)\n data: null,\n \n // Category icons mapping\n categoryIcons: {\n 'getting-started': 'ph-rocket-launch',\n 'core': 'ph-cube',\n 'components': 'ph-puzzle-piece',\n 'interactive': 'ph-cursor-click',\n 'data-display': 'ph-table',\n 'feedback': 'ph-bell',\n 'meta': 'ph-info',\n 'default': 'ph-file-text'\n },\n \n // Callbacks\n onSelect: null, // function(result) - called when result is selected\n onSearch: null, // function(query, results) - called after search\n onOpen: null, // function() - called when results open\n onClose: null, // function() - called when results close\n \n // Text customization\n emptyTitle: 'No results found',\n emptyText: 'Try different keywords or check spelling',\n placeholder: 'Search...'\n };\n\n /**\n * Search Component Factory\n * Creates a new search instance with the given configuration\n * \n * @param {Object} options - Configuration options\n * @returns {Object} Search instance\n */\n function createSearch(options) {\n const config = Object.assign({}, DEFAULTS, options || {});\n \n // Instance state\n const state = {\n initialized: false,\n index: [],\n results: [],\n activeIndex: -1,\n isOpen: false,\n query: '',\n container: null,\n input: null,\n resultsContainer: null,\n debounceTimer: null,\n boundHandlers: {}\n };\n\n function safeInvokeCallback(name, fn, ...args) {\n try {\n fn(...args);\n } catch (error) {\n console.warn('[Vanduo Search] Callback error in \"' + name + '\":', error);\n }\n }\n\n function setResultsHtml(html) {\n if (!state.resultsContainer) return;\n try {\n state.resultsContainer.innerHTML = html;\n } catch (error) {\n console.warn('[Vanduo Search] Failed to render results:', error);\n }\n }\n\n /**\n * Initialize the search component\n * Idempotent \u2014 safe to call more than once on the same instance.\n * Returns the instance on success, null if required DOM elements are missing.\n */\n function init() {\n if (state.initialized) {\n return instance;\n }\n\n state.container = document.querySelector(config.containerSelector);\n if (!state.container) {\n state.initialized = false;\n return null;\n }\n\n state.input = state.container.querySelector(config.inputSelector);\n state.resultsContainer = state.container.querySelector(config.resultsSelector);\n\n if (!state.input || !state.resultsContainer) {\n state.initialized = false;\n return null;\n }\n\n // Set placeholder if configured\n if (config.placeholder) {\n state.input.setAttribute('placeholder', config.placeholder);\n }\n\n // Build search index\n buildIndex();\n\n // Bind events\n bindEvents();\n\n // Set up ARIA attributes\n setupAria();\n\n state.initialized = true;\n return instance;\n }\n\n /**\n * Build search index from DOM or custom data\n */\n function buildIndex() {\n state.index = [];\n \n // Use custom data if provided\n if (config.data && Array.isArray(config.data)) {\n config.data.forEach(function(item) {\n state.index.push({\n id: item.id || slugify(item.title),\n title: item.title || '',\n category: item.category || '',\n categorySlug: slugify(item.category || ''),\n content: item.content || '',\n keywords: item.keywords || extractKeywordsFromText(item.title + ' ' + item.content),\n url: item.url || '#' + (item.id || slugify(item.title)),\n icon: item.icon || ''\n });\n });\n return;\n }\n\n // Build from DOM\n const sections = document.querySelectorAll(config.contentSelector);\n const categoryMap = buildCategoryMap();\n\n sections.forEach(function(section) {\n const id = section.id;\n if (!id) return;\n\n const titleEl = section.querySelector(config.titleSelector);\n const title = titleEl ? titleEl.textContent.replace(/v[\\d.]+/g, '').trim() : id;\n const category = categoryMap[id] || 'Documentation';\n const content = extractContent(section);\n const keywords = extractKeywords(section, title);\n const iconEl = titleEl ? titleEl.querySelector('i.ph') : null;\n let icon = '';\n if (iconEl && iconEl.classList) {\n for (let ci = 0; ci < iconEl.classList.length; ci++) {\n if (iconEl.classList[ci].indexOf('ph-') === 0) {\n icon = iconEl.classList[ci];\n break;\n }\n }\n }\n\n state.index.push({\n id: id,\n title: title,\n category: category,\n categorySlug: slugify(category),\n content: content,\n keywords: keywords,\n url: '#' + id,\n icon: icon\n });\n });\n }\n\n /**\n * Build a map of section IDs to their categories\n */\n function buildCategoryMap() {\n const map = {};\n let currentCategory = 'Documentation';\n const navItems = document.querySelectorAll(config.navSelector + ', ' + config.sectionSelector);\n\n navItems.forEach(function(item) {\n if (item.classList.contains('doc-nav-section')) {\n currentCategory = item.textContent.trim();\n } else {\n const href = item.getAttribute('href');\n if (href && href.startsWith('#')) {\n const id = href.substring(1);\n map[id] = currentCategory;\n }\n }\n });\n\n return map;\n }\n\n /**\n * Extract searchable content from a section\n */\n function extractContent(section) {\n const clone = section.cloneNode(true);\n \n const toRemove = clone.querySelectorAll(config.excludeFromContent);\n toRemove.forEach(function(el) {\n el.remove();\n });\n\n let text = clone.textContent || '';\n text = text.replace(/\\s+/g, ' ').trim();\n \n return text.substring(0, config.maxContentLength);\n }\n\n /**\n * Extract keywords from a section\n */\n function extractKeywords(section, title) {\n const keywords = [];\n \n // Add title words\n title.toLowerCase().split(/\\s+/).forEach(function(word) {\n if (word.length > 2) {\n keywords.push(word);\n }\n });\n\n // Add class names from code examples\n const codeBlocks = section.querySelectorAll('code');\n codeBlocks.forEach(function(code) {\n const text = code.textContent || '';\n const classMatches = text.match(/\\.([\\w-]+)/g);\n if (classMatches) {\n classMatches.forEach(function(match) {\n keywords.push(match.substring(1).toLowerCase());\n });\n }\n });\n\n // Add data attributes\n const dataAttrs = section.querySelectorAll('[data-tooltip], [data-modal]');\n dataAttrs.forEach(function(el) {\n const attrs = el.getAttributeNames().filter(function(name) {\n return name.startsWith('data-');\n });\n attrs.forEach(function(attr) {\n keywords.push(attr.replace('data-', ''));\n });\n });\n\n return Array.from(new Set(keywords));\n }\n\n /**\n * Extract keywords from text string\n */\n function extractKeywordsFromText(text) {\n const words = text.toLowerCase().split(/\\s+/);\n return words.filter(function(word) {\n return word.length > 2;\n });\n }\n\n /**\n * Convert string to slug\n */\n function slugify(str) {\n return str.toLowerCase().replace(/\\s+/g, '-').replace(/[^a-z0-9-]/g, '');\n }\n\n /**\n * Bind event listeners\n */\n function bindEvents() {\n // Store bound handlers for cleanup\n state.boundHandlers.handleInput = function(e) {\n handleInput(e);\n };\n state.boundHandlers.handleFocus = function() {\n if (state.query.length >= config.minQueryLength) {\n open();\n }\n };\n state.boundHandlers.handleKeydown = function(e) {\n handleKeydown(e);\n };\n state.boundHandlers.handleOutsideClick = function(e) {\n if (!state.container.contains(e.target)) {\n close();\n }\n };\n state.boundHandlers.handleGlobalKeydown = function(e) {\n if (config.keyboardShortcut && (e.metaKey || e.ctrlKey) && e.key === 'k') {\n e.preventDefault();\n state.input.focus();\n state.input.select();\n }\n };\n state.boundHandlers.handleResultClick = function(e) {\n const result = e.target.closest('.vd-doc-search-result');\n if (result) {\n const index = parseInt(result.dataset.index, 10);\n select(index);\n }\n };\n\n // Input events\n state.input.addEventListener('input', state.boundHandlers.handleInput);\n state.input.addEventListener('focus', state.boundHandlers.handleFocus);\n state.input.addEventListener('keydown', state.boundHandlers.handleKeydown);\n\n // Close on outside click\n document.addEventListener('click', state.boundHandlers.handleOutsideClick);\n\n // Global keyboard shortcut\n document.addEventListener('keydown', state.boundHandlers.handleGlobalKeydown);\n\n // Result click handling\n state.resultsContainer.addEventListener('click', state.boundHandlers.handleResultClick);\n }\n\n /**\n * Unbind event listeners\n */\n function unbindEvents() {\n if (state.input) {\n state.input.removeEventListener('input', state.boundHandlers.handleInput);\n state.input.removeEventListener('focus', state.boundHandlers.handleFocus);\n state.input.removeEventListener('keydown', state.boundHandlers.handleKeydown);\n }\n document.removeEventListener('click', state.boundHandlers.handleOutsideClick);\n document.removeEventListener('keydown', state.boundHandlers.handleGlobalKeydown);\n if (state.resultsContainer) {\n state.resultsContainer.removeEventListener('click', state.boundHandlers.handleResultClick);\n }\n }\n\n /**\n * Set up ARIA attributes\n */\n function setupAria() {\n const resultsId = state.resultsContainer.id || 'search-results-' + Math.random().toString(36).substr(2, 9);\n state.resultsContainer.id = resultsId;\n \n state.input.setAttribute('role', 'combobox');\n state.input.setAttribute('aria-autocomplete', 'list');\n state.input.setAttribute('aria-controls', resultsId);\n state.input.setAttribute('aria-expanded', 'false');\n \n state.resultsContainer.setAttribute('role', 'listbox');\n state.resultsContainer.setAttribute('aria-label', 'Search results');\n }\n\n /**\n * Handle input changes\n */\n function handleInput(e) {\n const query = e.target.value.trim();\n\n if (state.debounceTimer) {\n clearTimeout(state.debounceTimer);\n }\n\n state.debounceTimer = setTimeout(function() {\n state.query = query;\n\n if (query.length < config.minQueryLength) {\n close();\n return;\n }\n\n state.results = search(query);\n state.activeIndex = -1;\n render();\n open();\n\n // Callback\n if (typeof config.onSearch === 'function') {\n safeInvokeCallback('onSearch', config.onSearch, query, state.results);\n }\n }, config.debounceMs);\n }\n\n /**\n * Handle keyboard navigation\n */\n function handleKeydown(e) {\n if (!state.isOpen) {\n if (e.key === 'ArrowDown' && state.query.length >= config.minQueryLength) {\n e.preventDefault();\n state.results = search(state.query);\n render();\n open();\n }\n return;\n }\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n navigate(1);\n break;\n\n case 'ArrowUp':\n e.preventDefault();\n navigate(-1);\n break;\n\n case 'Enter':\n e.preventDefault();\n if (state.activeIndex >= 0) {\n select(state.activeIndex);\n } else if (state.results.length > 0) {\n select(0);\n }\n break;\n\n case 'Escape':\n e.preventDefault();\n close();\n break;\n\n case 'Tab':\n close();\n break;\n }\n }\n\n /**\n * Perform search\n */\n function search(query) {\n const terms = query.toLowerCase().split(/\\s+/).filter(function(t) {\n return t.length > 0;\n });\n const scored = [];\n\n state.index.forEach(function(entry) {\n let score = 0;\n const titleLower = entry.title.toLowerCase();\n const categoryLower = entry.category.toLowerCase();\n const contentLower = entry.content.toLowerCase();\n\n terms.forEach(function(term) {\n // Title match - highest priority\n if (titleLower.includes(term)) {\n score += 100;\n if (titleLower === term) {\n score += 50;\n } else if (titleLower.startsWith(term)) {\n score += 25;\n }\n }\n\n // Category match\n if (categoryLower.includes(term)) {\n score += 50;\n }\n\n // Keyword match\n const keywordMatch = entry.keywords.some(function(k) {\n return k.includes(term);\n });\n if (keywordMatch) {\n score += 30;\n }\n\n // Content match\n if (contentLower.includes(term)) {\n score += 10;\n }\n });\n\n if (score > 0) {\n scored.push({\n id: entry.id,\n title: entry.title,\n category: entry.category,\n categorySlug: entry.categorySlug,\n content: entry.content,\n url: entry.url,\n icon: entry.icon,\n score: score\n });\n }\n });\n\n scored.sort(function(a, b) {\n return b.score - a.score;\n });\n\n return scored.slice(0, config.maxResults);\n }\n\n /**\n * Render search results\n */\n function render() {\n if (state.results.length === 0) {\n setResultsHtml(renderEmpty());\n return;\n }\n\n let html = '
      ';\n\n state.results.forEach(function(result, index) {\n const isActive = index === state.activeIndex;\n const icon = result.icon || getCategoryIcon(result.categorySlug);\n const excerpt = getExcerpt(result.content, state.query);\n\n html += '
    • ' +\n '
      ' +\n '' +\n '
      ' +\n '
      ' +\n '
      ' + highlight(result.title, state.query) + '
      ' +\n '
      ' + escapeHtml(result.category) + '
      ' +\n '
      ' + highlight(excerpt, state.query) + '
      ' +\n '
      ' +\n '
    • ';\n });\n\n html += '
    ';\n html += renderFooter();\n\n setResultsHtml(html);\n }\n\n /**\n * Render empty state\n */\n function renderEmpty() {\n return '
    ' +\n '
    ' +\n '
    ' + escapeHtml(config.emptyTitle) + '
    ' +\n '
    ' + escapeHtml(config.emptyText) + '
    ' +\n '
    ';\n }\n\n /**\n * Render footer with keyboard hints\n */\n function renderFooter() {\n return '
    ' +\n '\u2191\u2193 to navigate' +\n '\u21B5 to select' +\n 'esc to close' +\n '
    ';\n }\n\n /**\n * Get icon for category\n */\n function getCategoryIcon(categorySlug) {\n return config.categoryIcons[categorySlug] || config.categoryIcons['default'] || 'ph-file-text';\n }\n\n /**\n * Get excerpt from content\n */\n function getExcerpt(content, query) {\n const terms = query.toLowerCase().split(/\\s+/);\n const contentLower = content.toLowerCase();\n const excerptLength = 100;\n\n let matchPos = -1;\n for (let i = 0; i < terms.length; i++) {\n const pos = contentLower.indexOf(terms[i]);\n if (pos !== -1 && (matchPos === -1 || pos < matchPos)) {\n matchPos = pos;\n }\n }\n\n if (matchPos === -1) {\n return content.substring(0, excerptLength) + '...';\n }\n\n const start = Math.max(0, matchPos - 30);\n const end = Math.min(content.length, matchPos + excerptLength);\n let excerpt = content.substring(start, end);\n\n if (start > 0) {\n excerpt = '...' + excerpt;\n }\n if (end < content.length) {\n excerpt = excerpt + '...';\n }\n\n return excerpt;\n }\n\n /**\n * Highlight matched terms in text\n */\n function highlight(text, query) {\n if (!query) return escapeHtml(text);\n\n const terms = query.toLowerCase().split(/\\s+/).filter(function(t) {\n return t.length > 0;\n });\n let escaped = escapeHtml(text);\n\n terms.forEach(function(term) {\n // Skip overly long terms to prevent ReDoS\n if (term.length > 50) return;\n const regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi');\n escaped = escaped.replace(regex, '<' + config.highlightTag + '>$1');\n });\n\n return escaped;\n }\n\n /**\n * Escape HTML entities\n */\n function escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Navigate results with keyboard\n */\n function navigate(direction) {\n let newIndex = state.activeIndex + direction;\n\n if (newIndex < 0) {\n newIndex = state.results.length - 1;\n } else if (newIndex >= state.results.length) {\n newIndex = 0;\n }\n\n setActiveIndex(newIndex);\n }\n\n /**\n * Set active result index\n */\n function setActiveIndex(index) {\n const prevActive = state.resultsContainer.querySelector('.vd-doc-search-result.is-active');\n if (prevActive) {\n prevActive.classList.remove('is-active');\n prevActive.setAttribute('aria-selected', 'false');\n }\n\n state.activeIndex = index;\n\n const newActive = state.resultsContainer.querySelector('[data-index=\"' + index + '\"]');\n if (newActive) {\n newActive.classList.add('is-active');\n newActive.setAttribute('aria-selected', 'true');\n state.input.setAttribute('aria-activedescendant', 'vd-doc-search-result-' + index);\n newActive.scrollIntoView({ block: 'nearest' });\n }\n }\n\n /**\n * Select a result\n */\n function select(index) {\n const result = state.results[index];\n if (!result) return;\n\n // Close search\n close();\n state.input.value = '';\n state.query = '';\n\n // Custom callback\n if (typeof config.onSelect === 'function') {\n safeInvokeCallback('onSelect', config.onSelect, result);\n return;\n }\n\n // Default behavior: navigate to section\n const section = document.querySelector(result.url);\n if (section) {\n section.scrollIntoView({ behavior: 'smooth', block: 'start' });\n window.history.pushState(null, '', result.url);\n updateSidebarActive(result.id);\n }\n }\n\n /**\n * Update sidebar navigation active state\n */\n function updateSidebarActive(sectionId) {\n const navLinks = document.querySelectorAll(config.navSelector);\n navLinks.forEach(function(link) {\n link.classList.remove('active');\n if (link.getAttribute('href') === '#' + sectionId) {\n link.classList.add('active');\n }\n });\n }\n\n /**\n * Open results dropdown\n */\n function open() {\n if (state.isOpen) return;\n\n state.isOpen = true;\n state.resultsContainer.classList.add('is-open');\n state.input.setAttribute('aria-expanded', 'true');\n\n if (typeof config.onOpen === 'function') {\n safeInvokeCallback('onOpen', config.onOpen);\n }\n }\n\n /**\n * Close results dropdown\n */\n function close() {\n if (!state.isOpen) return;\n\n state.isOpen = false;\n state.activeIndex = -1;\n state.resultsContainer.classList.remove('is-open');\n state.input.setAttribute('aria-expanded', 'false');\n state.input.removeAttribute('aria-activedescendant');\n\n if (typeof config.onClose === 'function') {\n safeInvokeCallback('onClose', config.onClose);\n }\n }\n\n /**\n * Destroy the component\n */\n function destroy() {\n unbindEvents();\n \n state.initialized = false;\n state.index = [];\n state.results = [];\n state.isOpen = false;\n state.query = '';\n\n if (state.debounceTimer) {\n clearTimeout(state.debounceTimer);\n }\n\n if (state.resultsContainer) {\n setResultsHtml('');\n }\n }\n\n /**\n * Rebuild the search index\n */\n function rebuild() {\n buildIndex();\n }\n\n /**\n * Update configuration\n */\n function setConfig(newConfig) {\n Object.assign(config, newConfig);\n }\n\n /**\n * Get current configuration\n */\n function getConfig() {\n return Object.assign({}, config);\n }\n\n /**\n * Get search index\n */\n function getIndex() {\n return state.index.slice();\n }\n\n // Public instance API\n const instance = {\n init: init,\n destroy: destroy,\n rebuild: rebuild,\n search: search,\n open: open,\n close: close,\n setConfig: setConfig,\n getConfig: getConfig,\n getIndex: getIndex\n };\n\n return instance;\n }\n\n /**\n * Search Component (singleton for backward compatibility)\n */\n const Search = {\n // Factory method \u2014 creates and auto-initializes a new independent instance.\n // Always returns the instance so callers retain a reference even if the\n // DOM container is not yet available (they can retry init() later).\n create: function(options) {\n const instance = createSearch(options);\n if (instance) {\n instance.init();\n }\n return instance || null;\n },\n \n // Default instance\n _instance: null,\n \n // Configuration (for default instance)\n config: Object.assign({}, DEFAULTS),\n\n /**\n * Initialize the default search instance\n */\n init: function(options) {\n if (this._instance) {\n this._instance.destroy();\n }\n \n if (options) {\n Object.assign(this.config, options);\n }\n \n this._instance = createSearch(this.config);\n return this._instance ? this._instance.init() : null;\n },\n\n /**\n * Destroy the default instance\n */\n destroy: function() {\n if (this._instance) {\n this._instance.destroy();\n this._instance = null;\n }\n },\n\n destroyAll: function() {\n this.destroy();\n },\n\n /**\n * Rebuild the default instance index\n */\n rebuild: function() {\n if (this._instance) {\n this._instance.rebuild();\n }\n },\n\n /**\n * Search using the default instance\n */\n search: function(query) {\n return this._instance ? this._instance.search(query) : [];\n },\n\n /**\n * Open the default instance\n */\n open: function() {\n if (this._instance) {\n this._instance.open();\n }\n },\n\n /**\n * Close the default instance\n */\n close: function() {\n if (this._instance) {\n this._instance.close();\n }\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('docSearch', Search);\n }\n\n // Expose globally (both names for compatibility)\n window.Search = Search;\n window.DocSearch = Search; // Backward compatibility\n window.VanduoDocSearch = Search; // New name compatibility\n\n})();\n", "/**\n * Vanduo Framework - Draggable Component\n * JavaScript functionality for draggable elements and drop zones\n */\n\n(function () {\n 'use strict';\n\n /**\n * Draggable Component\n */\n const Draggable = {\n // Store initialized draggables and their cleanup functions\n instances: new Map(),\n // Store current drag state\n currentDrag: null,\n // Store touch state\n touchState: null,\n // Feedback element\n feedbackElement: null,\n // Shared selector used by init and touch reorder\n containerSelector: '.vd-draggable-container, .vd-draggable-container-vertical',\n\n /**\n * Initialize draggable components\n */\n init: function () {\n const draggables = document.querySelectorAll('.vd-draggable, [data-draggable]');\n\n draggables.forEach(element => {\n if (this.instances.has(element)) {\n return;\n }\n this.initDraggable(element);\n });\n\n const containers = document.querySelectorAll(this.containerSelector);\n containers.forEach(container => {\n if (!this.instances.has(container)) {\n this.initContainer(container);\n }\n });\n\n const dropZones = document.querySelectorAll('.vd-drop-zone');\n dropZones.forEach(zone => {\n if (!this.instances.has(zone)) {\n this.initDropZone(zone);\n }\n });\n\n this.createFeedbackElement();\n },\n\n /**\n * Initialize a single draggable element\n * @param {HTMLElement} element - Draggable element\n */\n initDraggable: function (element) {\n const cleanupFunctions = [];\n\n // Make element draggable if not already\n if (!element.hasAttribute('draggable')) {\n element.setAttribute('draggable', 'true');\n }\n\n // Accessibility: add ARIA attributes\n if (!element.hasAttribute('tabindex')) {\n element.setAttribute('tabindex', '0');\n }\n element.setAttribute('role', 'option');\n element.setAttribute('aria-roledescription', 'draggable item');\n element.setAttribute('aria-grabbed', 'false');\n\n // Handle drag start\n const dragStartHandler = (e) => {\n this.handleDragStart(e, element);\n };\n element.addEventListener('dragstart', dragStartHandler);\n cleanupFunctions.push(() => element.removeEventListener('dragstart', dragStartHandler));\n\n // Handle drag\n const dragHandler = (e) => {\n this.handleDrag(e, element);\n };\n element.addEventListener('drag', dragHandler);\n cleanupFunctions.push(() => element.removeEventListener('drag', dragHandler));\n\n // Handle drag end\n const dragEndHandler = (e) => {\n this.handleDragEnd(e, element);\n };\n element.addEventListener('dragend', dragEndHandler);\n cleanupFunctions.push(() => element.removeEventListener('dragend', dragEndHandler));\n\n // Handle touch start (for mobile)\n const touchStartHandler = (e) => {\n this.handleTouchStart(e, element);\n };\n element.addEventListener('touchstart', touchStartHandler);\n cleanupFunctions.push(() => element.removeEventListener('touchstart', touchStartHandler));\n\n // Handle touch move (for mobile)\n // { passive: false } is required so that e.preventDefault() works\n // once the drag threshold is reached \u2014 without it, modern browsers\n // treat the listener as passive and silently ignore preventDefault().\n const touchMoveHandler = (e) => {\n this.handleTouchMove(e, element);\n };\n element.addEventListener('touchmove', touchMoveHandler, { passive: false });\n cleanupFunctions.push(() => element.removeEventListener('touchmove', touchMoveHandler));\n\n // Handle touch end (for mobile)\n const touchEndHandler = (e) => {\n this.handleTouchEnd(e, element);\n };\n element.addEventListener('touchend', touchEndHandler, { passive: false });\n cleanupFunctions.push(() => element.removeEventListener('touchend', touchEndHandler));\n\n // Handle touch cancel (for mobile)\n const touchCancelHandler = (e) => {\n this.handleTouchEnd(e, element);\n };\n element.addEventListener('touchcancel', touchCancelHandler);\n cleanupFunctions.push(() => element.removeEventListener('touchcancel', touchCancelHandler));\n\n // Keyboard navigation\n const keydownHandler = (e) => {\n this.handleKeydown(e, element);\n };\n element.addEventListener('keydown', keydownHandler);\n cleanupFunctions.push(() => element.removeEventListener('keydown', keydownHandler));\n\n this.instances.set(element, { cleanup: cleanupFunctions });\n },\n\n /**\n * Initialize a draggable container\n * @param {HTMLElement} container - Draggable container\n */\n initContainer: function (container) {\n // Accessibility: add ARIA role to container\n container.setAttribute('role', 'listbox');\n container.setAttribute('aria-label', container.getAttribute('aria-label') || 'Draggable items');\n\n const items = container.querySelectorAll('.vd-draggable-item');\n items.forEach(item => {\n if (!this.instances.has(item)) {\n this.initDraggable(item);\n }\n });\n\n const cleanupFunctions = [];\n\n // Handle drag enter\n const dragEnterHandler = (e) => {\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n };\n\n // Handle drag over for auto-sorting\n const dragOverHandler = (e) => {\n e.preventDefault(); // Necessary to allow drop\n e.dataTransfer.dropEffect = 'move';\n\n if (!this.currentDrag) return;\n const draggingEl = this.currentDrag.element;\n\n // Only reorder if dragging an item that belongs to this container (or if we support cross-container drag, but keep simple)\n if (!container.contains(draggingEl)) return;\n\n // Prevent jumps when the dragging finalizes with 0,0 coordinates near the end\n if (e.clientX === 0 && e.clientY === 0) return;\n\n this.handleReorder(container, draggingEl, e.clientX, e.clientY);\n };\n\n // Handle drop\n const dropHandler = (e) => {\n e.preventDefault(); // crucial to prevent the browser's default handling and snapping back\n };\n\n container.addEventListener('dragenter', dragEnterHandler);\n container.addEventListener('dragover', dragOverHandler);\n container.addEventListener('drop', dropHandler);\n\n cleanupFunctions.push(() => {\n container.removeEventListener('dragenter', dragEnterHandler);\n container.removeEventListener('dragover', dragOverHandler);\n container.removeEventListener('drop', dropHandler);\n });\n\n this.instances.set(container, { cleanup: cleanupFunctions });\n },\n\n /**\n * Initialize a drop zone\n * @param {HTMLElement} zone - Drop zone element\n */\n initDropZone: function (zone) {\n const cleanupFunctions = [];\n\n // Accessibility: add ARIA role to drop zone\n zone.setAttribute('role', 'region');\n zone.setAttribute('aria-dropeffect', 'move');\n if (!zone.hasAttribute('aria-label')) {\n zone.setAttribute('aria-label', 'Drop zone');\n }\n\n // Handle drag over\n const dragOverHandler = (e) => {\n e.preventDefault();\n this.handleDragOver(e, zone);\n };\n zone.addEventListener('dragover', dragOverHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragover', dragOverHandler));\n\n // Handle drag enter\n const dragEnterHandler = (e) => {\n e.preventDefault();\n this.handleDragEnter(e, zone);\n };\n zone.addEventListener('dragenter', dragEnterHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragenter', dragEnterHandler));\n\n // Handle drag leave\n const dragLeaveHandler = (e) => {\n this.handleDragLeave(e, zone);\n };\n zone.addEventListener('dragleave', dragLeaveHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragleave', dragLeaveHandler));\n\n // Handle drop\n const dropHandler = (e) => {\n e.preventDefault();\n this.handleDrop(e, zone);\n };\n zone.addEventListener('drop', dropHandler);\n cleanupFunctions.push(() => zone.removeEventListener('drop', dropHandler));\n\n this.instances.set(zone, { cleanup: cleanupFunctions });\n },\n\n /**\n * Create feedback element for drag operations\n */\n createFeedbackElement: function () {\n if (!this.feedbackElement) {\n // Reuse existing element if present\n const existing = document.querySelector('.vd-drag-feedback');\n if (existing) {\n this.feedbackElement = existing;\n return;\n }\n\n this.feedbackElement = document.createElement('div');\n this.feedbackElement.className = 'vd-drag-feedback hidden';\n this.feedbackElement.setAttribute('role', 'presentation');\n document.body.appendChild(this.feedbackElement);\n }\n },\n\n /**\n * Handle drag start event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDragStart: function (e, element) {\n // Add dragging class\n element.classList.add('is-dragging');\n\n // Accessibility: update aria-grabbed\n element.setAttribute('aria-grabbed', 'true');\n\n // Store drag state\n this.currentDrag = {\n element: element,\n initialPosition: { x: e.clientX, y: e.clientY },\n initialBounds: element.getBoundingClientRect(),\n data: this.getData(element)\n };\n\n // Set drag data\n e.dataTransfer.effectAllowed = 'move';\n e.dataTransfer.setData('text/plain', this.currentDrag.data);\n\n // We no longer suppress the native ghost image or manually update feedback\n // for mouse drags, relying on the browser's native rendering instead.\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:start', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: e.clientX, y: e.clientY }\n }\n }));\n },\n\n /**\n * Handle drag event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDrag: function (e, element) {\n // Guard against null state (race condition on fast interactions)\n if (!this.currentDrag) return;\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:drag', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: e.clientX, y: e.clientY },\n delta: {\n x: e.clientX - this.currentDrag.initialPosition.x,\n y: e.clientY - this.currentDrag.initialPosition.y\n }\n }\n }));\n },\n\n /**\n * Handle drag end event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDragEnd: function (e, element) {\n // Remove dragging class\n element.classList.remove('is-dragging');\n element.classList.add('is-dropped');\n setTimeout(() => element.classList.remove('is-dropped'), 300);\n\n // Accessibility: update aria-grabbed\n element.setAttribute('aria-grabbed', 'false');\n\n // Hide feedback\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n\n // Guard against null state\n const data = this.currentDrag?.data || this.getData(element);\n const initialPos = this.currentDrag?.initialPosition || { x: 0, y: 0 };\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:end', {\n bubbles: true,\n detail: {\n element: element,\n data: data,\n position: { x: e.clientX, y: e.clientY },\n delta: {\n x: e.clientX - initialPos.x,\n y: e.clientY - initialPos.y\n }\n }\n }));\n\n // Reset drag state\n this.currentDrag = null;\n },\n\n /**\n * Handle touch start event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchStart: function (e, element) {\n // Don't prevent default here \u2014 it blocks scrolling.\n // We only prevent default in touchmove once drag threshold is reached.\n const touch = e.touches[0];\n const rect = element.getBoundingClientRect();\n this.touchState = {\n element: element,\n startX: touch.clientX,\n startY: touch.clientY,\n lastX: touch.clientX,\n lastY: touch.clientY,\n // Keep preview anchored to the original grab point.\n offsetX: touch.clientX - rect.left,\n offsetY: touch.clientY - rect.top,\n startTime: Date.now(),\n isDragging: false\n };\n },\n\n /**\n * Handle touch move event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchMove: function (e, element) {\n if (!this.touchState) return;\n\n const touch = e.touches[0];\n this.touchState.lastX = touch.clientX;\n this.touchState.lastY = touch.clientY;\n const deltaX = touch.clientX - this.touchState.startX;\n const deltaY = touch.clientY - this.touchState.startY;\n\n // Only start dragging if moved a minimum distance\n if (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10) {\n // Now we know it's a drag, not a scroll \u2014 prevent default\n if (e.cancelable) e.preventDefault();\n\n if (!this.touchState.isDragging) {\n this.touchState.isDragging = true;\n element.classList.add('is-dragging');\n element.setAttribute('aria-grabbed', 'true');\n\n // Store drag state\n this.currentDrag = {\n element: element,\n initialPosition: { x: this.touchState.startX, y: this.touchState.startY },\n initialBounds: element.getBoundingClientRect(),\n data: this.getData(element),\n // Preserve where inside the element the drag started for accurate ghost positioning.\n offsetX: this.touchState.offsetX,\n offsetY: this.touchState.offsetY\n };\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:start', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: touch.clientX, y: touch.clientY }\n }\n }));\n }\n\n // Update feedback\n this.updateFeedback(touch.clientX, touch.clientY);\n\n // Dispatch event\n if (this.currentDrag) {\n element.dispatchEvent(new CustomEvent('draggable:drag', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: touch.clientX, y: touch.clientY },\n delta: { x: deltaX, y: deltaY }\n }\n }));\n\n this.updateTouchDropZone(touch.clientX, touch.clientY);\n\n // Reorder for touch\n const container = element.closest(this.containerSelector);\n if (container && container.contains(element)) {\n this.handleReorder(container, element, touch.clientX, touch.clientY);\n }\n }\n }\n },\n\n /**\n * Handle touch end event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchEnd: function (e, element) {\n if (this.touchState && this.touchState.isDragging) {\n if (e.cancelable) e.preventDefault();\n const endTouch = e.changedTouches?.[0];\n const endPosition = {\n x: endTouch?.clientX ?? this.touchState.lastX ?? this.touchState.startX,\n y: endTouch?.clientY ?? this.touchState.lastY ?? this.touchState.startY\n };\n\n const dropZone = this.resolveDropZoneAtPoint(endPosition.x, endPosition.y) || this.touchState.overZone;\n if (dropZone) {\n this.dispatchDrop(dropZone, endPosition);\n } else if (this.touchState.overZone) {\n this.touchState.overZone.classList.remove('is-drag-over');\n }\n\n element.classList.remove('is-dragging');\n element.classList.add('is-dropped');\n element.setAttribute('aria-grabbed', 'false');\n setTimeout(() => element.classList.remove('is-dropped'), 300);\n\n // Hide feedback\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n\n // Dispatch event\n const data = this.currentDrag?.data || this.getData(element);\n const startX = this.touchState?.startX || 0;\n const startY = this.touchState?.startY || 0;\n\n element.dispatchEvent(new CustomEvent('draggable:end', {\n bubbles: true,\n detail: {\n element: element,\n data: data,\n position: endPosition,\n delta: {\n x: endPosition.x - startX,\n y: endPosition.y - startY\n }\n }\n }));\n }\n\n // Reset states\n this.touchState = null;\n this.currentDrag = null;\n },\n\n /**\n * Handle drag over event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} _zone - Drop zone element\n */\n handleDragOver: function (e, _zone) {\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n },\n\n /**\n * Handle drag enter event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDragEnter: function (e, zone) {\n e.preventDefault();\n zone.classList.add('is-drag-over');\n },\n\n /**\n * Handle drag leave event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDragLeave: function (e, zone) {\n zone.classList.remove('is-drag-over');\n },\n\n /**\n * Handle drop event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDrop: function (e, zone) {\n e.preventDefault();\n this.dispatchDrop(zone, { x: e.clientX, y: e.clientY });\n },\n\n /**\n * Resolve a drop zone from viewport coordinates\n * @param {number} x\n * @param {number} y\n * @returns {HTMLElement|null}\n */\n resolveDropZoneAtPoint: function (x, y) {\n if (!Number.isFinite(x) || !Number.isFinite(y)) return null;\n\n // Prefer the full stacking list so overlays/top elements don't hide real drop targets.\n if (typeof document.elementsFromPoint === 'function') {\n const stacked = document.elementsFromPoint(x, y);\n for (const element of stacked) {\n const zone = element.closest('.vd-drop-zone');\n if (zone) return zone;\n }\n }\n\n const target = document.elementFromPoint(x, y);\n const targetZone = target ? target.closest('.vd-drop-zone') : null;\n if (targetZone) return targetZone;\n\n // Last-resort fallback for mobile emulation edge cases.\n const zones = document.querySelectorAll('.vd-drop-zone');\n for (const zone of zones) {\n const rect = zone.getBoundingClientRect();\n if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {\n return zone;\n }\n }\n\n return null;\n },\n\n /**\n * Track and update active drop-zone hover state on touch devices\n * @param {number} x\n * @param {number} y\n */\n updateTouchDropZone: function (x, y) {\n if (!this.touchState) return;\n\n const nextZone = this.resolveDropZoneAtPoint(x, y);\n const prevZone = this.touchState.overZone || null;\n\n if (prevZone && prevZone !== nextZone) {\n prevZone.classList.remove('is-drag-over');\n }\n\n if (nextZone && nextZone !== prevZone) {\n nextZone.classList.add('is-drag-over');\n }\n\n this.touchState.overZone = nextZone || null;\n },\n\n /**\n * Dispatch a normalized drop event for mouse and touch flows\n * @param {HTMLElement} zone\n * @param {{x:number, y:number}} position\n */\n dispatchDrop: function (zone, position) {\n zone.classList.remove('is-drag-over');\n zone.dispatchEvent(new CustomEvent('draggable:drop', {\n bubbles: true,\n detail: {\n zone: zone,\n element: this.currentDrag?.element,\n data: this.currentDrag?.data,\n position: position\n }\n }));\n },\n\n /**\n * Reorder elements in container based on cursor position\n * @param {HTMLElement} container \n * @param {HTMLElement} element \n * @param {number} clientX \n * @param {number} clientY \n */\n handleReorder: function (container, element, clientX, clientY) {\n const isVertical = container.classList.contains('vd-draggable-container-vertical');\n const siblings = [...container.querySelectorAll('.vd-draggable-item:not(.is-dragging), .vd-draggable:not(.is-dragging)')];\n\n const nextSibling = siblings.reduce((closest, child) => {\n const box = child.getBoundingClientRect();\n const offset = isVertical\n ? clientY - box.top - box.height / 2\n : clientX - box.left - box.width / 2;\n\n if (offset < 0 && offset > closest.offset) {\n return { offset: offset, element: child };\n } else {\n return closest;\n }\n }, { offset: Number.NEGATIVE_INFINITY }).element;\n\n if (nextSibling == null) {\n container.appendChild(element);\n } else {\n container.insertBefore(element, nextSibling);\n }\n },\n\n /**\n * Handle keyboard events\n * @param {KeyboardEvent} e - Keyboard event\n * @param {HTMLElement} element - Draggable element\n */\n handleKeydown: function (e, element) {\n switch (e.key) {\n case 'Enter':\n case ' ':\n e.preventDefault();\n // Trigger click or custom action\n element.click();\n break;\n case 'Escape':\n // Cancel drag if in progress\n if (element.classList.contains('is-dragging')) {\n element.classList.remove('is-dragging');\n element.setAttribute('aria-grabbed', 'false');\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n this.currentDrag = null;\n }\n break;\n case 'ArrowUp':\n case 'ArrowLeft': {\n e.preventDefault();\n const prev = element.previousElementSibling;\n if (prev && (prev.classList.contains('vd-draggable') || prev.classList.contains('vd-draggable-item'))) {\n element.parentNode.insertBefore(element, prev);\n element.focus();\n element.dispatchEvent(new CustomEvent('draggable:reorder', {\n bubbles: true,\n detail: { element: element, direction: 'up' }\n }));\n }\n break;\n }\n case 'ArrowDown':\n case 'ArrowRight': {\n e.preventDefault();\n const next = element.nextElementSibling;\n if (next && (next.classList.contains('vd-draggable') || next.classList.contains('vd-draggable-item'))) {\n element.parentNode.insertBefore(next, element);\n element.focus();\n element.dispatchEvent(new CustomEvent('draggable:reorder', {\n bubbles: true,\n detail: { element: element, direction: 'down' }\n }));\n }\n break;\n }\n }\n },\n\n /**\n * Get data from draggable element\n * @param {HTMLElement} element - Draggable element\n * @returns {string} Data associated with the element\n */\n getData: function (element) {\n return element.dataset.draggable || element.textContent.trim();\n },\n\n /**\n * Update drag feedback element\n * @param {number} x - Current X coordinate\n * @param {number} y - Current Y coordinate\n */\n updateFeedback: function (x, y) {\n if (!this.currentDrag) return;\n\n // Show feedback\n this.feedbackElement.classList.remove('hidden');\n\n // Update feedback content\n const rect = this.currentDrag.initialBounds;\n this.feedbackElement.innerHTML = '';\n const clone = this.currentDrag.element.cloneNode(true);\n this.feedbackElement.appendChild(clone);\n\n // Set styles\n const offsetX = this.currentDrag.offsetX ?? 20;\n const offsetY = this.currentDrag.offsetY ?? 20;\n Object.assign(this.feedbackElement.style, {\n left: (x - offsetX) + 'px',\n top: (y - offsetY) + 'px',\n width: rect.width + 'px',\n height: rect.height + 'px'\n });\n },\n\n /**\n * Make an element draggable programmatically\n * @param {HTMLElement|string} element - Element or selector\n * @param {Object} options - Configuration options\n */\n makeDraggable: function (element, options = {}) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n\n if (el && !this.instances.has(el)) {\n // Add classes and attributes\n el.classList.add('vd-draggable');\n el.setAttribute('draggable', 'true');\n\n // Set options\n if (options.data) {\n el.dataset.draggable = options.data;\n }\n\n // Initialize\n this.initDraggable(el);\n }\n },\n\n /**\n * Remove draggable functionality from an element\n * @param {HTMLElement|string} element - Element or selector\n */\n removeDraggable: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n\n if (el && this.instances.has(el)) {\n // Clean up\n const instance = this.instances.get(el);\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n\n // Remove classes and attributes\n el.classList.remove('vd-draggable');\n el.removeAttribute('draggable');\n el.removeAttribute('data-draggable');\n }\n },\n\n /**\n * Destroy a draggable instance and clean up event listeners\n * @param {HTMLElement} element - Draggable element\n */\n destroy: function (element) {\n this.removeDraggable(element);\n },\n\n /**\n * Destroy all draggable instances\n */\n destroyAll: function () {\n const instances = Array.from(this.instances.keys());\n instances.forEach(element => this.destroy(element));\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('draggable', Draggable);\n }\n\n // Expose globally\n window.VanduoDraggable = Draggable;\n})();\n", "/**\n * Vanduo Framework \u2013 LazyLoad Component\n * v1.2.1\n *\n * Provides two levels of API:\n *\n * LOW-LEVEL (generic IntersectionObserver wrapper)\n * VanduoLazyLoad.observe(element, callback, options?)\n * VanduoLazyLoad.unobserve(element)\n * VanduoLazyLoad.unobserveAll()\n *\n * HIGH-LEVEL (HTML-section fetcher, mirrors vanduo-docs SPA behaviour)\n * VanduoLazyLoad.loadSection(url, containerEl, options?)\n * options: { placeholder, threshold, rootMargin, onLoaded, onError }\n * placeholder: 'skeleton' | 'spinner' | HTMLString (default: 'skeleton')\n *\n * ATTRIBUTE-DRIVEN (zero-JS usage, wired by init())\n *
    \u2026
    \n *\n * EVENTS dispatched on the host element:\n * lazysection:loading \u2014 fetch started\n * lazysection:loaded \u2014 content injected\n * lazysection:error \u2014 fetch failed\n */\n\n(function () {\n 'use strict';\n\n /* \u2500\u2500 Private state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /** @type {Map} */\n const _observerMap = new Map();\n\n /* \u2500\u2500 Security helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Returns true only if `url` resolves to the same origin as the page\n * (relative paths and same-origin absolute URLs are allowed).\n * Cross-origin URLs are rejected to prevent SSRF-style fetch abuse.\n * @param {string} url\n * @returns {boolean}\n */\n function _isSafeUrl(url) {\n try {\n // Relative URLs (no origin) are always safe\n const resolved = new URL(url, window.location.href);\n return resolved.origin === window.location.origin;\n } catch (_) {\n return false;\n }\n }\n\n /**\n * Safely inject fetched HTML into a container by parsing it with\n * DOMParser (avoids script execution) and replacing children via\n * standard DOM APIs instead of raw innerHTML assignment.\n * @param {Element} containerEl\n * @param {string} html\n */\n function _safeInjectHtml(containerEl, html) {\n const parser = new DOMParser();\n const doc = parser.parseFromString(html.trim(), 'text/html'); // CodeQL [js/xss-through-dom] Intentional: parsed nodes are sanitized below before adoption into live DOM\n\n const DANGEROUS_TAGS = ['SCRIPT', 'IFRAME', 'OBJECT', 'EMBED', 'FORM', 'BASE', 'LINK', 'META', 'STYLE'];\n for (const tag of DANGEROUS_TAGS) {\n const els = doc.querySelectorAll(tag);\n for (let i = els.length - 1; i >= 0; i--) {\n els[i].parentNode.removeChild(els[i]);\n }\n }\n\n // Strip dangerous attributes (e.g. onerror, javascript: urls) from all elements\n function _sanitizeNode(node) {\n if (node.nodeType === Node.ELEMENT_NODE) {\n const attrs = node.attributes;\n for (let i = attrs.length - 1; i >= 0; i--) {\n const attrName = attrs[i].name.toLowerCase();\n const attrValue = attrs[i].value.toLowerCase();\n const trimmedValue = attrValue.trim();\n if (\n attrName.startsWith('on') ||\n trimmedValue.startsWith('javascript:') ||\n trimmedValue.startsWith('data:') ||\n trimmedValue.startsWith('vbscript:')\n ) {\n node.removeAttribute(attrs[i].name);\n }\n }\n const children = node.childNodes;\n for (let i = 0; i < children.length; i++) {\n _sanitizeNode(children[i]);\n }\n }\n }\n _sanitizeNode(doc.body);\n\n // Collect all top-level body children\n const nodes = Array.from(doc.body.childNodes);\n // Clear container and append parsed nodes\n while (containerEl.firstChild) {\n containerEl.removeChild(containerEl.firstChild);\n }\n nodes.forEach(function (node) {\n containerEl.appendChild(document.adoptNode(node));\n });\n }\n\n /* \u2500\u2500 Placeholder HTML \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n function _skeletonHtml() {\n return '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + 'Loading\u2026
    ';\n }\n\n function _spinnerHtml() {\n return '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + 'Loading\u2026
    ';\n }\n\n function _resolvePlaceholder(placeholder) {\n if (!placeholder || placeholder === 'skeleton') return _skeletonHtml();\n if (placeholder === 'spinner') return _spinnerHtml();\n // Caller-supplied HTML string\n return placeholder;\n }\n\n /* \u2500\u2500 Dispatch helper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n function _dispatch(el, eventName, detail) {\n el.dispatchEvent(new CustomEvent(eventName, { bubbles: true, detail: detail || {} }));\n }\n\n /* \u2500\u2500 VanduoLazyLoad \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const VanduoLazyLoad = {\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * LOW-LEVEL API\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Observe an element. `callback` is invoked once when the element\n * enters the viewport, then the element is automatically unobserved.\n *\n * @param {Element} element\n * @param {function(Element): void} callback\n * @param {{ threshold?: number, rootMargin?: string }} [options]\n */\n observe: function (element, callback, options) {\n if (!(element instanceof Element)) {\n console.warn('[VanduoLazyLoad] observe() requires a DOM Element.');\n return;\n }\n if (typeof callback !== 'function') {\n console.warn('[VanduoLazyLoad] observe() requires a callback function.');\n return;\n }\n // Already observed \u2014 ignore\n if (_observerMap.has(element)) return;\n\n const threshold = (options && options.threshold != null) ? options.threshold : 0;\n const rootMargin = (options && options.rootMargin) ? options.rootMargin : '0px';\n\n const observer = new IntersectionObserver(function (entries, obs) {\n entries.forEach(function (entry) {\n if (entry.isIntersecting) {\n obs.unobserve(entry.target);\n _observerMap.delete(entry.target);\n try {\n callback(entry.target);\n } catch (e) {\n console.error('[VanduoLazyLoad] Callback threw:', e);\n }\n }\n });\n }, { threshold: threshold, rootMargin: rootMargin });\n\n _observerMap.set(element, observer);\n observer.observe(element);\n },\n\n /**\n * Stop observing an element that was previously passed to observe().\n * @param {Element} element\n */\n unobserve: function (element) {\n const observer = _observerMap.get(element);\n if (observer) {\n observer.unobserve(element);\n _observerMap.delete(element);\n }\n },\n\n /**\n * Stop observing ALL currently observed elements.\n */\n unobserveAll: function () {\n _observerMap.forEach(function (observer, element) {\n observer.unobserve(element);\n });\n _observerMap.clear();\n },\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * HIGH-LEVEL API\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Fetch an HTML partial and inject it into `containerEl` when the\n * container enters the viewport. A placeholder is shown immediately.\n *\n * @param {string} url URL of the HTML partial to fetch\n * @param {Element} containerEl Target element whose content will be replaced\n * @param {{\n * placeholder?: 'skeleton'|'spinner'|string,\n * threshold?: number,\n * rootMargin?: string,\n * onLoaded?: function(Element): void,\n * onError?: function(Error): void\n * }} [options]\n */\n loadSection: function (url, containerEl, options) {\n if (typeof url !== 'string' || !url) {\n console.warn('[VanduoLazyLoad] loadSection() requires a non-empty URL string.');\n return;\n }\n if (!(containerEl instanceof Element)) {\n console.warn('[VanduoLazyLoad] loadSection() requires a DOM Element as containerEl.');\n return;\n }\n // Reject cross-origin URLs to prevent SSRF-style fetch abuse\n if (!_isSafeUrl(url)) {\n console.error('[VanduoLazyLoad] loadSection() blocked cross-origin URL:', url);\n return;\n }\n\n const opts = options || {};\n // Placeholders are known-safe static HTML strings built internally\n const placeholderHtml = _resolvePlaceholder(opts.placeholder);\n\n // Use _safeInjectHtml instead of innerHTML to prevent XSS from caller-supplied placeholders\n _safeInjectHtml(containerEl, placeholderHtml);\n _dispatch(containerEl, 'lazysection:loading', { url: url });\n\n // Fetch when visible\n this.observe(containerEl, function () {\n const controller = new window.AbortController();\n const timeoutId = setTimeout(function () { controller.abort(); }, 10000);\n\n window.fetch(url, { signal: controller.signal })\n .then(function (res) {\n clearTimeout(timeoutId);\n if (!res.ok) throw new Error('HTTP ' + res.status);\n return res.text();\n })\n .then(function (html) {\n // Use DOMParser to parse fetched content safely, avoiding\n // raw innerHTML assignment of externally-sourced strings\n _safeInjectHtml(containerEl, html);\n _dispatch(containerEl, 'lazysection:loaded', { url: url });\n // Re-init Vanduo components inside the new content\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.init();\n }\n if (typeof opts.onLoaded === 'function') {\n opts.onLoaded(containerEl);\n }\n })\n .catch(function (err) {\n // Build error node via DOM APIs \u2014 no dynamic HTML strings\n const alertEl = document.createElement('div');\n alertEl.className = 'vd-alert vd-alert-error';\n alertEl.setAttribute('role', 'alert');\n const msgEl = document.createElement('span');\n msgEl.textContent = 'Failed to load content. ';\n const detailEl = document.createElement('small');\n detailEl.style.opacity = '0.7';\n detailEl.textContent = err.message;\n alertEl.appendChild(msgEl);\n alertEl.appendChild(detailEl);\n while (containerEl.firstChild) {\n containerEl.removeChild(containerEl.firstChild);\n }\n containerEl.appendChild(alertEl);\n _dispatch(containerEl, 'lazysection:error', { url: url, error: err });\n console.error('[VanduoLazyLoad] loadSection failed:', err);\n if (typeof opts.onError === 'function') {\n opts.onError(err);\n }\n });\n }, { threshold: opts.threshold, rootMargin: opts.rootMargin });\n },\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * ATTRIBUTE-DRIVEN INIT\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Scan the DOM for [data-vd-lazy] elements and wire them up.\n * Safe to call multiple times \u2014 already-observed elements are skipped.\n */\n init: function () {\n const self = this;\n const elements = document.querySelectorAll('[data-vd-lazy]');\n elements.forEach(function (el) {\n // Skip already-observed or already-loaded elements\n if (_observerMap.has(el) || el.dataset.vdLazyState === 'loading' || el.dataset.vdLazyState === 'loaded') return;\n\n const url = el.getAttribute('data-vd-lazy');\n if (!url) return;\n\n el.dataset.vdLazyState = 'loading';\n const placeholder = el.getAttribute('data-vd-lazy-placeholder') || 'skeleton';\n\n self.loadSection(url, el, {\n placeholder: placeholder,\n onLoaded: function () {\n el.dataset.vdLazyState = 'loaded';\n },\n onError: function () {\n el.dataset.vdLazyState = 'error';\n }\n });\n });\n }\n };\n\n /* \u2500\u2500 Register with Vanduo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('LazyLoad', VanduoLazyLoad);\n }\n\n /* \u2500\u2500 Global convenience alias \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n window.VanduoLazyLoad = VanduoLazyLoad;\n\n})();\n", "/**\n * Vanduo Framework - Glass Scroll Activation\n * Generic scroll-aware glass activation via IntersectionObserver.\n *\n * Usage:\n * Add `data-glass-scroll` to any element carrying a `.vd-glass*` class.\n * By default the previous sibling is used as the sentinel.\n * Point to a custom sentinel via `data-glass-sentinel=\"\"`.\n *\n * Behaviour:\n * - Adds `.is-glass-active` when the sentinel leaves the viewport (scrolled past).\n * - Removes `.is-glass-active` when the sentinel re-enters the viewport.\n */\n(function () {\n 'use strict';\n\n const GlassScroll = {\n /** @type {Map} */\n observers: new Map(),\n\n init: function () {\n document.querySelectorAll('[data-glass-scroll]').forEach(el => {\n if (this.observers.has(el)) return;\n this.initElement(el);\n });\n },\n\n /**\n * Wire up a single scroll-activated glass element.\n * @param {HTMLElement} el\n */\n initElement: function (el) {\n const sentinelSelector = el.dataset.glassSentinel;\n let sentinel;\n\n if (sentinelSelector) {\n sentinel = document.querySelector(sentinelSelector);\n }\n\n if (!sentinel) {\n // Fall back to the previous sibling element\n sentinel = el.previousElementSibling;\n }\n\n if (!sentinel) {\n // No sentinel available \u2014 activate immediately so glass is always shown\n el.classList.add('is-glass-active');\n return;\n }\n\n const observer = new IntersectionObserver(\n (entries) => {\n entries.forEach(entry => {\n // Active when sentinel is NOT intersecting (scrolled past it)\n el.classList.toggle('is-glass-active', !entry.isIntersecting);\n });\n },\n { threshold: 0, rootMargin: '0px' }\n );\n\n observer.observe(sentinel);\n this.observers.set(el, observer);\n },\n\n /**\n * Disconnect and remove a single element's observer.\n * @param {HTMLElement} el\n */\n destroy: function (el) {\n const observer = this.observers.get(el);\n if (observer) {\n observer.disconnect();\n this.observers.delete(el);\n }\n },\n\n destroyAll: function () {\n this.observers.forEach((observer, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('glassScroll', GlassScroll);\n }\n\n window.VanduoGlassScroll = GlassScroll;\n})();\n", "/**\n * Vanduo Framework - Water Morph Effect Component\n * Liquid wave content-swap animation on click\n *\n * Usage:\n * Add .vd-morph or [data-vd-morph] to any element.\n * Provide .vd-morph-content.vd-morph-current and .vd-morph-content.vd-morph-next children.\n * Wave and shine layers are auto-created if absent.\n *\n * JS API:\n * VanduoMorph.morph(el) \u2014 trigger morph programmatically (from center)\n * VanduoMorph.destroy(el) \u2014 tear down listeners for one element\n * VanduoMorph.destroyAll() \u2014 tear down all instances\n */\n\n(function () {\n 'use strict';\n\n const MORPH_DURATION_MS = 750;\n\n const Morph = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-morph, [data-vd-morph]');\n elements.forEach(function (el) {\n if (Morph.instances.has(el)) return;\n if (el.getAttribute('data-vd-morph') === 'manual') return;\n Morph.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n Morph._ensureLayers(el);\n\n const cleanup = [];\n let morphing = false;\n\n const handleClick = function (e) {\n if (morphing) return;\n Morph._runMorph(el, e, function () { morphing = false; });\n morphing = true;\n };\n\n el.addEventListener('click', handleClick);\n cleanup.push(function () { el.removeEventListener('click', handleClick); });\n\n this.instances.set(el, { cleanup: cleanup });\n },\n\n morph: function (el) {\n if (!el) return;\n if (!this.instances.has(el)) this.initInstance(el);\n this._runMorph(el, null, null);\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(function (fn) { fn(); });\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) { Morph.destroy(el); });\n },\n\n /* \u2500\u2500 Internal helpers \u2500\u2500 */\n\n _ensureLayers: function (el) {\n if (!el.querySelector('.vd-morph-wave')) {\n const wave = document.createElement('span');\n wave.className = 'vd-morph-wave';\n wave.setAttribute('aria-hidden', 'true');\n el.insertBefore(wave, el.firstChild);\n }\n if (!el.querySelector('.vd-morph-shine')) {\n const shine = document.createElement('span');\n shine.className = 'vd-morph-shine';\n shine.setAttribute('aria-hidden', 'true');\n const waveEl = el.querySelector('.vd-morph-wave');\n if (waveEl && waveEl.nextSibling) {\n el.insertBefore(shine, waveEl.nextSibling);\n } else {\n el.insertBefore(shine, el.firstChild);\n }\n }\n },\n\n _runMorph: function (el, pointerEvent, onComplete) {\n const wave = el.querySelector('.vd-morph-wave');\n if (wave) {\n const rect = el.getBoundingClientRect();\n const cx = rect.left + rect.width / 2;\n const cy = rect.top + rect.height / 2;\n const px = pointerEvent ? (pointerEvent.clientX || cx) : cx;\n const py = pointerEvent ? (pointerEvent.clientY || cy) : cy;\n wave.style.left = (px - rect.left) + 'px';\n wave.style.top = (py - rect.top) + 'px';\n }\n\n el.classList.add('is-morphing');\n\n let duration = MORPH_DURATION_MS;\n const custom = getComputedStyle(el).getPropertyValue('--morph-duration');\n if (custom) {\n const parsed = parseFloat(custom);\n if (!isNaN(parsed)) duration = parsed * (custom.indexOf('ms') !== -1 ? 1 : 1000);\n }\n\n setTimeout(function () {\n el.classList.remove('is-morphing');\n\n const current = el.querySelector('.vd-morph-current');\n const next = el.querySelector('.vd-morph-next');\n if (current && next) {\n current.classList.remove('vd-morph-current');\n current.classList.add('vd-morph-next');\n next.classList.remove('vd-morph-next');\n next.classList.add('vd-morph-current');\n }\n\n if (typeof onComplete === 'function') onComplete();\n }, duration);\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('morph', Morph);\n }\n\n window.VanduoMorph = Morph;\n\n})();\n", "/**\n * Vanduo Framework \u2014 Expanding flex cards\n * Click / Enter / Space / Arrow keys to change the active card.\n *\n * Usage:\n *
    \n * Use data-vd-expanding-cards=\"manual\" to skip auto-init.\n */\n\n(function () {\n 'use strict';\n\n const ExpandingCards = {\n instances: new Map(),\n\n init: function () {\n document.querySelectorAll('.vd-expanding-cards').forEach(function (el) {\n if (el.getAttribute('data-vd-expanding-cards') === 'manual') return;\n if (ExpandingCards.instances.has(el)) return;\n ExpandingCards.initContainer(el);\n });\n },\n\n initContainer: function (container) {\n const cleanup = [];\n\n const getCards = function () {\n return Array.prototype.slice.call(container.querySelectorAll('.vd-expanding-card'));\n };\n\n const setActive = function (card) {\n const cards = getCards();\n if (!card || cards.indexOf(card) === -1) return;\n cards.forEach(function (c) {\n c.classList.toggle('is-active', c === card);\n });\n card.focus({ preventScroll: true });\n };\n\n const onClick = function (e) {\n const t = e.target;\n const card = t.closest ? t.closest('.vd-expanding-card') : null;\n if (!card || !container.contains(card)) return;\n setActive(card);\n };\n\n const onKeydown = function (e) {\n if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'Home' && e.key !== 'End') {\n return;\n }\n const cards = getCards().filter(function (c) {\n return c.offsetParent !== null || c.getClientRects().length > 0;\n });\n if (!cards.length) return;\n const activeEl = document.activeElement;\n let idx = cards.indexOf(activeEl);\n if (idx < 0) {\n idx = cards.findIndex(function (c) {\n return c.classList.contains('is-active');\n });\n }\n if (idx < 0) idx = 0;\n\n if (e.key === 'ArrowLeft') {\n e.preventDefault();\n setActive(cards[Math.max(0, idx - 1)]);\n } else if (e.key === 'ArrowRight') {\n e.preventDefault();\n setActive(cards[Math.min(cards.length - 1, idx + 1)]);\n } else if (e.key === 'Home') {\n e.preventDefault();\n setActive(cards[0]);\n } else if (e.key === 'End') {\n e.preventDefault();\n setActive(cards[cards.length - 1]);\n }\n };\n\n container.addEventListener('click', onClick);\n cleanup.push(function () {\n container.removeEventListener('click', onClick);\n });\n\n container.addEventListener('keydown', onKeydown);\n cleanup.push(function () {\n container.removeEventListener('keydown', onKeydown);\n });\n\n getCards().forEach(function (card) {\n if (!card.hasAttribute('tabindex')) {\n card.setAttribute('tabindex', '0');\n }\n card.setAttribute('role', 'button');\n if (!card.hasAttribute('aria-pressed')) {\n card.setAttribute('aria-pressed', card.classList.contains('is-active') ? 'true' : 'false');\n }\n });\n\n const syncAria = function () {\n getCards().forEach(function (card) {\n card.setAttribute('aria-pressed', card.classList.contains('is-active') ? 'true' : 'false');\n });\n };\n\n const mo = new MutationObserver(syncAria);\n mo.observe(container, { attributes: true, subtree: true, attributeFilter: ['class'] });\n cleanup.push(function () {\n mo.disconnect();\n });\n syncAria();\n\n ExpandingCards.instances.set(container, { cleanup: cleanup });\n },\n\n destroy: function (container) {\n const inst = this.instances.get(container);\n if (!inst) return;\n inst.cleanup.forEach(function (fn) {\n fn();\n });\n this.instances.delete(container);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) {\n ExpandingCards.destroy(el);\n });\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('expandingCards', ExpandingCards);\n }\n\n window.VanduoExpandingCards = ExpandingCards;\n})();\n", "/**\n * Vanduo Framework \u2014 Timeline animated reveal\n * Opt-in: add `.vd-timeline-animated` to a `.vd-timeline` container.\n * Uses IntersectionObserver to add `.is-revealed` per item with staggered delays.\n * Optional: `.vd-timeline-playback` with `[data-vd-timeline-prev|next|play|pause]` for stepped control.\n */\n\n(function () {\n 'use strict';\n\n const STAGGER_MS = 140;\n const MAX_STAGGER_INDEX = 7;\n const PLAY_INTERVAL_MS = 800;\n\n function countRevealedPrefix(items) {\n let count = 0;\n for (let i = 0; i < items.length; i++) {\n if (!items[i].classList.contains('is-revealed')) break;\n count++;\n }\n return count;\n }\n\n function findPlaybackControls(container) {\n return container.parentElement || document.body;\n }\n\n function initPlayback(container, items, cleanup) {\n items.forEach(function (item) {\n item.classList.remove('is-revealed');\n });\n\n const scope = findPlaybackControls(container);\n const prevBtn = scope.querySelector('[data-vd-timeline-prev]');\n const nextBtn = scope.querySelector('[data-vd-timeline-next]');\n const playBtn = scope.querySelector('[data-vd-timeline-play]');\n const pauseBtn = scope.querySelector('[data-vd-timeline-pause]');\n\n let playTimer = null;\n\n function updateNavButtons() {\n const k = countRevealedPrefix(items);\n const n = items.length;\n if (prevBtn) {\n const atStart = k === 0;\n prevBtn.disabled = atStart;\n prevBtn.setAttribute('aria-disabled', atStart ? 'true' : 'false');\n }\n if (nextBtn) {\n const atEnd = k >= n;\n nextBtn.disabled = atEnd;\n nextBtn.setAttribute('aria-disabled', atEnd ? 'true' : 'false');\n }\n if (playBtn) {\n playBtn.setAttribute('aria-pressed', playTimer ? 'true' : 'false');\n }\n if (pauseBtn) {\n pauseBtn.disabled = !playTimer;\n }\n }\n\n function stepNext() {\n const k = countRevealedPrefix(items);\n if (k < items.length) {\n items[k].classList.add('is-revealed');\n }\n updateNavButtons();\n }\n\n function stepPrev() {\n const k = countRevealedPrefix(items);\n if (k > 0) {\n items[k - 1].classList.remove('is-revealed');\n }\n updateNavButtons();\n }\n\n function play() {\n if (playTimer) return;\n playTimer = setInterval(function () {\n if (countRevealedPrefix(items) >= items.length) {\n pause();\n return;\n }\n stepNext();\n }, PLAY_INTERVAL_MS);\n updateNavButtons();\n }\n\n function pause() {\n if (playTimer) {\n clearInterval(playTimer);\n playTimer = null;\n }\n updateNavButtons();\n }\n\n function addClick(el, fn) {\n if (!el) return;\n const handler = function (e) {\n e.preventDefault();\n fn();\n };\n el.addEventListener('click', handler);\n cleanup.push(function () {\n el.removeEventListener('click', handler);\n });\n }\n\n addClick(prevBtn, stepPrev);\n addClick(nextBtn, stepNext);\n addClick(playBtn, play);\n addClick(pauseBtn, pause);\n\n cleanup.push(function () {\n pause();\n });\n\n updateNavButtons();\n\n return {\n stepNext: stepNext,\n stepPrev: stepPrev,\n play: play,\n pause: pause\n };\n }\n\n const Timeline = {\n instances: new Map(),\n\n init: function () {\n document.querySelectorAll('.vd-timeline.vd-timeline-animated').forEach(function (el) {\n if (Timeline.instances.has(el)) return;\n Timeline.initInstance(el);\n });\n },\n\n reinit: function () {\n Timeline.destroyAll();\n Timeline.init();\n },\n\n initInstance: function (container) {\n const cleanup = [];\n const items = Array.prototype.filter.call(container.children, function (child) {\n return child.classList && child.classList.contains('vd-timeline-item');\n });\n\n items.forEach(function (item, i) {\n const idx = Math.min(i, MAX_STAGGER_INDEX);\n item.style.setProperty('--vd-timeline-reveal-delay', (idx * STAGGER_MS) + 'ms');\n });\n\n const reducedMotion = typeof window.matchMedia === 'function'\n && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n if (reducedMotion) {\n items.forEach(function (item) {\n item.classList.add('is-revealed');\n });\n Timeline.instances.set(container, { cleanup: cleanup });\n return;\n }\n\n const playback = container.classList && container.classList.contains('vd-timeline-playback');\n\n if (playback) {\n const playbackApi = initPlayback(container, items, cleanup);\n Timeline.instances.set(container, { cleanup: cleanup, playback: playbackApi });\n return;\n }\n\n if (typeof IntersectionObserver === 'undefined') {\n items.forEach(function (item) {\n item.classList.add('is-revealed');\n });\n Timeline.instances.set(container, { cleanup: cleanup });\n return;\n }\n\n const observer = new IntersectionObserver(function (entries) {\n entries.forEach(function (entry) {\n if (!entry.isIntersecting) return;\n entry.target.classList.add('is-revealed');\n observer.unobserve(entry.target);\n });\n }, {\n root: null,\n rootMargin: '0px 0px -10% 0px',\n threshold: 0.15\n });\n\n items.forEach(function (item) {\n observer.observe(item);\n });\n\n cleanup.push(function () {\n observer.disconnect();\n });\n\n Timeline.instances.set(container, { cleanup: cleanup });\n },\n\n destroy: function (container) {\n const inst = this.instances.get(container);\n if (!inst) return;\n inst.cleanup.forEach(function (fn) {\n fn();\n });\n this.instances.delete(container);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) {\n Timeline.destroy(el);\n });\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('timeline', Timeline);\n }\n\n window.VanduoTimeline = Timeline;\n})();\n", "/**\n * Vanduo Framework - Flow (Carousel/Slider) Component\n * Touch-enabled carousel with slide/fade transitions, autoplay, indicators\n */\n\n(function () {\n 'use strict';\n\n const Flow = {\n instances: new Map(),\n\n init: function () {\n const carousels = document.querySelectorAll('.vd-flow, .vd-carousel');\n carousels.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const track = el.querySelector('.vd-flow-track');\n if (!track) return;\n\n const slides = Array.from(track.querySelectorAll('.vd-flow-slide'));\n if (slides.length === 0) return;\n\n const isFade = el.classList.contains('vd-flow-fade');\n const autoplay = el.hasAttribute('data-vd-autoplay');\n const interval = parseInt(el.getAttribute('data-vd-interval'), 10) || 5000;\n const loop = el.getAttribute('data-vd-loop') !== 'false';\n\n const state = {\n current: 0,\n total: slides.length,\n autoplayTimer: null,\n isFade: isFade,\n loop: loop,\n isDragging: false,\n startX: 0,\n currentX: 0,\n threshold: 50\n };\n\n const cleanup = [];\n\n // Set initial active slide\n slides.forEach((slide, i) => {\n slide.setAttribute('role', 'group');\n slide.setAttribute('aria-roledescription', 'slide');\n slide.setAttribute('aria-label', 'Slide ' + (i + 1) + ' of ' + slides.length);\n if (i === 0) slide.classList.add('is-active');\n });\n\n el.setAttribute('role', 'region');\n el.setAttribute('aria-roledescription', 'carousel');\n if (!el.getAttribute('aria-label')) {\n el.setAttribute('aria-label', 'Carousel');\n }\n\n // Live region for announcements\n const liveRegion = document.createElement('div');\n liveRegion.setAttribute('aria-live', 'polite');\n liveRegion.setAttribute('aria-atomic', 'true');\n liveRegion.className = 'sr-only';\n liveRegion.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);';\n el.appendChild(liveRegion);\n\n const goTo = (index, announce) => {\n if (announce === undefined) announce = true;\n let target = index;\n if (state.loop) {\n target = ((index % state.total) + state.total) % state.total;\n } else {\n target = Math.max(0, Math.min(index, state.total - 1));\n }\n\n const prev = state.current;\n state.current = target;\n\n if (state.isFade) {\n slides.forEach((s, i) => {\n s.classList.toggle('is-active', i === target);\n });\n } else {\n track.style.transform = 'translateX(-' + (target * 100) + '%)';\n }\n\n // Update indicators\n const indicators = el.querySelectorAll('.vd-flow-indicator');\n indicators.forEach((ind, i) => {\n ind.classList.toggle('is-active', i === target);\n ind.setAttribute('aria-selected', i === target ? 'true' : 'false');\n });\n\n // Update slide ARIA\n slides.forEach((s, i) => {\n s.setAttribute('aria-hidden', i !== target ? 'true' : 'false');\n });\n\n if (announce) {\n liveRegion.textContent = 'Slide ' + (target + 1) + ' of ' + state.total;\n }\n\n el.dispatchEvent(new CustomEvent('flow:change', {\n detail: { current: target, previous: prev, total: state.total }\n }));\n };\n\n const next = () => goTo(state.current + 1);\n const prev = () => goTo(state.current - 1);\n\n // Controls\n const prevBtn = el.querySelector('.vd-flow-prev');\n const nextBtn = el.querySelector('.vd-flow-next');\n\n if (prevBtn) {\n const h = () => prev();\n prevBtn.addEventListener('click', h);\n cleanup.push(() => prevBtn.removeEventListener('click', h));\n }\n if (nextBtn) {\n const h = () => next();\n nextBtn.addEventListener('click', h);\n cleanup.push(() => nextBtn.removeEventListener('click', h));\n }\n\n // Indicators\n const indicators = el.querySelectorAll('.vd-flow-indicator');\n indicators.forEach((ind, i) => {\n ind.setAttribute('role', 'tab');\n ind.setAttribute('aria-selected', i === 0 ? 'true' : 'false');\n ind.setAttribute('aria-label', 'Go to slide ' + (i + 1));\n const h = () => goTo(i);\n ind.addEventListener('click', h);\n cleanup.push(() => ind.removeEventListener('click', h));\n });\n\n // Keyboard navigation\n const keyHandler = (e) => {\n if (e.key === 'ArrowLeft') { prev(); e.preventDefault(); }\n if (e.key === 'ArrowRight') { next(); e.preventDefault(); }\n };\n el.setAttribute('tabindex', '0');\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n\n // Touch / pointer support\n const pointerDown = (e) => {\n state.isDragging = true;\n state.startX = e.clientX || (e.touches && e.touches[0].clientX) || 0;\n state.currentX = state.startX;\n el.classList.add('is-dragging');\n };\n\n const pointerMove = (e) => {\n if (!state.isDragging) return;\n state.currentX = e.clientX || (e.touches && e.touches[0].clientX) || 0;\n };\n\n const pointerUp = () => {\n if (!state.isDragging) return;\n state.isDragging = false;\n el.classList.remove('is-dragging');\n const diff = state.startX - state.currentX;\n if (Math.abs(diff) > state.threshold) {\n if (diff > 0) next();\n else prev();\n }\n };\n\n el.addEventListener('mousedown', pointerDown);\n el.addEventListener('mousemove', pointerMove);\n el.addEventListener('mouseup', pointerUp);\n el.addEventListener('mouseleave', pointerUp);\n el.addEventListener('touchstart', pointerDown, { passive: true });\n el.addEventListener('touchmove', pointerMove, { passive: true });\n el.addEventListener('touchend', pointerUp);\n\n cleanup.push(\n () => el.removeEventListener('mousedown', pointerDown),\n () => el.removeEventListener('mousemove', pointerMove),\n () => el.removeEventListener('mouseup', pointerUp),\n () => el.removeEventListener('mouseleave', pointerUp),\n () => el.removeEventListener('touchstart', pointerDown),\n () => el.removeEventListener('touchmove', pointerMove),\n () => el.removeEventListener('touchend', pointerUp)\n );\n\n // Autoplay\n const startAutoplay = () => {\n stopAutoplay();\n state.autoplayTimer = setInterval(next, interval);\n };\n\n const stopAutoplay = () => {\n if (state.autoplayTimer) {\n clearInterval(state.autoplayTimer);\n state.autoplayTimer = null;\n }\n };\n\n if (autoplay) {\n startAutoplay();\n const pauseHandler = () => stopAutoplay();\n const resumeHandler = () => startAutoplay();\n el.addEventListener('mouseenter', pauseHandler);\n el.addEventListener('mouseleave', resumeHandler);\n el.addEventListener('focusin', pauseHandler);\n el.addEventListener('focusout', resumeHandler);\n cleanup.push(\n () => el.removeEventListener('mouseenter', pauseHandler),\n () => el.removeEventListener('mouseleave', resumeHandler),\n () => el.removeEventListener('focusin', pauseHandler),\n () => el.removeEventListener('focusout', resumeHandler),\n () => stopAutoplay()\n );\n }\n\n // Initial ARIA state\n goTo(0, false);\n\n this.instances.set(el, {\n cleanup: cleanup,\n goTo: goTo,\n next: next,\n prev: prev,\n getState: () => ({ ...state })\n });\n },\n\n goTo: function (el, index) {\n const instance = this.instances.get(el);\n if (instance) instance.goTo(index);\n },\n\n next: function (el) {\n const instance = this.instances.get(el);\n if (instance) instance.next();\n },\n\n prev: function (el) {\n const instance = this.instances.get(el);\n if (instance) instance.prev();\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('flow', Flow);\n }\n\n window.VanduoFlow = Flow;\n\n})();\n", "/**\n * Vanduo Framework - Bubble (Popover) Component\n * Click-triggered rich HTML popover, reuses tooltip positioning concepts\n */\n\n(function () {\n 'use strict';\n\n const Bubble = {\n instances: new Map(),\n _globalCleanups: [],\n\n init: function () {\n const triggers = document.querySelectorAll('[data-vd-bubble], [data-vd-popover]');\n triggers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n\n if (this._globalCleanups.length === 0) {\n const outsideClick = (e) => {\n this.instances.forEach((inst, trigger) => {\n if (!inst.popover.contains(e.target) && !trigger.contains(e.target)) {\n this.hide(trigger);\n }\n });\n };\n const escHandler = (e) => {\n if (e.key === 'Escape') {\n this.instances.forEach((_, trigger) => this.hide(trigger));\n }\n };\n document.addEventListener('click', outsideClick, true);\n document.addEventListener('keydown', escHandler);\n this._globalCleanups.push(\n () => document.removeEventListener('click', outsideClick, true),\n () => document.removeEventListener('keydown', escHandler)\n );\n }\n },\n\n initInstance: function (trigger) {\n const cleanup = [];\n const placement = trigger.getAttribute('data-vd-bubble-placement') ||\n trigger.getAttribute('data-vd-popover-placement') || 'bottom';\n\n // Build popover element\n const popover = document.createElement('div');\n popover.className = 'vd-bubble-content';\n popover.setAttribute('role', 'dialog');\n popover.setAttribute('aria-modal', 'false');\n popover.setAttribute('data-placement', placement);\n\n const title = trigger.getAttribute('data-vd-bubble-title') ||\n trigger.getAttribute('data-vd-popover-title');\n const content = trigger.getAttribute('data-vd-bubble') ||\n trigger.getAttribute('data-vd-popover') || '';\n const htmlContent = trigger.getAttribute('data-vd-bubble-html') ||\n trigger.getAttribute('data-vd-popover-html');\n const allowSvg = trigger.hasAttribute('data-vd-bubble-allow-svg') ||\n trigger.hasAttribute('data-vd-popover-allow-svg');\n\n if (title) {\n const header = document.createElement('div');\n header.className = 'vd-bubble-header';\n const titleSpan = document.createElement('span');\n titleSpan.textContent = title;\n const closeBtn = document.createElement('button');\n closeBtn.className = 'vd-bubble-close';\n closeBtn.setAttribute('aria-label', 'Close');\n closeBtn.innerHTML = '×';\n header.appendChild(titleSpan);\n header.appendChild(closeBtn);\n popover.appendChild(header);\n\n const closeHandler = (e) => { e.stopPropagation(); this.hide(trigger); };\n closeBtn.addEventListener('click', closeHandler);\n cleanup.push(() => closeBtn.removeEventListener('click', closeHandler));\n }\n\n const body = document.createElement('div');\n body.className = 'vd-bubble-body';\n if (htmlContent) {\n if (typeof sanitizeHtml === 'function') {\n body.innerHTML = sanitizeHtml(htmlContent, { allowSvg });\n } else {\n body.textContent = htmlContent;\n }\n } else {\n body.textContent = content;\n }\n popover.appendChild(body);\n\n document.body.appendChild(popover);\n\n // ARIA on trigger\n const popId = 'vd-bubble-' + Math.random().toString(36).slice(2, 9);\n popover.id = popId;\n trigger.setAttribute('aria-haspopup', 'dialog');\n trigger.setAttribute('aria-expanded', 'false');\n trigger.setAttribute('aria-controls', popId);\n\n // Toggle on click\n const toggleHandler = (e) => {\n e.stopPropagation();\n if (popover.classList.contains('is-visible')) {\n this.hide(trigger);\n } else {\n this.hideAll();\n this.show(trigger);\n }\n };\n trigger.addEventListener('click', toggleHandler);\n cleanup.push(() => trigger.removeEventListener('click', toggleHandler));\n\n this.instances.set(trigger, { popover, cleanup, placement });\n },\n\n position: function (trigger, popover, placement) {\n const rect = trigger.getBoundingClientRect();\n const popRect = popover.getBoundingClientRect();\n const gap = 10;\n let top, left;\n\n switch (placement) {\n case 'top':\n top = rect.top - popRect.height - gap + window.scrollY;\n left = rect.left + (rect.width - popRect.width) / 2 + window.scrollX;\n break;\n case 'left':\n top = rect.top + (rect.height - popRect.height) / 2 + window.scrollY;\n left = rect.left - popRect.width - gap + window.scrollX;\n break;\n case 'right':\n top = rect.top + (rect.height - popRect.height) / 2 + window.scrollY;\n left = rect.right + gap + window.scrollX;\n break;\n default: // bottom\n top = rect.bottom + gap + window.scrollY;\n left = rect.left + (rect.width - popRect.width) / 2 + window.scrollX;\n }\n\n // Clamp to viewport\n left = Math.max(8, Math.min(left, window.innerWidth - popRect.width - 8));\n top = Math.max(8, top);\n\n popover.style.top = top + 'px';\n popover.style.left = left + 'px';\n },\n\n show: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n const { popover, placement } = instance;\n\n popover.style.display = 'block';\n popover.classList.add('is-visible');\n trigger.setAttribute('aria-expanded', 'true');\n\n requestAnimationFrame(() => {\n this.position(trigger, popover, placement);\n });\n\n trigger.dispatchEvent(new CustomEvent('bubble:show', {\n bubbles: true,\n detail: { trigger: trigger, placement: placement }\n }));\n },\n\n hide: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n instance.popover.classList.remove('is-visible');\n trigger.setAttribute('aria-expanded', 'false');\n trigger.dispatchEvent(new CustomEvent('bubble:hide', {\n bubbles: true,\n detail: { trigger: trigger }\n }));\n },\n\n hideAll: function () {\n this.instances.forEach((_, trigger) => this.hide(trigger));\n },\n\n destroy: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n if (instance.popover.parentNode) {\n instance.popover.parentNode.removeChild(instance.popover);\n }\n trigger.removeAttribute('aria-haspopup');\n trigger.removeAttribute('aria-expanded');\n trigger.removeAttribute('aria-controls');\n this.instances.delete(trigger);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, trigger) => this.destroy(trigger));\n this._globalCleanups.forEach(fn => fn());\n this._globalCleanups = [];\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('bubble', Bubble);\n }\n\n window.VanduoBubble = Bubble;\n\n})();\n", "/**\n * Vanduo Framework - Waypoint (Scrollspy) Component\n * Highlights navigation links based on scroll position using IntersectionObserver\n */\n\n(function () {\n 'use strict';\n\n const Waypoint = {\n instances: new Map(),\n\n init: function () {\n const navs = document.querySelectorAll('[data-vd-waypoint-nav], [data-vd-scrollspy-nav]');\n navs.forEach(nav => {\n if (this.instances.has(nav)) return;\n this.initInstance(nav);\n });\n },\n\n initInstance: function (nav) {\n const links = Array.from(nav.querySelectorAll('a[href^=\"#\"]'));\n if (links.length === 0) return;\n\n const cleanup = [];\n const offset = parseInt(nav.getAttribute('data-vd-waypoint-offset') || '80', 10);\n const sections = [];\n\n links.forEach(link => {\n const id = link.getAttribute('href').slice(1);\n const section = document.getElementById(id);\n if (section) {\n section.setAttribute('data-vd-waypoint-section', '');\n sections.push({ id, link, section });\n }\n });\n\n if (sections.length === 0) return;\n\n const activeSections = new Set();\n\n const setActive = (id) => {\n links.forEach(l => l.classList.remove('is-active'));\n const target = links.find(l => l.getAttribute('href') === '#' + id);\n if (target) {\n target.classList.add('is-active');\n nav.dispatchEvent(new CustomEvent('waypoint:change', {\n detail: { activeId: id, link: target }\n }));\n }\n };\n\n const rootMargin = '-' + offset + 'px 0px -40% 0px';\n\n const observer = new IntersectionObserver((entries) => {\n entries.forEach(entry => {\n if (entry.isIntersecting) {\n activeSections.add(entry.target.id);\n } else {\n activeSections.delete(entry.target.id);\n }\n });\n\n // Pick the topmost visible section\n for (let i = 0; i < sections.length; i++) {\n if (activeSections.has(sections[i].id)) {\n setActive(sections[i].id);\n return;\n }\n }\n }, {\n rootMargin: rootMargin,\n threshold: 0\n });\n\n sections.forEach(s => observer.observe(s.section));\n\n // Smooth scroll on click\n links.forEach(link => {\n const clickHandler = (e) => {\n e.preventDefault();\n const id = link.getAttribute('href').slice(1);\n const section = document.getElementById(id);\n if (section) {\n section.scrollIntoView({ behavior: 'smooth' });\n setActive(id);\n }\n };\n link.addEventListener('click', clickHandler);\n cleanup.push(() => link.removeEventListener('click', clickHandler));\n });\n\n cleanup.push(() => observer.disconnect());\n\n this.instances.set(nav, { observer, cleanup, sections, setActive });\n },\n\n refresh: function (nav) {\n this.destroy(nav);\n this.initInstance(nav);\n },\n\n destroy: function (nav) {\n const instance = this.instances.get(nav);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(nav);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, nav) => this.destroy(nav));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('waypoint', Waypoint);\n }\n\n window.VanduoWaypoint = Waypoint;\n\n})();\n", "/**\n * Vanduo Framework - Ripple (Waves Effect) Component\n * Adds expanding circle animation on click at pointer position\n */\n\n(function () {\n 'use strict';\n\n const Ripple = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-ripple, [data-vd-ripple]');\n elements.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n\n const createWave = (e) => {\n const rect = el.getBoundingClientRect();\n const size = Math.max(rect.width, rect.height);\n const x = (e.clientX || (e.touches && e.touches[0].clientX) || rect.left + rect.width / 2) - rect.left - size / 2;\n const y = (e.clientY || (e.touches && e.touches[0].clientY) || rect.top + rect.height / 2) - rect.top - size / 2;\n\n const wave = document.createElement('span');\n wave.className = 'vd-ripple-wave';\n wave.style.width = size + 'px';\n wave.style.height = size + 'px';\n wave.style.left = x + 'px';\n wave.style.top = y + 'px';\n\n el.appendChild(wave);\n\n wave.addEventListener('animationend', () => {\n if (wave.parentNode) wave.parentNode.removeChild(wave);\n });\n };\n\n el.addEventListener('mousedown', createWave);\n el.addEventListener('touchstart', createWave, { passive: true });\n\n cleanup.push(\n () => el.removeEventListener('mousedown', createWave),\n () => el.removeEventListener('touchstart', createWave)\n );\n\n this.instances.set(el, { cleanup });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n // Remove any lingering wave elements\n el.querySelectorAll('.vd-ripple-wave').forEach(w => w.remove());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('ripple', Ripple);\n }\n\n window.VanduoRipple = Ripple;\n\n})();\n", "/**\n * Vanduo Framework - Affix (Sticky) Component\n * Uses IntersectionObserver to toggle .is-stuck within the nearest scrollable parent\n */\n\n(function () {\n 'use strict';\n\n function isScrollable(element) {\n if (!element || element === document.body) return false;\n\n const style = window.getComputedStyle(element);\n const overflowY = style.overflowY;\n const overflowX = style.overflowX;\n const canScrollY = /(auto|scroll|overlay)/.test(overflowY) && element.scrollHeight > element.clientHeight;\n const canScrollX = /(auto|scroll|overlay)/.test(overflowX) && element.scrollWidth > element.clientWidth;\n\n return canScrollY || canScrollX;\n }\n\n function getScrollParent(element) {\n let parent = element.parentElement;\n\n while (parent && parent !== document.body && parent !== document.documentElement) {\n if (isScrollable(parent)) return parent;\n parent = parent.parentElement;\n }\n\n return null;\n }\n\n const Affix = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-affix, .vd-sticky, [data-vd-affix]');\n elements.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const parsedOffset = parseInt(el.getAttribute('data-vd-affix-offset') || '0', 10);\n const offset = Number.isNaN(parsedOffset) ? 0 : parsedOffset;\n const scrollParent = getScrollParent(el);\n let isStuck = false;\n\n const sentinel = document.createElement('div');\n sentinel.style.cssText = 'display:block;height:1px;margin-bottom:-1px;visibility:hidden;pointer-events:none;';\n el.parentNode.insertBefore(sentinel, el);\n\n el.style.setProperty('--affix-top-offset', offset + 'px');\n\n function stick() {\n if (isStuck) return;\n isStuck = true;\n el.classList.add('is-stuck');\n el.dispatchEvent(new CustomEvent('affix:stuck', {\n bubbles: true,\n detail: {\n offset: offset,\n root: scrollParent || window\n }\n }));\n }\n\n function unstick() {\n if (!isStuck) return;\n isStuck = false;\n el.classList.remove('is-stuck');\n el.dispatchEvent(new CustomEvent('affix:unstuck', {\n bubbles: true,\n detail: {\n offset: offset,\n root: scrollParent || window\n }\n }));\n }\n\n const observer = new IntersectionObserver(function (entries) {\n entries.forEach(entry => {\n if (!entry.isIntersecting) {\n stick();\n } else {\n unstick();\n }\n });\n }, {\n root: scrollParent,\n rootMargin: '-' + offset + 'px 0px 0px 0px',\n threshold: 0\n });\n\n observer.observe(sentinel);\n\n cleanup.push(\n () => observer.disconnect(),\n () => { if (sentinel.parentNode) sentinel.parentNode.removeChild(sentinel); },\n () => {\n el.classList.remove('is-stuck');\n el.style.removeProperty('--affix-top-offset');\n }\n );\n\n this.instances.set(el, { cleanup, observer, sentinel, scrollParent });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n el.classList.remove('is-stuck');\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('affix', Affix);\n }\n\n window.VanduoAffix = Affix;\n\n})();\n", "/**\n * Vanduo Framework - Suggest (Autocomplete) Component\n * Dropdown suggestion list with keyboard navigation, static/async data sources\n */\n\n(function () {\n 'use strict';\n\n /**\n * Escape HTML entities to prevent XSS when inserting into innerHTML\n * @param {string} text\n * @returns {string}\n */\n function _escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Allow same-origin URLs by default, or an explicit allowlist of origins.\n * @param {string} url\n * @param {string[]} allowlist\n * @returns {boolean}\n */\n function _isSafeUrl(url, allowlist) {\n try {\n const resolved = new URL(url, window.location.href);\n if (resolved.origin === window.location.origin) return true;\n return allowlist.includes(resolved.origin);\n } catch (_e) {\n return false;\n }\n }\n\n const Suggest = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-suggest], [data-vd-autocomplete]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const minChars = parseInt(input.getAttribute('data-vd-suggest-min-chars') || '1', 10);\n const url = input.getAttribute('data-vd-suggest-url') || '';\n const allowlistAttr = input.getAttribute('data-vd-suggest-allowlist') || '';\n const allowlist = allowlistAttr\n .split(',')\n .map(value => value.trim())\n .filter(Boolean);\n const staticData = input.getAttribute('data-vd-suggest') || input.getAttribute('data-vd-autocomplete') || '';\n let items = [];\n\n try { items = JSON.parse(staticData); } catch (_e) {\n items = staticData.split(',').map(s => s.trim()).filter(Boolean);\n }\n\n // Wrap input if not already wrapped\n let wrapper = input.closest('.vd-suggest-wrapper, .vd-autocomplete-wrapper');\n if (!wrapper) {\n wrapper = document.createElement('div');\n wrapper.className = 'vd-suggest-wrapper';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n }\n\n // Create dropdown list\n const list = document.createElement('ul');\n list.className = 'vd-suggest-list';\n list.setAttribute('role', 'listbox');\n const listId = 'vd-suggest-' + Math.random().toString(36).slice(2, 9);\n list.id = listId;\n wrapper.appendChild(list);\n\n // ARIA\n input.setAttribute('role', 'combobox');\n input.setAttribute('aria-autocomplete', 'list');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('aria-controls', listId);\n input.setAttribute('autocomplete', 'off');\n\n let highlighted = -1;\n let currentItems = [];\n let debounceTimer = null;\n\n const renderItems = (filtered, query) => {\n list.innerHTML = '';\n currentItems = filtered;\n highlighted = -1;\n\n if (filtered.length === 0) {\n const empty = document.createElement('li');\n empty.className = 'vd-suggest-empty';\n empty.textContent = 'No results';\n list.appendChild(empty);\n return;\n }\n\n filtered.forEach((item, i) => {\n const li = document.createElement('li');\n li.className = 'vd-suggest-item';\n li.setAttribute('role', 'option');\n li.id = listId + '-item-' + i;\n\n const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);\n if (query) {\n // Escape HTML first to prevent XSS, then highlight matches in the safe string\n const escaped = _escapeHtml(text);\n const re = new RegExp('(' + query.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi');\n li.innerHTML = escaped.replace(re, '$1');\n } else {\n li.textContent = text;\n }\n\n li.addEventListener('click', () => selectItem(i));\n list.appendChild(li);\n });\n };\n\n const open = () => {\n list.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n };\n\n const close = () => {\n list.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n highlighted = -1;\n input.removeAttribute('aria-activedescendant');\n };\n\n const selectItem = (index) => {\n const item = currentItems[index];\n const value = typeof item === 'object' ? (item.value || item.label || String(item)) : String(item);\n input.value = value;\n close();\n input.dispatchEvent(new CustomEvent('suggest:select', {\n detail: { value, item, index },\n bubbles: true\n }));\n };\n\n const highlight = (index) => {\n const listItems = list.querySelectorAll('.vd-suggest-item');\n listItems.forEach(li => li.classList.remove('is-highlighted'));\n if (index >= 0 && index < listItems.length) {\n highlighted = index;\n listItems[index].classList.add('is-highlighted');\n input.setAttribute('aria-activedescendant', listItems[index].id);\n listItems[index].scrollIntoView({ block: 'nearest' });\n }\n };\n\n const doSearch = async (query) => {\n if (query.length < minChars) { close(); return; }\n\n let filtered;\n if (url) {\n try {\n if (!_isSafeUrl(url, allowlist)) {\n console.warn('[VanduoSuggest] Blocked non-allowlisted URL:', url);\n filtered = [];\n } else {\n const separator = url.includes('?') ? '&' : '?';\n const res = await window.fetch(url + separator + 'q=' + encodeURIComponent(query));\n filtered = await res.json();\n }\n } catch (_e) {\n filtered = [];\n }\n } else {\n const lower = query.toLowerCase();\n filtered = items.filter(item => {\n const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);\n return text.toLowerCase().includes(lower);\n });\n }\n\n renderItems(filtered, query);\n if (filtered.length > 0) open();\n else open(); // show \"no results\"\n };\n\n const inputHandler = () => {\n clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => doSearch(input.value), 200);\n };\n\n const keyHandler = (e) => {\n if (!list.classList.contains('is-open')) {\n if (e.key === 'ArrowDown') { doSearch(input.value); e.preventDefault(); }\n return;\n }\n\n const total = currentItems.length;\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n highlight(highlighted < total - 1 ? highlighted + 1 : 0);\n break;\n case 'ArrowUp':\n e.preventDefault();\n highlight(highlighted > 0 ? highlighted - 1 : total - 1);\n break;\n case 'Enter':\n e.preventDefault();\n if (highlighted >= 0) selectItem(highlighted);\n break;\n case 'Escape':\n close();\n break;\n }\n };\n\n const blurHandler = () => {\n setTimeout(close, 200);\n };\n\n input.addEventListener('input', inputHandler);\n input.addEventListener('keydown', keyHandler);\n input.addEventListener('blur', blurHandler);\n input.addEventListener('focus', () => { if (input.value.length >= minChars) doSearch(input.value); });\n\n cleanup.push(\n () => input.removeEventListener('input', inputHandler),\n () => input.removeEventListener('keydown', keyHandler),\n () => input.removeEventListener('blur', blurHandler),\n () => clearTimeout(debounceTimer),\n () => { if (list.parentNode) list.parentNode.removeChild(list); }\n );\n\n this.instances.set(input, { cleanup, list, close });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('suggest', Suggest);\n }\n\n window.VanduoSuggest = Suggest;\n\n})();\n", "/**\n * Vanduo Framework - Validate (Form Validation) Component\n * Declarative validation via data attributes with real-time and on-submit modes\n */\n\n(function () {\n 'use strict';\n\n const Validate = {\n instances: new Map(),\n\n rules: {\n required: (value) => value.trim().length > 0,\n email: (value) => /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(value),\n url: (value) => { try { new URL(value); return true; } catch (_e) { return false; } },\n number: (value) => !isNaN(parseFloat(value)) && isFinite(value),\n min: (value, param) => value.length >= parseInt(param, 10),\n max: (value, param) => value.length <= parseInt(param, 10),\n minVal: (value, param) => parseFloat(value) >= parseFloat(param),\n maxVal: (value, param) => parseFloat(value) <= parseFloat(param),\n pattern: (value, param) => {\n try {\n // Cap regex length to prevent ReDoS from excessively complex patterns\n if (param.length > 100) return false;\n return new RegExp(param).test(value);\n } catch (_e) { return false; }\n },\n match: (value, param) => {\n try {\n const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(param) : param;\n const other = document.querySelector('[name=\"' + escaped + '\"]');\n return other ? value === other.value : false;\n } catch (_e) {\n return false;\n }\n }\n },\n\n messages: {\n required: 'This field is required',\n email: 'Please enter a valid email address',\n url: 'Please enter a valid URL',\n number: 'Please enter a valid number',\n min: 'Minimum {0} characters required',\n max: 'Maximum {0} characters allowed',\n minVal: 'Value must be at least {0}',\n maxVal: 'Value must be at most {0}',\n pattern: 'Invalid format',\n match: 'Fields do not match'\n },\n\n init: function () {\n const forms = document.querySelectorAll('[data-vd-validate], .vd-validate');\n forms.forEach(form => {\n if (this.instances.has(form)) return;\n this.initInstance(form);\n });\n },\n\n initInstance: function (form) {\n const cleanup = [];\n const mode = form.getAttribute('data-vd-validate-mode') || 'blur'; // blur | input | submit\n const fields = form.querySelectorAll('[data-vd-rules]');\n\n const validateField = (field) => {\n const rulesStr = field.getAttribute('data-vd-rules') || '';\n const rules = rulesStr.split('|').map(r => r.trim()).filter(Boolean);\n const value = field.value;\n const errors = [];\n\n for (const rule of rules) {\n const [name, ...params] = rule.split(':');\n const param = params.join(':');\n const validator = this.rules[name];\n\n if (validator && !validator(value, param)) {\n const customMsg = field.getAttribute('data-vd-msg-' + name);\n let msg = customMsg || this.messages[name] || 'Invalid';\n if (param) msg = msg.replace('{0}', param);\n errors.push(msg);\n break; // one error at a time\n }\n }\n\n this.setFieldState(field, errors);\n return errors.length === 0;\n };\n\n const validateAll = () => {\n let valid = true;\n fields.forEach(field => {\n if (!validateField(field)) valid = false;\n });\n return valid;\n };\n\n // Per-field listeners\n fields.forEach(field => {\n if (mode === 'input' || mode === 'blur') {\n const eventType = mode === 'input' ? 'input' : 'blur';\n const handler = () => validateField(field);\n field.addEventListener(eventType, handler);\n cleanup.push(() => field.removeEventListener(eventType, handler));\n\n if (mode === 'blur') {\n const inputClear = () => {\n if (field.classList.contains('is-invalid') || field.classList.contains('is-valid')) {\n validateField(field);\n }\n };\n field.addEventListener('input', inputClear);\n cleanup.push(() => field.removeEventListener('input', inputClear));\n }\n }\n });\n\n // Form submit\n const submitHandler = (e) => {\n const valid = validateAll();\n if (!valid) {\n e.preventDefault();\n e.stopPropagation();\n // Focus first invalid field\n const firstInvalid = form.querySelector('.is-invalid');\n if (firstInvalid) firstInvalid.focus();\n }\n form.dispatchEvent(new CustomEvent('validate:submit', {\n detail: { valid },\n bubbles: true\n }));\n };\n\n form.addEventListener('submit', submitHandler);\n cleanup.push(() => form.removeEventListener('submit', submitHandler));\n\n this.instances.set(form, { cleanup, validateAll, validateField });\n },\n\n setFieldState: function (field, errors) {\n const wrapper = field.closest('.vd-form-group') || field.parentElement;\n let errorEl = wrapper.querySelector('.vd-validate-error');\n\n field.classList.remove('is-valid', 'is-invalid');\n\n if (errors.length > 0) {\n field.classList.add('is-invalid');\n field.setAttribute('aria-invalid', 'true');\n if (!errorEl) {\n errorEl = document.createElement('div');\n errorEl.className = 'vd-validate-error';\n errorEl.id = 'vd-err-' + Math.random().toString(36).slice(2, 9);\n errorEl.setAttribute('role', 'alert');\n wrapper.appendChild(errorEl);\n }\n errorEl.textContent = errors[0];\n errorEl.style.display = '';\n field.setAttribute('aria-describedby', errorEl.id);\n } else if (field.value.trim()) {\n field.classList.add('is-valid');\n field.removeAttribute('aria-invalid');\n if (errorEl) errorEl.style.display = 'none';\n } else {\n field.removeAttribute('aria-invalid');\n if (errorEl) errorEl.style.display = 'none';\n }\n },\n\n validateForm: function (form) {\n const instance = this.instances.get(form);\n return instance ? instance.validateAll() : false;\n },\n\n addRule: function (name, validator, message) {\n this.rules[name] = validator;\n if (message) this.messages[name] = message;\n },\n\n destroy: function (form) {\n const instance = this.instances.get(form);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(form);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, form) => this.destroy(form));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('validate', Validate);\n }\n\n window.VanduoValidate = Validate;\n\n})();\n", "/**\n * Vanduo Framework - Datepicker Component\n * Calendar popup attached to input field with month/year navigation\n */\n\n(function () {\n 'use strict';\n\n const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];\n const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];\n\n function escapeRegexChar(c) {\n return c.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n }\n\n function buildParseFormat(format) {\n let regex = '^';\n const order = [];\n let i = 0;\n while (i < format.length) {\n const slice = format.slice(i);\n if (slice.toLowerCase().startsWith('yyyy')) {\n regex += '(\\\\d{4})';\n order.push('y');\n i += 4;\n } else if (slice.toLowerCase().startsWith('mm')) {\n regex += '(\\\\d{2})';\n order.push('m');\n i += 2;\n } else if (slice.toLowerCase().startsWith('dd')) {\n regex += '(\\\\d{2})';\n order.push('d');\n i += 2;\n } else {\n regex += escapeRegexChar(format[i]);\n i++;\n }\n }\n regex += '$';\n return { regex: new RegExp(regex), order };\n }\n\n function parseDateFromFormat(value, format) {\n if (!value || !format) return null;\n const { regex, order } = buildParseFormat(format);\n const m = value.trim().match(regex);\n if (!m) return null;\n let y;\n let mo;\n let d;\n let ci = 1;\n for (let k = 0; k < order.length; k++) {\n const part = order[k];\n const v = parseInt(m[ci++], 10);\n if (Number.isNaN(v)) return null;\n if (part === 'y') y = v;\n else if (part === 'm') mo = v - 1;\n else if (part === 'd') d = v;\n }\n if (y === undefined || mo === undefined || d === undefined) return null;\n const dt = new Date(y, mo, d);\n if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== d) return null;\n return dt;\n }\n\n function formatDate(d, format) {\n const yyyy = String(d.getFullYear());\n const mm = String(d.getMonth() + 1).padStart(2, '0');\n const dd = String(d.getDate()).padStart(2, '0');\n let out = '';\n let i = 0;\n while (i < format.length) {\n const slice = format.slice(i);\n if (slice.toLowerCase().startsWith('yyyy')) {\n out += yyyy;\n i += 4;\n } else if (slice.toLowerCase().startsWith('mm')) {\n out += mm;\n i += 2;\n } else if (slice.toLowerCase().startsWith('dd')) {\n out += dd;\n i += 2;\n } else {\n out += format[i];\n i++;\n }\n }\n return out;\n }\n\n function dateKey(d) {\n return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');\n }\n\n function addDays(d, n) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n x.setDate(x.getDate() + n);\n return x;\n }\n\n function addMonthsClamped(d, n) {\n return new Date(d.getFullYear(), d.getMonth() + n, d.getDate());\n }\n\n function parseYmdLocal(ymd) {\n if (!ymd || typeof ymd !== 'string') return null;\n const m = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(ymd.trim());\n if (!m) return null;\n const y = +m[1];\n const mo = +m[2] - 1;\n const day = +m[3];\n const dt = new Date(y, mo, day);\n if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== day) return null;\n return dt;\n }\n\n function startOfWeekSunday(d) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const day = x.getDay();\n x.setDate(x.getDate() - day);\n return x;\n }\n\n function endOfWeekSunday(d) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const day = x.getDay();\n x.setDate(x.getDate() + (6 - day));\n return x;\n }\n\n const Datepicker = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-datepicker]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const format = input.getAttribute('data-vd-datepicker-format') || 'YYYY-MM-DD';\n const minStr = input.getAttribute('data-vd-datepicker-min');\n const maxStr = input.getAttribute('data-vd-datepicker-max');\n const minDate = minStr ? parseYmdLocal(minStr) : null;\n const maxDate = maxStr ? parseYmdLocal(maxStr) : null;\n\n const today = new Date();\n let viewYear = today.getFullYear();\n let viewMonth = today.getMonth();\n let selectedDate = null;\n let viewMode = 'days'; // days | months | years\n let focusedDate = null;\n /** Prevents focus() after close from immediately re-opening the popup */\n let skipNextFocusOpen = false;\n\n const isDisabled = (d) => {\n if (minDate) {\n const t = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n if (t < minDate.getTime()) return true;\n }\n if (maxDate) {\n const t = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n if (t > maxDate.getTime()) return true;\n }\n return false;\n };\n\n const ensureMonthInRange = (y, m) => {\n if (!minDate && !maxDate) return { y: y, m: m };\n const first = new Date(y, m, 1);\n const last = new Date(y, m + 1, 0);\n if (minDate && last.getTime() < minDate.getTime()) {\n return { y: minDate.getFullYear(), m: minDate.getMonth() };\n }\n if (maxDate && first.getTime() > maxDate.getTime()) {\n return { y: maxDate.getFullYear(), m: maxDate.getMonth() };\n }\n return { y: y, m: m };\n };\n\n const firstSelectableInMonth = (y, m) => {\n const last = new Date(y, m + 1, 0).getDate();\n for (let day = 1; day <= last; day++) {\n const dt = new Date(y, m, day);\n if (!isDisabled(dt)) return dt;\n }\n return new Date(y, m, 1);\n };\n\n if (input.value) {\n const trimmed = input.value.trim();\n let parsed = parseDateFromFormat(trimmed, format);\n if (!parsed) {\n const fallback = new Date(trimmed);\n if (!isNaN(fallback.getTime())) parsed = fallback;\n }\n if (parsed) {\n selectedDate = parsed;\n viewYear = parsed.getFullYear();\n viewMonth = parsed.getMonth();\n }\n }\n\n const clampedInit = ensureMonthInRange(viewYear, viewMonth);\n viewYear = clampedInit.y;\n viewMonth = clampedInit.m;\n\n // Create popup\n const popup = document.createElement('div');\n popup.className = 'vd-datepicker-popup';\n popup.setAttribute('role', 'dialog');\n popup.setAttribute('aria-label', 'Choose date');\n popup.tabIndex = -1;\n\n const wrapper = document.createElement('div');\n wrapper.className = 'vd-suggest-wrapper';\n wrapper.style.position = 'relative';\n wrapper.style.display = 'inline-block';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n wrapper.appendChild(popup);\n\n const isSameDay = (a, b) => a && b &&\n a.getFullYear() === b.getFullYear() &&\n a.getMonth() === b.getMonth() &&\n a.getDate() === b.getDate();\n\n const selectDate = (date) => {\n selectedDate = date;\n viewYear = date.getFullYear();\n viewMonth = date.getMonth();\n input.value = formatDate(date, format);\n skipNextFocusOpen = true;\n close();\n input.dispatchEvent(new CustomEvent('datepicker:select', {\n detail: { date: date, formatted: input.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n input.focus();\n };\n\n const focusFocusedDay = () => {\n if (viewMode !== 'days' || !focusedDate) return;\n const key = dateKey(focusedDate);\n const btn = popup.querySelector('[data-vd-date=\"' + key + '\"]');\n if (btn && !btn.classList.contains('is-outside') && btn.getAttribute('aria-disabled') !== 'true') {\n btn.focus();\n }\n };\n\n const skipDisabled = (d, stepDir, maxSteps) => {\n let x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const step = stepDir > 0 ? 1 : -1;\n for (let i = 0; i < maxSteps; i++) {\n if (!isDisabled(x)) return x;\n x = addDays(x, step);\n }\n return d;\n };\n\n const createDayBtn = (day, outside, date) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-day';\n btn.textContent = day;\n btn.setAttribute('role', 'gridcell');\n\n if (outside) {\n btn.classList.add('is-outside');\n btn.tabIndex = -1;\n btn.setAttribute('aria-disabled', 'true');\n return btn;\n }\n\n btn.setAttribute('data-vd-date', dateKey(date));\n\n if (date && isSameDay(date, today)) btn.classList.add('is-today');\n if (date && isSameDay(date, selectedDate)) btn.classList.add('is-selected');\n if (date && isDisabled(date)) {\n btn.classList.add('is-disabled');\n btn.setAttribute('aria-disabled', 'true');\n btn.tabIndex = -1;\n return btn;\n }\n\n if (date) {\n const isFocused = focusedDate && isSameDay(date, focusedDate);\n btn.tabIndex = isFocused ? 0 : -1;\n\n btn.addEventListener('click', () => {\n selectedDate = date;\n viewYear = date.getFullYear();\n viewMonth = date.getMonth();\n focusedDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());\n input.value = formatDate(date, format);\n skipNextFocusOpen = true;\n close();\n input.dispatchEvent(new CustomEvent('datepicker:select', {\n detail: { date: date, formatted: input.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n input.focus();\n });\n }\n\n return btn;\n };\n\n const render = () => {\n popup.innerHTML = '';\n\n // Header\n const header = document.createElement('div');\n header.className = 'vd-datepicker-header';\n\n const prevBtn = document.createElement('button');\n prevBtn.type = 'button';\n prevBtn.className = 'vd-datepicker-prev';\n prevBtn.innerHTML = '‹';\n prevBtn.setAttribute('aria-label', 'Previous');\n\n const nextBtn = document.createElement('button');\n nextBtn.type = 'button';\n nextBtn.className = 'vd-datepicker-next';\n nextBtn.innerHTML = '›';\n nextBtn.setAttribute('aria-label', 'Next');\n\n const title = document.createElement('span');\n title.className = 'vd-datepicker-title';\n\n if (viewMode === 'days') {\n title.textContent = MONTHS[viewMonth] + ' ' + viewYear;\n title.addEventListener('click', () => { viewMode = 'months'; render(); });\n prevBtn.addEventListener('click', () => { viewMonth--; if (viewMonth < 0) { viewMonth = 11; viewYear--; } render(); });\n nextBtn.addEventListener('click', () => { viewMonth++; if (viewMonth > 11) { viewMonth = 0; viewYear++; } render(); });\n } else if (viewMode === 'months') {\n title.textContent = String(viewYear);\n title.addEventListener('click', () => { viewMode = 'years'; render(); });\n prevBtn.addEventListener('click', () => { viewYear--; render(); });\n nextBtn.addEventListener('click', () => { viewYear++; render(); });\n } else {\n const decadeStart = Math.floor(viewYear / 10) * 10;\n title.textContent = decadeStart + ' - ' + (decadeStart + 9);\n prevBtn.addEventListener('click', () => { viewYear -= 10; render(); });\n nextBtn.addEventListener('click', () => { viewYear += 10; render(); });\n }\n\n header.appendChild(prevBtn);\n header.appendChild(title);\n header.appendChild(nextBtn);\n popup.appendChild(header);\n\n if (viewMode === 'days') {\n const gridWrap = document.createElement('div');\n gridWrap.className = 'vd-datepicker-grid';\n gridWrap.setAttribute('role', 'grid');\n gridWrap.setAttribute('aria-label', 'Calendar');\n\n const weekdays = document.createElement('div');\n weekdays.className = 'vd-datepicker-weekdays';\n weekdays.setAttribute('role', 'row');\n DAYS.forEach(function (d) {\n const span = document.createElement('span');\n span.setAttribute('role', 'columnheader');\n span.setAttribute('aria-label', d);\n span.textContent = d;\n weekdays.appendChild(span);\n });\n gridWrap.appendChild(weekdays);\n\n const firstDay = new Date(viewYear, viewMonth, 1).getDay();\n const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();\n const daysInPrev = new Date(viewYear, viewMonth, 0).getDate();\n\n const cells = [];\n\n for (let i = firstDay - 1; i >= 0; i--) {\n const dayNum = daysInPrev - i;\n const prevMonth = viewMonth === 0 ? 11 : viewMonth - 1;\n const prevYear = viewMonth === 0 ? viewYear - 1 : viewYear;\n const date = new Date(prevYear, prevMonth, dayNum);\n cells.push({ day: dayNum, outside: true, date: date });\n }\n\n for (let d = 1; d <= daysInMonth; d++) {\n const date = new Date(viewYear, viewMonth, d);\n cells.push({ day: d, outside: false, date: date });\n }\n\n const totalCells = firstDay + daysInMonth;\n const remaining = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7);\n for (let i = 1; i <= remaining; i++) {\n const date = new Date(viewYear, viewMonth + 1, i);\n cells.push({ day: i, outside: true, date: date });\n }\n\n for (let r = 0; r < cells.length; r += 7) {\n const row = document.createElement('div');\n row.className = 'vd-datepicker-row';\n row.setAttribute('role', 'row');\n for (let c = 0; c < 7; c++) {\n const cell = cells[r + c];\n row.appendChild(createDayBtn(cell.day, cell.outside, cell.date));\n }\n gridWrap.appendChild(row);\n }\n\n popup.appendChild(gridWrap);\n } else if (viewMode === 'months') {\n const grid = document.createElement('div');\n grid.className = 'vd-datepicker-months';\n MONTHS.forEach((name, i) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-month-btn';\n btn.textContent = name.slice(0, 3);\n if (selectedDate && selectedDate.getFullYear() === viewYear && selectedDate.getMonth() === i) {\n btn.classList.add('is-selected');\n }\n btn.addEventListener('click', () => { viewMonth = i; viewMode = 'days'; render(); });\n grid.appendChild(btn);\n });\n popup.appendChild(grid);\n } else {\n const grid = document.createElement('div');\n grid.className = 'vd-datepicker-years';\n const decadeStart = Math.floor(viewYear / 10) * 10;\n for (let y = decadeStart - 1; y <= decadeStart + 10; y++) {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-year-btn';\n btn.textContent = y;\n if (selectedDate && selectedDate.getFullYear() === y) btn.classList.add('is-selected');\n if (y < decadeStart || y > decadeStart + 9) btn.style.opacity = '0.4';\n btn.addEventListener('click', () => { viewYear = y; viewMode = 'months'; render(); });\n grid.appendChild(btn);\n }\n popup.appendChild(grid);\n }\n };\n\n const handleGridKeydown = (e) => {\n if (!popup.classList.contains('is-open') || viewMode !== 'days') return;\n const grid = popup.querySelector('.vd-datepicker-grid');\n if (!grid || !grid.contains(e.target)) return;\n\n const key = e.key;\n if (key !== 'ArrowLeft' && key !== 'ArrowRight' && key !== 'ArrowUp' && key !== 'ArrowDown' &&\n key !== 'Home' && key !== 'End' && key !== 'PageUp' && key !== 'PageDown' &&\n key !== 'Enter' && key !== ' ' && key !== 'Escape') {\n return;\n }\n\n if (key === 'Escape') {\n e.preventDefault();\n e.stopPropagation();\n skipNextFocusOpen = true;\n close();\n input.focus();\n return;\n }\n\n if (!focusedDate) {\n focusedDate = firstSelectableInMonth(viewYear, viewMonth);\n }\n\n if (key === 'Enter' || key === ' ') {\n e.preventDefault();\n if (focusedDate && !isDisabled(focusedDate)) {\n selectDate(new Date(focusedDate.getFullYear(), focusedDate.getMonth(), focusedDate.getDate()));\n }\n return;\n }\n\n e.preventDefault();\n\n let next = new Date(focusedDate.getFullYear(), focusedDate.getMonth(), focusedDate.getDate());\n let skipDir = 1;\n\n if (key === 'ArrowLeft') {\n next = addDays(next, -1);\n skipDir = -1;\n } else if (key === 'ArrowRight') {\n next = addDays(next, 1);\n skipDir = 1;\n } else if (key === 'ArrowUp') {\n next = addDays(next, -7);\n skipDir = -1;\n } else if (key === 'ArrowDown') {\n next = addDays(next, 7);\n skipDir = 1;\n } else if (key === 'Home') {\n next = startOfWeekSunday(next);\n skipDir = 1;\n } else if (key === 'End') {\n next = endOfWeekSunday(next);\n skipDir = -1;\n } else if (key === 'PageUp') {\n next = addMonthsClamped(next, -1);\n skipDir = -1;\n } else if (key === 'PageDown') {\n next = addMonthsClamped(next, 1);\n skipDir = 1;\n }\n\n next = skipDisabled(next, skipDir, 400);\n\n if (next.getMonth() !== viewMonth || next.getFullYear() !== viewYear) {\n viewYear = next.getFullYear();\n viewMonth = next.getMonth();\n const cl = ensureMonthInRange(viewYear, viewMonth);\n viewYear = cl.y;\n viewMonth = cl.m;\n }\n\n focusedDate = next;\n render();\n requestAnimationFrame(focusFocusedDay);\n };\n\n const open = () => {\n viewMode = 'days';\n if (selectedDate) {\n viewYear = selectedDate.getFullYear();\n viewMonth = selectedDate.getMonth();\n }\n const cl = ensureMonthInRange(viewYear, viewMonth);\n viewYear = cl.y;\n viewMonth = cl.m;\n\n if (selectedDate) {\n focusedDate = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate());\n } else {\n focusedDate = firstSelectableInMonth(viewYear, viewMonth);\n }\n\n render();\n popup.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n requestAnimationFrame(focusFocusedDay);\n };\n\n const close = () => {\n popup.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n viewMode = 'days';\n };\n\n const focusHandler = () => {\n if (skipNextFocusOpen) {\n skipNextFocusOpen = false;\n return;\n }\n open();\n };\n const outsideHandler = (e) => {\n if (!wrapper.contains(e.target)) close();\n };\n const escHandler = (e) => {\n if (e.key === 'Escape' && popup.classList.contains('is-open')) {\n skipNextFocusOpen = true;\n close();\n input.focus();\n }\n };\n\n input.addEventListener('focus', focusHandler);\n document.addEventListener('click', outsideHandler, true);\n document.addEventListener('keydown', escHandler);\n popup.addEventListener('keydown', handleGridKeydown);\n\n input.setAttribute('aria-haspopup', 'dialog');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('autocomplete', 'off');\n\n cleanup.push(\n () => input.removeEventListener('focus', focusHandler),\n () => document.removeEventListener('click', outsideHandler, true),\n () => document.removeEventListener('keydown', escHandler),\n () => popup.removeEventListener('keydown', handleGridKeydown)\n );\n\n this.instances.set(input, { cleanup: cleanup, open: open, close: close, popup: popup });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('datepicker', Datepicker);\n }\n\n window.VanduoDatepicker = Datepicker;\n\n})();\n", "/**\n * Vanduo Framework - Timepicker Component\n * Dropdown time selection with 12h/24h format and configurable step intervals\n */\n\n(function () {\n 'use strict';\n\n const Timepicker = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-timepicker]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const is24h = input.getAttribute('data-vd-timepicker-format') === '24h';\n const step = parseInt(input.getAttribute('data-vd-timepicker-step') || '30', 10);\n\n // Create wrapper\n let wrapper = input.closest('.vd-suggest-wrapper');\n if (!wrapper) {\n wrapper = document.createElement('div');\n wrapper.style.position = 'relative';\n wrapper.style.display = 'inline-block';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n }\n\n // Create popup\n const popup = document.createElement('div');\n popup.className = 'vd-timepicker-popup';\n popup.setAttribute('role', 'listbox');\n wrapper.appendChild(popup);\n\n // Generate time slots\n const times = [];\n for (let h = 0; h < 24; h++) {\n for (let m = 0; m < 60; m += step) {\n const hh24 = String(h).padStart(2, '0');\n const mm = String(m).padStart(2, '0');\n\n if (is24h) {\n times.push({ display: hh24 + ':' + mm, value: hh24 + ':' + mm });\n } else {\n const period = h < 12 ? 'AM' : 'PM';\n const h12 = h === 0 ? 12 : (h > 12 ? h - 12 : h);\n const display = h12 + ':' + mm + ' ' + period;\n times.push({ display, value: hh24 + ':' + mm });\n }\n }\n }\n\n const render = () => {\n popup.innerHTML = '';\n times.forEach(t => {\n const item = document.createElement('div');\n item.className = 'vd-timepicker-item';\n item.setAttribute('role', 'option');\n item.textContent = t.display;\n\n if (input.value === t.value || input.value === t.display) {\n item.classList.add('is-selected');\n }\n\n item.addEventListener('click', () => {\n input.value = t.display;\n popup.querySelectorAll('.vd-timepicker-item').forEach(i => i.classList.remove('is-selected'));\n item.classList.add('is-selected');\n close();\n input.dispatchEvent(new CustomEvent('timepicker:select', {\n detail: { display: t.display, value: t.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n });\n\n popup.appendChild(item);\n });\n };\n\n const open = () => {\n render();\n popup.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n // Scroll to selected\n const selected = popup.querySelector('.is-selected');\n if (selected) selected.scrollIntoView({ block: 'center' });\n };\n\n const close = () => {\n popup.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n };\n\n const focusHandler = () => open();\n const outsideHandler = (e) => {\n if (!wrapper.contains(e.target)) close();\n };\n const escHandler = (e) => { if (e.key === 'Escape') close(); };\n\n input.addEventListener('focus', focusHandler);\n document.addEventListener('click', outsideHandler, true);\n document.addEventListener('keydown', escHandler);\n input.setAttribute('aria-haspopup', 'listbox');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('autocomplete', 'off');\n input.readOnly = true;\n\n cleanup.push(\n () => input.removeEventListener('focus', focusHandler),\n () => document.removeEventListener('click', outsideHandler, true),\n () => document.removeEventListener('keydown', escHandler)\n );\n\n this.instances.set(input, { cleanup, open, close });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('timepicker', Timepicker);\n }\n\n window.VanduoTimepicker = Timepicker;\n\n})();\n", "/**\n * Vanduo Framework - Stepper Component\n * Multi-step progress indicator with state management\n */\n\n(function () {\n 'use strict';\n\n const Stepper = {\n instances: new Map(),\n\n init: function () {\n const steppers = document.querySelectorAll('.vd-stepper');\n steppers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const items = Array.from(el.querySelectorAll('.vd-stepper-item'));\n const isClickable = el.classList.contains('vd-stepper-clickable');\n let currentIndex = items.findIndex(i => i.classList.contains('is-active'));\n if (currentIndex === -1) currentIndex = 0;\n\n const setStep = (index) => {\n if (index < 0 || index >= items.length) return;\n const prev = currentIndex;\n currentIndex = index;\n\n items.forEach((item, i) => {\n item.classList.remove('is-active', 'is-completed');\n if (i < index) item.classList.add('is-completed');\n else if (i === index) item.classList.add('is-active');\n });\n\n el.dispatchEvent(new CustomEvent('stepper:change', {\n detail: { current: index, previous: prev, total: items.length },\n bubbles: true\n }));\n };\n\n if (isClickable) {\n items.forEach((item, i) => {\n const handler = () => setStep(i);\n item.addEventListener('click', handler);\n cleanup.push(() => item.removeEventListener('click', handler));\n });\n }\n\n // Initialize current state\n setStep(currentIndex);\n\n this.instances.set(el, {\n cleanup,\n setStep,\n next: () => setStep(currentIndex + 1),\n prev: () => setStep(currentIndex - 1),\n getCurrent: () => currentIndex\n });\n },\n\n setStep: function (el, index) {\n const inst = this.instances.get(el);\n if (inst) inst.setStep(index);\n },\n\n next: function (el) {\n const inst = this.instances.get(el);\n if (inst) inst.next();\n },\n\n prev: function (el) {\n const inst = this.instances.get(el);\n if (inst) inst.prev();\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('stepper', Stepper);\n }\n\n window.VanduoStepper = Stepper;\n\n})();\n", "/**\n * Vanduo Framework - Rating Component\n * Star-based rating input with hover preview and read-only mode\n */\n\n(function () {\n 'use strict';\n\n const Rating = {\n instances: new Map(),\n\n init: function () {\n const ratings = document.querySelectorAll('[data-vd-rating]');\n ratings.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const max = parseInt(el.getAttribute('data-vd-rating-max') || '5', 10);\n const initialValue = parseFloat(el.getAttribute('data-vd-rating-value') || '0');\n const readonly = el.classList.contains('vd-rating-readonly') || el.hasAttribute('data-vd-rating-readonly');\n let currentValue = initialValue;\n\n el.classList.add('vd-rating');\n el.setAttribute('role', 'radiogroup');\n el.setAttribute('aria-label', el.getAttribute('aria-label') || 'Rating');\n\n // Clear existing stars\n el.innerHTML = '';\n\n // Create stars\n const stars = [];\n for (let i = 1; i <= max; i++) {\n const star = document.createElement('button');\n star.type = 'button';\n star.className = 'vd-rating-star';\n star.setAttribute('role', 'radio');\n star.setAttribute('aria-label', i + ' star' + (i > 1 ? 's' : ''));\n star.setAttribute('aria-checked', i <= currentValue ? 'true' : 'false');\n if (readonly) star.tabIndex = -1;\n stars.push(star);\n el.appendChild(star);\n }\n\n // Value display\n const valueDisplay = document.createElement('span');\n valueDisplay.className = 'vd-rating-value';\n valueDisplay.textContent = currentValue > 0 ? currentValue.toString() : '';\n el.appendChild(valueDisplay);\n\n const updateStars = (value) => {\n stars.forEach((star, i) => {\n star.classList.remove('is-active', 'is-half');\n const starNum = i + 1;\n if (starNum <= Math.floor(value)) {\n star.classList.add('is-active');\n } else if (starNum - 0.5 <= value) {\n star.classList.add('is-half');\n }\n star.setAttribute('aria-checked', starNum <= value ? 'true' : 'false');\n });\n valueDisplay.textContent = value > 0 ? value.toString() : '';\n };\n\n updateStars(currentValue);\n\n if (!readonly) {\n stars.forEach((star, i) => {\n const enterHandler = () => {\n stars.forEach((s, j) => {\n s.classList.toggle('is-hovered', j <= i);\n });\n };\n const leaveHandler = () => {\n stars.forEach(s => s.classList.remove('is-hovered'));\n };\n const clickHandler = () => {\n currentValue = i + 1;\n el.setAttribute('data-vd-rating-value', currentValue);\n updateStars(currentValue);\n el.dispatchEvent(new CustomEvent('rating:change', {\n detail: { value: currentValue, max },\n bubbles: true\n }));\n };\n\n star.addEventListener('mouseenter', enterHandler);\n star.addEventListener('mouseleave', leaveHandler);\n star.addEventListener('click', clickHandler);\n\n cleanup.push(\n () => star.removeEventListener('mouseenter', enterHandler),\n () => star.removeEventListener('mouseleave', leaveHandler),\n () => star.removeEventListener('click', clickHandler)\n );\n });\n\n // Keyboard\n const keyHandler = (e) => {\n if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {\n e.preventDefault();\n if (currentValue < max) {\n currentValue++;\n updateStars(currentValue);\n stars[currentValue - 1].focus();\n el.dispatchEvent(new CustomEvent('rating:change', { detail: { value: currentValue, max }, bubbles: true }));\n }\n } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {\n e.preventDefault();\n if (currentValue > 1) {\n currentValue--;\n updateStars(currentValue);\n stars[currentValue - 1].focus();\n el.dispatchEvent(new CustomEvent('rating:change', { detail: { value: currentValue, max }, bubbles: true }));\n }\n }\n };\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n }\n\n this.instances.set(el, {\n cleanup,\n getValue: () => currentValue,\n setValue: (v) => { currentValue = v; updateStars(v); }\n });\n },\n\n getValue: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getValue() : 0;\n },\n\n setValue: function (el, value) {\n const inst = this.instances.get(el);\n if (inst) inst.setValue(value);\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('rating', Rating);\n }\n\n window.VanduoRating = Rating;\n\n})();\n", "/**\n * Vanduo Framework - Transfer (Dual-list) Component\n * Source-to-target list transfer with search, select-all, and move actions\n */\n\n(function () {\n 'use strict';\n\n const Transfer = {\n instances: new Map(),\n\n init: function () {\n const transfers = document.querySelectorAll('[data-vd-transfer]');\n transfers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n el.classList.add('vd-transfer');\n\n let sourceData, targetData;\n try {\n const raw = JSON.parse(el.getAttribute('data-vd-transfer') || '[]');\n sourceData = raw.map((item, i) => ({\n id: item.id || 'item-' + i,\n label: item.label || item.text || String(item),\n selected: false\n }));\n } catch (_e) {\n sourceData = [];\n }\n targetData = [];\n\n const sourceSelected = new Set();\n const targetSelected = new Set();\n\n const render = () => {\n el.innerHTML = '';\n\n // Source panel\n const sourcePanel = createPanel('Source', sourceData, sourceSelected, 'source');\n // Actions\n const actions = document.createElement('div');\n actions.className = 'vd-transfer-actions';\n\n const moveRightBtn = document.createElement('button');\n moveRightBtn.type = 'button';\n moveRightBtn.className = 'vd-transfer-btn';\n moveRightBtn.innerHTML = '›';\n moveRightBtn.setAttribute('aria-label', 'Move to target');\n moveRightBtn.disabled = sourceSelected.size === 0;\n moveRightBtn.addEventListener('click', () => moveRight());\n\n const moveLeftBtn = document.createElement('button');\n moveLeftBtn.type = 'button';\n moveLeftBtn.className = 'vd-transfer-btn';\n moveLeftBtn.innerHTML = '‹';\n moveLeftBtn.setAttribute('aria-label', 'Move to source');\n moveLeftBtn.disabled = targetSelected.size === 0;\n moveLeftBtn.addEventListener('click', () => moveLeft());\n\n actions.appendChild(moveRightBtn);\n actions.appendChild(moveLeftBtn);\n\n // Target panel\n const targetPanel = createPanel('Target', targetData, targetSelected, 'target');\n\n el.appendChild(sourcePanel);\n el.appendChild(actions);\n el.appendChild(targetPanel);\n };\n\n const createPanel = (title, data, selected, _side) => {\n const panel = document.createElement('div');\n panel.className = 'vd-transfer-panel';\n\n const header = document.createElement('div');\n header.className = 'vd-transfer-header';\n const titleSpan = document.createElement('span');\n titleSpan.textContent = title;\n const count = document.createElement('span');\n count.className = 'vd-transfer-count';\n count.textContent = selected.size + '/' + data.length;\n header.appendChild(titleSpan);\n header.appendChild(count);\n panel.appendChild(header);\n\n // Search\n const searchDiv = document.createElement('div');\n searchDiv.className = 'vd-transfer-search';\n const searchInput = document.createElement('input');\n searchInput.type = 'text';\n searchInput.placeholder = 'Search...';\n searchInput.setAttribute('aria-label', 'Search ' + title.toLowerCase());\n searchDiv.appendChild(searchInput);\n panel.appendChild(searchDiv);\n\n // List\n const list = document.createElement('ul');\n list.className = 'vd-transfer-list';\n list.setAttribute('role', 'listbox');\n\n const renderList = (filter) => {\n list.innerHTML = '';\n const filtered = filter ? data.filter(d => {\n const label = (d.label || d.text || String(d)).toLowerCase();\n return label.includes(filter.toLowerCase());\n }) : data;\n filtered.forEach(item => {\n const li = document.createElement('li');\n li.className = 'vd-transfer-item';\n li.setAttribute('role', 'option');\n if (selected.has(item.id)) li.classList.add('is-selected');\n\n const checkbox = document.createElement('input');\n checkbox.type = 'checkbox';\n checkbox.checked = selected.has(item.id);\n checkbox.setAttribute('aria-label', item.label);\n\n const label = document.createElement('span');\n label.textContent = item.label;\n\n li.addEventListener('click', () => {\n if (selected.has(item.id)) selected.delete(item.id);\n else selected.add(item.id);\n render();\n });\n\n li.appendChild(checkbox);\n li.appendChild(label);\n list.appendChild(li);\n });\n };\n\n searchInput.addEventListener('input', () => renderList(searchInput.value));\n renderList('');\n\n panel.appendChild(list);\n return panel;\n };\n\n const moveRight = () => {\n const toMove = sourceData.filter(d => sourceSelected.has(d.id));\n sourceData = sourceData.filter(d => !sourceSelected.has(d.id));\n targetData = targetData.concat(toMove);\n sourceSelected.clear();\n render();\n fireChange();\n };\n\n const moveLeft = () => {\n const toMove = targetData.filter(d => targetSelected.has(d.id));\n targetData = targetData.filter(d => !targetSelected.has(d.id));\n sourceData = sourceData.concat(toMove);\n targetSelected.clear();\n render();\n fireChange();\n };\n\n const fireChange = () => {\n el.dispatchEvent(new CustomEvent('transfer:change', {\n detail: {\n source: sourceData.map(d => d.id),\n target: targetData.map(d => d.id)\n },\n bubbles: true\n }));\n };\n\n render();\n\n this.instances.set(el, {\n cleanup,\n getTarget: () => targetData.map(d => d.id),\n getSource: () => sourceData.map(d => d.id)\n });\n },\n\n getSelected: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getTarget() : [];\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n el.innerHTML = '';\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('transfer', Transfer);\n }\n\n window.VanduoTransfer = Transfer;\n\n})();\n", "/**\n * Vanduo Framework - Tree View Component\n * Hierarchical collapsible tree with checkbox selection and keyboard navigation\n */\n\n(function () {\n 'use strict';\n\n const Tree = {\n instances: new Map(),\n\n init: function () {\n const trees = document.querySelectorAll('[data-vd-tree]');\n trees.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const cascade = el.getAttribute('data-vd-tree-cascade') !== 'false';\n\n let data;\n try { data = JSON.parse(el.getAttribute('data-vd-tree') || '[]'); } catch (_e) { data = []; }\n\n el.classList.add('vd-tree');\n el.setAttribute('role', 'tree');\n\n const render = (items, parent) => {\n parent.innerHTML = '';\n items.forEach(item => {\n const node = document.createElement('li');\n node.className = 'vd-tree-node';\n node.setAttribute('role', 'treeitem');\n node.setAttribute('aria-expanded', item.open ? 'true' : 'false');\n if (item.open) node.classList.add('is-open');\n\n const content = document.createElement('div');\n content.className = 'vd-tree-node-content';\n\n // Toggle\n if (item.children && item.children.length > 0) {\n const toggle = document.createElement('button');\n toggle.type = 'button';\n toggle.className = 'vd-tree-toggle';\n toggle.setAttribute('aria-label', 'Toggle');\n toggle.addEventListener('click', (e) => {\n e.stopPropagation();\n item.open = !item.open;\n node.classList.toggle('is-open');\n node.setAttribute('aria-expanded', item.open ? 'true' : 'false');\n });\n content.appendChild(toggle);\n } else {\n const ph = document.createElement('span');\n ph.className = 'vd-tree-toggle-placeholder';\n content.appendChild(ph);\n }\n\n // Checkbox\n if (el.hasAttribute('data-vd-tree-checkbox')) {\n const cb = document.createElement('input');\n cb.type = 'checkbox';\n cb.className = 'vd-tree-checkbox';\n cb.checked = !!item.checked;\n cb.setAttribute('aria-label', item.label);\n cb.addEventListener('change', (e) => {\n e.stopPropagation();\n item.checked = cb.checked;\n if (cascade && item.children) {\n setChildChecked(item.children, cb.checked);\n render(data, el);\n }\n el.dispatchEvent(new CustomEvent('tree:check', {\n detail: { id: item.id, checked: cb.checked, label: item.label },\n bubbles: true\n }));\n });\n content.appendChild(cb);\n }\n\n // Icon\n if (item.icon) {\n const icon = document.createElement('span');\n icon.className = 'vd-tree-icon ' + item.icon;\n content.appendChild(icon);\n }\n\n // Label\n const label = document.createElement('span');\n label.className = 'vd-tree-label';\n label.textContent = item.label || '';\n content.appendChild(label);\n\n node.appendChild(content);\n\n // Children\n if (item.children && item.children.length > 0) {\n const childList = document.createElement('ul');\n childList.className = 'vd-tree-children';\n childList.setAttribute('role', 'group');\n render(item.children, childList);\n node.appendChild(childList);\n }\n\n parent.appendChild(node);\n });\n };\n\n const setChildChecked = (items, checked) => {\n items.forEach(item => {\n item.checked = checked;\n if (item.children) setChildChecked(item.children, checked);\n });\n };\n\n // Keyboard\n const keyHandler = (e) => {\n const focused = document.activeElement;\n if (!el.contains(focused)) return;\n\n const nodes = Array.from(el.querySelectorAll('.vd-tree-node-content'));\n const idx = nodes.indexOf(focused.closest('.vd-tree-node-content'));\n if (idx === -1) return;\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n if (idx < nodes.length - 1) {\n const next = nodes[idx + 1].querySelector('.vd-tree-toggle, .vd-tree-label');\n if (next) next.focus();\n }\n break;\n case 'ArrowUp':\n e.preventDefault();\n if (idx > 0) {\n const prev = nodes[idx - 1].querySelector('.vd-tree-toggle, .vd-tree-label');\n if (prev) prev.focus();\n }\n break;\n }\n };\n\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n\n render(data, el);\n\n this.instances.set(el, {\n cleanup,\n getData: () => data,\n getChecked: () => {\n const checked = [];\n const collect = (items) => {\n items.forEach(i => {\n if (i.checked) checked.push(i.id || i.label);\n if (i.children) collect(i.children);\n });\n };\n collect(data);\n return checked;\n }\n });\n },\n\n getChecked: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getChecked() : [];\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n el.innerHTML = '';\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('tree', Tree);\n }\n\n window.VanduoTree = Tree;\n\n})();\n", "/**\n * Vanduo Framework - Spotlight (Feature Discovery) Component\n * Guided tour with overlay highlight and step-through tooltip\n */\n\n(function () {\n 'use strict';\n\n const Spotlight = {\n _active: false,\n _steps: [],\n _currentStep: 0,\n _elements: {},\n _cleanup: [],\n _boundTriggers: new WeakMap(),\n _triggerElement: null,\n\n init: function () {\n const triggers = document.querySelectorAll('[data-vd-spotlight]');\n\n triggers.forEach(trigger => {\n if (this._boundTriggers.has(trigger)) return;\n\n const clickHandler = (event) => {\n event.preventDefault();\n\n const steps = this._parseSteps(trigger.getAttribute('data-vd-spotlight'));\n if (steps.length === 0) return;\n\n this.start(steps, { trigger });\n };\n\n trigger.addEventListener('click', clickHandler);\n this._boundTriggers.set(trigger, clickHandler);\n });\n },\n\n _parseSteps: function (raw) {\n if (typeof raw !== 'string' || raw.trim() === '') return [];\n\n try {\n const parsed = JSON.parse(raw);\n return this._normalizeSteps(parsed);\n } catch (error) {\n console.error('VanduoSpotlight: invalid data-vd-spotlight payload.', error);\n return [];\n }\n },\n\n _normalizeStep: function (step) {\n if (!step || typeof step !== 'object') return null;\n\n const target = step.target;\n const hasSelectorTarget = typeof target === 'string' && target.trim() !== '';\n const hasElementTarget = typeof Element !== 'undefined' && target instanceof Element;\n\n if (!hasSelectorTarget && !hasElementTarget) return null;\n\n const title = typeof step.title === 'string' ? step.title : '';\n const description = typeof step.description === 'string'\n ? step.description\n : (typeof step.content === 'string' ? step.content : '');\n\n return {\n target,\n title,\n description\n };\n },\n\n _normalizeSteps: function (steps) {\n if (!Array.isArray(steps)) return [];\n\n return steps\n .map(step => this._normalizeStep(step))\n .filter(Boolean);\n },\n\n start: function (steps, options) {\n if (this._active) this.stop();\n\n const normalizedSteps = this._normalizeSteps(steps);\n if (normalizedSteps.length === 0) return;\n\n const startOptions = options || {};\n\n this._steps = normalizedSteps;\n this._currentStep = 0;\n this._active = true;\n this._triggerElement = startOptions.trigger || (document.activeElement instanceof HTMLElement ? document.activeElement : null);\n\n // Create overlay\n const overlay = document.createElement('div');\n overlay.className = 'vd-spotlight-overlay';\n overlay.setAttribute('aria-hidden', 'true');\n document.body.appendChild(overlay);\n\n // Create tooltip\n const tooltip = document.createElement('div');\n tooltip.className = 'vd-spotlight-tooltip';\n tooltip.setAttribute('role', 'dialog');\n tooltip.setAttribute('aria-modal', 'true');\n tooltip.tabIndex = -1;\n document.body.appendChild(tooltip);\n\n this._elements = { overlay, tooltip };\n\n // ESC to close\n const escHandler = (e) => { if (e.key === 'Escape') this.stop(); };\n document.addEventListener('keydown', escHandler);\n this._cleanup.push(() => document.removeEventListener('keydown', escHandler));\n\n // Overlay click to close\n overlay.addEventListener('click', () => this.stop());\n\n this._showStep(this._currentStep);\n },\n\n _showStep: function (index) {\n const step = this._steps[index];\n if (!step) return;\n\n const target = typeof step.target === 'string' ? document.querySelector(step.target) : step.target;\n const { tooltip } = this._elements;\n\n // Remove previous highlight\n document.querySelectorAll('.vd-spotlight-target').forEach(el => {\n el.classList.remove('vd-spotlight-target');\n });\n\n // Highlight target\n if (target) {\n target.classList.add('vd-spotlight-target');\n target.scrollIntoView({ behavior: 'smooth', block: 'center' });\n }\n\n // Build tooltip content\n const total = this._steps.length;\n tooltip.innerHTML = '';\n tooltip.removeAttribute('aria-labelledby');\n tooltip.removeAttribute('aria-describedby');\n\n if (step.title) {\n const title = document.createElement('h4');\n title.className = 'vd-spotlight-title';\n title.id = 'vd-spotlight-title-' + index + '-' + Date.now();\n title.textContent = step.title;\n tooltip.appendChild(title);\n tooltip.setAttribute('aria-labelledby', title.id);\n }\n\n if (step.description) {\n const desc = document.createElement('p');\n desc.className = 'vd-spotlight-description';\n desc.id = 'vd-spotlight-description-' + index + '-' + Date.now();\n desc.textContent = step.description;\n tooltip.appendChild(desc);\n tooltip.setAttribute('aria-describedby', desc.id);\n }\n\n // Footer\n const footer = document.createElement('div');\n footer.className = 'vd-spotlight-footer';\n footer.setAttribute('aria-label', 'Step ' + (index + 1) + ' of ' + total);\n\n const counter = document.createElement('span');\n counter.className = 'vd-spotlight-counter';\n counter.textContent = (index + 1) + ' / ' + total;\n\n const actions = document.createElement('div');\n actions.className = 'vd-spotlight-actions';\n\n if (index > 0) {\n const prevBtn = document.createElement('button');\n prevBtn.type = 'button';\n prevBtn.className = 'vd-spotlight-btn';\n prevBtn.textContent = 'Back';\n prevBtn.addEventListener('click', () => this.prev());\n actions.appendChild(prevBtn);\n }\n\n const skipBtn = document.createElement('button');\n skipBtn.type = 'button';\n skipBtn.className = 'vd-spotlight-btn';\n skipBtn.textContent = 'Skip';\n skipBtn.addEventListener('click', () => this.stop());\n actions.appendChild(skipBtn);\n\n if (index < total - 1) {\n const nextBtn = document.createElement('button');\n nextBtn.type = 'button';\n nextBtn.className = 'vd-spotlight-btn vd-spotlight-btn-primary';\n nextBtn.textContent = 'Next';\n nextBtn.addEventListener('click', () => this.next());\n actions.appendChild(nextBtn);\n } else {\n const doneBtn = document.createElement('button');\n doneBtn.type = 'button';\n doneBtn.className = 'vd-spotlight-btn vd-spotlight-btn-primary';\n doneBtn.textContent = 'Done';\n doneBtn.addEventListener('click', () => this.stop());\n actions.appendChild(doneBtn);\n }\n\n footer.appendChild(counter);\n footer.appendChild(actions);\n tooltip.appendChild(footer);\n\n // Position tooltip near target\n if (target) {\n requestAnimationFrame(() => {\n const rect = target.getBoundingClientRect();\n const tRect = tooltip.getBoundingClientRect();\n let top = rect.bottom + 12 + window.scrollY;\n let left = rect.left + (rect.width - tRect.width) / 2 + window.scrollX;\n\n // Keep in viewport\n left = Math.max(8, Math.min(left, window.innerWidth - tRect.width - 8));\n if (top + tRect.height > window.innerHeight + window.scrollY) {\n top = rect.top - tRect.height - 12 + window.scrollY;\n }\n\n tooltip.style.top = top + 'px';\n tooltip.style.left = left + 'px';\n });\n }\n\n document.dispatchEvent(new CustomEvent('spotlight:step', {\n detail: { index, step: index, total, data: step }\n }));\n },\n\n next: function () {\n if (this._currentStep < this._steps.length - 1) {\n this._currentStep++;\n this._showStep(this._currentStep);\n }\n },\n\n prev: function () {\n if (this._currentStep > 0) {\n this._currentStep--;\n this._showStep(this._currentStep);\n }\n },\n\n stop: function () {\n if (!this._active) return;\n\n const total = this._steps.length;\n const detail = {\n completedSteps: total === 0 ? 0 : Math.min(this._currentStep + 1, total),\n total,\n completed: total > 0 && this._currentStep >= total - 1\n };\n\n this._active = false;\n\n document.querySelectorAll('.vd-spotlight-target').forEach(el => {\n el.classList.remove('vd-spotlight-target');\n });\n\n if (this._elements.overlay && this._elements.overlay.parentNode) {\n this._elements.overlay.parentNode.removeChild(this._elements.overlay);\n }\n if (this._elements.tooltip && this._elements.tooltip.parentNode) {\n this._elements.tooltip.parentNode.removeChild(this._elements.tooltip);\n }\n\n this._cleanup.forEach(fn => fn());\n this._cleanup = [];\n this._elements = {};\n this._steps = [];\n this._currentStep = 0;\n\n if (this._triggerElement && this._triggerElement.isConnected && typeof this._triggerElement.focus === 'function') {\n this._triggerElement.focus();\n }\n this._triggerElement = null;\n\n document.dispatchEvent(new CustomEvent('spotlight:end', { detail }));\n },\n\n destroyAll: function () {\n this.stop();\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('spotlight', Spotlight);\n }\n\n window.VanduoSpotlight = Spotlight;\n\n})();\n", "/**\n * Vanduo Framework - Music Player Component\n * HTML5 Audio-based music player with transport controls, volume,\n * and optional shuffle, seek bar, and playlist features.\n *\n * Options (passed to MusicPlayer.init or data-music-player-options):\n * tracks {Array} - [{name, url}] \u2014 required\n * volume {number} - Initial volume 0\u20131 (default 0.5)\n * shuffle {boolean} - Shuffle on init (default false)\n * showProgress {boolean} - Show seek/progress bar (default false)\n * showPlaylist {boolean} - Show expandable playlist panel (default false)\n * autoAdvance {boolean} - Auto-play next track on end (default true)\n * glass {boolean} - Frosted-glass surface styling (default false)\n * detachable {boolean} - Show detach/attach; float above page when detached (default false)\n * floatingPosition {string|null} - After detach: 'bottom-left' or 'bottom-right' (default null = bottom-right)\n * draggable {boolean} - When detached+detachable, drag by handle (default false)\n * minimizable {boolean} - Minimize/expand control (default false)\n * startMinimized {boolean} - On first detach, start minimized (default false)\n * persistPosition {boolean} - Save floating x/y in localStorage (default false)\n * persistKey {string} - Storage key for persist (default from element id)\n *\n * Custom events (all bubble, dispatched on container):\n * musicplayer:play \u2014 playback started\n * musicplayer:pause \u2014 playback paused\n * musicplayer:trackchange \u2014 detail: { index, name, url }\n * musicplayer:volumechange \u2014 detail: { volume }\n * musicplayer:ended \u2014 last track ended (autoAdvance=false only)\n * musicplayer:detach \u2014 after floating player is created\n * musicplayer:attach \u2014 after returned to document flow\n * musicplayer:minimize \u2014 collapsed to minimal controls\n * musicplayer:expand \u2014 full controls visible again\n *\n * Programmatic API:\n * MusicPlayer.init(container?, options?)\n * MusicPlayer.play(container)\n * MusicPlayer.pause(container)\n * MusicPlayer.toggle(container)\n * MusicPlayer.next(container)\n * MusicPlayer.previous(container)\n * MusicPlayer.setVolume(container, value)\n * MusicPlayer.setTrack(container, index)\n * MusicPlayer.shuffle(container)\n * MusicPlayer.detach(container, position?)\n * MusicPlayer.attach(container)\n * MusicPlayer.minimize(container)\n * MusicPlayer.expand(container)\n * MusicPlayer.toggleMinimize(container)\n * MusicPlayer.setPosition(container, 'bottom-left'|'bottom-right'|{x,y})\n * MusicPlayer.getState(container)\n * MusicPlayer.destroy(container)\n * MusicPlayer.destroyAll()\n */\n\n(function () {\n 'use strict';\n\n /* \u2500\u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Fisher-Yates shuffle (returns new array).\n * @param {Array} arr\n * @returns {Array}\n */\n function shuffleArray(arr) {\n const shuffled = arr.slice();\n for (let i = shuffled.length - 1; i > 0; i--) {\n const j = Math.floor(Math.random() * (i + 1));\n const tmp = shuffled[i];\n shuffled[i] = shuffled[j];\n shuffled[j] = tmp;\n }\n return shuffled;\n }\n\n /**\n * Format seconds as m:ss.\n * @param {number} seconds\n * @returns {string}\n */\n function formatTime(seconds) {\n if (!isFinite(seconds) || seconds < 0) return '0:00';\n const m = Math.floor(seconds / 60);\n const s = Math.floor(seconds % 60);\n return m + ':' + (s < 10 ? '0' : '') + s;\n }\n\n /**\n * Set CSS background-size on a range input to visually fill the track.\n * @param {HTMLInputElement} input\n */\n /**\n * @param {string|undefined} id\n * @returns {string}\n */\n function persistStorageKey(id) {\n return 'vanduo:music-player:' + (id && id.trim() ? id.trim() : 'default') + ':pos';\n }\n\n function updateRangeFill(input) {\n const min = parseFloat(input.min) || 0;\n const max = parseFloat(input.max) || 1;\n const val = parseFloat(input.value) || 0;\n const pct = ((val - min) / (max - min)) * 100;\n input.style.setProperty('--fill', pct + '%');\n // Fallback inline gradient for browsers without ::-moz-range-progress\n input.style.backgroundImage =\n 'linear-gradient(to right, var(--music-player-track-fill, currentColor) 0%, ' +\n 'var(--music-player-track-fill, currentColor) ' + pct + '%, ' +\n 'var(--music-player-track-bg, #ccc) ' + pct + '%, ' +\n 'var(--music-player-track-bg, #ccc) 100%)';\n }\n\n /* \u2500\u2500\u2500 Phosphor icon helper (matches framework icon style) \u2500 */\n\n /**\n * Return an element (Phosphor icon).\n * @param {string} name\n * @returns {HTMLElement}\n */\n function icon(name) {\n const el = document.createElement('i');\n el.className = 'ph ph-' + name;\n el.setAttribute('aria-hidden', 'true');\n return el;\n }\n\n /* \u2500\u2500\u2500 Component \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const MusicPlayer = {\n /** @type {Map} */\n instances: new Map(),\n\n /**\n * Default options.\n */\n defaults: {\n tracks: [],\n volume: 0.5,\n shuffle: false,\n showProgress: false,\n showPlaylist: false,\n autoAdvance: true,\n glass: false,\n detachable: false,\n /** @type {null|string} */\n floatingPosition: null,\n draggable: false,\n minimizable: false,\n startMinimized: false,\n persistPosition: false,\n persistKey: '',\n },\n\n /**\n * Auto-initialize all .vd-music-player / [data-music-player] elements.\n * Options can be provided via data-music-player-options (JSON string).\n */\n init: function () {\n document.querySelectorAll('.vd-music-player, [data-music-player]').forEach((el) => {\n if (this.instances.has(el)) return;\n\n let opts = {};\n const attr = el.getAttribute('data-music-player-options');\n if (attr) {\n try { opts = JSON.parse(attr); } catch (_) { /* ignore malformed JSON */ }\n }\n this.initPlayer(el, opts);\n });\n },\n\n /**\n * Initialize a single player element.\n * @param {HTMLElement} container\n * @param {Object} [options]\n */\n initPlayer: function (container, options) {\n const opts = Object.assign({}, this.defaults, options || {});\n\n // Validate and normalise tracks\n const rawTracks = Array.isArray(opts.tracks) ? opts.tracks : [];\n const tracks = rawTracks.filter((t) => t && typeof t.url === 'string' && t.url.trim());\n\n // Build shuffled working copy without mutating opts\n const trackList = opts.shuffle ? shuffleArray(tracks) : tracks.slice();\n\n /* \u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const state = {\n tracks: trackList,\n originalTracks: tracks.slice(),\n currentIndex: 0,\n isPlaying: false,\n volume: Math.max(0, Math.min(1, opts.volume)),\n shuffle: opts.shuffle,\n showProgress: opts.showProgress,\n showPlaylist: opts.showPlaylist,\n autoAdvance: opts.autoAdvance,\n audio: null,\n glass: Boolean(opts.glass),\n detachable: Boolean(opts.detachable),\n floatingPosition: opts.floatingPosition || 'bottom-right',\n draggable: Boolean(opts.draggable) && Boolean(opts.detachable),\n minimizable: Boolean(opts.minimizable),\n startMinimized: Boolean(opts.startMinimized),\n persistPosition: Boolean(opts.persistPosition),\n persistKey: typeof opts.persistKey === 'string' ? opts.persistKey : '',\n isDetached: false,\n isMinimized: false,\n _startMinimizeApplied: false,\n };\n\n /* \u2500\u2500 Audio element \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const audio = new Audio();\n audio.volume = state.volume;\n audio.preload = 'metadata';\n state.audio = audio;\n\n /* \u2500\u2500 Build DOM \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n this._buildDOM(container, state);\n\n // Grab references after DOM build\n const refs = {\n btnPlay: container.querySelector('.vd-music-player-btn-play'),\n btnPrev: container.querySelector('.vd-music-player-btn-prev'),\n btnNext: container.querySelector('.vd-music-player-btn-next'),\n btnShuffle: container.querySelector('.vd-music-player-btn-shuffle'),\n btnPlaylist: container.querySelector('.vd-music-player-btn-playlist'),\n btnDetach: container.querySelector('.vd-music-player-btn-detach'),\n btnAttach: container.querySelector('.vd-music-player-btn-attach'),\n btnMinimize: container.querySelector('.vd-music-player-btn-minimize'),\n dragHandle: container.querySelector('.vd-music-player-drag-handle'),\n trackName: container.querySelector('.vd-music-player-track-name'),\n volumeSlider: container.querySelector('.vd-music-player-volume-slider'),\n volumeIcon: container.querySelector('.vd-music-player-volume-icon'),\n progressBar: container.querySelector('.vd-music-player-progress-bar'),\n timeElapsed: container.querySelector('.vd-music-player-time-elapsed'),\n timeDuration: container.querySelector('.vd-music-player-time-duration'),\n playlistPanel: container.querySelector('.vd-music-player-playlist'),\n };\n\n /* \u2500\u2500 Internal render helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const renderPlayIcon = () => {\n const btn = refs.btnPlay;\n if (!btn) return;\n btn.innerHTML = '';\n btn.appendChild(icon(state.isPlaying ? 'pause' : 'play'));\n btn.setAttribute('aria-label', state.isPlaying ? 'Pause' : 'Play');\n btn.classList.toggle('is-active', state.isPlaying);\n };\n\n const renderTrackName = () => {\n const el = refs.trackName;\n if (!el) return;\n const track = state.tracks[state.currentIndex];\n if (track) {\n el.textContent = track.name || 'Unknown Track';\n el.classList.remove('is-idle');\n } else {\n el.textContent = 'No tracks loaded';\n el.classList.add('is-idle');\n }\n };\n\n const renderVolumeIcon = () => {\n const el = refs.volumeIcon;\n if (!el) return;\n el.innerHTML = '';\n const v = state.volume;\n const name = v === 0 ? 'speaker-none' : v < 0.5 ? 'speaker-low' : 'speaker-high';\n el.appendChild(icon(name));\n };\n\n const renderShuffleBtn = () => {\n const btn = refs.btnShuffle;\n if (!btn) return;\n btn.classList.toggle('is-active', state.shuffle);\n btn.setAttribute('aria-pressed', state.shuffle ? 'true' : 'false');\n };\n\n const renderPlaylistItems = () => {\n const panel = refs.playlistPanel;\n if (!panel) return;\n panel.innerHTML = '';\n state.tracks.forEach((track, i) => {\n const item = document.createElement('button');\n item.className =\n 'vd-music-player-playlist-item' + (i === state.currentIndex ? ' is-active' : '');\n item.type = 'button';\n item.setAttribute('data-index', String(i));\n item.setAttribute('aria-current', i === state.currentIndex ? 'true' : 'false');\n\n const num = document.createElement('span');\n num.className = 'vd-music-player-playlist-num';\n num.textContent = String(i + 1);\n\n const name = document.createElement('span');\n name.className = 'vd-music-player-playlist-name';\n name.textContent = track.name || 'Track ' + (i + 1);\n\n item.appendChild(num);\n item.appendChild(name);\n panel.appendChild(item);\n });\n };\n\n const renderProgress = () => {\n const bar = refs.progressBar;\n if (!bar || !audio.duration) return;\n const pct = (audio.currentTime / audio.duration) * 100;\n bar.value = String(pct);\n updateRangeFill(bar);\n if (refs.timeElapsed) refs.timeElapsed.textContent = formatTime(audio.currentTime);\n if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);\n };\n\n /* \u2500\u2500 Load track \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const loadTrack = (index, autoPlay) => {\n const track = state.tracks[index];\n if (!track) return;\n state.currentIndex = index;\n audio.src = track.url;\n renderTrackName();\n renderPlaylistItems();\n\n // Reset progress\n if (refs.progressBar) {\n refs.progressBar.value = '0';\n updateRangeFill(refs.progressBar);\n }\n if (refs.timeElapsed) refs.timeElapsed.textContent = '0:00';\n if (refs.timeDuration) refs.timeDuration.textContent = '0:00';\n\n container.dispatchEvent(\n new CustomEvent('musicplayer:trackchange', {\n bubbles: true,\n detail: { index, name: track.name, url: track.url },\n })\n );\n\n if (autoPlay) {\n audio.play().catch(() => { /* browser may block autoplay */ });\n }\n };\n\n /* \u2500\u2500 Audio event listeners \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const cleanupFunctions = [];\n\n const onPlay = () => {\n state.isPlaying = true;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:play', { bubbles: true }));\n };\n\n const onPause = () => {\n state.isPlaying = false;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:pause', { bubbles: true }));\n };\n\n const onEnded = () => {\n if (state.autoAdvance && state.tracks.length > 1) {\n const next = (state.currentIndex + 1) % state.tracks.length;\n loadTrack(next, true);\n } else {\n state.isPlaying = false;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:ended', { bubbles: true }));\n }\n };\n\n const onTimeUpdate = () => {\n if (state.showProgress) renderProgress();\n };\n\n const onLoadedMetadata = () => {\n if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);\n if (refs.progressBar) {\n refs.progressBar.max = '100';\n updateRangeFill(refs.progressBar);\n }\n };\n\n audio.addEventListener('play', onPlay);\n audio.addEventListener('pause', onPause);\n audio.addEventListener('ended', onEnded);\n audio.addEventListener('timeupdate', onTimeUpdate);\n audio.addEventListener('loadedmetadata', onLoadedMetadata);\n cleanupFunctions.push(() => {\n audio.removeEventListener('play', onPlay);\n audio.removeEventListener('pause', onPause);\n audio.removeEventListener('ended', onEnded);\n audio.removeEventListener('timeupdate', onTimeUpdate);\n audio.removeEventListener('loadedmetadata', onLoadedMetadata);\n audio.pause();\n audio.src = '';\n });\n\n /* \u2500\u2500 Control button listeners \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n if (refs.btnPlay) {\n const handler = () => {\n if (!audio.src && state.tracks.length) loadTrack(state.currentIndex, false);\n if (state.isPlaying) {\n audio.pause();\n } else {\n audio.play().catch(() => {});\n }\n };\n refs.btnPlay.addEventListener('click', handler);\n cleanupFunctions.push(() => refs.btnPlay.removeEventListener('click', handler));\n\n // Keyboard: Space / Enter (already native for `;\n }\n\n // Generate neutral color swatches\n let neutralSwatches = '';\n for (const [key, value] of Object.entries(this.NEUTRAL_COLORS)) {\n neutralSwatches += ``;\n }\n\n // Generate radius buttons\n let radiusButtons = '';\n this.RADIUS_OPTIONS.forEach(r => {\n radiusButtons += ``;\n });\n\n // Generate font options\n let fontOptions = '';\n for (const [key, value] of Object.entries(this.FONT_OPTIONS)) {\n fontOptions += ``;\n }\n\n return `\n
    \n

    Customize Theme

    \n \n
    \n
    \n\n
    \n \n
    \n ${primarySwatches}\n
    \n
    \n
    \n \n
    \n ${neutralSwatches}\n
    \n
    \n
    \n \n
    \n ${radiusButtons}\n
    \n
    \n
    \n \n \n
    \n
    \n
    \n \n
    \n `;\n },\n\n /**\n * Bind event listeners\n */\n /**\n * Check whether the current primary color is one of the auto-defaults\n * (i.e. the user hasn't explicitly picked a non-default color).\n */\n isUsingDefaultPrimary: function () {\n return this.state.primary === this.DEFAULTS.PRIMARY_LIGHT ||\n this.state.primary === this.DEFAULTS.PRIMARY_DARK;\n },\n\n /**\n * When primary is still one of the auto-default palette keys (black/amber) but\n * localStorage was written under a different theme (or OS changed in system mode),\n * align in-memory state before applyAllPreferences runs \u2014 avoids amber+light / black+dark drift.\n */\n _normalizeDefaultPrimaryIfStaleWithStoredTheme: function () {\n if (!this.isUsingDefaultPrimary()) {\n return;\n }\n const expected = this.getDefaultPrimary(this.state.theme);\n if (this.state.primary !== expected) {\n this.state.primary = expected;\n }\n },\n\n bindEvents: function () {\n this.bindTriggerEvents();\n\n this.bindPanelEvents();\n\n // Listen for OS dark/light changes so we can swap the default primary\n if (window.matchMedia) {\n const mq = window.matchMedia('(prefers-color-scheme: dark)');\n const handler = () => {\n if (this.state.theme === 'system' && this.isUsingDefaultPrimary()) {\n const newDefault = this.getDefaultPrimary('system');\n if (newDefault !== this.state.primary) {\n this.applyPrimary(newDefault);\n this.updateUI();\n }\n }\n };\n mq.addEventListener('change', handler);\n this._cleanup.push(() => mq.removeEventListener('change', handler));\n }\n\n // Close on outside click\n this.addListener(document, 'click', (e) => {\n if (this.state.isOpen && this.elements.customizer && !this.elements.customizer.contains(e.target)) {\n this.close();\n }\n });\n\n // Close on Escape key\n this.addListener(document, 'keydown', (e) => {\n if (e.key === 'Escape' && this.state.isOpen) {\n this.close();\n }\n });\n },\n\n bindTriggerEvents: function () {\n this.elements.triggers.forEach((trigger) => {\n if (trigger.getAttribute('data-customizer-trigger-initialized') === 'true') {\n return;\n }\n this.addListener(trigger, 'click', (e) => {\n e.preventDefault();\n e.stopPropagation();\n this.elements.activeTrigger = trigger;\n this.elements.trigger = trigger;\n this.toggle();\n });\n trigger.setAttribute('data-customizer-trigger-initialized', 'true');\n });\n },\n\n /**\n * Toggle panel open/close\n */\n toggle: function () {\n if (this.state.isOpen) {\n this.close();\n } else {\n this.open();\n }\n },\n\n /**\n * Open the panel\n */\n open: function () {\n this.state.isOpen = true;\n\n // Ensure panel is positioned correctly before opening\n this.positionPanel();\n\n if (this.elements.panel) {\n this.elements.panel.classList.add('is-open');\n }\n this.elements.triggers.forEach((trigger) => trigger.setAttribute('aria-expanded', 'false'));\n if (this.elements.trigger) this.elements.trigger.setAttribute('aria-expanded', 'true');\n if (this.elements.overlay) {\n this.elements.overlay.classList.add('is-active');\n }\n\n this.dispatchEvent('panel-open', { isOpen: true });\n },\n\n /**\n * Close the panel\n */\n close: function () {\n this.state.isOpen = false;\n\n if (this.elements.panel) {\n this.elements.panel.classList.remove('is-open');\n }\n this.elements.triggers.forEach((trigger) => trigger.setAttribute('aria-expanded', 'false'));\n if (this.elements.overlay) {\n this.elements.overlay.classList.remove('is-active');\n }\n\n this.dispatchEvent('panel-close', { isOpen: false });\n },\n\n /**\n * Update UI to reflect current state\n */\n updateUI: function () {\n if (!this.elements.panel) return;\n\n // Update primary color swatches\n this.elements.panel.querySelectorAll('[data-color]').forEach(swatch => {\n swatch.classList.toggle('is-active', swatch.dataset.color === this.state.primary);\n });\n\n // Update neutral color swatches\n this.elements.panel.querySelectorAll('[data-neutral]').forEach(swatch => {\n swatch.classList.toggle('is-active', swatch.dataset.neutral === this.state.neutral);\n });\n\n // Update radius buttons\n this.elements.panel.querySelectorAll('[data-radius]').forEach(btn => {\n btn.classList.toggle('is-active', btn.dataset.radius === this.state.radius);\n });\n\n // Update font selector\n const fontSelect = this.elements.panel.querySelector('[data-customizer-font]');\n if (fontSelect) {\n fontSelect.value = this.state.font;\n }\n\n },\n\n /**\n * Reset all preferences to defaults\n */\n reset: function () {\n this.applyTheme(this.DEFAULTS.THEME);\n this.applyPrimary(this.getDefaultPrimary(this.DEFAULTS.THEME));\n this.applyNeutral(this.DEFAULTS.NEUTRAL);\n this.applyRadius(this.DEFAULTS.RADIUS);\n this.applyFont(this.DEFAULTS.FONT);\n this.updateUI();\n\n this.dispatchEvent('reset', { state: { ...this.state } });\n },\n\n /**\n * Get current state\n */\n getState: function () {\n return { ...this.state };\n },\n\n /**\n * Programmatically set preferences\n */\n setPreferences: function (prefs) {\n if (prefs.primary) this.applyPrimary(prefs.primary);\n if (prefs.neutral) this.applyNeutral(prefs.neutral);\n if (prefs.radius) this.applyRadius(prefs.radius);\n if (prefs.font) this.applyFont(prefs.font);\n if (prefs.theme) this.applyTheme(prefs.theme);\n this.updateUI();\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 destroyAll: function () {\n this._cleanup.forEach(fn => fn());\n this._cleanup = [];\n\n if (this.elements.panel) {\n this.elements.panel.removeAttribute('data-customizer-initialized');\n }\n\n this.close();\n this.isInitialized = false;\n }\n };\n\n // Register component\n if (window.Vanduo) {\n window.Vanduo.register('themeCustomizer', ThemeCustomizer);\n }\n\n // Expose globally for convenience\n window.ThemeCustomizer = ThemeCustomizer;\n})();\n", "/**\n * Vanduo Framework - Theme Switcher\n * Handles light/dark/system theme toggling and persistence\n */\n\n(function () {\n 'use strict';\n\n const ThemeSwitcher = {\n isInitialized: false,\n _mediaQuery: null,\n _onMediaChange: null,\n\n init: function () {\n this.STORAGE_KEY = 'vanduo-theme-preference';\n this.state = {\n preference: this.getPreference() // 'light', 'dark', or 'system'\n };\n\n if (this.isInitialized) {\n this.applyTheme();\n this.renderUI();\n this.updateUI();\n return;\n }\n\n this.isInitialized = true;\n\n this.applyTheme();\n this.listenForSystemChanges();\n this.renderUI();\n\n console.log('Vanduo Theme Switcher initialized');\n },\n\n getPreference: function () {\n return this.getStorageValue(this.STORAGE_KEY, 'system');\n },\n\n setPreference: function (pref) {\n this.state.preference = pref;\n this.setStorageValue(this.STORAGE_KEY, pref);\n this.applyTheme();\n \n // Coordinate with ThemeCustomizer for primary color swap if available\n // Check _isApplying flag to prevent circular updates\n if (window.ThemeCustomizer && window.ThemeCustomizer.applyTheme && !window.ThemeCustomizer._isApplying) {\n window.ThemeCustomizer.applyTheme(pref);\n }\n \n this.updateUI();\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 applyTheme: function () {\n const pref = this.state.preference;\n\n if (pref === 'system') {\n // When in system mode, we remove the data attribute to let the media query take over\n // or we can explicitly set it. Explicitly setting it ensures consistency if we rely on [data-theme]\n // But if we rely on @media in CSS, we might want to remove attributes.\n // However, our CSS strategy uses :root:not([data-theme=\"light\"]) inside media query for system dark fallback\n // which is a bit complex.\n\n // Simpler approach: \n // If preference is system, REMOVE data-theme attribute. Let CSS media queries handle it.\n document.documentElement.removeAttribute('data-theme');\n } else {\n document.documentElement.setAttribute('data-theme', pref);\n }\n },\n\n listenForSystemChanges: function () {\n if (this._mediaQuery && this._onMediaChange) {\n return;\n }\n\n this._mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n this._onMediaChange = _e => {\n if (this.state.preference === 'system') {\n // Re-apply (effectively just to ensure consistency, though removing attribute usually suffices)\n this.applyTheme();\n // Keep default primary (black/amber) aligned when OS scheme changes while in system mode\n if (window.ThemeCustomizer && typeof window.ThemeCustomizer.applyTheme === 'function' && !window.ThemeCustomizer._isApplying) {\n window.ThemeCustomizer.applyTheme('system');\n }\n }\n };\n this._mediaQuery.addEventListener('change', this._onMediaChange);\n },\n\n // Helper to facilitate UI creation if needed, though often UI is in HTML\n renderUI: function () {\n // Look for any uninitialized theme toggles\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"]');\n toggles.forEach(toggle => {\n if (toggle.getAttribute('data-theme-initialized') === 'true') {\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n }\n return;\n }\n\n // Simplified UI Binding - assumes a select or a button cycle\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n const onChange = (e) => {\n this.setPreference(e.target.value);\n };\n toggle.addEventListener('change', onChange);\n toggle._themeToggleHandler = onChange;\n } else {\n // Button implementation (cycle)\n const onClick = () => {\n const modes = ['system', 'light', 'dark'];\n const nextIndex = (modes.indexOf(this.state.preference) + 1) % modes.length;\n this.setPreference(modes[nextIndex]);\n };\n toggle.addEventListener('click', onClick);\n toggle._themeToggleHandler = onClick;\n }\n // Mark as initialized\n toggle.setAttribute('data-theme-initialized', 'true');\n });\n },\n\n updateUI: function () {\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"]');\n toggles.forEach(toggle => {\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n } else {\n // Update button text/icon if needed\n // e.g. toggle.textContent = this.state.preference;\n // For now, assume the user handles visual state or generic text\n\n // If there is an icon or text span inside, update it\n const span = toggle.querySelector('.theme-current-label');\n if (span) {\n span.textContent = this.state.preference.charAt(0).toUpperCase() + this.state.preference.slice(1);\n }\n }\n });\n },\n\n destroyAll: function () {\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"][data-theme-initialized=\"true\"]');\n toggles.forEach(toggle => {\n if (toggle._themeToggleHandler) {\n const eventName = toggle.tagName === 'SELECT' ? 'change' : 'click';\n toggle.removeEventListener(eventName, toggle._themeToggleHandler);\n delete toggle._themeToggleHandler;\n }\n toggle.removeAttribute('data-theme-initialized');\n });\n\n if (this._mediaQuery && this._onMediaChange) {\n this._mediaQuery.removeEventListener('change', this._onMediaChange);\n }\n\n this._mediaQuery = null;\n this._onMediaChange = null;\n this.isInitialized = false;\n }\n };\n\n // Register component\n if (window.Vanduo) {\n window.Vanduo.register('themeSwitcher', ThemeSwitcher);\n }\n})();\n", "/**\n * Vanduo Framework - Toast Component\n * Popup notifications for user feedback\n */\n\n(function() {\n 'use strict';\n\n /**\n * Toast Component\n */\n const Toast = {\n // Default options\n defaults: {\n position: 'top-right',\n duration: 5000,\n dismissible: true,\n showProgress: true,\n pauseOnHover: true\n },\n\n // Container cache\n containers: {},\n\n /**\n * Get or create a toast container for a position\n * @param {string} position - Container position\n * @returns {HTMLElement} Toast container element\n */\n getContainer: function(position) {\n if (this.containers[position]) {\n return this.containers[position];\n }\n\n const container = document.createElement('div');\n container.className = `vd-toast-container vd-toast-container-${position}`;\n container.setAttribute('role', 'status');\n container.setAttribute('aria-live', 'polite');\n container.setAttribute('aria-atomic', 'false');\n document.body.appendChild(container);\n this.containers[position] = container;\n\n return container;\n },\n\n /**\n * Show a toast notification\n * @param {Object|string} options - Toast options or message string\n * @param {string} [type] - Toast type (success, error, warning, info)\n * @param {number} [duration] - Auto-dismiss duration in ms\n * @returns {HTMLElement} Toast element\n */\n show: function(options, type, duration) {\n // Support simple API:\n // - Toast.show('Message', 'success', 3000)\n // - Toast.show('Message', { type: 'success', duration: 3000 })\n if (typeof options === 'string') {\n if (type && typeof type === 'object') {\n options = Object.assign({}, type, {\n message: options\n });\n } else {\n options = {\n message: options,\n type: type,\n duration: duration\n };\n }\n }\n\n const config = Object.assign({}, this.defaults, options);\n const container = this.getContainer(config.position);\n\n // Create toast element\n const toast = document.createElement('div');\n toast.className = 'vd-toast';\n\n if (config.type) {\n toast.classList.add(`vd-toast-${config.type}`);\n }\n\n if (config.solid) {\n toast.classList.add('vd-toast-solid');\n }\n\n if (config.showProgress && config.duration > 0) {\n toast.classList.add('vd-toast-with-progress');\n }\n\n // Build toast content\n let html = '';\n\n // Icon (sanitize custom icons, default icons are trusted SVG)\n if (config.icon) {\n const allowSvg = config.iconAllowSvg === true;\n const safeIcon = typeof sanitizeHtml === 'function'\n ? sanitizeHtml(config.icon, { allowSvg })\n : escapeHtml(config.icon);\n html += `${safeIcon}`;\n } else if (config.type) {\n html += `${this.getDefaultIcon(config.type)}`;\n }\n\n // Local escape helper \u2014 guarantees HTML-safe output even if the\n // global escapeHtml utility is not loaded in the current bundle.\n const _esc = typeof escapeHtml === 'function'\n ? escapeHtml\n : function (s) {\n const d = document.createElement('div');\n d.appendChild(document.createTextNode(s));\n return d.innerHTML;\n };\n\n // Content (escape text to prevent injection)\n html += '
    ';\n if (config.title) {\n html += `
    ${_esc(String(config.title))}
    `;\n }\n if (config.message) {\n html += `
    ${_esc(String(config.message))}
    `;\n }\n html += '
    ';\n\n // Close button\n if (config.dismissible) {\n html += '';\n }\n\n // Progress bar\n if (config.showProgress && config.duration > 0) {\n const safeDuration = parseInt(config.duration, 10) || 0;\n html += `
    `;\n }\n\n toast.innerHTML = html;\n\n // Add to container\n container.appendChild(toast);\n\n toast._toastCleanup = [];\n\n // Set up close button handler\n if (config.dismissible) {\n const closeBtn = toast.querySelector('.vd-toast-close');\n const onClose = () => {\n this.dismiss(toast);\n };\n closeBtn.addEventListener('click', onClose);\n toast._toastCleanup.push(() => closeBtn.removeEventListener('click', onClose));\n }\n\n // Pause on hover\n let timeoutId = null;\n let remainingTime = config.duration;\n let startTime = null;\n\n const startTimer = () => {\n if (config.duration > 0) {\n startTime = Date.now();\n timeoutId = setTimeout(() => {\n this.dismiss(toast);\n }, remainingTime);\n toast._toastTimeoutId = timeoutId;\n\n // Resume progress animation\n const progress = toast.querySelector('.vd-toast-progress');\n if (progress) {\n progress.style.animationPlayState = 'running';\n }\n }\n };\n\n const pauseTimer = () => {\n if (timeoutId) {\n clearTimeout(timeoutId);\n timeoutId = null;\n toast._toastTimeoutId = null;\n remainingTime -= Date.now() - startTime;\n\n // Pause progress animation\n const progress = toast.querySelector('.vd-toast-progress');\n if (progress) {\n progress.style.animationPlayState = 'paused';\n }\n }\n };\n\n if (config.pauseOnHover) {\n toast.addEventListener('mouseenter', pauseTimer);\n toast.addEventListener('mouseleave', startTimer);\n toast._toastCleanup.push(\n () => toast.removeEventListener('mouseenter', pauseTimer),\n () => toast.removeEventListener('mouseleave', startTimer)\n );\n }\n\n // Trigger enter animation\n requestAnimationFrame(() => {\n toast.classList.add('is-visible');\n startTimer();\n });\n\n // Store config on element for later access\n toast._toastConfig = config;\n\n // Dispatch show event\n const showEvent = new CustomEvent('toast:show', {\n bubbles: true,\n detail: { toast, config }\n });\n toast.dispatchEvent(showEvent);\n\n return toast;\n },\n\n /**\n * Dismiss a toast\n * @param {HTMLElement} toast - Toast element to dismiss\n */\n dismiss: function(toast) {\n if (!toast || toast.classList.contains('is-exiting')) return;\n\n if (toast._toastTimeoutId) {\n clearTimeout(toast._toastTimeoutId);\n toast._toastTimeoutId = null;\n }\n\n toast.classList.remove('is-visible');\n toast.classList.add('is-exiting');\n\n // Dispatch dismiss event\n const dismissEvent = new CustomEvent('toast:dismiss', {\n bubbles: true,\n detail: { toast }\n });\n toast.dispatchEvent(dismissEvent);\n\n // Remove after animation\n const handleTransitionEnd = () => {\n toast.removeEventListener('transitionend', handleTransitionEnd);\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n };\n\n toast.addEventListener('transitionend', handleTransitionEnd);\n\n // Fallback removal if transition doesn't fire\n setTimeout(() => {\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n }, 400);\n },\n\n /**\n * Destroy all toasts and containers\n */\n destroyAll: function() {\n Object.keys(this.containers).forEach(position => {\n const container = this.containers[position];\n if (!container) return;\n\n const toasts = container.querySelectorAll('.vd-toast');\n toasts.forEach(toast => {\n if (toast._toastTimeoutId) {\n clearTimeout(toast._toastTimeoutId);\n }\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n });\n\n if (container.parentElement) {\n container.parentElement.removeChild(container);\n }\n });\n\n this.containers = {};\n },\n\n /**\n * Dismiss all toasts\n * @param {string} [position] - Optional position to clear (clears all if not specified)\n */\n dismissAll: function(position) {\n if (position && this.containers[position]) {\n const toasts = this.containers[position].querySelectorAll('.vd-toast');\n toasts.forEach(toast => this.dismiss(toast));\n } else {\n Object.values(this.containers).forEach(container => {\n const toasts = container.querySelectorAll('.vd-toast');\n toasts.forEach(toast => this.dismiss(toast));\n });\n }\n },\n\n /**\n * Get default icon SVG for a type\n * @param {string} type - Toast type\n * @returns {string} SVG icon markup\n */\n getDefaultIcon: function(type) {\n const icons = {\n success: '',\n error: '',\n warning: '',\n info: ''\n };\n\n return icons[type] || '';\n },\n\n /**\n * Convenience methods for common toast types\n */\n success: function(message, options) {\n return this.show(Object.assign({ message, type: 'success' }, options));\n },\n\n error: function(message, options) {\n return this.show(Object.assign({ message, type: 'error' }, options));\n },\n\n warning: function(message, options) {\n return this.show(Object.assign({ message, type: 'warning' }, options));\n },\n\n info: function(message, options) {\n return this.show(Object.assign({ message, type: 'info' }, options));\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('toast', Toast);\n }\n\n // Also expose globally for convenience\n window.Toast = Toast;\n\n})();\n", "/**\n * Vanduo Framework - Tooltips Component\n * JavaScript functionality for tooltips\n */\n\n(function () {\n 'use strict';\n\n /**\n * Tooltips Component\n */\n const Tooltips = {\n tooltips: new Map(),\n delayTimers: new Map(),\n\n /**\n * Sanitize HTML \u2014 delegates to shared sanitizeHtml from helpers.js\n * @param {string} input\n * @param {{ allowSvg?: boolean }} [options]\n * @returns {string} sanitized HTML\n */\n sanitizeHtml: function (input, options) {\n if (typeof sanitizeHtml === 'function') {\n return sanitizeHtml(input, options);\n }\n // Fallback: content originates from developer-authored data attributes,\n // so returning it as-is is no worse than any other inline HTML.\n return input || '';\n },\n\n /**\n * Initialize tooltips\n */\n init: function () {\n const elements = document.querySelectorAll('[data-tooltip], [data-tooltip-html]');\n\n elements.forEach(element => {\n if (this.tooltips.has(element)) {\n return;\n }\n this.initTooltip(element);\n });\n },\n\n /**\n * Initialize a single tooltip\n * @param {HTMLElement} element - Element with tooltip\n */\n initTooltip: function (element) {\n const tooltip = this.createTooltip(element);\n const cleanupFunctions = [];\n\n // Show on hover/focus\n const enterHandler = () => { this.showTooltip(element, tooltip); };\n const leaveHandler = () => { this.hideTooltip(element, tooltip); };\n const focusHandler = () => { this.showTooltip(element, tooltip); };\n const blurHandler = () => { this.hideTooltip(element, tooltip); };\n\n element.addEventListener('mouseenter', enterHandler);\n element.addEventListener('mouseleave', leaveHandler);\n element.addEventListener('focus', focusHandler);\n element.addEventListener('blur', blurHandler);\n\n cleanupFunctions.push(\n () => element.removeEventListener('mouseenter', enterHandler),\n () => element.removeEventListener('mouseleave', leaveHandler),\n () => element.removeEventListener('focus', focusHandler),\n () => element.removeEventListener('blur', blurHandler)\n );\n\n this.tooltips.set(element, { tooltip, cleanup: cleanupFunctions });\n },\n\n /**\n * Create tooltip element\n * @param {HTMLElement} element - Target element\n * @returns {HTMLElement} Tooltip element\n */\n createTooltip: function (element) {\n const tooltip = document.createElement('div');\n tooltip.className = 'vd-tooltip';\n tooltip.setAttribute('role', 'tooltip');\n tooltip.setAttribute('aria-hidden', 'true');\n\n // Generate unique ID and link via aria-describedby\n const tooltipId = 'tooltip-' + Math.random().toString(36).substr(2, 9);\n tooltip.id = tooltipId;\n element.setAttribute('aria-describedby', tooltipId);\n\n // Get content\n const htmlContent = element.dataset.tooltipHtml;\n const textContent = element.dataset.tooltip;\n\n if (htmlContent) {\n const allowSvg = element.hasAttribute('data-tooltip-allow-svg');\n tooltip.innerHTML = this.sanitizeHtml(htmlContent, { allowSvg });\n tooltip.classList.add('vd-tooltip-html');\n } else if (textContent) {\n tooltip.textContent = textContent;\n }\n\n // Get placement\n const placement = element.dataset.tooltipPlacement || element.dataset.placement || 'top';\n tooltip.setAttribute('data-placement', placement);\n tooltip.classList.add(`vd-tooltip-${placement}`);\n\n // Get variant\n if (element.dataset.tooltipVariant) {\n tooltip.classList.add(`vd-tooltip-${element.dataset.tooltipVariant}`);\n }\n\n // Get size\n if (element.dataset.tooltipSize) {\n tooltip.classList.add(`vd-tooltip-${element.dataset.tooltipSize}`);\n }\n\n // Get delay\n const delay = parseInt(element.dataset.tooltipDelay) || 0;\n tooltip.dataset.delay = delay;\n\n document.body.appendChild(tooltip);\n\n return tooltip;\n },\n\n /**\n * Show tooltip\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n showTooltip: function (element, tooltip) {\n const delay = parseInt(tooltip.dataset.delay) || 0;\n\n if (delay > 0) {\n const timer = setTimeout(() => {\n this.positionTooltip(element, tooltip);\n tooltip.classList.add('is-visible');\n tooltip.setAttribute('aria-hidden', 'false');\n }, delay);\n this.delayTimers.set(element, timer);\n } else {\n this.positionTooltip(element, tooltip);\n tooltip.classList.add('is-visible');\n tooltip.setAttribute('aria-hidden', 'false');\n }\n },\n\n /**\n * Hide tooltip\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n hideTooltip: function (element, tooltip) {\n // Clear delay timer if exists\n const timer = this.delayTimers.get(element);\n if (timer) {\n clearTimeout(timer);\n this.delayTimers.delete(element);\n }\n\n tooltip.classList.remove('is-visible');\n tooltip.setAttribute('aria-hidden', 'true');\n },\n\n /**\n * Position tooltip relative to element\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n positionTooltip: function (element, tooltip) {\n const placement = tooltip.dataset.placement || 'top';\n const rect = element.getBoundingClientRect();\n const tooltipRect = tooltip.getBoundingClientRect();\n const scrollTop = window.pageYOffset || document.documentElement.scrollTop;\n const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;\n\n let top = 0;\n let left = 0;\n\n switch (placement) {\n case 'top':\n top = rect.top + scrollTop - tooltipRect.height - 8;\n left = rect.left + scrollLeft + (rect.width / 2) - (tooltipRect.width / 2);\n break;\n case 'bottom':\n top = rect.bottom + scrollTop + 8;\n left = rect.left + scrollLeft + (rect.width / 2) - (tooltipRect.width / 2);\n break;\n case 'left':\n top = rect.top + scrollTop + (rect.height / 2) - (tooltipRect.height / 2);\n left = rect.left + scrollLeft - tooltipRect.width - 8;\n break;\n case 'right':\n top = rect.top + scrollTop + (rect.height / 2) - (tooltipRect.height / 2);\n left = rect.right + scrollLeft + 8;\n break;\n }\n\n // Prevent overflow\n const viewportWidth = window.innerWidth;\n const viewportHeight = window.innerHeight;\n const padding = 8;\n\n if (left < padding) {\n left = padding;\n } else if (left + tooltipRect.width > viewportWidth - padding) {\n left = viewportWidth - tooltipRect.width - padding;\n }\n\n if (top < scrollTop + padding) {\n top = scrollTop + padding;\n } else if (top + tooltipRect.height > scrollTop + viewportHeight - padding) {\n top = scrollTop + viewportHeight - tooltipRect.height - padding;\n }\n\n // Use single style assignment with transform for better performance\n tooltip.style.cssText = `position: absolute; top: 0; left: 0; transform: translate(${left}px, ${top}px);`;\n },\n\n /**\n * Show tooltip programmatically\n * @param {HTMLElement|string} element - Target element or selector\n */\n show: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n this.showTooltip(el, tooltip);\n }\n },\n\n /**\n * Hide tooltip programmatically\n * @param {HTMLElement|string} element - Target element or selector\n */\n hide: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n this.hideTooltip(el, tooltip);\n }\n },\n\n /**\n * Update tooltip content\n * @param {HTMLElement|string} element - Target element or selector\n * @param {string} content - New content\n * @param {boolean} isHtml - Whether content is HTML\n */\n update: function (element, content, isHtml = false) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n if (isHtml) {\n const allowSvg = el.hasAttribute('data-tooltip-allow-svg');\n tooltip.innerHTML = this.sanitizeHtml(content, { allowSvg });\n tooltip.classList.add('vd-tooltip-html');\n } else {\n tooltip.textContent = content;\n tooltip.classList.remove('vd-tooltip-html');\n }\n }\n },\n\n /**\n * Destroy a tooltip instance and clean up\n * @param {HTMLElement} element - Element with tooltip\n */\n destroy: function (element) {\n const data = this.tooltips.get(element);\n if (!data) return;\n\n // Clear any pending timer\n const timer = this.delayTimers.get(element);\n if (timer) {\n clearTimeout(timer);\n this.delayTimers.delete(element);\n }\n\n data.cleanup.forEach(fn => fn());\n\n // Remove tooltip element from DOM\n if (data.tooltip && data.tooltip.parentNode) {\n data.tooltip.parentNode.removeChild(data.tooltip);\n }\n\n this.tooltips.delete(element);\n },\n\n /**\n * Destroy all tooltip instances\n */\n destroyAll: function () {\n this.tooltips.forEach((data, element) => {\n this.destroy(element);\n });\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('tooltips', Tooltips);\n }\n\n // Expose globally\n window.VanduoTooltips = Tooltips;\n\n})();\n", "/**\n * Vanduo Framework - Search Component\n * Client-side search functionality for content pages\n * \n * @example Basic usage (initialize with defaults)\n * // HTML:\n * //
    \n * // \n * //
    \n * //
    \n * \n * @example Custom configuration\n * var search = Search.create({\n * containerSelector: '.my-search',\n * contentSelector: 'article[id]',\n * titleSelector: 'h2, h3',\n * maxResults: 5,\n * onSelect: function(result) {\n * console.log('Selected:', result.title);\n * }\n * });\n * \n * @example With custom data source\n * var search = Search.create({\n * containerSelector: '.my-search',\n * data: [\n * { id: 'item1', title: 'First Item', content: 'Description...', category: 'Category A' },\n * { id: 'item2', title: 'Second Item', content: 'Description...', category: 'Category B' }\n * ]\n * });\n */\n\n(function() {\n 'use strict';\n\n /**\n * Default configuration\n */\n const DEFAULTS = {\n // Behavior\n minQueryLength: 2,\n maxResults: 10,\n debounceMs: 150,\n highlightTag: 'mark',\n keyboardShortcut: true, // Enable Cmd/Ctrl+K shortcut\n \n // Selectors (for DOM-based indexing)\n containerSelector: '.vd-doc-search',\n inputSelector: '.vd-doc-search-input',\n resultsSelector: '.vd-doc-search-results',\n contentSelector: '.doc-content section[id]',\n titleSelector: '.demo-title, h2, h3',\n navSelector: '.doc-nav-link',\n sectionSelector: '.doc-nav-section',\n \n // Content extraction\n excludeFromContent: 'pre, code, script, style',\n maxContentLength: 500,\n \n // Custom data source (alternative to DOM indexing)\n data: null,\n \n // Category icons mapping\n categoryIcons: {\n 'getting-started': 'ph-rocket-launch',\n 'core': 'ph-cube',\n 'components': 'ph-puzzle-piece',\n 'interactive': 'ph-cursor-click',\n 'data-display': 'ph-table',\n 'feedback': 'ph-bell',\n 'meta': 'ph-info',\n 'default': 'ph-file-text'\n },\n \n // Callbacks\n onSelect: null, // function(result) - called when result is selected\n onSearch: null, // function(query, results) - called after search\n onOpen: null, // function() - called when results open\n onClose: null, // function() - called when results close\n \n // Text customization\n emptyTitle: 'No results found',\n emptyText: 'Try different keywords or check spelling',\n placeholder: 'Search...'\n };\n\n /**\n * Search Component Factory\n * Creates a new search instance with the given configuration\n * \n * @param {Object} options - Configuration options\n * @returns {Object} Search instance\n */\n function createSearch(options) {\n const config = Object.assign({}, DEFAULTS, options || {});\n \n // Instance state\n const state = {\n initialized: false,\n index: [],\n results: [],\n activeIndex: -1,\n isOpen: false,\n query: '',\n container: null,\n input: null,\n resultsContainer: null,\n debounceTimer: null,\n boundHandlers: {}\n };\n\n function safeInvokeCallback(name, fn, ...args) {\n try {\n fn(...args);\n } catch (error) {\n console.warn('[Vanduo Search] Callback error in \"' + name + '\":', error);\n }\n }\n\n function setResultsHtml(html) {\n if (!state.resultsContainer) return;\n try {\n state.resultsContainer.innerHTML = html;\n } catch (error) {\n console.warn('[Vanduo Search] Failed to render results:', error);\n }\n }\n\n /**\n * Initialize the search component\n * Idempotent \u2014 safe to call more than once on the same instance.\n * Returns the instance on success, null if required DOM elements are missing.\n */\n function init() {\n if (state.initialized) {\n return instance;\n }\n\n state.container = document.querySelector(config.containerSelector);\n if (!state.container) {\n state.initialized = false;\n return null;\n }\n\n state.input = state.container.querySelector(config.inputSelector);\n state.resultsContainer = state.container.querySelector(config.resultsSelector);\n\n if (!state.input || !state.resultsContainer) {\n state.initialized = false;\n return null;\n }\n\n // Set placeholder if configured\n if (config.placeholder) {\n state.input.setAttribute('placeholder', config.placeholder);\n }\n\n // Build search index\n buildIndex();\n\n // Bind events\n bindEvents();\n\n // Set up ARIA attributes\n setupAria();\n\n state.initialized = true;\n return instance;\n }\n\n /**\n * Build search index from DOM or custom data\n */\n function buildIndex() {\n state.index = [];\n \n // Use custom data if provided\n if (config.data && Array.isArray(config.data)) {\n config.data.forEach(function(item) {\n state.index.push({\n id: item.id || slugify(item.title),\n title: item.title || '',\n category: item.category || '',\n categorySlug: slugify(item.category || ''),\n content: item.content || '',\n keywords: item.keywords || extractKeywordsFromText(item.title + ' ' + item.content),\n url: item.url || '#' + (item.id || slugify(item.title)),\n icon: item.icon || ''\n });\n });\n return;\n }\n\n // Build from DOM\n const sections = document.querySelectorAll(config.contentSelector);\n const categoryMap = buildCategoryMap();\n\n sections.forEach(function(section) {\n const id = section.id;\n if (!id) return;\n\n const titleEl = section.querySelector(config.titleSelector);\n const title = titleEl ? titleEl.textContent.replace(/v[\\d.]+/g, '').trim() : id;\n const category = categoryMap[id] || 'Documentation';\n const content = extractContent(section);\n const keywords = extractKeywords(section, title);\n const iconEl = titleEl ? titleEl.querySelector('i.ph') : null;\n let icon = '';\n if (iconEl && iconEl.classList) {\n for (let ci = 0; ci < iconEl.classList.length; ci++) {\n if (iconEl.classList[ci].indexOf('ph-') === 0) {\n icon = iconEl.classList[ci];\n break;\n }\n }\n }\n\n state.index.push({\n id: id,\n title: title,\n category: category,\n categorySlug: slugify(category),\n content: content,\n keywords: keywords,\n url: '#' + id,\n icon: icon\n });\n });\n }\n\n /**\n * Build a map of section IDs to their categories\n */\n function buildCategoryMap() {\n const map = {};\n let currentCategory = 'Documentation';\n const navItems = document.querySelectorAll(config.navSelector + ', ' + config.sectionSelector);\n\n navItems.forEach(function(item) {\n if (item.classList.contains('doc-nav-section')) {\n currentCategory = item.textContent.trim();\n } else {\n const href = item.getAttribute('href');\n if (href && href.startsWith('#')) {\n const id = href.substring(1);\n map[id] = currentCategory;\n }\n }\n });\n\n return map;\n }\n\n /**\n * Extract searchable content from a section\n */\n function extractContent(section) {\n const clone = section.cloneNode(true);\n \n const toRemove = clone.querySelectorAll(config.excludeFromContent);\n toRemove.forEach(function(el) {\n el.remove();\n });\n\n let text = clone.textContent || '';\n text = text.replace(/\\s+/g, ' ').trim();\n \n return text.substring(0, config.maxContentLength);\n }\n\n /**\n * Extract keywords from a section\n */\n function extractKeywords(section, title) {\n const keywords = [];\n \n // Add title words\n title.toLowerCase().split(/\\s+/).forEach(function(word) {\n if (word.length > 2) {\n keywords.push(word);\n }\n });\n\n // Add class names from code examples\n const codeBlocks = section.querySelectorAll('code');\n codeBlocks.forEach(function(code) {\n const text = code.textContent || '';\n const classMatches = text.match(/\\.([\\w-]+)/g);\n if (classMatches) {\n classMatches.forEach(function(match) {\n keywords.push(match.substring(1).toLowerCase());\n });\n }\n });\n\n // Add data attributes\n const dataAttrs = section.querySelectorAll('[data-tooltip], [data-modal]');\n dataAttrs.forEach(function(el) {\n const attrs = el.getAttributeNames().filter(function(name) {\n return name.startsWith('data-');\n });\n attrs.forEach(function(attr) {\n keywords.push(attr.replace('data-', ''));\n });\n });\n\n return Array.from(new Set(keywords));\n }\n\n /**\n * Extract keywords from text string\n */\n function extractKeywordsFromText(text) {\n const words = text.toLowerCase().split(/\\s+/);\n return words.filter(function(word) {\n return word.length > 2;\n });\n }\n\n /**\n * Convert string to slug\n */\n function slugify(str) {\n return str.toLowerCase().replace(/\\s+/g, '-').replace(/[^a-z0-9-]/g, '');\n }\n\n /**\n * Bind event listeners\n */\n function bindEvents() {\n // Store bound handlers for cleanup\n state.boundHandlers.handleInput = function(e) {\n handleInput(e);\n };\n state.boundHandlers.handleFocus = function() {\n if (state.query.length >= config.minQueryLength) {\n open();\n }\n };\n state.boundHandlers.handleKeydown = function(e) {\n handleKeydown(e);\n };\n state.boundHandlers.handleOutsideClick = function(e) {\n if (!state.container.contains(e.target)) {\n close();\n }\n };\n state.boundHandlers.handleGlobalKeydown = function(e) {\n if (config.keyboardShortcut && (e.metaKey || e.ctrlKey) && e.key === 'k') {\n e.preventDefault();\n state.input.focus();\n state.input.select();\n }\n };\n state.boundHandlers.handleResultClick = function(e) {\n const result = e.target.closest('.vd-doc-search-result');\n if (result) {\n const index = parseInt(result.dataset.index, 10);\n select(index);\n }\n };\n\n // Input events\n state.input.addEventListener('input', state.boundHandlers.handleInput);\n state.input.addEventListener('focus', state.boundHandlers.handleFocus);\n state.input.addEventListener('keydown', state.boundHandlers.handleKeydown);\n\n // Close on outside click\n document.addEventListener('click', state.boundHandlers.handleOutsideClick);\n\n // Global keyboard shortcut\n document.addEventListener('keydown', state.boundHandlers.handleGlobalKeydown);\n\n // Result click handling\n state.resultsContainer.addEventListener('click', state.boundHandlers.handleResultClick);\n }\n\n /**\n * Unbind event listeners\n */\n function unbindEvents() {\n if (state.input) {\n state.input.removeEventListener('input', state.boundHandlers.handleInput);\n state.input.removeEventListener('focus', state.boundHandlers.handleFocus);\n state.input.removeEventListener('keydown', state.boundHandlers.handleKeydown);\n }\n document.removeEventListener('click', state.boundHandlers.handleOutsideClick);\n document.removeEventListener('keydown', state.boundHandlers.handleGlobalKeydown);\n if (state.resultsContainer) {\n state.resultsContainer.removeEventListener('click', state.boundHandlers.handleResultClick);\n }\n }\n\n /**\n * Set up ARIA attributes\n */\n function setupAria() {\n const resultsId = state.resultsContainer.id || 'search-results-' + Math.random().toString(36).substr(2, 9);\n state.resultsContainer.id = resultsId;\n \n state.input.setAttribute('role', 'combobox');\n state.input.setAttribute('aria-autocomplete', 'list');\n state.input.setAttribute('aria-controls', resultsId);\n state.input.setAttribute('aria-expanded', 'false');\n \n state.resultsContainer.setAttribute('role', 'listbox');\n state.resultsContainer.setAttribute('aria-label', 'Search results');\n }\n\n /**\n * Handle input changes\n */\n function handleInput(e) {\n const query = e.target.value.trim();\n\n if (state.debounceTimer) {\n clearTimeout(state.debounceTimer);\n }\n\n state.debounceTimer = setTimeout(function() {\n state.query = query;\n\n if (query.length < config.minQueryLength) {\n close();\n return;\n }\n\n state.results = search(query);\n state.activeIndex = -1;\n render();\n open();\n\n // Callback\n if (typeof config.onSearch === 'function') {\n safeInvokeCallback('onSearch', config.onSearch, query, state.results);\n }\n }, config.debounceMs);\n }\n\n /**\n * Handle keyboard navigation\n */\n function handleKeydown(e) {\n if (!state.isOpen) {\n if (e.key === 'ArrowDown' && state.query.length >= config.minQueryLength) {\n e.preventDefault();\n state.results = search(state.query);\n render();\n open();\n }\n return;\n }\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n navigate(1);\n break;\n\n case 'ArrowUp':\n e.preventDefault();\n navigate(-1);\n break;\n\n case 'Enter':\n e.preventDefault();\n if (state.activeIndex >= 0) {\n select(state.activeIndex);\n } else if (state.results.length > 0) {\n select(0);\n }\n break;\n\n case 'Escape':\n e.preventDefault();\n close();\n break;\n\n case 'Tab':\n close();\n break;\n }\n }\n\n /**\n * Perform search\n */\n function search(query) {\n const terms = query.toLowerCase().split(/\\s+/).filter(function(t) {\n return t.length > 0;\n });\n const scored = [];\n\n state.index.forEach(function(entry) {\n let score = 0;\n const titleLower = entry.title.toLowerCase();\n const categoryLower = entry.category.toLowerCase();\n const contentLower = entry.content.toLowerCase();\n\n terms.forEach(function(term) {\n // Title match - highest priority\n if (titleLower.includes(term)) {\n score += 100;\n if (titleLower === term) {\n score += 50;\n } else if (titleLower.startsWith(term)) {\n score += 25;\n }\n }\n\n // Category match\n if (categoryLower.includes(term)) {\n score += 50;\n }\n\n // Keyword match\n const keywordMatch = entry.keywords.some(function(k) {\n return k.includes(term);\n });\n if (keywordMatch) {\n score += 30;\n }\n\n // Content match\n if (contentLower.includes(term)) {\n score += 10;\n }\n });\n\n if (score > 0) {\n scored.push({\n id: entry.id,\n title: entry.title,\n category: entry.category,\n categorySlug: entry.categorySlug,\n content: entry.content,\n url: entry.url,\n icon: entry.icon,\n score: score\n });\n }\n });\n\n scored.sort(function(a, b) {\n return b.score - a.score;\n });\n\n return scored.slice(0, config.maxResults);\n }\n\n /**\n * Render search results\n */\n function render() {\n if (state.results.length === 0) {\n setResultsHtml(renderEmpty());\n return;\n }\n\n let html = '
      ';\n\n state.results.forEach(function(result, index) {\n const isActive = index === state.activeIndex;\n const icon = result.icon || getCategoryIcon(result.categorySlug);\n const excerpt = getExcerpt(result.content, state.query);\n\n html += '
    • ' +\n '
      ' +\n '' +\n '
      ' +\n '
      ' +\n '
      ' + highlight(result.title, state.query) + '
      ' +\n '
      ' + escapeHtml(result.category) + '
      ' +\n '
      ' + highlight(excerpt, state.query) + '
      ' +\n '
      ' +\n '
    • ';\n });\n\n html += '
    ';\n html += renderFooter();\n\n setResultsHtml(html);\n }\n\n /**\n * Render empty state\n */\n function renderEmpty() {\n return '
    ' +\n '
    ' +\n '
    ' + escapeHtml(config.emptyTitle) + '
    ' +\n '
    ' + escapeHtml(config.emptyText) + '
    ' +\n '
    ';\n }\n\n /**\n * Render footer with keyboard hints\n */\n function renderFooter() {\n return '
    ' +\n '\u2191\u2193 to navigate' +\n '\u21B5 to select' +\n 'esc to close' +\n '
    ';\n }\n\n /**\n * Get icon for category\n */\n function getCategoryIcon(categorySlug) {\n return config.categoryIcons[categorySlug] || config.categoryIcons['default'] || 'ph-file-text';\n }\n\n /**\n * Get excerpt from content\n */\n function getExcerpt(content, query) {\n const terms = query.toLowerCase().split(/\\s+/);\n const contentLower = content.toLowerCase();\n const excerptLength = 100;\n\n let matchPos = -1;\n for (let i = 0; i < terms.length; i++) {\n const pos = contentLower.indexOf(terms[i]);\n if (pos !== -1 && (matchPos === -1 || pos < matchPos)) {\n matchPos = pos;\n }\n }\n\n if (matchPos === -1) {\n return content.substring(0, excerptLength) + '...';\n }\n\n const start = Math.max(0, matchPos - 30);\n const end = Math.min(content.length, matchPos + excerptLength);\n let excerpt = content.substring(start, end);\n\n if (start > 0) {\n excerpt = '...' + excerpt;\n }\n if (end < content.length) {\n excerpt = excerpt + '...';\n }\n\n return excerpt;\n }\n\n /**\n * Highlight matched terms in text\n */\n function highlight(text, query) {\n if (!query) return escapeHtml(text);\n\n const terms = query.toLowerCase().split(/\\s+/).filter(function(t) {\n return t.length > 0;\n });\n let escaped = escapeHtml(text);\n\n terms.forEach(function(term) {\n // Skip overly long terms to prevent ReDoS\n if (term.length > 50) return;\n const regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi');\n escaped = escaped.replace(regex, '<' + config.highlightTag + '>$1');\n });\n\n return escaped;\n }\n\n /**\n * Escape HTML entities\n */\n function escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Navigate results with keyboard\n */\n function navigate(direction) {\n let newIndex = state.activeIndex + direction;\n\n if (newIndex < 0) {\n newIndex = state.results.length - 1;\n } else if (newIndex >= state.results.length) {\n newIndex = 0;\n }\n\n setActiveIndex(newIndex);\n }\n\n /**\n * Set active result index\n */\n function setActiveIndex(index) {\n const prevActive = state.resultsContainer.querySelector('.vd-doc-search-result.is-active');\n if (prevActive) {\n prevActive.classList.remove('is-active');\n prevActive.setAttribute('aria-selected', 'false');\n }\n\n state.activeIndex = index;\n\n const newActive = state.resultsContainer.querySelector('[data-index=\"' + index + '\"]');\n if (newActive) {\n newActive.classList.add('is-active');\n newActive.setAttribute('aria-selected', 'true');\n state.input.setAttribute('aria-activedescendant', 'vd-doc-search-result-' + index);\n newActive.scrollIntoView({ block: 'nearest' });\n }\n }\n\n /**\n * Select a result\n */\n function select(index) {\n const result = state.results[index];\n if (!result) return;\n\n // Close search\n close();\n state.input.value = '';\n state.query = '';\n\n // Custom callback\n if (typeof config.onSelect === 'function') {\n safeInvokeCallback('onSelect', config.onSelect, result);\n return;\n }\n\n // Default behavior: navigate to section\n const section = document.querySelector(result.url);\n if (section) {\n section.scrollIntoView({ behavior: 'smooth', block: 'start' });\n window.history.pushState(null, '', result.url);\n updateSidebarActive(result.id);\n }\n }\n\n /**\n * Update sidebar navigation active state\n */\n function updateSidebarActive(sectionId) {\n const navLinks = document.querySelectorAll(config.navSelector);\n navLinks.forEach(function(link) {\n link.classList.remove('active');\n if (link.getAttribute('href') === '#' + sectionId) {\n link.classList.add('active');\n }\n });\n }\n\n /**\n * Open results dropdown\n */\n function open() {\n if (state.isOpen) return;\n\n state.isOpen = true;\n state.resultsContainer.classList.add('is-open');\n state.input.setAttribute('aria-expanded', 'true');\n\n if (typeof config.onOpen === 'function') {\n safeInvokeCallback('onOpen', config.onOpen);\n }\n }\n\n /**\n * Close results dropdown\n */\n function close() {\n if (!state.isOpen) return;\n\n state.isOpen = false;\n state.activeIndex = -1;\n state.resultsContainer.classList.remove('is-open');\n state.input.setAttribute('aria-expanded', 'false');\n state.input.removeAttribute('aria-activedescendant');\n\n if (typeof config.onClose === 'function') {\n safeInvokeCallback('onClose', config.onClose);\n }\n }\n\n /**\n * Destroy the component\n */\n function destroy() {\n unbindEvents();\n \n state.initialized = false;\n state.index = [];\n state.results = [];\n state.isOpen = false;\n state.query = '';\n\n if (state.debounceTimer) {\n clearTimeout(state.debounceTimer);\n }\n\n if (state.resultsContainer) {\n setResultsHtml('');\n }\n }\n\n /**\n * Rebuild the search index\n */\n function rebuild() {\n buildIndex();\n }\n\n /**\n * Update configuration\n */\n function setConfig(newConfig) {\n Object.assign(config, newConfig);\n }\n\n /**\n * Get current configuration\n */\n function getConfig() {\n return Object.assign({}, config);\n }\n\n /**\n * Get search index\n */\n function getIndex() {\n return state.index.slice();\n }\n\n // Public instance API\n const instance = {\n init: init,\n destroy: destroy,\n rebuild: rebuild,\n search: search,\n open: open,\n close: close,\n setConfig: setConfig,\n getConfig: getConfig,\n getIndex: getIndex\n };\n\n return instance;\n }\n\n /**\n * Search Component (singleton for backward compatibility)\n */\n const Search = {\n // Factory method \u2014 creates and auto-initializes a new independent instance.\n // Always returns the instance so callers retain a reference even if the\n // DOM container is not yet available (they can retry init() later).\n create: function(options) {\n const instance = createSearch(options);\n if (instance) {\n instance.init();\n }\n return instance || null;\n },\n \n // Default instance\n _instance: null,\n \n // Configuration (for default instance)\n config: Object.assign({}, DEFAULTS),\n\n /**\n * Initialize the default search instance\n */\n init: function(options) {\n if (this._instance) {\n this._instance.destroy();\n }\n \n if (options) {\n Object.assign(this.config, options);\n }\n \n this._instance = createSearch(this.config);\n return this._instance ? this._instance.init() : null;\n },\n\n /**\n * Destroy the default instance\n */\n destroy: function() {\n if (this._instance) {\n this._instance.destroy();\n this._instance = null;\n }\n },\n\n destroyAll: function() {\n this.destroy();\n },\n\n /**\n * Rebuild the default instance index\n */\n rebuild: function() {\n if (this._instance) {\n this._instance.rebuild();\n }\n },\n\n /**\n * Search using the default instance\n */\n search: function(query) {\n return this._instance ? this._instance.search(query) : [];\n },\n\n /**\n * Open the default instance\n */\n open: function() {\n if (this._instance) {\n this._instance.open();\n }\n },\n\n /**\n * Close the default instance\n */\n close: function() {\n if (this._instance) {\n this._instance.close();\n }\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('docSearch', Search);\n }\n\n // Expose globally (both names for compatibility)\n window.Search = Search;\n window.DocSearch = Search; // Backward compatibility\n window.VanduoDocSearch = Search; // New name compatibility\n\n})();\n", "/**\n * Vanduo Framework - Draggable Component\n * JavaScript functionality for draggable elements and drop zones\n */\n\n(function () {\n 'use strict';\n\n /**\n * Draggable Component\n */\n const Draggable = {\n // Store initialized draggables and their cleanup functions\n instances: new Map(),\n // Store current drag state\n currentDrag: null,\n // Store touch state\n touchState: null,\n // Feedback element\n feedbackElement: null,\n // Shared selector used by init and touch reorder\n containerSelector: '.vd-draggable-container, .vd-draggable-container-vertical',\n\n /**\n * Initialize draggable components\n */\n init: function () {\n const draggables = document.querySelectorAll('.vd-draggable, [data-draggable]');\n\n draggables.forEach(element => {\n if (this.instances.has(element)) {\n return;\n }\n this.initDraggable(element);\n });\n\n const containers = document.querySelectorAll(this.containerSelector);\n containers.forEach(container => {\n if (!this.instances.has(container)) {\n this.initContainer(container);\n }\n });\n\n const dropZones = document.querySelectorAll('.vd-drop-zone');\n dropZones.forEach(zone => {\n if (!this.instances.has(zone)) {\n this.initDropZone(zone);\n }\n });\n\n this.createFeedbackElement();\n },\n\n /**\n * Initialize a single draggable element\n * @param {HTMLElement} element - Draggable element\n */\n initDraggable: function (element) {\n const cleanupFunctions = [];\n\n // Make element draggable if not already\n if (!element.hasAttribute('draggable')) {\n element.setAttribute('draggable', 'true');\n }\n\n // Accessibility: add ARIA attributes\n if (!element.hasAttribute('tabindex')) {\n element.setAttribute('tabindex', '0');\n }\n element.setAttribute('role', 'option');\n element.setAttribute('aria-roledescription', 'draggable item');\n element.setAttribute('aria-grabbed', 'false');\n\n // Handle drag start\n const dragStartHandler = (e) => {\n this.handleDragStart(e, element);\n };\n element.addEventListener('dragstart', dragStartHandler);\n cleanupFunctions.push(() => element.removeEventListener('dragstart', dragStartHandler));\n\n // Handle drag\n const dragHandler = (e) => {\n this.handleDrag(e, element);\n };\n element.addEventListener('drag', dragHandler);\n cleanupFunctions.push(() => element.removeEventListener('drag', dragHandler));\n\n // Handle drag end\n const dragEndHandler = (e) => {\n this.handleDragEnd(e, element);\n };\n element.addEventListener('dragend', dragEndHandler);\n cleanupFunctions.push(() => element.removeEventListener('dragend', dragEndHandler));\n\n // Handle touch start (for mobile)\n const touchStartHandler = (e) => {\n this.handleTouchStart(e, element);\n };\n element.addEventListener('touchstart', touchStartHandler);\n cleanupFunctions.push(() => element.removeEventListener('touchstart', touchStartHandler));\n\n // Handle touch move (for mobile)\n // { passive: false } is required so that e.preventDefault() works\n // once the drag threshold is reached \u2014 without it, modern browsers\n // treat the listener as passive and silently ignore preventDefault().\n const touchMoveHandler = (e) => {\n this.handleTouchMove(e, element);\n };\n element.addEventListener('touchmove', touchMoveHandler, { passive: false });\n cleanupFunctions.push(() => element.removeEventListener('touchmove', touchMoveHandler));\n\n // Handle touch end (for mobile)\n const touchEndHandler = (e) => {\n this.handleTouchEnd(e, element);\n };\n element.addEventListener('touchend', touchEndHandler, { passive: false });\n cleanupFunctions.push(() => element.removeEventListener('touchend', touchEndHandler));\n\n // Handle touch cancel (for mobile)\n const touchCancelHandler = (e) => {\n this.handleTouchEnd(e, element);\n };\n element.addEventListener('touchcancel', touchCancelHandler);\n cleanupFunctions.push(() => element.removeEventListener('touchcancel', touchCancelHandler));\n\n // Keyboard navigation\n const keydownHandler = (e) => {\n this.handleKeydown(e, element);\n };\n element.addEventListener('keydown', keydownHandler);\n cleanupFunctions.push(() => element.removeEventListener('keydown', keydownHandler));\n\n this.instances.set(element, { cleanup: cleanupFunctions });\n },\n\n /**\n * Initialize a draggable container\n * @param {HTMLElement} container - Draggable container\n */\n initContainer: function (container) {\n // Accessibility: add ARIA role to container\n container.setAttribute('role', 'listbox');\n container.setAttribute('aria-label', container.getAttribute('aria-label') || 'Draggable items');\n\n const items = container.querySelectorAll('.vd-draggable-item');\n items.forEach(item => {\n if (!this.instances.has(item)) {\n this.initDraggable(item);\n }\n });\n\n const cleanupFunctions = [];\n\n // Handle drag enter\n const dragEnterHandler = (e) => {\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n };\n\n // Handle drag over for auto-sorting\n const dragOverHandler = (e) => {\n e.preventDefault(); // Necessary to allow drop\n e.dataTransfer.dropEffect = 'move';\n\n if (!this.currentDrag) return;\n const draggingEl = this.currentDrag.element;\n\n // Only reorder if dragging an item that belongs to this container (or if we support cross-container drag, but keep simple)\n if (!container.contains(draggingEl)) return;\n\n // Prevent jumps when the dragging finalizes with 0,0 coordinates near the end\n if (e.clientX === 0 && e.clientY === 0) return;\n\n this.handleReorder(container, draggingEl, e.clientX, e.clientY);\n };\n\n // Handle drop\n const dropHandler = (e) => {\n e.preventDefault(); // crucial to prevent the browser's default handling and snapping back\n };\n\n container.addEventListener('dragenter', dragEnterHandler);\n container.addEventListener('dragover', dragOverHandler);\n container.addEventListener('drop', dropHandler);\n\n cleanupFunctions.push(() => {\n container.removeEventListener('dragenter', dragEnterHandler);\n container.removeEventListener('dragover', dragOverHandler);\n container.removeEventListener('drop', dropHandler);\n });\n\n this.instances.set(container, { cleanup: cleanupFunctions });\n },\n\n /**\n * Initialize a drop zone\n * @param {HTMLElement} zone - Drop zone element\n */\n initDropZone: function (zone) {\n const cleanupFunctions = [];\n\n // Accessibility: add ARIA role to drop zone\n zone.setAttribute('role', 'region');\n zone.setAttribute('aria-dropeffect', 'move');\n if (!zone.hasAttribute('aria-label')) {\n zone.setAttribute('aria-label', 'Drop zone');\n }\n\n // Handle drag over\n const dragOverHandler = (e) => {\n e.preventDefault();\n this.handleDragOver(e, zone);\n };\n zone.addEventListener('dragover', dragOverHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragover', dragOverHandler));\n\n // Handle drag enter\n const dragEnterHandler = (e) => {\n e.preventDefault();\n this.handleDragEnter(e, zone);\n };\n zone.addEventListener('dragenter', dragEnterHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragenter', dragEnterHandler));\n\n // Handle drag leave\n const dragLeaveHandler = (e) => {\n this.handleDragLeave(e, zone);\n };\n zone.addEventListener('dragleave', dragLeaveHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragleave', dragLeaveHandler));\n\n // Handle drop\n const dropHandler = (e) => {\n e.preventDefault();\n this.handleDrop(e, zone);\n };\n zone.addEventListener('drop', dropHandler);\n cleanupFunctions.push(() => zone.removeEventListener('drop', dropHandler));\n\n this.instances.set(zone, { cleanup: cleanupFunctions });\n },\n\n /**\n * Create feedback element for drag operations\n */\n createFeedbackElement: function () {\n if (!this.feedbackElement) {\n // Reuse existing element if present\n const existing = document.querySelector('.vd-drag-feedback');\n if (existing) {\n this.feedbackElement = existing;\n return;\n }\n\n this.feedbackElement = document.createElement('div');\n this.feedbackElement.className = 'vd-drag-feedback hidden';\n this.feedbackElement.setAttribute('role', 'presentation');\n document.body.appendChild(this.feedbackElement);\n }\n },\n\n /**\n * Handle drag start event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDragStart: function (e, element) {\n // Add dragging class\n element.classList.add('is-dragging');\n\n // Accessibility: update aria-grabbed\n element.setAttribute('aria-grabbed', 'true');\n\n // Store drag state\n this.currentDrag = {\n element: element,\n initialPosition: { x: e.clientX, y: e.clientY },\n initialBounds: element.getBoundingClientRect(),\n data: this.getData(element)\n };\n\n // Set drag data\n e.dataTransfer.effectAllowed = 'move';\n e.dataTransfer.setData('text/plain', this.currentDrag.data);\n\n // We no longer suppress the native ghost image or manually update feedback\n // for mouse drags, relying on the browser's native rendering instead.\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:start', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: e.clientX, y: e.clientY }\n }\n }));\n },\n\n /**\n * Handle drag event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDrag: function (e, element) {\n // Guard against null state (race condition on fast interactions)\n if (!this.currentDrag) return;\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:drag', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: e.clientX, y: e.clientY },\n delta: {\n x: e.clientX - this.currentDrag.initialPosition.x,\n y: e.clientY - this.currentDrag.initialPosition.y\n }\n }\n }));\n },\n\n /**\n * Handle drag end event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDragEnd: function (e, element) {\n // Remove dragging class\n element.classList.remove('is-dragging');\n element.classList.add('is-dropped');\n setTimeout(() => element.classList.remove('is-dropped'), 300);\n\n // Accessibility: update aria-grabbed\n element.setAttribute('aria-grabbed', 'false');\n\n // Hide feedback\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n\n // Guard against null state\n const data = this.currentDrag?.data || this.getData(element);\n const initialPos = this.currentDrag?.initialPosition || { x: 0, y: 0 };\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:end', {\n bubbles: true,\n detail: {\n element: element,\n data: data,\n position: { x: e.clientX, y: e.clientY },\n delta: {\n x: e.clientX - initialPos.x,\n y: e.clientY - initialPos.y\n }\n }\n }));\n\n // Reset drag state\n this.currentDrag = null;\n },\n\n /**\n * Handle touch start event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchStart: function (e, element) {\n // Don't prevent default here \u2014 it blocks scrolling.\n // We only prevent default in touchmove once drag threshold is reached.\n const touch = e.touches[0];\n const rect = element.getBoundingClientRect();\n this.touchState = {\n element: element,\n startX: touch.clientX,\n startY: touch.clientY,\n lastX: touch.clientX,\n lastY: touch.clientY,\n // Keep preview anchored to the original grab point.\n offsetX: touch.clientX - rect.left,\n offsetY: touch.clientY - rect.top,\n startTime: Date.now(),\n isDragging: false\n };\n },\n\n /**\n * Handle touch move event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchMove: function (e, element) {\n if (!this.touchState) return;\n\n const touch = e.touches[0];\n this.touchState.lastX = touch.clientX;\n this.touchState.lastY = touch.clientY;\n const deltaX = touch.clientX - this.touchState.startX;\n const deltaY = touch.clientY - this.touchState.startY;\n\n // Only start dragging if moved a minimum distance\n if (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10) {\n // Now we know it's a drag, not a scroll \u2014 prevent default\n if (e.cancelable) e.preventDefault();\n\n if (!this.touchState.isDragging) {\n this.touchState.isDragging = true;\n element.classList.add('is-dragging');\n element.setAttribute('aria-grabbed', 'true');\n\n // Store drag state\n this.currentDrag = {\n element: element,\n initialPosition: { x: this.touchState.startX, y: this.touchState.startY },\n initialBounds: element.getBoundingClientRect(),\n data: this.getData(element),\n // Preserve where inside the element the drag started for accurate ghost positioning.\n offsetX: this.touchState.offsetX,\n offsetY: this.touchState.offsetY\n };\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:start', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: touch.clientX, y: touch.clientY }\n }\n }));\n }\n\n // Update feedback\n this.updateFeedback(touch.clientX, touch.clientY);\n\n // Dispatch event\n if (this.currentDrag) {\n element.dispatchEvent(new CustomEvent('draggable:drag', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: touch.clientX, y: touch.clientY },\n delta: { x: deltaX, y: deltaY }\n }\n }));\n\n this.updateTouchDropZone(touch.clientX, touch.clientY);\n\n // Reorder for touch\n const container = element.closest(this.containerSelector);\n if (container && container.contains(element)) {\n this.handleReorder(container, element, touch.clientX, touch.clientY);\n }\n }\n }\n },\n\n /**\n * Handle touch end event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchEnd: function (e, element) {\n if (this.touchState && this.touchState.isDragging) {\n if (e.cancelable) e.preventDefault();\n const endTouch = e.changedTouches?.[0];\n const endPosition = {\n x: endTouch?.clientX ?? this.touchState.lastX ?? this.touchState.startX,\n y: endTouch?.clientY ?? this.touchState.lastY ?? this.touchState.startY\n };\n\n const dropZone = this.resolveDropZoneAtPoint(endPosition.x, endPosition.y) || this.touchState.overZone;\n if (dropZone) {\n this.dispatchDrop(dropZone, endPosition);\n } else if (this.touchState.overZone) {\n this.touchState.overZone.classList.remove('is-drag-over');\n }\n\n element.classList.remove('is-dragging');\n element.classList.add('is-dropped');\n element.setAttribute('aria-grabbed', 'false');\n setTimeout(() => element.classList.remove('is-dropped'), 300);\n\n // Hide feedback\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n\n // Dispatch event\n const data = this.currentDrag?.data || this.getData(element);\n const startX = this.touchState?.startX || 0;\n const startY = this.touchState?.startY || 0;\n\n element.dispatchEvent(new CustomEvent('draggable:end', {\n bubbles: true,\n detail: {\n element: element,\n data: data,\n position: endPosition,\n delta: {\n x: endPosition.x - startX,\n y: endPosition.y - startY\n }\n }\n }));\n }\n\n // Reset states\n this.touchState = null;\n this.currentDrag = null;\n },\n\n /**\n * Handle drag over event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} _zone - Drop zone element\n */\n handleDragOver: function (e, _zone) {\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n },\n\n /**\n * Handle drag enter event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDragEnter: function (e, zone) {\n e.preventDefault();\n zone.classList.add('is-drag-over');\n },\n\n /**\n * Handle drag leave event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDragLeave: function (e, zone) {\n zone.classList.remove('is-drag-over');\n },\n\n /**\n * Handle drop event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDrop: function (e, zone) {\n e.preventDefault();\n this.dispatchDrop(zone, { x: e.clientX, y: e.clientY });\n },\n\n /**\n * Resolve a drop zone from viewport coordinates\n * @param {number} x\n * @param {number} y\n * @returns {HTMLElement|null}\n */\n resolveDropZoneAtPoint: function (x, y) {\n if (!Number.isFinite(x) || !Number.isFinite(y)) return null;\n\n // Prefer the full stacking list so overlays/top elements don't hide real drop targets.\n if (typeof document.elementsFromPoint === 'function') {\n const stacked = document.elementsFromPoint(x, y);\n for (const element of stacked) {\n const zone = element.closest('.vd-drop-zone');\n if (zone) return zone;\n }\n }\n\n const target = document.elementFromPoint(x, y);\n const targetZone = target ? target.closest('.vd-drop-zone') : null;\n if (targetZone) return targetZone;\n\n // Last-resort fallback for mobile emulation edge cases.\n const zones = document.querySelectorAll('.vd-drop-zone');\n for (const zone of zones) {\n const rect = zone.getBoundingClientRect();\n if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {\n return zone;\n }\n }\n\n return null;\n },\n\n /**\n * Track and update active drop-zone hover state on touch devices\n * @param {number} x\n * @param {number} y\n */\n updateTouchDropZone: function (x, y) {\n if (!this.touchState) return;\n\n const nextZone = this.resolveDropZoneAtPoint(x, y);\n const prevZone = this.touchState.overZone || null;\n\n if (prevZone && prevZone !== nextZone) {\n prevZone.classList.remove('is-drag-over');\n }\n\n if (nextZone && nextZone !== prevZone) {\n nextZone.classList.add('is-drag-over');\n }\n\n this.touchState.overZone = nextZone || null;\n },\n\n /**\n * Dispatch a normalized drop event for mouse and touch flows\n * @param {HTMLElement} zone\n * @param {{x:number, y:number}} position\n */\n dispatchDrop: function (zone, position) {\n zone.classList.remove('is-drag-over');\n zone.dispatchEvent(new CustomEvent('draggable:drop', {\n bubbles: true,\n detail: {\n zone: zone,\n element: this.currentDrag?.element,\n data: this.currentDrag?.data,\n position: position\n }\n }));\n },\n\n /**\n * Reorder elements in container based on cursor position\n * @param {HTMLElement} container \n * @param {HTMLElement} element \n * @param {number} clientX \n * @param {number} clientY \n */\n handleReorder: function (container, element, clientX, clientY) {\n const isVertical = container.classList.contains('vd-draggable-container-vertical');\n const siblings = [...container.querySelectorAll('.vd-draggable-item:not(.is-dragging), .vd-draggable:not(.is-dragging)')];\n\n const nextSibling = siblings.reduce((closest, child) => {\n const box = child.getBoundingClientRect();\n const offset = isVertical\n ? clientY - box.top - box.height / 2\n : clientX - box.left - box.width / 2;\n\n if (offset < 0 && offset > closest.offset) {\n return { offset: offset, element: child };\n } else {\n return closest;\n }\n }, { offset: Number.NEGATIVE_INFINITY }).element;\n\n if (nextSibling == null) {\n container.appendChild(element);\n } else {\n container.insertBefore(element, nextSibling);\n }\n },\n\n /**\n * Handle keyboard events\n * @param {KeyboardEvent} e - Keyboard event\n * @param {HTMLElement} element - Draggable element\n */\n handleKeydown: function (e, element) {\n switch (e.key) {\n case 'Enter':\n case ' ':\n e.preventDefault();\n // Trigger click or custom action\n element.click();\n break;\n case 'Escape':\n // Cancel drag if in progress\n if (element.classList.contains('is-dragging')) {\n element.classList.remove('is-dragging');\n element.setAttribute('aria-grabbed', 'false');\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n this.currentDrag = null;\n }\n break;\n case 'ArrowUp':\n case 'ArrowLeft': {\n e.preventDefault();\n const prev = element.previousElementSibling;\n if (prev && (prev.classList.contains('vd-draggable') || prev.classList.contains('vd-draggable-item'))) {\n element.parentNode.insertBefore(element, prev);\n element.focus();\n element.dispatchEvent(new CustomEvent('draggable:reorder', {\n bubbles: true,\n detail: { element: element, direction: 'up' }\n }));\n }\n break;\n }\n case 'ArrowDown':\n case 'ArrowRight': {\n e.preventDefault();\n const next = element.nextElementSibling;\n if (next && (next.classList.contains('vd-draggable') || next.classList.contains('vd-draggable-item'))) {\n element.parentNode.insertBefore(next, element);\n element.focus();\n element.dispatchEvent(new CustomEvent('draggable:reorder', {\n bubbles: true,\n detail: { element: element, direction: 'down' }\n }));\n }\n break;\n }\n }\n },\n\n /**\n * Get data from draggable element\n * @param {HTMLElement} element - Draggable element\n * @returns {string} Data associated with the element\n */\n getData: function (element) {\n return element.dataset.draggable || element.textContent.trim();\n },\n\n /**\n * Update drag feedback element\n * @param {number} x - Current X coordinate\n * @param {number} y - Current Y coordinate\n */\n updateFeedback: function (x, y) {\n if (!this.currentDrag) return;\n\n // Show feedback\n this.feedbackElement.classList.remove('hidden');\n\n // Update feedback content\n const rect = this.currentDrag.initialBounds;\n this.feedbackElement.innerHTML = '';\n const clone = this.currentDrag.element.cloneNode(true);\n this.feedbackElement.appendChild(clone);\n\n // Set styles\n const offsetX = this.currentDrag.offsetX ?? 20;\n const offsetY = this.currentDrag.offsetY ?? 20;\n Object.assign(this.feedbackElement.style, {\n left: (x - offsetX) + 'px',\n top: (y - offsetY) + 'px',\n width: rect.width + 'px',\n height: rect.height + 'px'\n });\n },\n\n /**\n * Make an element draggable programmatically\n * @param {HTMLElement|string} element - Element or selector\n * @param {Object} options - Configuration options\n */\n makeDraggable: function (element, options = {}) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n\n if (el && !this.instances.has(el)) {\n // Add classes and attributes\n el.classList.add('vd-draggable');\n el.setAttribute('draggable', 'true');\n\n // Set options\n if (options.data) {\n el.dataset.draggable = options.data;\n }\n\n // Initialize\n this.initDraggable(el);\n }\n },\n\n /**\n * Remove draggable functionality from an element\n * @param {HTMLElement|string} element - Element or selector\n */\n removeDraggable: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n\n if (el && this.instances.has(el)) {\n // Clean up\n const instance = this.instances.get(el);\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n\n // Remove classes and attributes\n el.classList.remove('vd-draggable');\n el.removeAttribute('draggable');\n el.removeAttribute('data-draggable');\n }\n },\n\n /**\n * Destroy a draggable instance and clean up event listeners\n * @param {HTMLElement} element - Draggable element\n */\n destroy: function (element) {\n this.removeDraggable(element);\n },\n\n /**\n * Destroy all draggable instances\n */\n destroyAll: function () {\n const instances = Array.from(this.instances.keys());\n instances.forEach(element => this.destroy(element));\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('draggable', Draggable);\n }\n\n // Expose globally\n window.VanduoDraggable = Draggable;\n})();\n", "/**\n * Vanduo Framework \u2013 LazyLoad Component\n * v1.2.1\n *\n * Provides two levels of API:\n *\n * LOW-LEVEL (generic IntersectionObserver wrapper)\n * VanduoLazyLoad.observe(element, callback, options?)\n * VanduoLazyLoad.unobserve(element)\n * VanduoLazyLoad.unobserveAll()\n *\n * HIGH-LEVEL (HTML-section fetcher, mirrors vanduo-docs SPA behaviour)\n * VanduoLazyLoad.loadSection(url, containerEl, options?)\n * options: { placeholder, threshold, rootMargin, onLoaded, onError }\n * placeholder: 'skeleton' | 'spinner' | HTMLString (default: 'skeleton')\n *\n * ATTRIBUTE-DRIVEN (zero-JS usage, wired by init())\n *
    \u2026
    \n *\n * EVENTS dispatched on the host element:\n * lazysection:loading \u2014 fetch started\n * lazysection:loaded \u2014 content injected\n * lazysection:error \u2014 fetch failed\n */\n\n(function () {\n 'use strict';\n\n /* \u2500\u2500 Private state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /** @type {Map} */\n const _observerMap = new Map();\n\n /* \u2500\u2500 Security helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Returns true only if `url` resolves to the same origin as the page\n * (relative paths and same-origin absolute URLs are allowed).\n * Cross-origin URLs are rejected to prevent SSRF-style fetch abuse.\n * @param {string} url\n * @returns {boolean}\n */\n function _isSafeUrl(url) {\n try {\n // Relative URLs (no origin) are always safe\n const resolved = new URL(url, window.location.href);\n return resolved.origin === window.location.origin;\n } catch (_) {\n return false;\n }\n }\n\n /**\n * Safely inject fetched HTML into a container by parsing it with\n * DOMParser (avoids script execution) and replacing children via\n * standard DOM APIs instead of raw innerHTML assignment.\n * @param {Element} containerEl\n * @param {string} html\n */\n function _safeInjectHtml(containerEl, html) {\n const parser = new DOMParser();\n const doc = parser.parseFromString(html.trim(), 'text/html'); // CodeQL [js/xss-through-dom] Intentional: parsed nodes are sanitized below before adoption into live DOM\n\n const DANGEROUS_TAGS = ['SCRIPT', 'IFRAME', 'OBJECT', 'EMBED', 'FORM', 'BASE', 'LINK', 'META', 'STYLE'];\n for (const tag of DANGEROUS_TAGS) {\n const els = doc.querySelectorAll(tag);\n for (let i = els.length - 1; i >= 0; i--) {\n els[i].parentNode.removeChild(els[i]);\n }\n }\n\n // Strip dangerous attributes (e.g. onerror, javascript: urls) from all elements\n function _sanitizeNode(node) {\n if (node.nodeType === Node.ELEMENT_NODE) {\n const attrs = node.attributes;\n for (let i = attrs.length - 1; i >= 0; i--) {\n const attrName = attrs[i].name.toLowerCase();\n const attrValue = attrs[i].value.toLowerCase();\n const trimmedValue = attrValue.trim();\n if (\n attrName.startsWith('on') ||\n trimmedValue.startsWith('javascript:') ||\n trimmedValue.startsWith('data:') ||\n trimmedValue.startsWith('vbscript:')\n ) {\n node.removeAttribute(attrs[i].name);\n }\n }\n const children = node.childNodes;\n for (let i = 0; i < children.length; i++) {\n _sanitizeNode(children[i]);\n }\n }\n }\n _sanitizeNode(doc.body);\n\n // Collect all top-level body children\n const nodes = Array.from(doc.body.childNodes);\n // Clear container and append parsed nodes\n while (containerEl.firstChild) {\n containerEl.removeChild(containerEl.firstChild);\n }\n nodes.forEach(function (node) {\n containerEl.appendChild(document.adoptNode(node));\n });\n }\n\n /* \u2500\u2500 Placeholder HTML \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n function _skeletonHtml() {\n return '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + 'Loading\u2026
    ';\n }\n\n function _spinnerHtml() {\n return '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + 'Loading\u2026
    ';\n }\n\n function _resolvePlaceholder(placeholder) {\n if (!placeholder || placeholder === 'skeleton') return _skeletonHtml();\n if (placeholder === 'spinner') return _spinnerHtml();\n // Caller-supplied HTML string\n return placeholder;\n }\n\n /* \u2500\u2500 Dispatch helper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n function _dispatch(el, eventName, detail) {\n el.dispatchEvent(new CustomEvent(eventName, { bubbles: true, detail: detail || {} }));\n }\n\n /* \u2500\u2500 VanduoLazyLoad \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const VanduoLazyLoad = {\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * LOW-LEVEL API\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Observe an element. `callback` is invoked once when the element\n * enters the viewport, then the element is automatically unobserved.\n *\n * @param {Element} element\n * @param {function(Element): void} callback\n * @param {{ threshold?: number, rootMargin?: string }} [options]\n */\n observe: function (element, callback, options) {\n if (!(element instanceof Element)) {\n console.warn('[VanduoLazyLoad] observe() requires a DOM Element.');\n return;\n }\n if (typeof callback !== 'function') {\n console.warn('[VanduoLazyLoad] observe() requires a callback function.');\n return;\n }\n // Already observed \u2014 ignore\n if (_observerMap.has(element)) return;\n\n const threshold = (options && options.threshold != null) ? options.threshold : 0;\n const rootMargin = (options && options.rootMargin) ? options.rootMargin : '0px';\n\n const observer = new IntersectionObserver(function (entries, obs) {\n entries.forEach(function (entry) {\n if (entry.isIntersecting) {\n obs.unobserve(entry.target);\n _observerMap.delete(entry.target);\n try {\n callback(entry.target);\n } catch (e) {\n console.error('[VanduoLazyLoad] Callback threw:', e);\n }\n }\n });\n }, { threshold: threshold, rootMargin: rootMargin });\n\n _observerMap.set(element, observer);\n observer.observe(element);\n },\n\n /**\n * Stop observing an element that was previously passed to observe().\n * @param {Element} element\n */\n unobserve: function (element) {\n const observer = _observerMap.get(element);\n if (observer) {\n observer.unobserve(element);\n _observerMap.delete(element);\n }\n },\n\n /**\n * Stop observing ALL currently observed elements.\n */\n unobserveAll: function () {\n _observerMap.forEach(function (observer, element) {\n observer.unobserve(element);\n });\n _observerMap.clear();\n },\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * HIGH-LEVEL API\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Fetch an HTML partial and inject it into `containerEl` when the\n * container enters the viewport. A placeholder is shown immediately.\n *\n * @param {string} url URL of the HTML partial to fetch\n * @param {Element} containerEl Target element whose content will be replaced\n * @param {{\n * placeholder?: 'skeleton'|'spinner'|string,\n * threshold?: number,\n * rootMargin?: string,\n * onLoaded?: function(Element): void,\n * onError?: function(Error): void\n * }} [options]\n */\n loadSection: function (url, containerEl, options) {\n if (typeof url !== 'string' || !url) {\n console.warn('[VanduoLazyLoad] loadSection() requires a non-empty URL string.');\n return;\n }\n if (!(containerEl instanceof Element)) {\n console.warn('[VanduoLazyLoad] loadSection() requires a DOM Element as containerEl.');\n return;\n }\n // Reject cross-origin URLs to prevent SSRF-style fetch abuse\n if (!_isSafeUrl(url)) {\n console.error('[VanduoLazyLoad] loadSection() blocked cross-origin URL:', url);\n return;\n }\n\n const opts = options || {};\n // Placeholders are known-safe static HTML strings built internally\n const placeholderHtml = _resolvePlaceholder(opts.placeholder);\n\n // Use _safeInjectHtml instead of innerHTML to prevent XSS from caller-supplied placeholders\n _safeInjectHtml(containerEl, placeholderHtml);\n _dispatch(containerEl, 'lazysection:loading', { url: url });\n\n // Fetch when visible\n this.observe(containerEl, function () {\n const controller = new window.AbortController();\n const timeoutId = setTimeout(function () { controller.abort(); }, 10000);\n\n window.fetch(url, { signal: controller.signal })\n .then(function (res) {\n clearTimeout(timeoutId);\n if (!res.ok) throw new Error('HTTP ' + res.status);\n return res.text();\n })\n .then(function (html) {\n // Use DOMParser to parse fetched content safely, avoiding\n // raw innerHTML assignment of externally-sourced strings\n _safeInjectHtml(containerEl, html);\n _dispatch(containerEl, 'lazysection:loaded', { url: url });\n // Re-init Vanduo components inside the new content\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.init();\n }\n if (typeof opts.onLoaded === 'function') {\n opts.onLoaded(containerEl);\n }\n })\n .catch(function (err) {\n // Build error node via DOM APIs \u2014 no dynamic HTML strings\n const alertEl = document.createElement('div');\n alertEl.className = 'vd-alert vd-alert-error';\n alertEl.setAttribute('role', 'alert');\n const msgEl = document.createElement('span');\n msgEl.textContent = 'Failed to load content. ';\n const detailEl = document.createElement('small');\n detailEl.style.opacity = '0.7';\n detailEl.textContent = err.message;\n alertEl.appendChild(msgEl);\n alertEl.appendChild(detailEl);\n while (containerEl.firstChild) {\n containerEl.removeChild(containerEl.firstChild);\n }\n containerEl.appendChild(alertEl);\n _dispatch(containerEl, 'lazysection:error', { url: url, error: err });\n console.error('[VanduoLazyLoad] loadSection failed:', err);\n if (typeof opts.onError === 'function') {\n opts.onError(err);\n }\n });\n }, { threshold: opts.threshold, rootMargin: opts.rootMargin });\n },\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * ATTRIBUTE-DRIVEN INIT\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Scan the DOM for [data-vd-lazy] elements and wire them up.\n * Safe to call multiple times \u2014 already-observed elements are skipped.\n */\n init: function () {\n const self = this;\n const elements = document.querySelectorAll('[data-vd-lazy]');\n elements.forEach(function (el) {\n // Skip already-observed or already-loaded elements\n if (_observerMap.has(el) || el.dataset.vdLazyState === 'loading' || el.dataset.vdLazyState === 'loaded') return;\n\n const url = el.getAttribute('data-vd-lazy');\n if (!url) return;\n\n el.dataset.vdLazyState = 'loading';\n const placeholder = el.getAttribute('data-vd-lazy-placeholder') || 'skeleton';\n\n self.loadSection(url, el, {\n placeholder: placeholder,\n onLoaded: function () {\n el.dataset.vdLazyState = 'loaded';\n },\n onError: function () {\n el.dataset.vdLazyState = 'error';\n }\n });\n });\n }\n };\n\n /* \u2500\u2500 Register with Vanduo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('LazyLoad', VanduoLazyLoad);\n }\n\n /* \u2500\u2500 Global convenience alias \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n window.VanduoLazyLoad = VanduoLazyLoad;\n\n})();\n", "/**\n * Vanduo Framework - Glass Scroll Activation\n * Generic scroll-aware glass activation via IntersectionObserver.\n *\n * Usage:\n * Add `data-glass-scroll` to any element carrying a `.vd-glass*` class.\n * By default the previous sibling is used as the sentinel.\n * Point to a custom sentinel via `data-glass-sentinel=\"\"`.\n *\n * Behaviour:\n * - Adds `.is-glass-active` when the sentinel leaves the viewport (scrolled past).\n * - Removes `.is-glass-active` when the sentinel re-enters the viewport.\n */\n(function () {\n 'use strict';\n\n const GlassScroll = {\n /** @type {Map} */\n observers: new Map(),\n\n init: function () {\n document.querySelectorAll('[data-glass-scroll]').forEach(el => {\n if (this.observers.has(el)) return;\n this.initElement(el);\n });\n },\n\n /**\n * Wire up a single scroll-activated glass element.\n * @param {HTMLElement} el\n */\n initElement: function (el) {\n const sentinelSelector = el.dataset.glassSentinel;\n let sentinel;\n\n if (sentinelSelector) {\n sentinel = document.querySelector(sentinelSelector);\n }\n\n if (!sentinel) {\n // Fall back to the previous sibling element\n sentinel = el.previousElementSibling;\n }\n\n if (!sentinel) {\n // No sentinel available \u2014 activate immediately so glass is always shown\n el.classList.add('is-glass-active');\n return;\n }\n\n const observer = new IntersectionObserver(\n (entries) => {\n entries.forEach(entry => {\n // Active when sentinel is NOT intersecting (scrolled past it)\n el.classList.toggle('is-glass-active', !entry.isIntersecting);\n });\n },\n { threshold: 0, rootMargin: '0px' }\n );\n\n observer.observe(sentinel);\n this.observers.set(el, observer);\n },\n\n /**\n * Disconnect and remove a single element's observer.\n * @param {HTMLElement} el\n */\n destroy: function (el) {\n const observer = this.observers.get(el);\n if (observer) {\n observer.disconnect();\n this.observers.delete(el);\n }\n },\n\n destroyAll: function () {\n this.observers.forEach((observer, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('glassScroll', GlassScroll);\n }\n\n window.VanduoGlassScroll = GlassScroll;\n})();\n", "/**\n * Vanduo Framework - Water Morph Effect Component\n * Liquid wave content-swap animation on click\n *\n * Usage:\n * Add .vd-morph or [data-vd-morph] to any element.\n * Provide .vd-morph-content.vd-morph-current and .vd-morph-content.vd-morph-next children.\n * Wave and shine layers are auto-created if absent.\n *\n * JS API:\n * VanduoMorph.morph(el) \u2014 trigger morph programmatically (from center)\n * VanduoMorph.destroy(el) \u2014 tear down listeners for one element\n * VanduoMorph.destroyAll() \u2014 tear down all instances\n */\n\n(function () {\n 'use strict';\n\n const MORPH_DURATION_MS = 750;\n\n const Morph = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-morph, [data-vd-morph]');\n elements.forEach(function (el) {\n if (Morph.instances.has(el)) return;\n if (el.getAttribute('data-vd-morph') === 'manual') return;\n Morph.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n Morph._ensureLayers(el);\n\n const cleanup = [];\n let morphing = false;\n\n const handleClick = function (e) {\n if (morphing) return;\n Morph._runMorph(el, e, function () { morphing = false; });\n morphing = true;\n };\n\n el.addEventListener('click', handleClick);\n cleanup.push(function () { el.removeEventListener('click', handleClick); });\n\n this.instances.set(el, { cleanup: cleanup });\n },\n\n morph: function (el) {\n if (!el) return;\n if (!this.instances.has(el)) this.initInstance(el);\n this._runMorph(el, null, null);\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(function (fn) { fn(); });\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) { Morph.destroy(el); });\n },\n\n /* \u2500\u2500 Internal helpers \u2500\u2500 */\n\n _ensureLayers: function (el) {\n if (!el.querySelector('.vd-morph-wave')) {\n const wave = document.createElement('span');\n wave.className = 'vd-morph-wave';\n wave.setAttribute('aria-hidden', 'true');\n el.insertBefore(wave, el.firstChild);\n }\n if (!el.querySelector('.vd-morph-shine')) {\n const shine = document.createElement('span');\n shine.className = 'vd-morph-shine';\n shine.setAttribute('aria-hidden', 'true');\n const waveEl = el.querySelector('.vd-morph-wave');\n if (waveEl && waveEl.nextSibling) {\n el.insertBefore(shine, waveEl.nextSibling);\n } else {\n el.insertBefore(shine, el.firstChild);\n }\n }\n },\n\n _runMorph: function (el, pointerEvent, onComplete) {\n const wave = el.querySelector('.vd-morph-wave');\n if (wave) {\n const rect = el.getBoundingClientRect();\n const cx = rect.left + rect.width / 2;\n const cy = rect.top + rect.height / 2;\n const px = pointerEvent ? (pointerEvent.clientX || cx) : cx;\n const py = pointerEvent ? (pointerEvent.clientY || cy) : cy;\n wave.style.left = (px - rect.left) + 'px';\n wave.style.top = (py - rect.top) + 'px';\n }\n\n el.classList.add('is-morphing');\n\n let duration = MORPH_DURATION_MS;\n const custom = getComputedStyle(el).getPropertyValue('--morph-duration');\n if (custom) {\n const parsed = parseFloat(custom);\n if (!isNaN(parsed)) duration = parsed * (custom.indexOf('ms') !== -1 ? 1 : 1000);\n }\n\n setTimeout(function () {\n el.classList.remove('is-morphing');\n\n const current = el.querySelector('.vd-morph-current');\n const next = el.querySelector('.vd-morph-next');\n if (current && next) {\n current.classList.remove('vd-morph-current');\n current.classList.add('vd-morph-next');\n next.classList.remove('vd-morph-next');\n next.classList.add('vd-morph-current');\n }\n\n if (typeof onComplete === 'function') onComplete();\n }, duration);\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('morph', Morph);\n }\n\n window.VanduoMorph = Morph;\n\n})();\n", "/**\n * Vanduo Framework \u2014 Expanding flex cards\n * Click / Enter / Space / Arrow keys to change the active card.\n *\n * Usage:\n *
    \n * Use data-vd-expanding-cards=\"manual\" to skip auto-init.\n */\n\n(function () {\n 'use strict';\n\n const ExpandingCards = {\n instances: new Map(),\n\n init: function () {\n document.querySelectorAll('.vd-expanding-cards').forEach(function (el) {\n if (el.getAttribute('data-vd-expanding-cards') === 'manual') return;\n if (ExpandingCards.instances.has(el)) return;\n ExpandingCards.initContainer(el);\n });\n },\n\n initContainer: function (container) {\n const cleanup = [];\n\n const getCards = function () {\n return Array.prototype.slice.call(container.querySelectorAll('.vd-expanding-card'));\n };\n\n const setActive = function (card) {\n const cards = getCards();\n if (!card || cards.indexOf(card) === -1) return;\n cards.forEach(function (c) {\n c.classList.toggle('is-active', c === card);\n });\n card.focus({ preventScroll: true });\n };\n\n const onClick = function (e) {\n const t = e.target;\n const card = t.closest ? t.closest('.vd-expanding-card') : null;\n if (!card || !container.contains(card)) return;\n setActive(card);\n };\n\n const onKeydown = function (e) {\n if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'Home' && e.key !== 'End') {\n return;\n }\n const cards = getCards().filter(function (c) {\n return c.offsetParent !== null || c.getClientRects().length > 0;\n });\n if (!cards.length) return;\n const activeEl = document.activeElement;\n let idx = cards.indexOf(activeEl);\n if (idx < 0) {\n idx = cards.findIndex(function (c) {\n return c.classList.contains('is-active');\n });\n }\n if (idx < 0) idx = 0;\n\n if (e.key === 'ArrowLeft') {\n e.preventDefault();\n setActive(cards[Math.max(0, idx - 1)]);\n } else if (e.key === 'ArrowRight') {\n e.preventDefault();\n setActive(cards[Math.min(cards.length - 1, idx + 1)]);\n } else if (e.key === 'Home') {\n e.preventDefault();\n setActive(cards[0]);\n } else if (e.key === 'End') {\n e.preventDefault();\n setActive(cards[cards.length - 1]);\n }\n };\n\n container.addEventListener('click', onClick);\n cleanup.push(function () {\n container.removeEventListener('click', onClick);\n });\n\n container.addEventListener('keydown', onKeydown);\n cleanup.push(function () {\n container.removeEventListener('keydown', onKeydown);\n });\n\n getCards().forEach(function (card) {\n if (!card.hasAttribute('tabindex')) {\n card.setAttribute('tabindex', '0');\n }\n card.setAttribute('role', 'button');\n if (!card.hasAttribute('aria-pressed')) {\n card.setAttribute('aria-pressed', card.classList.contains('is-active') ? 'true' : 'false');\n }\n });\n\n const syncAria = function () {\n getCards().forEach(function (card) {\n card.setAttribute('aria-pressed', card.classList.contains('is-active') ? 'true' : 'false');\n });\n };\n\n const mo = new MutationObserver(syncAria);\n mo.observe(container, { attributes: true, subtree: true, attributeFilter: ['class'] });\n cleanup.push(function () {\n mo.disconnect();\n });\n syncAria();\n\n ExpandingCards.instances.set(container, { cleanup: cleanup });\n },\n\n destroy: function (container) {\n const inst = this.instances.get(container);\n if (!inst) return;\n inst.cleanup.forEach(function (fn) {\n fn();\n });\n this.instances.delete(container);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) {\n ExpandingCards.destroy(el);\n });\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('expandingCards', ExpandingCards);\n }\n\n window.VanduoExpandingCards = ExpandingCards;\n})();\n", "/**\n * Vanduo Framework \u2014 Timeline animated reveal\n * Opt-in: add `.vd-timeline-animated` to a `.vd-timeline` container.\n * Uses IntersectionObserver to add `.is-revealed` per item with staggered delays.\n * Optional: `.vd-timeline-playback` with `[data-vd-timeline-prev|next|play|pause]` for stepped control.\n */\n\n(function () {\n 'use strict';\n\n const STAGGER_MS = 140;\n const MAX_STAGGER_INDEX = 7;\n const PLAY_INTERVAL_MS = 800;\n\n function countRevealedPrefix(items) {\n let count = 0;\n for (let i = 0; i < items.length; i++) {\n if (!items[i].classList.contains('is-revealed')) break;\n count++;\n }\n return count;\n }\n\n function findPlaybackControls(container) {\n return container.parentElement || document.body;\n }\n\n function initPlayback(container, items, cleanup) {\n items.forEach(function (item) {\n item.classList.remove('is-revealed');\n });\n\n const scope = findPlaybackControls(container);\n const prevBtn = scope.querySelector('[data-vd-timeline-prev]');\n const nextBtn = scope.querySelector('[data-vd-timeline-next]');\n const playBtn = scope.querySelector('[data-vd-timeline-play]');\n const pauseBtn = scope.querySelector('[data-vd-timeline-pause]');\n\n let playTimer = null;\n\n function updateNavButtons() {\n const k = countRevealedPrefix(items);\n const n = items.length;\n if (prevBtn) {\n const atStart = k === 0;\n prevBtn.disabled = atStart;\n prevBtn.setAttribute('aria-disabled', atStart ? 'true' : 'false');\n }\n if (nextBtn) {\n const atEnd = k >= n;\n nextBtn.disabled = atEnd;\n nextBtn.setAttribute('aria-disabled', atEnd ? 'true' : 'false');\n }\n if (playBtn) {\n playBtn.setAttribute('aria-pressed', playTimer ? 'true' : 'false');\n }\n if (pauseBtn) {\n pauseBtn.disabled = !playTimer;\n }\n }\n\n function stepNext() {\n const k = countRevealedPrefix(items);\n if (k < items.length) {\n items[k].classList.add('is-revealed');\n }\n updateNavButtons();\n }\n\n function stepPrev() {\n const k = countRevealedPrefix(items);\n if (k > 0) {\n items[k - 1].classList.remove('is-revealed');\n }\n updateNavButtons();\n }\n\n function play() {\n if (playTimer) return;\n playTimer = setInterval(function () {\n if (countRevealedPrefix(items) >= items.length) {\n pause();\n return;\n }\n stepNext();\n }, PLAY_INTERVAL_MS);\n updateNavButtons();\n }\n\n function pause() {\n if (playTimer) {\n clearInterval(playTimer);\n playTimer = null;\n }\n updateNavButtons();\n }\n\n function addClick(el, fn) {\n if (!el) return;\n const handler = function (e) {\n e.preventDefault();\n fn();\n };\n el.addEventListener('click', handler);\n cleanup.push(function () {\n el.removeEventListener('click', handler);\n });\n }\n\n addClick(prevBtn, stepPrev);\n addClick(nextBtn, stepNext);\n addClick(playBtn, play);\n addClick(pauseBtn, pause);\n\n cleanup.push(function () {\n pause();\n });\n\n updateNavButtons();\n\n return {\n stepNext: stepNext,\n stepPrev: stepPrev,\n play: play,\n pause: pause\n };\n }\n\n const Timeline = {\n instances: new Map(),\n\n init: function () {\n document.querySelectorAll('.vd-timeline.vd-timeline-animated').forEach(function (el) {\n if (Timeline.instances.has(el)) return;\n Timeline.initInstance(el);\n });\n },\n\n reinit: function () {\n Timeline.destroyAll();\n Timeline.init();\n },\n\n initInstance: function (container) {\n const cleanup = [];\n const items = Array.prototype.filter.call(container.children, function (child) {\n return child.classList && child.classList.contains('vd-timeline-item');\n });\n\n items.forEach(function (item, i) {\n const idx = Math.min(i, MAX_STAGGER_INDEX);\n item.style.setProperty('--vd-timeline-reveal-delay', (idx * STAGGER_MS) + 'ms');\n });\n\n const reducedMotion = typeof window.matchMedia === 'function'\n && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n if (reducedMotion) {\n items.forEach(function (item) {\n item.classList.add('is-revealed');\n });\n Timeline.instances.set(container, { cleanup: cleanup });\n return;\n }\n\n const playback = container.classList && container.classList.contains('vd-timeline-playback');\n\n if (playback) {\n const playbackApi = initPlayback(container, items, cleanup);\n Timeline.instances.set(container, { cleanup: cleanup, playback: playbackApi });\n return;\n }\n\n if (typeof IntersectionObserver === 'undefined') {\n items.forEach(function (item) {\n item.classList.add('is-revealed');\n });\n Timeline.instances.set(container, { cleanup: cleanup });\n return;\n }\n\n const observer = new IntersectionObserver(function (entries) {\n entries.forEach(function (entry) {\n if (!entry.isIntersecting) return;\n entry.target.classList.add('is-revealed');\n observer.unobserve(entry.target);\n });\n }, {\n root: null,\n rootMargin: '0px 0px -10% 0px',\n threshold: 0.15\n });\n\n items.forEach(function (item) {\n observer.observe(item);\n });\n\n cleanup.push(function () {\n observer.disconnect();\n });\n\n Timeline.instances.set(container, { cleanup: cleanup });\n },\n\n destroy: function (container) {\n const inst = this.instances.get(container);\n if (!inst) return;\n inst.cleanup.forEach(function (fn) {\n fn();\n });\n this.instances.delete(container);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) {\n Timeline.destroy(el);\n });\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('timeline', Timeline);\n }\n\n window.VanduoTimeline = Timeline;\n})();\n", "/**\n * Vanduo Framework - Flow (Carousel/Slider) Component\n * Touch-enabled carousel with slide/fade transitions, autoplay, indicators\n */\n\n(function () {\n 'use strict';\n\n const Flow = {\n instances: new Map(),\n\n init: function () {\n const carousels = document.querySelectorAll('.vd-flow, .vd-carousel');\n carousels.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const track = el.querySelector('.vd-flow-track');\n if (!track) return;\n\n const slides = Array.from(track.querySelectorAll('.vd-flow-slide'));\n if (slides.length === 0) return;\n\n const isFade = el.classList.contains('vd-flow-fade');\n const autoplay = el.hasAttribute('data-vd-autoplay');\n const interval = parseInt(el.getAttribute('data-vd-interval'), 10) || 5000;\n const loop = el.getAttribute('data-vd-loop') !== 'false';\n\n const state = {\n current: 0,\n total: slides.length,\n autoplayTimer: null,\n isFade: isFade,\n loop: loop,\n isDragging: false,\n startX: 0,\n currentX: 0,\n threshold: 50\n };\n\n const cleanup = [];\n\n // Set initial active slide\n slides.forEach((slide, i) => {\n slide.setAttribute('role', 'group');\n slide.setAttribute('aria-roledescription', 'slide');\n slide.setAttribute('aria-label', 'Slide ' + (i + 1) + ' of ' + slides.length);\n if (i === 0) slide.classList.add('is-active');\n });\n\n el.setAttribute('role', 'region');\n el.setAttribute('aria-roledescription', 'carousel');\n if (!el.getAttribute('aria-label')) {\n el.setAttribute('aria-label', 'Carousel');\n }\n\n // Live region for announcements\n const liveRegion = document.createElement('div');\n liveRegion.setAttribute('aria-live', 'polite');\n liveRegion.setAttribute('aria-atomic', 'true');\n liveRegion.className = 'sr-only';\n liveRegion.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);';\n el.appendChild(liveRegion);\n\n const goTo = (index, announce) => {\n if (announce === undefined) announce = true;\n let target = index;\n if (state.loop) {\n target = ((index % state.total) + state.total) % state.total;\n } else {\n target = Math.max(0, Math.min(index, state.total - 1));\n }\n\n const prev = state.current;\n state.current = target;\n\n if (state.isFade) {\n slides.forEach((s, i) => {\n s.classList.toggle('is-active', i === target);\n });\n } else {\n track.style.transform = 'translateX(-' + (target * 100) + '%)';\n }\n\n // Update indicators\n const indicators = el.querySelectorAll('.vd-flow-indicator');\n indicators.forEach((ind, i) => {\n ind.classList.toggle('is-active', i === target);\n ind.setAttribute('aria-selected', i === target ? 'true' : 'false');\n });\n\n // Update slide ARIA\n slides.forEach((s, i) => {\n s.setAttribute('aria-hidden', i !== target ? 'true' : 'false');\n });\n\n if (announce) {\n liveRegion.textContent = 'Slide ' + (target + 1) + ' of ' + state.total;\n }\n\n el.dispatchEvent(new CustomEvent('flow:change', {\n detail: { current: target, previous: prev, total: state.total }\n }));\n };\n\n const next = () => goTo(state.current + 1);\n const prev = () => goTo(state.current - 1);\n\n // Controls\n const prevBtn = el.querySelector('.vd-flow-prev');\n const nextBtn = el.querySelector('.vd-flow-next');\n\n if (prevBtn) {\n const h = () => prev();\n prevBtn.addEventListener('click', h);\n cleanup.push(() => prevBtn.removeEventListener('click', h));\n }\n if (nextBtn) {\n const h = () => next();\n nextBtn.addEventListener('click', h);\n cleanup.push(() => nextBtn.removeEventListener('click', h));\n }\n\n // Indicators\n const indicators = el.querySelectorAll('.vd-flow-indicator');\n indicators.forEach((ind, i) => {\n ind.setAttribute('role', 'tab');\n ind.setAttribute('aria-selected', i === 0 ? 'true' : 'false');\n ind.setAttribute('aria-label', 'Go to slide ' + (i + 1));\n const h = () => goTo(i);\n ind.addEventListener('click', h);\n cleanup.push(() => ind.removeEventListener('click', h));\n });\n\n // Keyboard navigation\n const keyHandler = (e) => {\n if (e.key === 'ArrowLeft') { prev(); e.preventDefault(); }\n if (e.key === 'ArrowRight') { next(); e.preventDefault(); }\n };\n el.setAttribute('tabindex', '0');\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n\n // Touch / pointer support\n const pointerDown = (e) => {\n state.isDragging = true;\n state.startX = e.clientX || (e.touches && e.touches[0].clientX) || 0;\n state.currentX = state.startX;\n el.classList.add('is-dragging');\n };\n\n const pointerMove = (e) => {\n if (!state.isDragging) return;\n state.currentX = e.clientX || (e.touches && e.touches[0].clientX) || 0;\n };\n\n const pointerUp = () => {\n if (!state.isDragging) return;\n state.isDragging = false;\n el.classList.remove('is-dragging');\n const diff = state.startX - state.currentX;\n if (Math.abs(diff) > state.threshold) {\n if (diff > 0) next();\n else prev();\n }\n };\n\n el.addEventListener('mousedown', pointerDown);\n el.addEventListener('mousemove', pointerMove);\n el.addEventListener('mouseup', pointerUp);\n el.addEventListener('mouseleave', pointerUp);\n el.addEventListener('touchstart', pointerDown, { passive: true });\n el.addEventListener('touchmove', pointerMove, { passive: true });\n el.addEventListener('touchend', pointerUp);\n\n cleanup.push(\n () => el.removeEventListener('mousedown', pointerDown),\n () => el.removeEventListener('mousemove', pointerMove),\n () => el.removeEventListener('mouseup', pointerUp),\n () => el.removeEventListener('mouseleave', pointerUp),\n () => el.removeEventListener('touchstart', pointerDown),\n () => el.removeEventListener('touchmove', pointerMove),\n () => el.removeEventListener('touchend', pointerUp)\n );\n\n // Autoplay\n const startAutoplay = () => {\n stopAutoplay();\n state.autoplayTimer = setInterval(next, interval);\n };\n\n const stopAutoplay = () => {\n if (state.autoplayTimer) {\n clearInterval(state.autoplayTimer);\n state.autoplayTimer = null;\n }\n };\n\n if (autoplay) {\n startAutoplay();\n const pauseHandler = () => stopAutoplay();\n const resumeHandler = () => startAutoplay();\n el.addEventListener('mouseenter', pauseHandler);\n el.addEventListener('mouseleave', resumeHandler);\n el.addEventListener('focusin', pauseHandler);\n el.addEventListener('focusout', resumeHandler);\n cleanup.push(\n () => el.removeEventListener('mouseenter', pauseHandler),\n () => el.removeEventListener('mouseleave', resumeHandler),\n () => el.removeEventListener('focusin', pauseHandler),\n () => el.removeEventListener('focusout', resumeHandler),\n () => stopAutoplay()\n );\n }\n\n // Initial ARIA state\n goTo(0, false);\n\n this.instances.set(el, {\n cleanup: cleanup,\n goTo: goTo,\n next: next,\n prev: prev,\n getState: () => ({ ...state })\n });\n },\n\n goTo: function (el, index) {\n const instance = this.instances.get(el);\n if (instance) instance.goTo(index);\n },\n\n next: function (el) {\n const instance = this.instances.get(el);\n if (instance) instance.next();\n },\n\n prev: function (el) {\n const instance = this.instances.get(el);\n if (instance) instance.prev();\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('flow', Flow);\n }\n\n window.VanduoFlow = Flow;\n\n})();\n", "/**\n * Vanduo Framework - Bubble (Popover) Component\n * Click-triggered rich HTML popover, reuses tooltip positioning concepts\n */\n\n(function () {\n 'use strict';\n\n const Bubble = {\n instances: new Map(),\n _globalCleanups: [],\n\n init: function () {\n const triggers = document.querySelectorAll('[data-vd-bubble], [data-vd-popover]');\n triggers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n\n if (this._globalCleanups.length === 0) {\n const outsideClick = (e) => {\n this.instances.forEach((inst, trigger) => {\n if (!inst.popover.contains(e.target) && !trigger.contains(e.target)) {\n this.hide(trigger);\n }\n });\n };\n const escHandler = (e) => {\n if (e.key === 'Escape') {\n this.instances.forEach((_, trigger) => this.hide(trigger));\n }\n };\n document.addEventListener('click', outsideClick, true);\n document.addEventListener('keydown', escHandler);\n this._globalCleanups.push(\n () => document.removeEventListener('click', outsideClick, true),\n () => document.removeEventListener('keydown', escHandler)\n );\n }\n },\n\n initInstance: function (trigger) {\n const cleanup = [];\n const placement = trigger.getAttribute('data-vd-bubble-placement') ||\n trigger.getAttribute('data-vd-popover-placement') || 'bottom';\n\n // Build popover element\n const popover = document.createElement('div');\n popover.className = 'vd-bubble-content';\n popover.setAttribute('role', 'dialog');\n popover.setAttribute('aria-modal', 'false');\n popover.setAttribute('data-placement', placement);\n\n const title = trigger.getAttribute('data-vd-bubble-title') ||\n trigger.getAttribute('data-vd-popover-title');\n const content = trigger.getAttribute('data-vd-bubble') ||\n trigger.getAttribute('data-vd-popover') || '';\n const htmlContent = trigger.getAttribute('data-vd-bubble-html') ||\n trigger.getAttribute('data-vd-popover-html');\n const allowSvg = trigger.hasAttribute('data-vd-bubble-allow-svg') ||\n trigger.hasAttribute('data-vd-popover-allow-svg');\n\n if (title) {\n const header = document.createElement('div');\n header.className = 'vd-bubble-header';\n const titleSpan = document.createElement('span');\n titleSpan.textContent = title;\n const closeBtn = document.createElement('button');\n closeBtn.className = 'vd-bubble-close';\n closeBtn.setAttribute('aria-label', 'Close');\n closeBtn.innerHTML = '×';\n header.appendChild(titleSpan);\n header.appendChild(closeBtn);\n popover.appendChild(header);\n\n const closeHandler = (e) => { e.stopPropagation(); this.hide(trigger); };\n closeBtn.addEventListener('click', closeHandler);\n cleanup.push(() => closeBtn.removeEventListener('click', closeHandler));\n }\n\n const body = document.createElement('div');\n body.className = 'vd-bubble-body';\n if (htmlContent) {\n if (typeof sanitizeHtml === 'function') {\n body.innerHTML = sanitizeHtml(htmlContent, { allowSvg });\n } else {\n body.textContent = htmlContent;\n }\n } else {\n body.textContent = content;\n }\n popover.appendChild(body);\n\n document.body.appendChild(popover);\n\n // ARIA on trigger\n const popId = 'vd-bubble-' + Math.random().toString(36).slice(2, 9);\n popover.id = popId;\n trigger.setAttribute('aria-haspopup', 'dialog');\n trigger.setAttribute('aria-expanded', 'false');\n trigger.setAttribute('aria-controls', popId);\n\n // Toggle on click\n const toggleHandler = (e) => {\n e.stopPropagation();\n if (popover.classList.contains('is-visible')) {\n this.hide(trigger);\n } else {\n this.hideAll();\n this.show(trigger);\n }\n };\n trigger.addEventListener('click', toggleHandler);\n cleanup.push(() => trigger.removeEventListener('click', toggleHandler));\n\n this.instances.set(trigger, { popover, cleanup, placement });\n },\n\n position: function (trigger, popover, placement) {\n const rect = trigger.getBoundingClientRect();\n const popRect = popover.getBoundingClientRect();\n const gap = 10;\n let top, left;\n\n switch (placement) {\n case 'top':\n top = rect.top - popRect.height - gap + window.scrollY;\n left = rect.left + (rect.width - popRect.width) / 2 + window.scrollX;\n break;\n case 'left':\n top = rect.top + (rect.height - popRect.height) / 2 + window.scrollY;\n left = rect.left - popRect.width - gap + window.scrollX;\n break;\n case 'right':\n top = rect.top + (rect.height - popRect.height) / 2 + window.scrollY;\n left = rect.right + gap + window.scrollX;\n break;\n default: // bottom\n top = rect.bottom + gap + window.scrollY;\n left = rect.left + (rect.width - popRect.width) / 2 + window.scrollX;\n }\n\n // Clamp to viewport\n left = Math.max(8, Math.min(left, window.innerWidth - popRect.width - 8));\n top = Math.max(8, top);\n\n popover.style.top = top + 'px';\n popover.style.left = left + 'px';\n },\n\n show: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n const { popover, placement } = instance;\n\n popover.style.display = 'block';\n popover.classList.add('is-visible');\n trigger.setAttribute('aria-expanded', 'true');\n\n requestAnimationFrame(() => {\n this.position(trigger, popover, placement);\n });\n\n trigger.dispatchEvent(new CustomEvent('bubble:show', {\n bubbles: true,\n detail: { trigger: trigger, placement: placement }\n }));\n },\n\n hide: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n instance.popover.classList.remove('is-visible');\n trigger.setAttribute('aria-expanded', 'false');\n trigger.dispatchEvent(new CustomEvent('bubble:hide', {\n bubbles: true,\n detail: { trigger: trigger }\n }));\n },\n\n hideAll: function () {\n this.instances.forEach((_, trigger) => this.hide(trigger));\n },\n\n destroy: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n if (instance.popover.parentNode) {\n instance.popover.parentNode.removeChild(instance.popover);\n }\n trigger.removeAttribute('aria-haspopup');\n trigger.removeAttribute('aria-expanded');\n trigger.removeAttribute('aria-controls');\n this.instances.delete(trigger);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, trigger) => this.destroy(trigger));\n this._globalCleanups.forEach(fn => fn());\n this._globalCleanups = [];\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('bubble', Bubble);\n }\n\n window.VanduoBubble = Bubble;\n\n})();\n", "/**\n * Vanduo Framework - Waypoint (Scrollspy) Component\n * Highlights navigation links based on scroll position using IntersectionObserver\n */\n\n(function () {\n 'use strict';\n\n const Waypoint = {\n instances: new Map(),\n\n init: function () {\n const navs = document.querySelectorAll('[data-vd-waypoint-nav], [data-vd-scrollspy-nav]');\n navs.forEach(nav => {\n if (this.instances.has(nav)) return;\n this.initInstance(nav);\n });\n },\n\n initInstance: function (nav) {\n const links = Array.from(nav.querySelectorAll('a[href^=\"#\"]'));\n if (links.length === 0) return;\n\n const cleanup = [];\n const offset = parseInt(nav.getAttribute('data-vd-waypoint-offset') || '80', 10);\n const sections = [];\n\n links.forEach(link => {\n const id = link.getAttribute('href').slice(1);\n const section = document.getElementById(id);\n if (section) {\n section.setAttribute('data-vd-waypoint-section', '');\n sections.push({ id, link, section });\n }\n });\n\n if (sections.length === 0) return;\n\n const activeSections = new Set();\n\n const setActive = (id) => {\n links.forEach(l => l.classList.remove('is-active'));\n const target = links.find(l => l.getAttribute('href') === '#' + id);\n if (target) {\n target.classList.add('is-active');\n nav.dispatchEvent(new CustomEvent('waypoint:change', {\n detail: { activeId: id, link: target }\n }));\n }\n };\n\n const rootMargin = '-' + offset + 'px 0px -40% 0px';\n\n const observer = new IntersectionObserver((entries) => {\n entries.forEach(entry => {\n if (entry.isIntersecting) {\n activeSections.add(entry.target.id);\n } else {\n activeSections.delete(entry.target.id);\n }\n });\n\n // Pick the topmost visible section\n for (let i = 0; i < sections.length; i++) {\n if (activeSections.has(sections[i].id)) {\n setActive(sections[i].id);\n return;\n }\n }\n }, {\n rootMargin: rootMargin,\n threshold: 0\n });\n\n sections.forEach(s => observer.observe(s.section));\n\n // Smooth scroll on click\n links.forEach(link => {\n const clickHandler = (e) => {\n e.preventDefault();\n const id = link.getAttribute('href').slice(1);\n const section = document.getElementById(id);\n if (section) {\n section.scrollIntoView({ behavior: 'smooth' });\n setActive(id);\n }\n };\n link.addEventListener('click', clickHandler);\n cleanup.push(() => link.removeEventListener('click', clickHandler));\n });\n\n cleanup.push(() => observer.disconnect());\n\n this.instances.set(nav, { observer, cleanup, sections, setActive });\n },\n\n refresh: function (nav) {\n this.destroy(nav);\n this.initInstance(nav);\n },\n\n destroy: function (nav) {\n const instance = this.instances.get(nav);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(nav);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, nav) => this.destroy(nav));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('waypoint', Waypoint);\n }\n\n window.VanduoWaypoint = Waypoint;\n\n})();\n", "/**\n * Vanduo Framework - Ripple (Waves Effect) Component\n * Adds expanding circle animation on click at pointer position\n */\n\n(function () {\n 'use strict';\n\n const Ripple = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-ripple, [data-vd-ripple]');\n elements.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n\n const createWave = (e) => {\n const rect = el.getBoundingClientRect();\n const size = Math.max(rect.width, rect.height);\n const x = (e.clientX || (e.touches && e.touches[0].clientX) || rect.left + rect.width / 2) - rect.left - size / 2;\n const y = (e.clientY || (e.touches && e.touches[0].clientY) || rect.top + rect.height / 2) - rect.top - size / 2;\n\n const wave = document.createElement('span');\n wave.className = 'vd-ripple-wave';\n wave.style.width = size + 'px';\n wave.style.height = size + 'px';\n wave.style.left = x + 'px';\n wave.style.top = y + 'px';\n\n el.appendChild(wave);\n\n wave.addEventListener('animationend', () => {\n if (wave.parentNode) wave.parentNode.removeChild(wave);\n });\n };\n\n el.addEventListener('mousedown', createWave);\n el.addEventListener('touchstart', createWave, { passive: true });\n\n cleanup.push(\n () => el.removeEventListener('mousedown', createWave),\n () => el.removeEventListener('touchstart', createWave)\n );\n\n this.instances.set(el, { cleanup });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n // Remove any lingering wave elements\n el.querySelectorAll('.vd-ripple-wave').forEach(w => w.remove());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('ripple', Ripple);\n }\n\n window.VanduoRipple = Ripple;\n\n})();\n", "/**\n * Vanduo Framework - Affix (Sticky) Component\n * Uses IntersectionObserver to toggle .is-stuck within the nearest scrollable parent\n */\n\n(function () {\n 'use strict';\n\n function isScrollable(element) {\n if (!element || element === document.body) return false;\n\n const style = window.getComputedStyle(element);\n const overflowY = style.overflowY;\n const overflowX = style.overflowX;\n const canScrollY = /(auto|scroll|overlay)/.test(overflowY) && element.scrollHeight > element.clientHeight;\n const canScrollX = /(auto|scroll|overlay)/.test(overflowX) && element.scrollWidth > element.clientWidth;\n\n return canScrollY || canScrollX;\n }\n\n function getScrollParent(element) {\n let parent = element.parentElement;\n\n while (parent && parent !== document.body && parent !== document.documentElement) {\n if (isScrollable(parent)) return parent;\n parent = parent.parentElement;\n }\n\n return null;\n }\n\n const Affix = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-affix, .vd-sticky, [data-vd-affix]');\n elements.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const parsedOffset = parseInt(el.getAttribute('data-vd-affix-offset') || '0', 10);\n const offset = Number.isNaN(parsedOffset) ? 0 : parsedOffset;\n const scrollParent = getScrollParent(el);\n let isStuck = false;\n\n const sentinel = document.createElement('div');\n sentinel.style.cssText = 'display:block;height:1px;margin-bottom:-1px;visibility:hidden;pointer-events:none;';\n el.parentNode.insertBefore(sentinel, el);\n\n el.style.setProperty('--affix-top-offset', offset + 'px');\n\n function stick() {\n if (isStuck) return;\n isStuck = true;\n el.classList.add('is-stuck');\n el.dispatchEvent(new CustomEvent('affix:stuck', {\n bubbles: true,\n detail: {\n offset: offset,\n root: scrollParent || window\n }\n }));\n }\n\n function unstick() {\n if (!isStuck) return;\n isStuck = false;\n el.classList.remove('is-stuck');\n el.dispatchEvent(new CustomEvent('affix:unstuck', {\n bubbles: true,\n detail: {\n offset: offset,\n root: scrollParent || window\n }\n }));\n }\n\n const observer = new IntersectionObserver(function (entries) {\n entries.forEach(entry => {\n if (!entry.isIntersecting) {\n stick();\n } else {\n unstick();\n }\n });\n }, {\n root: scrollParent,\n rootMargin: '-' + offset + 'px 0px 0px 0px',\n threshold: 0\n });\n\n observer.observe(sentinel);\n\n cleanup.push(\n () => observer.disconnect(),\n () => { if (sentinel.parentNode) sentinel.parentNode.removeChild(sentinel); },\n () => {\n el.classList.remove('is-stuck');\n el.style.removeProperty('--affix-top-offset');\n }\n );\n\n this.instances.set(el, { cleanup, observer, sentinel, scrollParent });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n el.classList.remove('is-stuck');\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('affix', Affix);\n }\n\n window.VanduoAffix = Affix;\n\n})();\n", "/**\n * Vanduo Framework - Suggest (Autocomplete) Component\n * Dropdown suggestion list with keyboard navigation, static/async data sources\n */\n\n(function () {\n 'use strict';\n\n /**\n * Escape HTML entities to prevent XSS when inserting into innerHTML\n * @param {string} text\n * @returns {string}\n */\n function _escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Allow same-origin URLs by default, or an explicit allowlist of origins.\n * @param {string} url\n * @param {string[]} allowlist\n * @returns {boolean}\n */\n function _isSafeUrl(url, allowlist) {\n try {\n const resolved = new URL(url, window.location.href);\n if (resolved.origin === window.location.origin) return true;\n return allowlist.includes(resolved.origin);\n } catch (_e) {\n return false;\n }\n }\n\n const Suggest = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-suggest], [data-vd-autocomplete]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const minChars = parseInt(input.getAttribute('data-vd-suggest-min-chars') || '1', 10);\n const url = input.getAttribute('data-vd-suggest-url') || '';\n const allowlistAttr = input.getAttribute('data-vd-suggest-allowlist') || '';\n const allowlist = allowlistAttr\n .split(',')\n .map(value => value.trim())\n .filter(Boolean);\n const staticData = input.getAttribute('data-vd-suggest') || input.getAttribute('data-vd-autocomplete') || '';\n let items = [];\n\n try { items = JSON.parse(staticData); } catch (_e) {\n items = staticData.split(',').map(s => s.trim()).filter(Boolean);\n }\n\n // Wrap input if not already wrapped\n let wrapper = input.closest('.vd-suggest-wrapper, .vd-autocomplete-wrapper');\n if (!wrapper) {\n wrapper = document.createElement('div');\n wrapper.className = 'vd-suggest-wrapper';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n }\n\n // Create dropdown list\n const list = document.createElement('ul');\n list.className = 'vd-suggest-list';\n list.setAttribute('role', 'listbox');\n const listId = 'vd-suggest-' + Math.random().toString(36).slice(2, 9);\n list.id = listId;\n wrapper.appendChild(list);\n\n // ARIA\n input.setAttribute('role', 'combobox');\n input.setAttribute('aria-autocomplete', 'list');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('aria-controls', listId);\n input.setAttribute('autocomplete', 'off');\n\n let highlighted = -1;\n let currentItems = [];\n let debounceTimer = null;\n\n const renderItems = (filtered, query) => {\n list.innerHTML = '';\n currentItems = filtered;\n highlighted = -1;\n\n if (filtered.length === 0) {\n const empty = document.createElement('li');\n empty.className = 'vd-suggest-empty';\n empty.textContent = 'No results';\n list.appendChild(empty);\n return;\n }\n\n filtered.forEach((item, i) => {\n const li = document.createElement('li');\n li.className = 'vd-suggest-item';\n li.setAttribute('role', 'option');\n li.id = listId + '-item-' + i;\n\n const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);\n if (query) {\n // Escape HTML first to prevent XSS, then highlight matches in the safe string\n const escaped = _escapeHtml(text);\n const re = new RegExp('(' + query.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi');\n li.innerHTML = escaped.replace(re, '$1');\n } else {\n li.textContent = text;\n }\n\n li.addEventListener('click', () => selectItem(i));\n list.appendChild(li);\n });\n };\n\n const open = () => {\n list.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n };\n\n const close = () => {\n list.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n highlighted = -1;\n input.removeAttribute('aria-activedescendant');\n };\n\n const selectItem = (index) => {\n const item = currentItems[index];\n const value = typeof item === 'object' ? (item.value || item.label || String(item)) : String(item);\n input.value = value;\n close();\n input.dispatchEvent(new CustomEvent('suggest:select', {\n detail: { value, item, index },\n bubbles: true\n }));\n };\n\n const highlight = (index) => {\n const listItems = list.querySelectorAll('.vd-suggest-item');\n listItems.forEach(li => li.classList.remove('is-highlighted'));\n if (index >= 0 && index < listItems.length) {\n highlighted = index;\n listItems[index].classList.add('is-highlighted');\n input.setAttribute('aria-activedescendant', listItems[index].id);\n listItems[index].scrollIntoView({ block: 'nearest' });\n }\n };\n\n const doSearch = async (query) => {\n if (query.length < minChars) { close(); return; }\n\n let filtered;\n if (url) {\n try {\n if (!_isSafeUrl(url, allowlist)) {\n console.warn('[VanduoSuggest] Blocked non-allowlisted URL:', url);\n filtered = [];\n } else {\n const separator = url.includes('?') ? '&' : '?';\n const res = await window.fetch(url + separator + 'q=' + encodeURIComponent(query));\n filtered = await res.json();\n }\n } catch (_e) {\n filtered = [];\n }\n } else {\n const lower = query.toLowerCase();\n filtered = items.filter(item => {\n const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);\n return text.toLowerCase().includes(lower);\n });\n }\n\n renderItems(filtered, query);\n if (filtered.length > 0) open();\n else open(); // show \"no results\"\n };\n\n const inputHandler = () => {\n clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => doSearch(input.value), 200);\n };\n\n const keyHandler = (e) => {\n if (!list.classList.contains('is-open')) {\n if (e.key === 'ArrowDown') { doSearch(input.value); e.preventDefault(); }\n return;\n }\n\n const total = currentItems.length;\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n highlight(highlighted < total - 1 ? highlighted + 1 : 0);\n break;\n case 'ArrowUp':\n e.preventDefault();\n highlight(highlighted > 0 ? highlighted - 1 : total - 1);\n break;\n case 'Enter':\n e.preventDefault();\n if (highlighted >= 0) selectItem(highlighted);\n break;\n case 'Escape':\n close();\n break;\n }\n };\n\n const blurHandler = () => {\n setTimeout(close, 200);\n };\n\n input.addEventListener('input', inputHandler);\n input.addEventListener('keydown', keyHandler);\n input.addEventListener('blur', blurHandler);\n input.addEventListener('focus', () => { if (input.value.length >= minChars) doSearch(input.value); });\n\n cleanup.push(\n () => input.removeEventListener('input', inputHandler),\n () => input.removeEventListener('keydown', keyHandler),\n () => input.removeEventListener('blur', blurHandler),\n () => clearTimeout(debounceTimer),\n () => { if (list.parentNode) list.parentNode.removeChild(list); }\n );\n\n this.instances.set(input, { cleanup, list, close });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('suggest', Suggest);\n }\n\n window.VanduoSuggest = Suggest;\n\n})();\n", "/**\n * Vanduo Framework - Validate (Form Validation) Component\n * Declarative validation via data attributes with real-time and on-submit modes\n */\n\n(function () {\n 'use strict';\n\n const Validate = {\n instances: new Map(),\n\n rules: {\n required: (value) => value.trim().length > 0,\n email: (value) => /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(value),\n url: (value) => { try { new URL(value); return true; } catch (_e) { return false; } },\n number: (value) => !isNaN(parseFloat(value)) && isFinite(value),\n min: (value, param) => value.length >= parseInt(param, 10),\n max: (value, param) => value.length <= parseInt(param, 10),\n minVal: (value, param) => parseFloat(value) >= parseFloat(param),\n maxVal: (value, param) => parseFloat(value) <= parseFloat(param),\n pattern: (value, param) => {\n try {\n // Cap regex length to prevent ReDoS from excessively complex patterns\n if (param.length > 100) return false;\n return new RegExp(param).test(value);\n } catch (_e) { return false; }\n },\n match: (value, param) => {\n try {\n const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(param) : param;\n const other = document.querySelector('[name=\"' + escaped + '\"]');\n return other ? value === other.value : false;\n } catch (_e) {\n return false;\n }\n }\n },\n\n messages: {\n required: 'This field is required',\n email: 'Please enter a valid email address',\n url: 'Please enter a valid URL',\n number: 'Please enter a valid number',\n min: 'Minimum {0} characters required',\n max: 'Maximum {0} characters allowed',\n minVal: 'Value must be at least {0}',\n maxVal: 'Value must be at most {0}',\n pattern: 'Invalid format',\n match: 'Fields do not match'\n },\n\n init: function () {\n const forms = document.querySelectorAll('[data-vd-validate], .vd-validate');\n forms.forEach(form => {\n if (this.instances.has(form)) return;\n this.initInstance(form);\n });\n },\n\n initInstance: function (form) {\n const cleanup = [];\n const mode = form.getAttribute('data-vd-validate-mode') || 'blur'; // blur | input | submit\n const fields = form.querySelectorAll('[data-vd-rules]');\n\n const validateField = (field) => {\n const rulesStr = field.getAttribute('data-vd-rules') || '';\n const rules = rulesStr.split('|').map(r => r.trim()).filter(Boolean);\n const value = field.value;\n const errors = [];\n\n for (const rule of rules) {\n const [name, ...params] = rule.split(':');\n const param = params.join(':');\n const validator = this.rules[name];\n\n if (validator && !validator(value, param)) {\n const customMsg = field.getAttribute('data-vd-msg-' + name);\n let msg = customMsg || this.messages[name] || 'Invalid';\n if (param) msg = msg.replace('{0}', param);\n errors.push(msg);\n break; // one error at a time\n }\n }\n\n this.setFieldState(field, errors);\n return errors.length === 0;\n };\n\n const validateAll = () => {\n let valid = true;\n fields.forEach(field => {\n if (!validateField(field)) valid = false;\n });\n return valid;\n };\n\n // Per-field listeners\n fields.forEach(field => {\n if (mode === 'input' || mode === 'blur') {\n const eventType = mode === 'input' ? 'input' : 'blur';\n const handler = () => validateField(field);\n field.addEventListener(eventType, handler);\n cleanup.push(() => field.removeEventListener(eventType, handler));\n\n if (mode === 'blur') {\n const inputClear = () => {\n if (field.classList.contains('is-invalid') || field.classList.contains('is-valid')) {\n validateField(field);\n }\n };\n field.addEventListener('input', inputClear);\n cleanup.push(() => field.removeEventListener('input', inputClear));\n }\n }\n });\n\n // Form submit\n const submitHandler = (e) => {\n const valid = validateAll();\n if (!valid) {\n e.preventDefault();\n e.stopPropagation();\n // Focus first invalid field\n const firstInvalid = form.querySelector('.is-invalid');\n if (firstInvalid) firstInvalid.focus();\n }\n form.dispatchEvent(new CustomEvent('validate:submit', {\n detail: { valid },\n bubbles: true\n }));\n };\n\n form.addEventListener('submit', submitHandler);\n cleanup.push(() => form.removeEventListener('submit', submitHandler));\n\n this.instances.set(form, { cleanup, validateAll, validateField });\n },\n\n setFieldState: function (field, errors) {\n const wrapper = field.closest('.vd-form-group') || field.parentElement;\n let errorEl = wrapper.querySelector('.vd-validate-error');\n\n field.classList.remove('is-valid', 'is-invalid');\n\n if (errors.length > 0) {\n field.classList.add('is-invalid');\n field.setAttribute('aria-invalid', 'true');\n if (!errorEl) {\n errorEl = document.createElement('div');\n errorEl.className = 'vd-validate-error';\n errorEl.id = 'vd-err-' + Math.random().toString(36).slice(2, 9);\n errorEl.setAttribute('role', 'alert');\n wrapper.appendChild(errorEl);\n }\n errorEl.textContent = errors[0];\n errorEl.style.display = '';\n field.setAttribute('aria-describedby', errorEl.id);\n } else if (field.value.trim()) {\n field.classList.add('is-valid');\n field.removeAttribute('aria-invalid');\n if (errorEl) errorEl.style.display = 'none';\n } else {\n field.removeAttribute('aria-invalid');\n if (errorEl) errorEl.style.display = 'none';\n }\n },\n\n validateForm: function (form) {\n const instance = this.instances.get(form);\n return instance ? instance.validateAll() : false;\n },\n\n addRule: function (name, validator, message) {\n this.rules[name] = validator;\n if (message) this.messages[name] = message;\n },\n\n destroy: function (form) {\n const instance = this.instances.get(form);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(form);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, form) => this.destroy(form));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('validate', Validate);\n }\n\n window.VanduoValidate = Validate;\n\n})();\n", "/**\n * Vanduo Framework - Datepicker Component\n * Calendar popup attached to input field with month/year navigation\n */\n\n(function () {\n 'use strict';\n\n const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];\n const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];\n\n function escapeRegexChar(c) {\n return c.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n }\n\n function buildParseFormat(format) {\n let regex = '^';\n const order = [];\n let i = 0;\n while (i < format.length) {\n const slice = format.slice(i);\n if (slice.toLowerCase().startsWith('yyyy')) {\n regex += '(\\\\d{4})';\n order.push('y');\n i += 4;\n } else if (slice.toLowerCase().startsWith('mm')) {\n regex += '(\\\\d{2})';\n order.push('m');\n i += 2;\n } else if (slice.toLowerCase().startsWith('dd')) {\n regex += '(\\\\d{2})';\n order.push('d');\n i += 2;\n } else {\n regex += escapeRegexChar(format[i]);\n i++;\n }\n }\n regex += '$';\n return { regex: new RegExp(regex), order };\n }\n\n function parseDateFromFormat(value, format) {\n if (!value || !format) return null;\n const { regex, order } = buildParseFormat(format);\n const m = value.trim().match(regex);\n if (!m) return null;\n let y;\n let mo;\n let d;\n let ci = 1;\n for (let k = 0; k < order.length; k++) {\n const part = order[k];\n const v = parseInt(m[ci++], 10);\n if (Number.isNaN(v)) return null;\n if (part === 'y') y = v;\n else if (part === 'm') mo = v - 1;\n else if (part === 'd') d = v;\n }\n if (y === undefined || mo === undefined || d === undefined) return null;\n const dt = new Date(y, mo, d);\n if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== d) return null;\n return dt;\n }\n\n function formatDate(d, format) {\n const yyyy = String(d.getFullYear());\n const mm = String(d.getMonth() + 1).padStart(2, '0');\n const dd = String(d.getDate()).padStart(2, '0');\n let out = '';\n let i = 0;\n while (i < format.length) {\n const slice = format.slice(i);\n if (slice.toLowerCase().startsWith('yyyy')) {\n out += yyyy;\n i += 4;\n } else if (slice.toLowerCase().startsWith('mm')) {\n out += mm;\n i += 2;\n } else if (slice.toLowerCase().startsWith('dd')) {\n out += dd;\n i += 2;\n } else {\n out += format[i];\n i++;\n }\n }\n return out;\n }\n\n function dateKey(d) {\n return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');\n }\n\n function addDays(d, n) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n x.setDate(x.getDate() + n);\n return x;\n }\n\n function addMonthsClamped(d, n) {\n return new Date(d.getFullYear(), d.getMonth() + n, d.getDate());\n }\n\n function parseYmdLocal(ymd) {\n if (!ymd || typeof ymd !== 'string') return null;\n const m = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(ymd.trim());\n if (!m) return null;\n const y = +m[1];\n const mo = +m[2] - 1;\n const day = +m[3];\n const dt = new Date(y, mo, day);\n if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== day) return null;\n return dt;\n }\n\n function startOfWeekSunday(d) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const day = x.getDay();\n x.setDate(x.getDate() - day);\n return x;\n }\n\n function endOfWeekSunday(d) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const day = x.getDay();\n x.setDate(x.getDate() + (6 - day));\n return x;\n }\n\n const Datepicker = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-datepicker]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const format = input.getAttribute('data-vd-datepicker-format') || 'YYYY-MM-DD';\n const minStr = input.getAttribute('data-vd-datepicker-min');\n const maxStr = input.getAttribute('data-vd-datepicker-max');\n const minDate = minStr ? parseYmdLocal(minStr) : null;\n const maxDate = maxStr ? parseYmdLocal(maxStr) : null;\n\n const today = new Date();\n let viewYear = today.getFullYear();\n let viewMonth = today.getMonth();\n let selectedDate = null;\n let viewMode = 'days'; // days | months | years\n let focusedDate = null;\n /** Prevents focus() after close from immediately re-opening the popup */\n let skipNextFocusOpen = false;\n\n const isDisabled = (d) => {\n if (minDate) {\n const t = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n if (t < minDate.getTime()) return true;\n }\n if (maxDate) {\n const t = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n if (t > maxDate.getTime()) return true;\n }\n return false;\n };\n\n const ensureMonthInRange = (y, m) => {\n if (!minDate && !maxDate) return { y: y, m: m };\n const first = new Date(y, m, 1);\n const last = new Date(y, m + 1, 0);\n if (minDate && last.getTime() < minDate.getTime()) {\n return { y: minDate.getFullYear(), m: minDate.getMonth() };\n }\n if (maxDate && first.getTime() > maxDate.getTime()) {\n return { y: maxDate.getFullYear(), m: maxDate.getMonth() };\n }\n return { y: y, m: m };\n };\n\n const firstSelectableInMonth = (y, m) => {\n const last = new Date(y, m + 1, 0).getDate();\n for (let day = 1; day <= last; day++) {\n const dt = new Date(y, m, day);\n if (!isDisabled(dt)) return dt;\n }\n return new Date(y, m, 1);\n };\n\n if (input.value) {\n const trimmed = input.value.trim();\n let parsed = parseDateFromFormat(trimmed, format);\n if (!parsed) {\n const fallback = new Date(trimmed);\n if (!isNaN(fallback.getTime())) parsed = fallback;\n }\n if (parsed) {\n selectedDate = parsed;\n viewYear = parsed.getFullYear();\n viewMonth = parsed.getMonth();\n }\n }\n\n const clampedInit = ensureMonthInRange(viewYear, viewMonth);\n viewYear = clampedInit.y;\n viewMonth = clampedInit.m;\n\n // Create popup\n const popup = document.createElement('div');\n popup.className = 'vd-datepicker-popup';\n popup.setAttribute('role', 'dialog');\n popup.setAttribute('aria-label', 'Choose date');\n popup.tabIndex = -1;\n\n const wrapper = document.createElement('div');\n wrapper.className = 'vd-suggest-wrapper';\n wrapper.style.position = 'relative';\n wrapper.style.display = 'inline-block';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n wrapper.appendChild(popup);\n\n const isSameDay = (a, b) => a && b &&\n a.getFullYear() === b.getFullYear() &&\n a.getMonth() === b.getMonth() &&\n a.getDate() === b.getDate();\n\n const selectDate = (date) => {\n selectedDate = date;\n viewYear = date.getFullYear();\n viewMonth = date.getMonth();\n input.value = formatDate(date, format);\n skipNextFocusOpen = true;\n close();\n input.dispatchEvent(new CustomEvent('datepicker:select', {\n detail: { date: date, formatted: input.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n input.focus();\n };\n\n const focusFocusedDay = () => {\n if (viewMode !== 'days' || !focusedDate) return;\n const key = dateKey(focusedDate);\n const btn = popup.querySelector('[data-vd-date=\"' + key + '\"]');\n if (btn && !btn.classList.contains('is-outside') && btn.getAttribute('aria-disabled') !== 'true') {\n btn.focus();\n }\n };\n\n const skipDisabled = (d, stepDir, maxSteps) => {\n let x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const step = stepDir > 0 ? 1 : -1;\n for (let i = 0; i < maxSteps; i++) {\n if (!isDisabled(x)) return x;\n x = addDays(x, step);\n }\n return d;\n };\n\n const createDayBtn = (day, outside, date) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-day';\n btn.textContent = day;\n btn.setAttribute('role', 'gridcell');\n\n if (outside) {\n btn.classList.add('is-outside');\n btn.tabIndex = -1;\n btn.setAttribute('aria-disabled', 'true');\n return btn;\n }\n\n btn.setAttribute('data-vd-date', dateKey(date));\n\n if (date && isSameDay(date, today)) btn.classList.add('is-today');\n if (date && isSameDay(date, selectedDate)) btn.classList.add('is-selected');\n if (date && isDisabled(date)) {\n btn.classList.add('is-disabled');\n btn.setAttribute('aria-disabled', 'true');\n btn.tabIndex = -1;\n return btn;\n }\n\n if (date) {\n const isFocused = focusedDate && isSameDay(date, focusedDate);\n btn.tabIndex = isFocused ? 0 : -1;\n\n btn.addEventListener('click', () => {\n selectedDate = date;\n viewYear = date.getFullYear();\n viewMonth = date.getMonth();\n focusedDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());\n input.value = formatDate(date, format);\n skipNextFocusOpen = true;\n close();\n input.dispatchEvent(new CustomEvent('datepicker:select', {\n detail: { date: date, formatted: input.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n input.focus();\n });\n }\n\n return btn;\n };\n\n const render = () => {\n popup.innerHTML = '';\n\n // Header\n const header = document.createElement('div');\n header.className = 'vd-datepicker-header';\n\n const prevBtn = document.createElement('button');\n prevBtn.type = 'button';\n prevBtn.className = 'vd-datepicker-prev';\n prevBtn.innerHTML = '‹';\n prevBtn.setAttribute('aria-label', 'Previous');\n\n const nextBtn = document.createElement('button');\n nextBtn.type = 'button';\n nextBtn.className = 'vd-datepicker-next';\n nextBtn.innerHTML = '›';\n nextBtn.setAttribute('aria-label', 'Next');\n\n const title = document.createElement('span');\n title.className = 'vd-datepicker-title';\n\n if (viewMode === 'days') {\n title.textContent = MONTHS[viewMonth] + ' ' + viewYear;\n title.addEventListener('click', () => { viewMode = 'months'; render(); });\n prevBtn.addEventListener('click', () => { viewMonth--; if (viewMonth < 0) { viewMonth = 11; viewYear--; } render(); });\n nextBtn.addEventListener('click', () => { viewMonth++; if (viewMonth > 11) { viewMonth = 0; viewYear++; } render(); });\n } else if (viewMode === 'months') {\n title.textContent = String(viewYear);\n title.addEventListener('click', () => { viewMode = 'years'; render(); });\n prevBtn.addEventListener('click', () => { viewYear--; render(); });\n nextBtn.addEventListener('click', () => { viewYear++; render(); });\n } else {\n const decadeStart = Math.floor(viewYear / 10) * 10;\n title.textContent = decadeStart + ' - ' + (decadeStart + 9);\n prevBtn.addEventListener('click', () => { viewYear -= 10; render(); });\n nextBtn.addEventListener('click', () => { viewYear += 10; render(); });\n }\n\n header.appendChild(prevBtn);\n header.appendChild(title);\n header.appendChild(nextBtn);\n popup.appendChild(header);\n\n if (viewMode === 'days') {\n const gridWrap = document.createElement('div');\n gridWrap.className = 'vd-datepicker-grid';\n gridWrap.setAttribute('role', 'grid');\n gridWrap.setAttribute('aria-label', 'Calendar');\n\n const weekdays = document.createElement('div');\n weekdays.className = 'vd-datepicker-weekdays';\n weekdays.setAttribute('role', 'row');\n DAYS.forEach(function (d) {\n const span = document.createElement('span');\n span.setAttribute('role', 'columnheader');\n span.setAttribute('aria-label', d);\n span.textContent = d;\n weekdays.appendChild(span);\n });\n gridWrap.appendChild(weekdays);\n\n const firstDay = new Date(viewYear, viewMonth, 1).getDay();\n const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();\n const daysInPrev = new Date(viewYear, viewMonth, 0).getDate();\n\n const cells = [];\n\n for (let i = firstDay - 1; i >= 0; i--) {\n const dayNum = daysInPrev - i;\n const prevMonth = viewMonth === 0 ? 11 : viewMonth - 1;\n const prevYear = viewMonth === 0 ? viewYear - 1 : viewYear;\n const date = new Date(prevYear, prevMonth, dayNum);\n cells.push({ day: dayNum, outside: true, date: date });\n }\n\n for (let d = 1; d <= daysInMonth; d++) {\n const date = new Date(viewYear, viewMonth, d);\n cells.push({ day: d, outside: false, date: date });\n }\n\n const totalCells = firstDay + daysInMonth;\n const remaining = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7);\n for (let i = 1; i <= remaining; i++) {\n const date = new Date(viewYear, viewMonth + 1, i);\n cells.push({ day: i, outside: true, date: date });\n }\n\n for (let r = 0; r < cells.length; r += 7) {\n const row = document.createElement('div');\n row.className = 'vd-datepicker-row';\n row.setAttribute('role', 'row');\n for (let c = 0; c < 7; c++) {\n const cell = cells[r + c];\n row.appendChild(createDayBtn(cell.day, cell.outside, cell.date));\n }\n gridWrap.appendChild(row);\n }\n\n popup.appendChild(gridWrap);\n } else if (viewMode === 'months') {\n const grid = document.createElement('div');\n grid.className = 'vd-datepicker-months';\n MONTHS.forEach((name, i) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-month-btn';\n btn.textContent = name.slice(0, 3);\n if (selectedDate && selectedDate.getFullYear() === viewYear && selectedDate.getMonth() === i) {\n btn.classList.add('is-selected');\n }\n btn.addEventListener('click', () => { viewMonth = i; viewMode = 'days'; render(); });\n grid.appendChild(btn);\n });\n popup.appendChild(grid);\n } else {\n const grid = document.createElement('div');\n grid.className = 'vd-datepicker-years';\n const decadeStart = Math.floor(viewYear / 10) * 10;\n for (let y = decadeStart - 1; y <= decadeStart + 10; y++) {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-year-btn';\n btn.textContent = y;\n if (selectedDate && selectedDate.getFullYear() === y) btn.classList.add('is-selected');\n if (y < decadeStart || y > decadeStart + 9) btn.style.opacity = '0.4';\n btn.addEventListener('click', () => { viewYear = y; viewMode = 'months'; render(); });\n grid.appendChild(btn);\n }\n popup.appendChild(grid);\n }\n };\n\n const handleGridKeydown = (e) => {\n if (!popup.classList.contains('is-open') || viewMode !== 'days') return;\n const grid = popup.querySelector('.vd-datepicker-grid');\n if (!grid || !grid.contains(e.target)) return;\n\n const key = e.key;\n if (key !== 'ArrowLeft' && key !== 'ArrowRight' && key !== 'ArrowUp' && key !== 'ArrowDown' &&\n key !== 'Home' && key !== 'End' && key !== 'PageUp' && key !== 'PageDown' &&\n key !== 'Enter' && key !== ' ' && key !== 'Escape') {\n return;\n }\n\n if (key === 'Escape') {\n e.preventDefault();\n e.stopPropagation();\n skipNextFocusOpen = true;\n close();\n input.focus();\n return;\n }\n\n if (!focusedDate) {\n focusedDate = firstSelectableInMonth(viewYear, viewMonth);\n }\n\n if (key === 'Enter' || key === ' ') {\n e.preventDefault();\n if (focusedDate && !isDisabled(focusedDate)) {\n selectDate(new Date(focusedDate.getFullYear(), focusedDate.getMonth(), focusedDate.getDate()));\n }\n return;\n }\n\n e.preventDefault();\n\n let next = new Date(focusedDate.getFullYear(), focusedDate.getMonth(), focusedDate.getDate());\n let skipDir = 1;\n\n if (key === 'ArrowLeft') {\n next = addDays(next, -1);\n skipDir = -1;\n } else if (key === 'ArrowRight') {\n next = addDays(next, 1);\n skipDir = 1;\n } else if (key === 'ArrowUp') {\n next = addDays(next, -7);\n skipDir = -1;\n } else if (key === 'ArrowDown') {\n next = addDays(next, 7);\n skipDir = 1;\n } else if (key === 'Home') {\n next = startOfWeekSunday(next);\n skipDir = 1;\n } else if (key === 'End') {\n next = endOfWeekSunday(next);\n skipDir = -1;\n } else if (key === 'PageUp') {\n next = addMonthsClamped(next, -1);\n skipDir = -1;\n } else if (key === 'PageDown') {\n next = addMonthsClamped(next, 1);\n skipDir = 1;\n }\n\n next = skipDisabled(next, skipDir, 400);\n\n if (next.getMonth() !== viewMonth || next.getFullYear() !== viewYear) {\n viewYear = next.getFullYear();\n viewMonth = next.getMonth();\n const cl = ensureMonthInRange(viewYear, viewMonth);\n viewYear = cl.y;\n viewMonth = cl.m;\n }\n\n focusedDate = next;\n render();\n requestAnimationFrame(focusFocusedDay);\n };\n\n const open = () => {\n viewMode = 'days';\n if (selectedDate) {\n viewYear = selectedDate.getFullYear();\n viewMonth = selectedDate.getMonth();\n }\n const cl = ensureMonthInRange(viewYear, viewMonth);\n viewYear = cl.y;\n viewMonth = cl.m;\n\n if (selectedDate) {\n focusedDate = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate());\n } else {\n focusedDate = firstSelectableInMonth(viewYear, viewMonth);\n }\n\n render();\n popup.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n requestAnimationFrame(focusFocusedDay);\n };\n\n const close = () => {\n popup.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n viewMode = 'days';\n };\n\n const focusHandler = () => {\n if (skipNextFocusOpen) {\n skipNextFocusOpen = false;\n return;\n }\n open();\n };\n const outsideHandler = (e) => {\n if (!wrapper.contains(e.target)) close();\n };\n const escHandler = (e) => {\n if (e.key === 'Escape' && popup.classList.contains('is-open')) {\n skipNextFocusOpen = true;\n close();\n input.focus();\n }\n };\n\n input.addEventListener('focus', focusHandler);\n document.addEventListener('click', outsideHandler, true);\n document.addEventListener('keydown', escHandler);\n popup.addEventListener('keydown', handleGridKeydown);\n\n input.setAttribute('aria-haspopup', 'dialog');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('autocomplete', 'off');\n\n cleanup.push(\n () => input.removeEventListener('focus', focusHandler),\n () => document.removeEventListener('click', outsideHandler, true),\n () => document.removeEventListener('keydown', escHandler),\n () => popup.removeEventListener('keydown', handleGridKeydown)\n );\n\n this.instances.set(input, { cleanup: cleanup, open: open, close: close, popup: popup });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('datepicker', Datepicker);\n }\n\n window.VanduoDatepicker = Datepicker;\n\n})();\n", "/**\n * Vanduo Framework - Timepicker Component\n * Dropdown time selection with 12h/24h format and configurable step intervals\n */\n\n(function () {\n 'use strict';\n\n const Timepicker = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-timepicker]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const is24h = input.getAttribute('data-vd-timepicker-format') === '24h';\n const step = parseInt(input.getAttribute('data-vd-timepicker-step') || '30', 10);\n\n // Create wrapper\n let wrapper = input.closest('.vd-suggest-wrapper');\n if (!wrapper) {\n wrapper = document.createElement('div');\n wrapper.style.position = 'relative';\n wrapper.style.display = 'inline-block';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n }\n\n // Create popup\n const popup = document.createElement('div');\n popup.className = 'vd-timepicker-popup';\n popup.setAttribute('role', 'listbox');\n wrapper.appendChild(popup);\n\n // Generate time slots\n const times = [];\n for (let h = 0; h < 24; h++) {\n for (let m = 0; m < 60; m += step) {\n const hh24 = String(h).padStart(2, '0');\n const mm = String(m).padStart(2, '0');\n\n if (is24h) {\n times.push({ display: hh24 + ':' + mm, value: hh24 + ':' + mm });\n } else {\n const period = h < 12 ? 'AM' : 'PM';\n const h12 = h === 0 ? 12 : (h > 12 ? h - 12 : h);\n const display = h12 + ':' + mm + ' ' + period;\n times.push({ display, value: hh24 + ':' + mm });\n }\n }\n }\n\n const render = () => {\n popup.innerHTML = '';\n times.forEach(t => {\n const item = document.createElement('div');\n item.className = 'vd-timepicker-item';\n item.setAttribute('role', 'option');\n item.textContent = t.display;\n\n if (input.value === t.value || input.value === t.display) {\n item.classList.add('is-selected');\n }\n\n item.addEventListener('click', () => {\n input.value = t.display;\n popup.querySelectorAll('.vd-timepicker-item').forEach(i => i.classList.remove('is-selected'));\n item.classList.add('is-selected');\n close();\n input.dispatchEvent(new CustomEvent('timepicker:select', {\n detail: { display: t.display, value: t.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n });\n\n popup.appendChild(item);\n });\n };\n\n const open = () => {\n render();\n popup.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n // Scroll to selected\n const selected = popup.querySelector('.is-selected');\n if (selected) selected.scrollIntoView({ block: 'center' });\n };\n\n const close = () => {\n popup.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n };\n\n const focusHandler = () => open();\n const outsideHandler = (e) => {\n if (!wrapper.contains(e.target)) close();\n };\n const escHandler = (e) => { if (e.key === 'Escape') close(); };\n\n input.addEventListener('focus', focusHandler);\n document.addEventListener('click', outsideHandler, true);\n document.addEventListener('keydown', escHandler);\n input.setAttribute('aria-haspopup', 'listbox');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('autocomplete', 'off');\n input.readOnly = true;\n\n cleanup.push(\n () => input.removeEventListener('focus', focusHandler),\n () => document.removeEventListener('click', outsideHandler, true),\n () => document.removeEventListener('keydown', escHandler)\n );\n\n this.instances.set(input, { cleanup, open, close });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('timepicker', Timepicker);\n }\n\n window.VanduoTimepicker = Timepicker;\n\n})();\n", "/**\n * Vanduo Framework - Stepper Component\n * Multi-step progress indicator with state management\n */\n\n(function () {\n 'use strict';\n\n const Stepper = {\n instances: new Map(),\n\n init: function () {\n const steppers = document.querySelectorAll('.vd-stepper');\n steppers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const items = Array.from(el.querySelectorAll('.vd-stepper-item'));\n const isClickable = el.classList.contains('vd-stepper-clickable');\n let currentIndex = items.findIndex(i => i.classList.contains('is-active'));\n if (currentIndex === -1) currentIndex = 0;\n\n const setStep = (index) => {\n if (index < 0 || index >= items.length) return;\n const prev = currentIndex;\n currentIndex = index;\n\n items.forEach((item, i) => {\n item.classList.remove('is-active', 'is-completed');\n if (i < index) item.classList.add('is-completed');\n else if (i === index) item.classList.add('is-active');\n });\n\n el.dispatchEvent(new CustomEvent('stepper:change', {\n detail: { current: index, previous: prev, total: items.length },\n bubbles: true\n }));\n };\n\n if (isClickable) {\n items.forEach((item, i) => {\n const handler = () => setStep(i);\n item.addEventListener('click', handler);\n cleanup.push(() => item.removeEventListener('click', handler));\n });\n }\n\n // Initialize current state\n setStep(currentIndex);\n\n this.instances.set(el, {\n cleanup,\n setStep,\n next: () => setStep(currentIndex + 1),\n prev: () => setStep(currentIndex - 1),\n getCurrent: () => currentIndex\n });\n },\n\n setStep: function (el, index) {\n const inst = this.instances.get(el);\n if (inst) inst.setStep(index);\n },\n\n next: function (el) {\n const inst = this.instances.get(el);\n if (inst) inst.next();\n },\n\n prev: function (el) {\n const inst = this.instances.get(el);\n if (inst) inst.prev();\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('stepper', Stepper);\n }\n\n window.VanduoStepper = Stepper;\n\n})();\n", "/**\n * Vanduo Framework - Rating Component\n * Star-based rating input with hover preview and read-only mode\n */\n\n(function () {\n 'use strict';\n\n const Rating = {\n instances: new Map(),\n\n init: function () {\n const ratings = document.querySelectorAll('[data-vd-rating]');\n ratings.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const max = parseInt(el.getAttribute('data-vd-rating-max') || '5', 10);\n const initialValue = parseFloat(el.getAttribute('data-vd-rating-value') || '0');\n const readonly = el.classList.contains('vd-rating-readonly') || el.hasAttribute('data-vd-rating-readonly');\n let currentValue = initialValue;\n\n el.classList.add('vd-rating');\n el.setAttribute('role', 'radiogroup');\n el.setAttribute('aria-label', el.getAttribute('aria-label') || 'Rating');\n\n // Clear existing stars\n el.innerHTML = '';\n\n // Create stars\n const stars = [];\n for (let i = 1; i <= max; i++) {\n const star = document.createElement('button');\n star.type = 'button';\n star.className = 'vd-rating-star';\n star.setAttribute('role', 'radio');\n star.setAttribute('aria-label', i + ' star' + (i > 1 ? 's' : ''));\n star.setAttribute('aria-checked', i <= currentValue ? 'true' : 'false');\n if (readonly) star.tabIndex = -1;\n stars.push(star);\n el.appendChild(star);\n }\n\n // Value display\n const valueDisplay = document.createElement('span');\n valueDisplay.className = 'vd-rating-value';\n valueDisplay.textContent = currentValue > 0 ? currentValue.toString() : '';\n el.appendChild(valueDisplay);\n\n const updateStars = (value) => {\n stars.forEach((star, i) => {\n star.classList.remove('is-active', 'is-half');\n const starNum = i + 1;\n if (starNum <= Math.floor(value)) {\n star.classList.add('is-active');\n } else if (starNum - 0.5 <= value) {\n star.classList.add('is-half');\n }\n star.setAttribute('aria-checked', starNum <= value ? 'true' : 'false');\n });\n valueDisplay.textContent = value > 0 ? value.toString() : '';\n };\n\n updateStars(currentValue);\n\n if (!readonly) {\n stars.forEach((star, i) => {\n const enterHandler = () => {\n stars.forEach((s, j) => {\n s.classList.toggle('is-hovered', j <= i);\n });\n };\n const leaveHandler = () => {\n stars.forEach(s => s.classList.remove('is-hovered'));\n };\n const clickHandler = () => {\n currentValue = i + 1;\n el.setAttribute('data-vd-rating-value', currentValue);\n updateStars(currentValue);\n el.dispatchEvent(new CustomEvent('rating:change', {\n detail: { value: currentValue, max },\n bubbles: true\n }));\n };\n\n star.addEventListener('mouseenter', enterHandler);\n star.addEventListener('mouseleave', leaveHandler);\n star.addEventListener('click', clickHandler);\n\n cleanup.push(\n () => star.removeEventListener('mouseenter', enterHandler),\n () => star.removeEventListener('mouseleave', leaveHandler),\n () => star.removeEventListener('click', clickHandler)\n );\n });\n\n // Keyboard\n const keyHandler = (e) => {\n if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {\n e.preventDefault();\n if (currentValue < max) {\n currentValue++;\n updateStars(currentValue);\n stars[currentValue - 1].focus();\n el.dispatchEvent(new CustomEvent('rating:change', { detail: { value: currentValue, max }, bubbles: true }));\n }\n } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {\n e.preventDefault();\n if (currentValue > 1) {\n currentValue--;\n updateStars(currentValue);\n stars[currentValue - 1].focus();\n el.dispatchEvent(new CustomEvent('rating:change', { detail: { value: currentValue, max }, bubbles: true }));\n }\n }\n };\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n }\n\n this.instances.set(el, {\n cleanup,\n getValue: () => currentValue,\n setValue: (v) => { currentValue = v; updateStars(v); }\n });\n },\n\n getValue: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getValue() : 0;\n },\n\n setValue: function (el, value) {\n const inst = this.instances.get(el);\n if (inst) inst.setValue(value);\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('rating', Rating);\n }\n\n window.VanduoRating = Rating;\n\n})();\n", "/**\n * Vanduo Framework - Transfer (Dual-list) Component\n * Source-to-target list transfer with search, select-all, and move actions\n */\n\n(function () {\n 'use strict';\n\n const Transfer = {\n instances: new Map(),\n\n init: function () {\n const transfers = document.querySelectorAll('[data-vd-transfer]');\n transfers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n el.classList.add('vd-transfer');\n\n let sourceData, targetData;\n try {\n const raw = JSON.parse(el.getAttribute('data-vd-transfer') || '[]');\n sourceData = raw.map((item, i) => ({\n id: item.id || 'item-' + i,\n label: item.label || item.text || String(item),\n selected: false\n }));\n } catch (_e) {\n sourceData = [];\n }\n targetData = [];\n\n const sourceSelected = new Set();\n const targetSelected = new Set();\n\n const render = () => {\n el.innerHTML = '';\n\n // Source panel\n const sourcePanel = createPanel('Source', sourceData, sourceSelected, 'source');\n // Actions\n const actions = document.createElement('div');\n actions.className = 'vd-transfer-actions';\n\n const moveRightBtn = document.createElement('button');\n moveRightBtn.type = 'button';\n moveRightBtn.className = 'vd-transfer-btn';\n moveRightBtn.innerHTML = '›';\n moveRightBtn.setAttribute('aria-label', 'Move to target');\n moveRightBtn.disabled = sourceSelected.size === 0;\n moveRightBtn.addEventListener('click', () => moveRight());\n\n const moveLeftBtn = document.createElement('button');\n moveLeftBtn.type = 'button';\n moveLeftBtn.className = 'vd-transfer-btn';\n moveLeftBtn.innerHTML = '‹';\n moveLeftBtn.setAttribute('aria-label', 'Move to source');\n moveLeftBtn.disabled = targetSelected.size === 0;\n moveLeftBtn.addEventListener('click', () => moveLeft());\n\n actions.appendChild(moveRightBtn);\n actions.appendChild(moveLeftBtn);\n\n // Target panel\n const targetPanel = createPanel('Target', targetData, targetSelected, 'target');\n\n el.appendChild(sourcePanel);\n el.appendChild(actions);\n el.appendChild(targetPanel);\n };\n\n const createPanel = (title, data, selected, _side) => {\n const panel = document.createElement('div');\n panel.className = 'vd-transfer-panel';\n\n const header = document.createElement('div');\n header.className = 'vd-transfer-header';\n const titleSpan = document.createElement('span');\n titleSpan.textContent = title;\n const count = document.createElement('span');\n count.className = 'vd-transfer-count';\n count.textContent = selected.size + '/' + data.length;\n header.appendChild(titleSpan);\n header.appendChild(count);\n panel.appendChild(header);\n\n // Search\n const searchDiv = document.createElement('div');\n searchDiv.className = 'vd-transfer-search';\n const searchInput = document.createElement('input');\n searchInput.type = 'text';\n searchInput.placeholder = 'Search...';\n searchInput.setAttribute('aria-label', 'Search ' + title.toLowerCase());\n searchDiv.appendChild(searchInput);\n panel.appendChild(searchDiv);\n\n // List\n const list = document.createElement('ul');\n list.className = 'vd-transfer-list';\n list.setAttribute('role', 'listbox');\n\n const renderList = (filter) => {\n list.innerHTML = '';\n const filtered = filter ? data.filter(d => {\n const label = (d.label || d.text || String(d)).toLowerCase();\n return label.includes(filter.toLowerCase());\n }) : data;\n filtered.forEach(item => {\n const li = document.createElement('li');\n li.className = 'vd-transfer-item';\n li.setAttribute('role', 'option');\n if (selected.has(item.id)) li.classList.add('is-selected');\n\n const checkbox = document.createElement('input');\n checkbox.type = 'checkbox';\n checkbox.checked = selected.has(item.id);\n checkbox.setAttribute('aria-label', item.label);\n\n const label = document.createElement('span');\n label.textContent = item.label;\n\n li.addEventListener('click', () => {\n if (selected.has(item.id)) selected.delete(item.id);\n else selected.add(item.id);\n render();\n });\n\n li.appendChild(checkbox);\n li.appendChild(label);\n list.appendChild(li);\n });\n };\n\n searchInput.addEventListener('input', () => renderList(searchInput.value));\n renderList('');\n\n panel.appendChild(list);\n return panel;\n };\n\n const moveRight = () => {\n const toMove = sourceData.filter(d => sourceSelected.has(d.id));\n sourceData = sourceData.filter(d => !sourceSelected.has(d.id));\n targetData = targetData.concat(toMove);\n sourceSelected.clear();\n render();\n fireChange();\n };\n\n const moveLeft = () => {\n const toMove = targetData.filter(d => targetSelected.has(d.id));\n targetData = targetData.filter(d => !targetSelected.has(d.id));\n sourceData = sourceData.concat(toMove);\n targetSelected.clear();\n render();\n fireChange();\n };\n\n const fireChange = () => {\n el.dispatchEvent(new CustomEvent('transfer:change', {\n detail: {\n source: sourceData.map(d => d.id),\n target: targetData.map(d => d.id)\n },\n bubbles: true\n }));\n };\n\n render();\n\n this.instances.set(el, {\n cleanup,\n getTarget: () => targetData.map(d => d.id),\n getSource: () => sourceData.map(d => d.id)\n });\n },\n\n getSelected: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getTarget() : [];\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n el.innerHTML = '';\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('transfer', Transfer);\n }\n\n window.VanduoTransfer = Transfer;\n\n})();\n", "/**\n * Vanduo Framework - Tree View Component\n * Hierarchical collapsible tree with checkbox selection and keyboard navigation\n */\n\n(function () {\n 'use strict';\n\n const Tree = {\n instances: new Map(),\n\n init: function () {\n const trees = document.querySelectorAll('[data-vd-tree]');\n trees.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const cascade = el.getAttribute('data-vd-tree-cascade') !== 'false';\n\n let data;\n try { data = JSON.parse(el.getAttribute('data-vd-tree') || '[]'); } catch (_e) { data = []; }\n\n el.classList.add('vd-tree');\n el.setAttribute('role', 'tree');\n\n const render = (items, parent) => {\n parent.innerHTML = '';\n items.forEach(item => {\n const node = document.createElement('li');\n node.className = 'vd-tree-node';\n node.setAttribute('role', 'treeitem');\n node.setAttribute('aria-expanded', item.open ? 'true' : 'false');\n if (item.open) node.classList.add('is-open');\n\n const content = document.createElement('div');\n content.className = 'vd-tree-node-content';\n\n // Toggle\n if (item.children && item.children.length > 0) {\n const toggle = document.createElement('button');\n toggle.type = 'button';\n toggle.className = 'vd-tree-toggle';\n toggle.setAttribute('aria-label', 'Toggle');\n toggle.addEventListener('click', (e) => {\n e.stopPropagation();\n item.open = !item.open;\n node.classList.toggle('is-open');\n node.setAttribute('aria-expanded', item.open ? 'true' : 'false');\n });\n content.appendChild(toggle);\n } else {\n const ph = document.createElement('span');\n ph.className = 'vd-tree-toggle-placeholder';\n content.appendChild(ph);\n }\n\n // Checkbox\n if (el.hasAttribute('data-vd-tree-checkbox')) {\n const cb = document.createElement('input');\n cb.type = 'checkbox';\n cb.className = 'vd-tree-checkbox';\n cb.checked = !!item.checked;\n cb.setAttribute('aria-label', item.label);\n cb.addEventListener('change', (e) => {\n e.stopPropagation();\n item.checked = cb.checked;\n if (cascade && item.children) {\n setChildChecked(item.children, cb.checked);\n render(data, el);\n }\n el.dispatchEvent(new CustomEvent('tree:check', {\n detail: { id: item.id, checked: cb.checked, label: item.label },\n bubbles: true\n }));\n });\n content.appendChild(cb);\n }\n\n // Icon\n if (item.icon) {\n const icon = document.createElement('span');\n icon.className = 'vd-tree-icon ' + item.icon;\n content.appendChild(icon);\n }\n\n // Label\n const label = document.createElement('span');\n label.className = 'vd-tree-label';\n label.textContent = item.label || '';\n content.appendChild(label);\n\n node.appendChild(content);\n\n // Children\n if (item.children && item.children.length > 0) {\n const childList = document.createElement('ul');\n childList.className = 'vd-tree-children';\n childList.setAttribute('role', 'group');\n render(item.children, childList);\n node.appendChild(childList);\n }\n\n parent.appendChild(node);\n });\n };\n\n const setChildChecked = (items, checked) => {\n items.forEach(item => {\n item.checked = checked;\n if (item.children) setChildChecked(item.children, checked);\n });\n };\n\n // Keyboard\n const keyHandler = (e) => {\n const focused = document.activeElement;\n if (!el.contains(focused)) return;\n\n const nodes = Array.from(el.querySelectorAll('.vd-tree-node-content'));\n const idx = nodes.indexOf(focused.closest('.vd-tree-node-content'));\n if (idx === -1) return;\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n if (idx < nodes.length - 1) {\n const next = nodes[idx + 1].querySelector('.vd-tree-toggle, .vd-tree-label');\n if (next) next.focus();\n }\n break;\n case 'ArrowUp':\n e.preventDefault();\n if (idx > 0) {\n const prev = nodes[idx - 1].querySelector('.vd-tree-toggle, .vd-tree-label');\n if (prev) prev.focus();\n }\n break;\n }\n };\n\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n\n render(data, el);\n\n this.instances.set(el, {\n cleanup,\n getData: () => data,\n getChecked: () => {\n const checked = [];\n const collect = (items) => {\n items.forEach(i => {\n if (i.checked) checked.push(i.id || i.label);\n if (i.children) collect(i.children);\n });\n };\n collect(data);\n return checked;\n }\n });\n },\n\n getChecked: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getChecked() : [];\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n el.innerHTML = '';\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('tree', Tree);\n }\n\n window.VanduoTree = Tree;\n\n})();\n", "/**\n * Vanduo Framework - Spotlight (Feature Discovery) Component\n * Guided tour with overlay highlight and step-through tooltip\n */\n\n(function () {\n 'use strict';\n\n const Spotlight = {\n _active: false,\n _steps: [],\n _currentStep: 0,\n _elements: {},\n _cleanup: [],\n _boundTriggers: new WeakMap(),\n _triggerElement: null,\n\n init: function () {\n const triggers = document.querySelectorAll('[data-vd-spotlight]');\n\n triggers.forEach(trigger => {\n if (this._boundTriggers.has(trigger)) return;\n\n const clickHandler = (event) => {\n event.preventDefault();\n\n const steps = this._parseSteps(trigger.getAttribute('data-vd-spotlight'));\n if (steps.length === 0) return;\n\n this.start(steps, { trigger });\n };\n\n trigger.addEventListener('click', clickHandler);\n this._boundTriggers.set(trigger, clickHandler);\n });\n },\n\n _parseSteps: function (raw) {\n if (typeof raw !== 'string' || raw.trim() === '') return [];\n\n try {\n const parsed = JSON.parse(raw);\n return this._normalizeSteps(parsed);\n } catch (error) {\n console.error('VanduoSpotlight: invalid data-vd-spotlight payload.', error);\n return [];\n }\n },\n\n _normalizeStep: function (step) {\n if (!step || typeof step !== 'object') return null;\n\n const target = step.target;\n const hasSelectorTarget = typeof target === 'string' && target.trim() !== '';\n const hasElementTarget = typeof Element !== 'undefined' && target instanceof Element;\n\n if (!hasSelectorTarget && !hasElementTarget) return null;\n\n const title = typeof step.title === 'string' ? step.title : '';\n const description = typeof step.description === 'string'\n ? step.description\n : (typeof step.content === 'string' ? step.content : '');\n\n return {\n target,\n title,\n description\n };\n },\n\n _normalizeSteps: function (steps) {\n if (!Array.isArray(steps)) return [];\n\n return steps\n .map(step => this._normalizeStep(step))\n .filter(Boolean);\n },\n\n start: function (steps, options) {\n if (this._active) this.stop();\n\n const normalizedSteps = this._normalizeSteps(steps);\n if (normalizedSteps.length === 0) return;\n\n const startOptions = options || {};\n\n this._steps = normalizedSteps;\n this._currentStep = 0;\n this._active = true;\n this._triggerElement = startOptions.trigger || (document.activeElement instanceof HTMLElement ? document.activeElement : null);\n\n // Create overlay\n const overlay = document.createElement('div');\n overlay.className = 'vd-spotlight-overlay';\n overlay.setAttribute('aria-hidden', 'true');\n document.body.appendChild(overlay);\n\n // Create tooltip\n const tooltip = document.createElement('div');\n tooltip.className = 'vd-spotlight-tooltip';\n tooltip.setAttribute('role', 'dialog');\n tooltip.setAttribute('aria-modal', 'true');\n tooltip.tabIndex = -1;\n document.body.appendChild(tooltip);\n\n this._elements = { overlay, tooltip };\n\n // ESC to close\n const escHandler = (e) => { if (e.key === 'Escape') this.stop(); };\n document.addEventListener('keydown', escHandler);\n this._cleanup.push(() => document.removeEventListener('keydown', escHandler));\n\n // Overlay click to close\n overlay.addEventListener('click', () => this.stop());\n\n this._showStep(this._currentStep);\n },\n\n _showStep: function (index) {\n const step = this._steps[index];\n if (!step) return;\n\n const target = typeof step.target === 'string' ? document.querySelector(step.target) : step.target;\n const { tooltip } = this._elements;\n\n // Remove previous highlight\n document.querySelectorAll('.vd-spotlight-target').forEach(el => {\n el.classList.remove('vd-spotlight-target');\n });\n\n // Highlight target\n if (target) {\n target.classList.add('vd-spotlight-target');\n target.scrollIntoView({ behavior: 'smooth', block: 'center' });\n }\n\n // Build tooltip content\n const total = this._steps.length;\n tooltip.innerHTML = '';\n tooltip.removeAttribute('aria-labelledby');\n tooltip.removeAttribute('aria-describedby');\n\n if (step.title) {\n const title = document.createElement('h4');\n title.className = 'vd-spotlight-title';\n title.id = 'vd-spotlight-title-' + index + '-' + Date.now();\n title.textContent = step.title;\n tooltip.appendChild(title);\n tooltip.setAttribute('aria-labelledby', title.id);\n }\n\n if (step.description) {\n const desc = document.createElement('p');\n desc.className = 'vd-spotlight-description';\n desc.id = 'vd-spotlight-description-' + index + '-' + Date.now();\n desc.textContent = step.description;\n tooltip.appendChild(desc);\n tooltip.setAttribute('aria-describedby', desc.id);\n }\n\n // Footer\n const footer = document.createElement('div');\n footer.className = 'vd-spotlight-footer';\n footer.setAttribute('aria-label', 'Step ' + (index + 1) + ' of ' + total);\n\n const counter = document.createElement('span');\n counter.className = 'vd-spotlight-counter';\n counter.textContent = (index + 1) + ' / ' + total;\n\n const actions = document.createElement('div');\n actions.className = 'vd-spotlight-actions';\n\n if (index > 0) {\n const prevBtn = document.createElement('button');\n prevBtn.type = 'button';\n prevBtn.className = 'vd-spotlight-btn';\n prevBtn.textContent = 'Back';\n prevBtn.addEventListener('click', () => this.prev());\n actions.appendChild(prevBtn);\n }\n\n const skipBtn = document.createElement('button');\n skipBtn.type = 'button';\n skipBtn.className = 'vd-spotlight-btn';\n skipBtn.textContent = 'Skip';\n skipBtn.addEventListener('click', () => this.stop());\n actions.appendChild(skipBtn);\n\n if (index < total - 1) {\n const nextBtn = document.createElement('button');\n nextBtn.type = 'button';\n nextBtn.className = 'vd-spotlight-btn vd-spotlight-btn-primary';\n nextBtn.textContent = 'Next';\n nextBtn.addEventListener('click', () => this.next());\n actions.appendChild(nextBtn);\n } else {\n const doneBtn = document.createElement('button');\n doneBtn.type = 'button';\n doneBtn.className = 'vd-spotlight-btn vd-spotlight-btn-primary';\n doneBtn.textContent = 'Done';\n doneBtn.addEventListener('click', () => this.stop());\n actions.appendChild(doneBtn);\n }\n\n footer.appendChild(counter);\n footer.appendChild(actions);\n tooltip.appendChild(footer);\n\n // Position tooltip near target\n if (target) {\n requestAnimationFrame(() => {\n const rect = target.getBoundingClientRect();\n const tRect = tooltip.getBoundingClientRect();\n let top = rect.bottom + 12 + window.scrollY;\n let left = rect.left + (rect.width - tRect.width) / 2 + window.scrollX;\n\n // Keep in viewport\n left = Math.max(8, Math.min(left, window.innerWidth - tRect.width - 8));\n if (top + tRect.height > window.innerHeight + window.scrollY) {\n top = rect.top - tRect.height - 12 + window.scrollY;\n }\n\n tooltip.style.top = top + 'px';\n tooltip.style.left = left + 'px';\n });\n }\n\n document.dispatchEvent(new CustomEvent('spotlight:step', {\n detail: { index, step: index, total, data: step }\n }));\n },\n\n next: function () {\n if (this._currentStep < this._steps.length - 1) {\n this._currentStep++;\n this._showStep(this._currentStep);\n }\n },\n\n prev: function () {\n if (this._currentStep > 0) {\n this._currentStep--;\n this._showStep(this._currentStep);\n }\n },\n\n stop: function () {\n if (!this._active) return;\n\n const total = this._steps.length;\n const detail = {\n completedSteps: total === 0 ? 0 : Math.min(this._currentStep + 1, total),\n total,\n completed: total > 0 && this._currentStep >= total - 1\n };\n\n this._active = false;\n\n document.querySelectorAll('.vd-spotlight-target').forEach(el => {\n el.classList.remove('vd-spotlight-target');\n });\n\n if (this._elements.overlay && this._elements.overlay.parentNode) {\n this._elements.overlay.parentNode.removeChild(this._elements.overlay);\n }\n if (this._elements.tooltip && this._elements.tooltip.parentNode) {\n this._elements.tooltip.parentNode.removeChild(this._elements.tooltip);\n }\n\n this._cleanup.forEach(fn => fn());\n this._cleanup = [];\n this._elements = {};\n this._steps = [];\n this._currentStep = 0;\n\n if (this._triggerElement && this._triggerElement.isConnected && typeof this._triggerElement.focus === 'function') {\n this._triggerElement.focus();\n }\n this._triggerElement = null;\n\n document.dispatchEvent(new CustomEvent('spotlight:end', { detail }));\n },\n\n destroyAll: function () {\n this.stop();\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('spotlight', Spotlight);\n }\n\n window.VanduoSpotlight = Spotlight;\n\n})();\n", "/**\n * Vanduo Framework - Music Player Component\n * HTML5 Audio-based music player with transport controls, volume,\n * and optional shuffle, seek bar, and playlist features.\n *\n * Options (passed to MusicPlayer.init or data-music-player-options):\n * tracks {Array} - [{name, url}] \u2014 required\n * volume {number} - Initial volume 0\u20131 (default 0.5)\n * shuffle {boolean} - Shuffle on init (default false)\n * showProgress {boolean} - Show seek/progress bar (default false)\n * showPlaylist {boolean} - Show expandable playlist panel (default false)\n * autoAdvance {boolean} - Auto-play next track on end (default true)\n * glass {boolean} - Frosted-glass surface styling (default false)\n * detachable {boolean} - Show detach/attach; float above page when detached (default false)\n * floatingPosition {string|null} - After detach: 'bottom-left' or 'bottom-right' (default null = bottom-right)\n * draggable {boolean} - When detached+detachable, drag by handle (default false)\n * minimizable {boolean} - Minimize/expand control (default false)\n * startMinimized {boolean} - On first detach, start minimized (default false)\n * persistPosition {boolean} - Save floating x/y in localStorage (default false)\n * persistKey {string} - Storage key for persist (default from element id)\n *\n * Custom events (all bubble, dispatched on container):\n * musicplayer:play \u2014 playback started\n * musicplayer:pause \u2014 playback paused\n * musicplayer:trackchange \u2014 detail: { index, name, url }\n * musicplayer:volumechange \u2014 detail: { volume }\n * musicplayer:ended \u2014 last track ended (autoAdvance=false only)\n * musicplayer:detach \u2014 after floating player is created\n * musicplayer:attach \u2014 after returned to document flow\n * musicplayer:minimize \u2014 collapsed to minimal controls\n * musicplayer:expand \u2014 full controls visible again\n *\n * Programmatic API:\n * MusicPlayer.init(container?, options?)\n * MusicPlayer.play(container)\n * MusicPlayer.pause(container)\n * MusicPlayer.toggle(container)\n * MusicPlayer.next(container)\n * MusicPlayer.previous(container)\n * MusicPlayer.setVolume(container, value)\n * MusicPlayer.setTrack(container, index)\n * MusicPlayer.shuffle(container)\n * MusicPlayer.detach(container, position?)\n * MusicPlayer.attach(container)\n * MusicPlayer.minimize(container)\n * MusicPlayer.expand(container)\n * MusicPlayer.toggleMinimize(container)\n * MusicPlayer.setPosition(container, 'bottom-left'|'bottom-right'|{x,y})\n * MusicPlayer.getState(container)\n * MusicPlayer.destroy(container)\n * MusicPlayer.destroyAll()\n */\n\n(function () {\n 'use strict';\n\n /* \u2500\u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Fisher-Yates shuffle (returns new array).\n * @param {Array} arr\n * @returns {Array}\n */\n function shuffleArray(arr) {\n const shuffled = arr.slice();\n for (let i = shuffled.length - 1; i > 0; i--) {\n const j = Math.floor(Math.random() * (i + 1));\n const tmp = shuffled[i];\n shuffled[i] = shuffled[j];\n shuffled[j] = tmp;\n }\n return shuffled;\n }\n\n /**\n * Format seconds as m:ss.\n * @param {number} seconds\n * @returns {string}\n */\n function formatTime(seconds) {\n if (!isFinite(seconds) || seconds < 0) return '0:00';\n const m = Math.floor(seconds / 60);\n const s = Math.floor(seconds % 60);\n return m + ':' + (s < 10 ? '0' : '') + s;\n }\n\n /**\n * Set CSS background-size on a range input to visually fill the track.\n * @param {HTMLInputElement} input\n */\n /**\n * @param {string|undefined} id\n * @returns {string}\n */\n function persistStorageKey(id) {\n return 'vanduo:music-player:' + (id && id.trim() ? id.trim() : 'default') + ':pos';\n }\n\n function updateRangeFill(input) {\n const min = parseFloat(input.min) || 0;\n const max = parseFloat(input.max) || 1;\n const val = parseFloat(input.value) || 0;\n const pct = ((val - min) / (max - min)) * 100;\n input.style.setProperty('--fill', pct + '%');\n // Fallback inline gradient for browsers without ::-moz-range-progress\n input.style.backgroundImage =\n 'linear-gradient(to right, var(--music-player-track-fill, currentColor) 0%, ' +\n 'var(--music-player-track-fill, currentColor) ' + pct + '%, ' +\n 'var(--music-player-track-bg, #ccc) ' + pct + '%, ' +\n 'var(--music-player-track-bg, #ccc) 100%)';\n }\n\n /* \u2500\u2500\u2500 Phosphor icon helper (matches framework icon style) \u2500 */\n\n /**\n * Return an element (Phosphor icon).\n * @param {string} name\n * @returns {HTMLElement}\n */\n function icon(name) {\n const el = document.createElement('i');\n el.className = 'ph ph-' + name;\n el.setAttribute('aria-hidden', 'true');\n return el;\n }\n\n /* \u2500\u2500\u2500 Component \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const MusicPlayer = {\n /** @type {Map} */\n instances: new Map(),\n\n /**\n * Default options.\n */\n defaults: {\n tracks: [],\n volume: 0.5,\n shuffle: false,\n showProgress: false,\n showPlaylist: false,\n autoAdvance: true,\n glass: false,\n detachable: false,\n /** @type {null|string} */\n floatingPosition: null,\n draggable: false,\n minimizable: false,\n startMinimized: false,\n persistPosition: false,\n persistKey: '',\n },\n\n /**\n * Auto-initialize all .vd-music-player / [data-music-player] elements.\n * Options can be provided via data-music-player-options (JSON string).\n */\n init: function () {\n document.querySelectorAll('.vd-music-player, [data-music-player]').forEach((el) => {\n if (this.instances.has(el)) return;\n\n let opts = {};\n const attr = el.getAttribute('data-music-player-options');\n if (attr) {\n try { opts = JSON.parse(attr); } catch (_) { /* ignore malformed JSON */ }\n }\n this.initPlayer(el, opts);\n });\n },\n\n /**\n * Initialize a single player element.\n * @param {HTMLElement} container\n * @param {Object} [options]\n */\n initPlayer: function (container, options) {\n const opts = Object.assign({}, this.defaults, options || {});\n\n // Validate and normalise tracks\n const rawTracks = Array.isArray(opts.tracks) ? opts.tracks : [];\n const tracks = rawTracks.filter((t) => t && typeof t.url === 'string' && t.url.trim());\n\n // Build shuffled working copy without mutating opts\n const trackList = opts.shuffle ? shuffleArray(tracks) : tracks.slice();\n\n /* \u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const state = {\n tracks: trackList,\n originalTracks: tracks.slice(),\n currentIndex: 0,\n isPlaying: false,\n volume: Math.max(0, Math.min(1, opts.volume)),\n shuffle: opts.shuffle,\n showProgress: opts.showProgress,\n showPlaylist: opts.showPlaylist,\n autoAdvance: opts.autoAdvance,\n audio: null,\n glass: Boolean(opts.glass),\n detachable: Boolean(opts.detachable),\n floatingPosition: opts.floatingPosition || 'bottom-right',\n draggable: Boolean(opts.draggable) && Boolean(opts.detachable),\n minimizable: Boolean(opts.minimizable),\n startMinimized: Boolean(opts.startMinimized),\n persistPosition: Boolean(opts.persistPosition),\n persistKey: typeof opts.persistKey === 'string' ? opts.persistKey : '',\n isDetached: false,\n isMinimized: false,\n _startMinimizeApplied: false,\n };\n\n /* \u2500\u2500 Audio element \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const audio = new Audio();\n audio.volume = state.volume;\n audio.preload = 'metadata';\n state.audio = audio;\n\n /* \u2500\u2500 Build DOM \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n this._buildDOM(container, state);\n\n // Grab references after DOM build\n const refs = {\n btnPlay: container.querySelector('.vd-music-player-btn-play'),\n btnPrev: container.querySelector('.vd-music-player-btn-prev'),\n btnNext: container.querySelector('.vd-music-player-btn-next'),\n btnShuffle: container.querySelector('.vd-music-player-btn-shuffle'),\n btnPlaylist: container.querySelector('.vd-music-player-btn-playlist'),\n btnDetach: container.querySelector('.vd-music-player-btn-detach'),\n btnAttach: container.querySelector('.vd-music-player-btn-attach'),\n btnMinimize: container.querySelector('.vd-music-player-btn-minimize'),\n dragHandle: container.querySelector('.vd-music-player-drag-handle'),\n trackName: container.querySelector('.vd-music-player-track-name'),\n volumeSlider: container.querySelector('.vd-music-player-volume-slider'),\n volumeIcon: container.querySelector('.vd-music-player-volume-icon'),\n progressBar: container.querySelector('.vd-music-player-progress-bar'),\n timeElapsed: container.querySelector('.vd-music-player-time-elapsed'),\n timeDuration: container.querySelector('.vd-music-player-time-duration'),\n playlistPanel: container.querySelector('.vd-music-player-playlist'),\n };\n\n /* \u2500\u2500 Internal render helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const renderPlayIcon = () => {\n const btn = refs.btnPlay;\n if (!btn) return;\n btn.innerHTML = '';\n btn.appendChild(icon(state.isPlaying ? 'pause' : 'play'));\n btn.setAttribute('aria-label', state.isPlaying ? 'Pause' : 'Play');\n btn.classList.toggle('is-active', state.isPlaying);\n };\n\n const renderTrackName = () => {\n const el = refs.trackName;\n if (!el) return;\n const track = state.tracks[state.currentIndex];\n if (track) {\n el.textContent = track.name || 'Unknown Track';\n el.classList.remove('is-idle');\n } else {\n el.textContent = 'No tracks loaded';\n el.classList.add('is-idle');\n }\n };\n\n const renderVolumeIcon = () => {\n const el = refs.volumeIcon;\n if (!el) return;\n el.innerHTML = '';\n const v = state.volume;\n const name = v === 0 ? 'speaker-none' : v < 0.5 ? 'speaker-low' : 'speaker-high';\n el.appendChild(icon(name));\n };\n\n const renderShuffleBtn = () => {\n const btn = refs.btnShuffle;\n if (!btn) return;\n btn.classList.toggle('is-active', state.shuffle);\n btn.setAttribute('aria-pressed', state.shuffle ? 'true' : 'false');\n };\n\n const renderPlaylistItems = () => {\n const panel = refs.playlistPanel;\n if (!panel) return;\n panel.innerHTML = '';\n state.tracks.forEach((track, i) => {\n const item = document.createElement('button');\n item.className =\n 'vd-music-player-playlist-item' + (i === state.currentIndex ? ' is-active' : '');\n item.type = 'button';\n item.setAttribute('data-index', String(i));\n item.setAttribute('aria-current', i === state.currentIndex ? 'true' : 'false');\n\n const num = document.createElement('span');\n num.className = 'vd-music-player-playlist-num';\n num.textContent = String(i + 1);\n\n const name = document.createElement('span');\n name.className = 'vd-music-player-playlist-name';\n name.textContent = track.name || 'Track ' + (i + 1);\n\n item.appendChild(num);\n item.appendChild(name);\n panel.appendChild(item);\n });\n };\n\n const renderProgress = () => {\n const bar = refs.progressBar;\n if (!bar || !audio.duration) return;\n const pct = (audio.currentTime / audio.duration) * 100;\n bar.value = String(pct);\n updateRangeFill(bar);\n if (refs.timeElapsed) refs.timeElapsed.textContent = formatTime(audio.currentTime);\n if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);\n };\n\n /* \u2500\u2500 Load track \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const loadTrack = (index, autoPlay) => {\n const track = state.tracks[index];\n if (!track) return;\n state.currentIndex = index;\n audio.src = track.url;\n renderTrackName();\n renderPlaylistItems();\n\n // Reset progress\n if (refs.progressBar) {\n refs.progressBar.value = '0';\n updateRangeFill(refs.progressBar);\n }\n if (refs.timeElapsed) refs.timeElapsed.textContent = '0:00';\n if (refs.timeDuration) refs.timeDuration.textContent = '0:00';\n\n container.dispatchEvent(\n new CustomEvent('musicplayer:trackchange', {\n bubbles: true,\n detail: { index, name: track.name, url: track.url },\n })\n );\n\n if (autoPlay) {\n audio.play().catch(() => { /* browser may block autoplay */ });\n }\n };\n\n /* \u2500\u2500 Audio event listeners \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const cleanupFunctions = [];\n\n const onPlay = () => {\n state.isPlaying = true;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:play', { bubbles: true }));\n };\n\n const onPause = () => {\n state.isPlaying = false;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:pause', { bubbles: true }));\n };\n\n const onEnded = () => {\n if (state.autoAdvance && state.tracks.length > 1) {\n const next = (state.currentIndex + 1) % state.tracks.length;\n loadTrack(next, true);\n } else {\n state.isPlaying = false;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:ended', { bubbles: true }));\n }\n };\n\n const onTimeUpdate = () => {\n if (state.showProgress) renderProgress();\n };\n\n const onLoadedMetadata = () => {\n if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);\n if (refs.progressBar) {\n refs.progressBar.max = '100';\n updateRangeFill(refs.progressBar);\n }\n };\n\n audio.addEventListener('play', onPlay);\n audio.addEventListener('pause', onPause);\n audio.addEventListener('ended', onEnded);\n audio.addEventListener('timeupdate', onTimeUpdate);\n audio.addEventListener('loadedmetadata', onLoadedMetadata);\n cleanupFunctions.push(() => {\n audio.removeEventListener('play', onPlay);\n audio.removeEventListener('pause', onPause);\n audio.removeEventListener('ended', onEnded);\n audio.removeEventListener('timeupdate', onTimeUpdate);\n audio.removeEventListener('loadedmetadata', onLoadedMetadata);\n audio.pause();\n audio.src = '';\n });\n\n /* \u2500\u2500 Control button listeners \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n if (refs.btnPlay) {\n const handler = () => {\n if (!audio.src && state.tracks.length) loadTrack(state.currentIndex, false);\n if (state.isPlaying) {\n audio.pause();\n } else {\n audio.play().catch(() => {});\n }\n };\n refs.btnPlay.addEventListener('click', handler);\n cleanupFunctions.push(() => refs.btnPlay.removeEventListener('click', handler));\n\n // Keyboard: Space / Enter (already native for `;\n }\n\n // Generate neutral color swatches\n let neutralSwatches = '';\n for (const [key, value] of Object.entries(this.NEUTRAL_COLORS)) {\n neutralSwatches += ``;\n }\n\n // Generate radius buttons\n let radiusButtons = '';\n this.RADIUS_OPTIONS.forEach(r => {\n radiusButtons += ``;\n });\n\n // Generate font options\n let fontOptions = '';\n for (const [key, value] of Object.entries(this.FONT_OPTIONS)) {\n fontOptions += ``;\n }\n\n return `\n
    \n

    Customize Theme

    \n \n
    \n
    \n\n
    \n \n
    \n ${primarySwatches}\n
    \n
    \n
    \n \n
    \n ${neutralSwatches}\n
    \n
    \n
    \n \n
    \n ${radiusButtons}\n
    \n
    \n
    \n \n \n
    \n
    \n
    \n \n
    \n `;\n },\n\n /**\n * Bind event listeners\n */\n /**\n * Check whether the current primary color is one of the auto-defaults\n * (i.e. the user hasn't explicitly picked a non-default color).\n */\n isUsingDefaultPrimary: function () {\n return this.state.primary === this.DEFAULTS.PRIMARY_LIGHT ||\n this.state.primary === this.DEFAULTS.PRIMARY_DARK;\n },\n\n /**\n * When primary is still one of the auto-default palette keys (black/amber) but\n * localStorage was written under a different theme (or OS changed in system mode),\n * align in-memory state before applyAllPreferences runs \u2014 avoids amber+light / black+dark drift.\n */\n _normalizeDefaultPrimaryIfStaleWithStoredTheme: function () {\n if (!this.isUsingDefaultPrimary()) {\n return;\n }\n const expected = this.getDefaultPrimary(this.state.theme);\n if (this.state.primary !== expected) {\n this.state.primary = expected;\n }\n },\n\n bindEvents: function () {\n this.bindTriggerEvents();\n\n this.bindPanelEvents();\n\n // Listen for OS dark/light changes so we can swap the default primary\n if (window.matchMedia) {\n const mq = window.matchMedia('(prefers-color-scheme: dark)');\n const handler = () => {\n if (this.state.theme === 'system' && this.isUsingDefaultPrimary()) {\n const newDefault = this.getDefaultPrimary('system');\n if (newDefault !== this.state.primary) {\n this.applyPrimary(newDefault);\n this.updateUI();\n }\n }\n };\n mq.addEventListener('change', handler);\n this._cleanup.push(() => mq.removeEventListener('change', handler));\n }\n\n // Close on outside click\n this.addListener(document, 'click', (e) => {\n if (this.state.isOpen && this.elements.customizer && !this.elements.customizer.contains(e.target)) {\n this.close();\n }\n });\n\n // Close on Escape key\n this.addListener(document, 'keydown', (e) => {\n if (e.key === 'Escape' && this.state.isOpen) {\n this.close();\n }\n });\n },\n\n bindTriggerEvents: function () {\n this.elements.triggers.forEach((trigger) => {\n if (trigger.getAttribute('data-customizer-trigger-initialized') === 'true') {\n return;\n }\n this.addListener(trigger, 'click', (e) => {\n e.preventDefault();\n e.stopPropagation();\n this.elements.activeTrigger = trigger;\n this.elements.trigger = trigger;\n this.toggle();\n });\n trigger.setAttribute('data-customizer-trigger-initialized', 'true');\n });\n },\n\n /**\n * Toggle panel open/close\n */\n toggle: function () {\n if (this.state.isOpen) {\n this.close();\n } else {\n this.open();\n }\n },\n\n /**\n * Open the panel\n */\n open: function () {\n this.state.isOpen = true;\n\n // Ensure panel is positioned correctly before opening\n this.positionPanel();\n\n if (this.elements.panel) {\n this.elements.panel.classList.add('is-open');\n }\n this.elements.triggers.forEach((trigger) => trigger.setAttribute('aria-expanded', 'false'));\n if (this.elements.trigger) this.elements.trigger.setAttribute('aria-expanded', 'true');\n if (this.elements.overlay) {\n this.elements.overlay.classList.add('is-active');\n }\n\n this.dispatchEvent('panel-open', { isOpen: true });\n },\n\n /**\n * Close the panel\n */\n close: function () {\n this.state.isOpen = false;\n\n if (this.elements.panel) {\n this.elements.panel.classList.remove('is-open');\n }\n this.elements.triggers.forEach((trigger) => trigger.setAttribute('aria-expanded', 'false'));\n if (this.elements.overlay) {\n this.elements.overlay.classList.remove('is-active');\n }\n\n this.dispatchEvent('panel-close', { isOpen: false });\n },\n\n /**\n * Update UI to reflect current state\n */\n updateUI: function () {\n if (!this.elements.panel) return;\n\n // Update primary color swatches\n this.elements.panel.querySelectorAll('[data-color]').forEach(swatch => {\n swatch.classList.toggle('is-active', swatch.dataset.color === this.state.primary);\n });\n\n // Update neutral color swatches\n this.elements.panel.querySelectorAll('[data-neutral]').forEach(swatch => {\n swatch.classList.toggle('is-active', swatch.dataset.neutral === this.state.neutral);\n });\n\n // Update radius buttons\n this.elements.panel.querySelectorAll('[data-radius]').forEach(btn => {\n btn.classList.toggle('is-active', btn.dataset.radius === this.state.radius);\n });\n\n // Update font selector\n const fontSelect = this.elements.panel.querySelector('[data-customizer-font]');\n if (fontSelect) {\n fontSelect.value = this.state.font;\n }\n\n },\n\n /**\n * Reset all preferences to defaults\n */\n reset: function () {\n this.applyTheme(this.DEFAULTS.THEME);\n this.applyPrimary(this.getDefaultPrimary(this.DEFAULTS.THEME));\n this.applyNeutral(this.DEFAULTS.NEUTRAL);\n this.applyRadius(this.DEFAULTS.RADIUS);\n this.applyFont(this.DEFAULTS.FONT);\n this.updateUI();\n\n this.dispatchEvent('reset', { state: { ...this.state } });\n },\n\n /**\n * Get current state\n */\n getState: function () {\n return { ...this.state };\n },\n\n /**\n * Programmatically set preferences\n */\n setPreferences: function (prefs) {\n if (prefs.primary) this.applyPrimary(prefs.primary);\n if (prefs.neutral) this.applyNeutral(prefs.neutral);\n if (prefs.radius) this.applyRadius(prefs.radius);\n if (prefs.font) this.applyFont(prefs.font);\n if (prefs.theme) this.applyTheme(prefs.theme);\n this.updateUI();\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 destroyAll: function () {\n this._cleanup.forEach(fn => fn());\n this._cleanup = [];\n\n if (this.elements.panel) {\n this.elements.panel.removeAttribute('data-customizer-initialized');\n }\n\n this.close();\n this.isInitialized = false;\n }\n };\n\n // Register component\n if (window.Vanduo) {\n window.Vanduo.register('themeCustomizer', ThemeCustomizer);\n }\n\n // Expose globally for convenience\n window.ThemeCustomizer = ThemeCustomizer;\n})();\n", "/**\n * Vanduo Framework - Theme Switcher\n * Handles light/dark/system theme toggling and persistence\n */\n\n(function () {\n 'use strict';\n\n const ThemeSwitcher = {\n isInitialized: false,\n _mediaQuery: null,\n _onMediaChange: null,\n\n init: function () {\n this.STORAGE_KEY = 'vanduo-theme-preference';\n this.state = {\n preference: this.getPreference() // 'light', 'dark', or 'system'\n };\n\n if (this.isInitialized) {\n this.applyTheme();\n this.renderUI();\n this.updateUI();\n return;\n }\n\n this.isInitialized = true;\n\n this.applyTheme();\n this.listenForSystemChanges();\n this.renderUI();\n\n console.log('Vanduo Theme Switcher initialized');\n },\n\n getPreference: function () {\n return this.getStorageValue(this.STORAGE_KEY, 'system');\n },\n\n setPreference: function (pref) {\n this.state.preference = pref;\n this.setStorageValue(this.STORAGE_KEY, pref);\n this.applyTheme();\n \n // Coordinate with ThemeCustomizer for primary color swap if available\n // Check _isApplying flag to prevent circular updates\n if (window.ThemeCustomizer && window.ThemeCustomizer.applyTheme && !window.ThemeCustomizer._isApplying) {\n window.ThemeCustomizer.applyTheme(pref);\n }\n \n this.updateUI();\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 applyTheme: function () {\n const pref = this.state.preference;\n\n if (pref === 'system') {\n // When in system mode, we remove the data attribute to let the media query take over\n // or we can explicitly set it. Explicitly setting it ensures consistency if we rely on [data-theme]\n // But if we rely on @media in CSS, we might want to remove attributes.\n // However, our CSS strategy uses :root:not([data-theme=\"light\"]) inside media query for system dark fallback\n // which is a bit complex.\n\n // Simpler approach: \n // If preference is system, REMOVE data-theme attribute. Let CSS media queries handle it.\n document.documentElement.removeAttribute('data-theme');\n } else {\n document.documentElement.setAttribute('data-theme', pref);\n }\n },\n\n listenForSystemChanges: function () {\n if (this._mediaQuery && this._onMediaChange) {\n return;\n }\n\n this._mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n this._onMediaChange = _e => {\n if (this.state.preference === 'system') {\n // Re-apply (effectively just to ensure consistency, though removing attribute usually suffices)\n this.applyTheme();\n // Keep default primary (black/amber) aligned when OS scheme changes while in system mode\n if (window.ThemeCustomizer && typeof window.ThemeCustomizer.applyTheme === 'function' && !window.ThemeCustomizer._isApplying) {\n window.ThemeCustomizer.applyTheme('system');\n }\n }\n };\n this._mediaQuery.addEventListener('change', this._onMediaChange);\n },\n\n // Helper to facilitate UI creation if needed, though often UI is in HTML\n renderUI: function () {\n // Look for any uninitialized theme toggles\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"]');\n toggles.forEach(toggle => {\n if (toggle.getAttribute('data-theme-initialized') === 'true') {\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n }\n return;\n }\n\n // Simplified UI Binding - assumes a select or a button cycle\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n const onChange = (e) => {\n this.setPreference(e.target.value);\n };\n toggle.addEventListener('change', onChange);\n toggle._themeToggleHandler = onChange;\n } else {\n // Button implementation (cycle)\n const onClick = () => {\n const modes = ['system', 'light', 'dark'];\n const nextIndex = (modes.indexOf(this.state.preference) + 1) % modes.length;\n this.setPreference(modes[nextIndex]);\n };\n toggle.addEventListener('click', onClick);\n toggle._themeToggleHandler = onClick;\n }\n // Mark as initialized\n toggle.setAttribute('data-theme-initialized', 'true');\n });\n },\n\n updateUI: function () {\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"]');\n toggles.forEach(toggle => {\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n } else {\n // Update button text/icon if needed\n // e.g. toggle.textContent = this.state.preference;\n // For now, assume the user handles visual state or generic text\n\n // If there is an icon or text span inside, update it\n const span = toggle.querySelector('.theme-current-label');\n if (span) {\n span.textContent = this.state.preference.charAt(0).toUpperCase() + this.state.preference.slice(1);\n }\n }\n });\n },\n\n destroyAll: function () {\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"][data-theme-initialized=\"true\"]');\n toggles.forEach(toggle => {\n if (toggle._themeToggleHandler) {\n const eventName = toggle.tagName === 'SELECT' ? 'change' : 'click';\n toggle.removeEventListener(eventName, toggle._themeToggleHandler);\n delete toggle._themeToggleHandler;\n }\n toggle.removeAttribute('data-theme-initialized');\n });\n\n if (this._mediaQuery && this._onMediaChange) {\n this._mediaQuery.removeEventListener('change', this._onMediaChange);\n }\n\n this._mediaQuery = null;\n this._onMediaChange = null;\n this.isInitialized = false;\n }\n };\n\n // Register component\n if (window.Vanduo) {\n window.Vanduo.register('themeSwitcher', ThemeSwitcher);\n }\n})();\n", "/**\n * Vanduo Framework - Toast Component\n * Popup notifications for user feedback\n */\n\n(function() {\n 'use strict';\n\n /**\n * Toast Component\n */\n const Toast = {\n // Default options\n defaults: {\n position: 'top-right',\n duration: 5000,\n dismissible: true,\n showProgress: true,\n pauseOnHover: true\n },\n\n // Container cache\n containers: {},\n\n /**\n * Get or create a toast container for a position\n * @param {string} position - Container position\n * @returns {HTMLElement} Toast container element\n */\n getContainer: function(position) {\n if (this.containers[position]) {\n return this.containers[position];\n }\n\n const container = document.createElement('div');\n container.className = `vd-toast-container vd-toast-container-${position}`;\n container.setAttribute('role', 'status');\n container.setAttribute('aria-live', 'polite');\n container.setAttribute('aria-atomic', 'false');\n document.body.appendChild(container);\n this.containers[position] = container;\n\n return container;\n },\n\n /**\n * Show a toast notification\n * @param {Object|string} options - Toast options or message string\n * @param {string} [type] - Toast type (success, error, warning, info)\n * @param {number} [duration] - Auto-dismiss duration in ms\n * @returns {HTMLElement} Toast element\n */\n show: function(options, type, duration) {\n // Support simple API:\n // - Toast.show('Message', 'success', 3000)\n // - Toast.show('Message', { type: 'success', duration: 3000 })\n if (typeof options === 'string') {\n if (type && typeof type === 'object') {\n options = Object.assign({}, type, {\n message: options\n });\n } else {\n options = {\n message: options,\n type: type,\n duration: duration\n };\n }\n }\n\n const config = Object.assign({}, this.defaults, options);\n const container = this.getContainer(config.position);\n\n // Create toast element\n const toast = document.createElement('div');\n toast.className = 'vd-toast';\n\n if (config.type) {\n toast.classList.add(`vd-toast-${config.type}`);\n }\n\n if (config.solid) {\n toast.classList.add('vd-toast-solid');\n }\n\n if (config.showProgress && config.duration > 0) {\n toast.classList.add('vd-toast-with-progress');\n }\n\n // Build toast content\n let html = '';\n\n // Icon (sanitize custom icons, default icons are trusted SVG)\n if (config.icon) {\n const allowSvg = config.iconAllowSvg === true;\n const safeIcon = typeof sanitizeHtml === 'function'\n ? sanitizeHtml(config.icon, { allowSvg })\n : escapeHtml(config.icon);\n html += `${safeIcon}`;\n } else if (config.type) {\n html += `${this.getDefaultIcon(config.type)}`;\n }\n\n // Local escape helper \u2014 guarantees HTML-safe output even if the\n // global escapeHtml utility is not loaded in the current bundle.\n const _esc = typeof escapeHtml === 'function'\n ? escapeHtml\n : function (s) {\n const d = document.createElement('div');\n d.appendChild(document.createTextNode(s));\n return d.innerHTML;\n };\n\n // Content (escape text to prevent injection)\n html += '
    ';\n if (config.title) {\n html += `
    ${_esc(String(config.title))}
    `;\n }\n if (config.message) {\n html += `
    ${_esc(String(config.message))}
    `;\n }\n html += '
    ';\n\n // Close button\n if (config.dismissible) {\n html += '';\n }\n\n // Progress bar\n if (config.showProgress && config.duration > 0) {\n const safeDuration = parseInt(config.duration, 10) || 0;\n html += `
    `;\n }\n\n toast.innerHTML = html;\n\n // Add to container\n container.appendChild(toast);\n\n toast._toastCleanup = [];\n\n // Set up close button handler\n if (config.dismissible) {\n const closeBtn = toast.querySelector('.vd-toast-close');\n const onClose = () => {\n this.dismiss(toast);\n };\n closeBtn.addEventListener('click', onClose);\n toast._toastCleanup.push(() => closeBtn.removeEventListener('click', onClose));\n }\n\n // Pause on hover\n let timeoutId = null;\n let remainingTime = config.duration;\n let startTime = null;\n\n const startTimer = () => {\n if (config.duration > 0) {\n startTime = Date.now();\n timeoutId = setTimeout(() => {\n this.dismiss(toast);\n }, remainingTime);\n toast._toastTimeoutId = timeoutId;\n\n // Resume progress animation\n const progress = toast.querySelector('.vd-toast-progress');\n if (progress) {\n progress.style.animationPlayState = 'running';\n }\n }\n };\n\n const pauseTimer = () => {\n if (timeoutId) {\n clearTimeout(timeoutId);\n timeoutId = null;\n toast._toastTimeoutId = null;\n remainingTime -= Date.now() - startTime;\n\n // Pause progress animation\n const progress = toast.querySelector('.vd-toast-progress');\n if (progress) {\n progress.style.animationPlayState = 'paused';\n }\n }\n };\n\n if (config.pauseOnHover) {\n toast.addEventListener('mouseenter', pauseTimer);\n toast.addEventListener('mouseleave', startTimer);\n toast._toastCleanup.push(\n () => toast.removeEventListener('mouseenter', pauseTimer),\n () => toast.removeEventListener('mouseleave', startTimer)\n );\n }\n\n // Trigger enter animation\n requestAnimationFrame(() => {\n toast.classList.add('is-visible');\n startTimer();\n });\n\n // Store config on element for later access\n toast._toastConfig = config;\n\n // Dispatch show event\n const showEvent = new CustomEvent('toast:show', {\n bubbles: true,\n detail: { toast, config }\n });\n toast.dispatchEvent(showEvent);\n\n return toast;\n },\n\n /**\n * Dismiss a toast\n * @param {HTMLElement} toast - Toast element to dismiss\n */\n dismiss: function(toast) {\n if (!toast || toast.classList.contains('is-exiting')) return;\n\n if (toast._toastTimeoutId) {\n clearTimeout(toast._toastTimeoutId);\n toast._toastTimeoutId = null;\n }\n\n toast.classList.remove('is-visible');\n toast.classList.add('is-exiting');\n\n // Dispatch dismiss event\n const dismissEvent = new CustomEvent('toast:dismiss', {\n bubbles: true,\n detail: { toast }\n });\n toast.dispatchEvent(dismissEvent);\n\n // Remove after animation\n const handleTransitionEnd = () => {\n toast.removeEventListener('transitionend', handleTransitionEnd);\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n };\n\n toast.addEventListener('transitionend', handleTransitionEnd);\n\n // Fallback removal if transition doesn't fire\n setTimeout(() => {\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n }, 400);\n },\n\n /**\n * Destroy all toasts and containers\n */\n destroyAll: function() {\n Object.keys(this.containers).forEach(position => {\n const container = this.containers[position];\n if (!container) return;\n\n const toasts = container.querySelectorAll('.vd-toast');\n toasts.forEach(toast => {\n if (toast._toastTimeoutId) {\n clearTimeout(toast._toastTimeoutId);\n }\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n });\n\n if (container.parentElement) {\n container.parentElement.removeChild(container);\n }\n });\n\n this.containers = {};\n },\n\n /**\n * Dismiss all toasts\n * @param {string} [position] - Optional position to clear (clears all if not specified)\n */\n dismissAll: function(position) {\n if (position && this.containers[position]) {\n const toasts = this.containers[position].querySelectorAll('.vd-toast');\n toasts.forEach(toast => this.dismiss(toast));\n } else {\n Object.values(this.containers).forEach(container => {\n const toasts = container.querySelectorAll('.vd-toast');\n toasts.forEach(toast => this.dismiss(toast));\n });\n }\n },\n\n /**\n * Get default icon SVG for a type\n * @param {string} type - Toast type\n * @returns {string} SVG icon markup\n */\n getDefaultIcon: function(type) {\n const icons = {\n success: '',\n error: '',\n warning: '',\n info: ''\n };\n\n return icons[type] || '';\n },\n\n /**\n * Convenience methods for common toast types\n */\n success: function(message, options) {\n return this.show(Object.assign({ message, type: 'success' }, options));\n },\n\n error: function(message, options) {\n return this.show(Object.assign({ message, type: 'error' }, options));\n },\n\n warning: function(message, options) {\n return this.show(Object.assign({ message, type: 'warning' }, options));\n },\n\n info: function(message, options) {\n return this.show(Object.assign({ message, type: 'info' }, options));\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('toast', Toast);\n }\n\n // Also expose globally for convenience\n window.Toast = Toast;\n\n})();\n", "/**\n * Vanduo Framework - Tooltips Component\n * JavaScript functionality for tooltips\n */\n\n(function () {\n 'use strict';\n\n /**\n * Tooltips Component\n */\n const Tooltips = {\n tooltips: new Map(),\n delayTimers: new Map(),\n\n /**\n * Sanitize HTML \u2014 delegates to shared sanitizeHtml from helpers.js\n * @param {string} input\n * @param {{ allowSvg?: boolean }} [options]\n * @returns {string} sanitized HTML\n */\n sanitizeHtml: function (input, options) {\n if (typeof sanitizeHtml === 'function') {\n return sanitizeHtml(input, options);\n }\n // Fallback: content originates from developer-authored data attributes,\n // so returning it as-is is no worse than any other inline HTML.\n return input || '';\n },\n\n /**\n * Initialize tooltips\n */\n init: function () {\n const elements = document.querySelectorAll('[data-tooltip], [data-tooltip-html]');\n\n elements.forEach(element => {\n if (this.tooltips.has(element)) {\n return;\n }\n this.initTooltip(element);\n });\n },\n\n /**\n * Initialize a single tooltip\n * @param {HTMLElement} element - Element with tooltip\n */\n initTooltip: function (element) {\n const tooltip = this.createTooltip(element);\n const cleanupFunctions = [];\n\n // Show on hover/focus\n const enterHandler = () => { this.showTooltip(element, tooltip); };\n const leaveHandler = () => { this.hideTooltip(element, tooltip); };\n const focusHandler = () => { this.showTooltip(element, tooltip); };\n const blurHandler = () => { this.hideTooltip(element, tooltip); };\n\n element.addEventListener('mouseenter', enterHandler);\n element.addEventListener('mouseleave', leaveHandler);\n element.addEventListener('focus', focusHandler);\n element.addEventListener('blur', blurHandler);\n\n cleanupFunctions.push(\n () => element.removeEventListener('mouseenter', enterHandler),\n () => element.removeEventListener('mouseleave', leaveHandler),\n () => element.removeEventListener('focus', focusHandler),\n () => element.removeEventListener('blur', blurHandler)\n );\n\n this.tooltips.set(element, { tooltip, cleanup: cleanupFunctions });\n },\n\n /**\n * Create tooltip element\n * @param {HTMLElement} element - Target element\n * @returns {HTMLElement} Tooltip element\n */\n createTooltip: function (element) {\n const tooltip = document.createElement('div');\n tooltip.className = 'vd-tooltip';\n tooltip.setAttribute('role', 'tooltip');\n tooltip.setAttribute('aria-hidden', 'true');\n\n // Generate unique ID and link via aria-describedby\n const tooltipId = 'tooltip-' + Math.random().toString(36).substr(2, 9);\n tooltip.id = tooltipId;\n element.setAttribute('aria-describedby', tooltipId);\n\n // Get content\n const htmlContent = element.dataset.tooltipHtml;\n const textContent = element.dataset.tooltip;\n\n if (htmlContent) {\n const allowSvg = element.hasAttribute('data-tooltip-allow-svg');\n tooltip.innerHTML = this.sanitizeHtml(htmlContent, { allowSvg });\n tooltip.classList.add('vd-tooltip-html');\n } else if (textContent) {\n tooltip.textContent = textContent;\n }\n\n // Get placement\n const placement = element.dataset.tooltipPlacement || element.dataset.placement || 'top';\n tooltip.setAttribute('data-placement', placement);\n tooltip.classList.add(`vd-tooltip-${placement}`);\n\n // Get variant\n if (element.dataset.tooltipVariant) {\n tooltip.classList.add(`vd-tooltip-${element.dataset.tooltipVariant}`);\n }\n\n // Get size\n if (element.dataset.tooltipSize) {\n tooltip.classList.add(`vd-tooltip-${element.dataset.tooltipSize}`);\n }\n\n // Get delay\n const delay = parseInt(element.dataset.tooltipDelay) || 0;\n tooltip.dataset.delay = delay;\n\n document.body.appendChild(tooltip);\n\n return tooltip;\n },\n\n /**\n * Show tooltip\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n showTooltip: function (element, tooltip) {\n const delay = parseInt(tooltip.dataset.delay) || 0;\n\n if (delay > 0) {\n const timer = setTimeout(() => {\n this.positionTooltip(element, tooltip);\n tooltip.classList.add('is-visible');\n tooltip.setAttribute('aria-hidden', 'false');\n }, delay);\n this.delayTimers.set(element, timer);\n } else {\n this.positionTooltip(element, tooltip);\n tooltip.classList.add('is-visible');\n tooltip.setAttribute('aria-hidden', 'false');\n }\n },\n\n /**\n * Hide tooltip\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n hideTooltip: function (element, tooltip) {\n // Clear delay timer if exists\n const timer = this.delayTimers.get(element);\n if (timer) {\n clearTimeout(timer);\n this.delayTimers.delete(element);\n }\n\n tooltip.classList.remove('is-visible');\n tooltip.setAttribute('aria-hidden', 'true');\n },\n\n /**\n * Position tooltip relative to element\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n positionTooltip: function (element, tooltip) {\n const placement = tooltip.dataset.placement || 'top';\n const rect = element.getBoundingClientRect();\n const tooltipRect = tooltip.getBoundingClientRect();\n const scrollTop = window.pageYOffset || document.documentElement.scrollTop;\n const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;\n\n let top = 0;\n let left = 0;\n\n switch (placement) {\n case 'top':\n top = rect.top + scrollTop - tooltipRect.height - 8;\n left = rect.left + scrollLeft + (rect.width / 2) - (tooltipRect.width / 2);\n break;\n case 'bottom':\n top = rect.bottom + scrollTop + 8;\n left = rect.left + scrollLeft + (rect.width / 2) - (tooltipRect.width / 2);\n break;\n case 'left':\n top = rect.top + scrollTop + (rect.height / 2) - (tooltipRect.height / 2);\n left = rect.left + scrollLeft - tooltipRect.width - 8;\n break;\n case 'right':\n top = rect.top + scrollTop + (rect.height / 2) - (tooltipRect.height / 2);\n left = rect.right + scrollLeft + 8;\n break;\n }\n\n // Prevent overflow\n const viewportWidth = window.innerWidth;\n const viewportHeight = window.innerHeight;\n const padding = 8;\n\n if (left < padding) {\n left = padding;\n } else if (left + tooltipRect.width > viewportWidth - padding) {\n left = viewportWidth - tooltipRect.width - padding;\n }\n\n if (top < scrollTop + padding) {\n top = scrollTop + padding;\n } else if (top + tooltipRect.height > scrollTop + viewportHeight - padding) {\n top = scrollTop + viewportHeight - tooltipRect.height - padding;\n }\n\n // Use single style assignment with transform for better performance\n tooltip.style.cssText = `position: absolute; top: 0; left: 0; transform: translate(${left}px, ${top}px);`;\n },\n\n /**\n * Show tooltip programmatically\n * @param {HTMLElement|string} element - Target element or selector\n */\n show: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n this.showTooltip(el, tooltip);\n }\n },\n\n /**\n * Hide tooltip programmatically\n * @param {HTMLElement|string} element - Target element or selector\n */\n hide: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n this.hideTooltip(el, tooltip);\n }\n },\n\n /**\n * Update tooltip content\n * @param {HTMLElement|string} element - Target element or selector\n * @param {string} content - New content\n * @param {boolean} isHtml - Whether content is HTML\n */\n update: function (element, content, isHtml = false) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n if (isHtml) {\n const allowSvg = el.hasAttribute('data-tooltip-allow-svg');\n tooltip.innerHTML = this.sanitizeHtml(content, { allowSvg });\n tooltip.classList.add('vd-tooltip-html');\n } else {\n tooltip.textContent = content;\n tooltip.classList.remove('vd-tooltip-html');\n }\n }\n },\n\n /**\n * Destroy a tooltip instance and clean up\n * @param {HTMLElement} element - Element with tooltip\n */\n destroy: function (element) {\n const data = this.tooltips.get(element);\n if (!data) return;\n\n // Clear any pending timer\n const timer = this.delayTimers.get(element);\n if (timer) {\n clearTimeout(timer);\n this.delayTimers.delete(element);\n }\n\n data.cleanup.forEach(fn => fn());\n\n // Remove tooltip element from DOM\n if (data.tooltip && data.tooltip.parentNode) {\n data.tooltip.parentNode.removeChild(data.tooltip);\n }\n\n this.tooltips.delete(element);\n },\n\n /**\n * Destroy all tooltip instances\n */\n destroyAll: function () {\n this.tooltips.forEach((data, element) => {\n this.destroy(element);\n });\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('tooltips', Tooltips);\n }\n\n // Expose globally\n window.VanduoTooltips = Tooltips;\n\n})();\n", "/**\n * Vanduo Framework - Search Component\n * Client-side search functionality for content pages\n * \n * @example Basic usage (initialize with defaults)\n * // HTML:\n * //
    \n * // \n * //
    \n * //
    \n * \n * @example Custom configuration\n * var search = Search.create({\n * containerSelector: '.my-search',\n * contentSelector: 'article[id]',\n * titleSelector: 'h2, h3',\n * maxResults: 5,\n * onSelect: function(result) {\n * console.log('Selected:', result.title);\n * }\n * });\n * \n * @example With custom data source\n * var search = Search.create({\n * containerSelector: '.my-search',\n * data: [\n * { id: 'item1', title: 'First Item', content: 'Description...', category: 'Category A' },\n * { id: 'item2', title: 'Second Item', content: 'Description...', category: 'Category B' }\n * ]\n * });\n */\n\n(function() {\n 'use strict';\n\n /**\n * Default configuration\n */\n const DEFAULTS = {\n // Behavior\n minQueryLength: 2,\n maxResults: 10,\n debounceMs: 150,\n highlightTag: 'mark',\n keyboardShortcut: true, // Enable Cmd/Ctrl+K shortcut\n \n // Selectors (for DOM-based indexing)\n containerSelector: '.vd-doc-search',\n inputSelector: '.vd-doc-search-input',\n resultsSelector: '.vd-doc-search-results',\n contentSelector: '.doc-content section[id]',\n titleSelector: '.demo-title, h2, h3',\n navSelector: '.doc-nav-link',\n sectionSelector: '.doc-nav-section',\n \n // Content extraction\n excludeFromContent: 'pre, code, script, style',\n maxContentLength: 500,\n \n // Custom data source (alternative to DOM indexing)\n data: null,\n \n // Category icons mapping\n categoryIcons: {\n 'getting-started': 'ph-rocket-launch',\n 'core': 'ph-cube',\n 'components': 'ph-puzzle-piece',\n 'interactive': 'ph-cursor-click',\n 'data-display': 'ph-table',\n 'feedback': 'ph-bell',\n 'meta': 'ph-info',\n 'default': 'ph-file-text'\n },\n \n // Callbacks\n onSelect: null, // function(result) - called when result is selected\n onSearch: null, // function(query, results) - called after search\n onOpen: null, // function() - called when results open\n onClose: null, // function() - called when results close\n \n // Text customization\n emptyTitle: 'No results found',\n emptyText: 'Try different keywords or check spelling',\n placeholder: 'Search...'\n };\n\n /**\n * Search Component Factory\n * Creates a new search instance with the given configuration\n * \n * @param {Object} options - Configuration options\n * @returns {Object} Search instance\n */\n function createSearch(options) {\n const config = Object.assign({}, DEFAULTS, options || {});\n \n // Instance state\n const state = {\n initialized: false,\n index: [],\n results: [],\n activeIndex: -1,\n isOpen: false,\n query: '',\n container: null,\n input: null,\n resultsContainer: null,\n debounceTimer: null,\n boundHandlers: {}\n };\n\n function safeInvokeCallback(name, fn, ...args) {\n try {\n fn(...args);\n } catch (error) {\n console.warn('[Vanduo Search] Callback error in \"' + name + '\":', error);\n }\n }\n\n function setResultsHtml(html) {\n if (!state.resultsContainer) return;\n try {\n state.resultsContainer.innerHTML = html;\n } catch (error) {\n console.warn('[Vanduo Search] Failed to render results:', error);\n }\n }\n\n /**\n * Initialize the search component\n * Idempotent \u2014 safe to call more than once on the same instance.\n * Returns the instance on success, null if required DOM elements are missing.\n */\n function init() {\n if (state.initialized) {\n return instance;\n }\n\n state.container = document.querySelector(config.containerSelector);\n if (!state.container) {\n state.initialized = false;\n return null;\n }\n\n state.input = state.container.querySelector(config.inputSelector);\n state.resultsContainer = state.container.querySelector(config.resultsSelector);\n\n if (!state.input || !state.resultsContainer) {\n state.initialized = false;\n return null;\n }\n\n // Set placeholder if configured\n if (config.placeholder) {\n state.input.setAttribute('placeholder', config.placeholder);\n }\n\n // Build search index\n buildIndex();\n\n // Bind events\n bindEvents();\n\n // Set up ARIA attributes\n setupAria();\n\n state.initialized = true;\n return instance;\n }\n\n /**\n * Build search index from DOM or custom data\n */\n function buildIndex() {\n state.index = [];\n \n // Use custom data if provided\n if (config.data && Array.isArray(config.data)) {\n config.data.forEach(function(item) {\n state.index.push({\n id: item.id || slugify(item.title),\n title: item.title || '',\n category: item.category || '',\n categorySlug: slugify(item.category || ''),\n content: item.content || '',\n keywords: item.keywords || extractKeywordsFromText(item.title + ' ' + item.content),\n url: item.url || '#' + (item.id || slugify(item.title)),\n icon: item.icon || ''\n });\n });\n return;\n }\n\n // Build from DOM\n const sections = document.querySelectorAll(config.contentSelector);\n const categoryMap = buildCategoryMap();\n\n sections.forEach(function(section) {\n const id = section.id;\n if (!id) return;\n\n const titleEl = section.querySelector(config.titleSelector);\n const title = titleEl ? titleEl.textContent.replace(/v[\\d.]+/g, '').trim() : id;\n const category = categoryMap[id] || 'Documentation';\n const content = extractContent(section);\n const keywords = extractKeywords(section, title);\n const iconEl = titleEl ? titleEl.querySelector('i.ph') : null;\n let icon = '';\n if (iconEl && iconEl.classList) {\n for (let ci = 0; ci < iconEl.classList.length; ci++) {\n if (iconEl.classList[ci].indexOf('ph-') === 0) {\n icon = iconEl.classList[ci];\n break;\n }\n }\n }\n\n state.index.push({\n id: id,\n title: title,\n category: category,\n categorySlug: slugify(category),\n content: content,\n keywords: keywords,\n url: '#' + id,\n icon: icon\n });\n });\n }\n\n /**\n * Build a map of section IDs to their categories\n */\n function buildCategoryMap() {\n const map = {};\n let currentCategory = 'Documentation';\n const navItems = document.querySelectorAll(config.navSelector + ', ' + config.sectionSelector);\n\n navItems.forEach(function(item) {\n if (item.classList.contains('doc-nav-section')) {\n currentCategory = item.textContent.trim();\n } else {\n const href = item.getAttribute('href');\n if (href && href.startsWith('#')) {\n const id = href.substring(1);\n map[id] = currentCategory;\n }\n }\n });\n\n return map;\n }\n\n /**\n * Extract searchable content from a section\n */\n function extractContent(section) {\n const clone = section.cloneNode(true);\n \n const toRemove = clone.querySelectorAll(config.excludeFromContent);\n toRemove.forEach(function(el) {\n el.remove();\n });\n\n let text = clone.textContent || '';\n text = text.replace(/\\s+/g, ' ').trim();\n \n return text.substring(0, config.maxContentLength);\n }\n\n /**\n * Extract keywords from a section\n */\n function extractKeywords(section, title) {\n const keywords = [];\n \n // Add title words\n title.toLowerCase().split(/\\s+/).forEach(function(word) {\n if (word.length > 2) {\n keywords.push(word);\n }\n });\n\n // Add class names from code examples\n const codeBlocks = section.querySelectorAll('code');\n codeBlocks.forEach(function(code) {\n const text = code.textContent || '';\n const classMatches = text.match(/\\.([\\w-]+)/g);\n if (classMatches) {\n classMatches.forEach(function(match) {\n keywords.push(match.substring(1).toLowerCase());\n });\n }\n });\n\n // Add data attributes\n const dataAttrs = section.querySelectorAll('[data-tooltip], [data-modal]');\n dataAttrs.forEach(function(el) {\n const attrs = el.getAttributeNames().filter(function(name) {\n return name.startsWith('data-');\n });\n attrs.forEach(function(attr) {\n keywords.push(attr.replace('data-', ''));\n });\n });\n\n return Array.from(new Set(keywords));\n }\n\n /**\n * Extract keywords from text string\n */\n function extractKeywordsFromText(text) {\n const words = text.toLowerCase().split(/\\s+/);\n return words.filter(function(word) {\n return word.length > 2;\n });\n }\n\n /**\n * Convert string to slug\n */\n function slugify(str) {\n return str.toLowerCase().replace(/\\s+/g, '-').replace(/[^a-z0-9-]/g, '');\n }\n\n /**\n * Bind event listeners\n */\n function bindEvents() {\n // Store bound handlers for cleanup\n state.boundHandlers.handleInput = function(e) {\n handleInput(e);\n };\n state.boundHandlers.handleFocus = function() {\n if (state.query.length >= config.minQueryLength) {\n open();\n }\n };\n state.boundHandlers.handleKeydown = function(e) {\n handleKeydown(e);\n };\n state.boundHandlers.handleOutsideClick = function(e) {\n if (!state.container.contains(e.target)) {\n close();\n }\n };\n state.boundHandlers.handleGlobalKeydown = function(e) {\n if (config.keyboardShortcut && (e.metaKey || e.ctrlKey) && e.key === 'k') {\n e.preventDefault();\n state.input.focus();\n state.input.select();\n }\n };\n state.boundHandlers.handleResultClick = function(e) {\n const result = e.target.closest('.vd-doc-search-result');\n if (result) {\n const index = parseInt(result.dataset.index, 10);\n select(index);\n }\n };\n\n // Input events\n state.input.addEventListener('input', state.boundHandlers.handleInput);\n state.input.addEventListener('focus', state.boundHandlers.handleFocus);\n state.input.addEventListener('keydown', state.boundHandlers.handleKeydown);\n\n // Close on outside click\n document.addEventListener('click', state.boundHandlers.handleOutsideClick);\n\n // Global keyboard shortcut\n document.addEventListener('keydown', state.boundHandlers.handleGlobalKeydown);\n\n // Result click handling\n state.resultsContainer.addEventListener('click', state.boundHandlers.handleResultClick);\n }\n\n /**\n * Unbind event listeners\n */\n function unbindEvents() {\n if (state.input) {\n state.input.removeEventListener('input', state.boundHandlers.handleInput);\n state.input.removeEventListener('focus', state.boundHandlers.handleFocus);\n state.input.removeEventListener('keydown', state.boundHandlers.handleKeydown);\n }\n document.removeEventListener('click', state.boundHandlers.handleOutsideClick);\n document.removeEventListener('keydown', state.boundHandlers.handleGlobalKeydown);\n if (state.resultsContainer) {\n state.resultsContainer.removeEventListener('click', state.boundHandlers.handleResultClick);\n }\n }\n\n /**\n * Set up ARIA attributes\n */\n function setupAria() {\n const resultsId = state.resultsContainer.id || 'search-results-' + Math.random().toString(36).substr(2, 9);\n state.resultsContainer.id = resultsId;\n \n state.input.setAttribute('role', 'combobox');\n state.input.setAttribute('aria-autocomplete', 'list');\n state.input.setAttribute('aria-controls', resultsId);\n state.input.setAttribute('aria-expanded', 'false');\n \n state.resultsContainer.setAttribute('role', 'listbox');\n state.resultsContainer.setAttribute('aria-label', 'Search results');\n }\n\n /**\n * Handle input changes\n */\n function handleInput(e) {\n const query = e.target.value.trim();\n\n if (state.debounceTimer) {\n clearTimeout(state.debounceTimer);\n }\n\n state.debounceTimer = setTimeout(function() {\n state.query = query;\n\n if (query.length < config.minQueryLength) {\n close();\n return;\n }\n\n state.results = search(query);\n state.activeIndex = -1;\n render();\n open();\n\n // Callback\n if (typeof config.onSearch === 'function') {\n safeInvokeCallback('onSearch', config.onSearch, query, state.results);\n }\n }, config.debounceMs);\n }\n\n /**\n * Handle keyboard navigation\n */\n function handleKeydown(e) {\n if (!state.isOpen) {\n if (e.key === 'ArrowDown' && state.query.length >= config.minQueryLength) {\n e.preventDefault();\n state.results = search(state.query);\n render();\n open();\n }\n return;\n }\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n navigate(1);\n break;\n\n case 'ArrowUp':\n e.preventDefault();\n navigate(-1);\n break;\n\n case 'Enter':\n e.preventDefault();\n if (state.activeIndex >= 0) {\n select(state.activeIndex);\n } else if (state.results.length > 0) {\n select(0);\n }\n break;\n\n case 'Escape':\n e.preventDefault();\n close();\n break;\n\n case 'Tab':\n close();\n break;\n }\n }\n\n /**\n * Perform search\n */\n function search(query) {\n const terms = query.toLowerCase().split(/\\s+/).filter(function(t) {\n return t.length > 0;\n });\n const scored = [];\n\n state.index.forEach(function(entry) {\n let score = 0;\n const titleLower = entry.title.toLowerCase();\n const categoryLower = entry.category.toLowerCase();\n const contentLower = entry.content.toLowerCase();\n\n terms.forEach(function(term) {\n // Title match - highest priority\n if (titleLower.includes(term)) {\n score += 100;\n if (titleLower === term) {\n score += 50;\n } else if (titleLower.startsWith(term)) {\n score += 25;\n }\n }\n\n // Category match\n if (categoryLower.includes(term)) {\n score += 50;\n }\n\n // Keyword match\n const keywordMatch = entry.keywords.some(function(k) {\n return k.includes(term);\n });\n if (keywordMatch) {\n score += 30;\n }\n\n // Content match\n if (contentLower.includes(term)) {\n score += 10;\n }\n });\n\n if (score > 0) {\n scored.push({\n id: entry.id,\n title: entry.title,\n category: entry.category,\n categorySlug: entry.categorySlug,\n content: entry.content,\n url: entry.url,\n icon: entry.icon,\n score: score\n });\n }\n });\n\n scored.sort(function(a, b) {\n return b.score - a.score;\n });\n\n return scored.slice(0, config.maxResults);\n }\n\n /**\n * Render search results\n */\n function render() {\n if (state.results.length === 0) {\n setResultsHtml(renderEmpty());\n return;\n }\n\n let html = '
      ';\n\n state.results.forEach(function(result, index) {\n const isActive = index === state.activeIndex;\n const icon = result.icon || getCategoryIcon(result.categorySlug);\n const excerpt = getExcerpt(result.content, state.query);\n\n html += '
    • ' +\n '
      ' +\n '' +\n '
      ' +\n '
      ' +\n '
      ' + highlight(result.title, state.query) + '
      ' +\n '
      ' + escapeHtml(result.category) + '
      ' +\n '
      ' + highlight(excerpt, state.query) + '
      ' +\n '
      ' +\n '
    • ';\n });\n\n html += '
    ';\n html += renderFooter();\n\n setResultsHtml(html);\n }\n\n /**\n * Render empty state\n */\n function renderEmpty() {\n return '
    ' +\n '
    ' +\n '
    ' + escapeHtml(config.emptyTitle) + '
    ' +\n '
    ' + escapeHtml(config.emptyText) + '
    ' +\n '
    ';\n }\n\n /**\n * Render footer with keyboard hints\n */\n function renderFooter() {\n return '
    ' +\n '\u2191\u2193 to navigate' +\n '\u21B5 to select' +\n 'esc to close' +\n '
    ';\n }\n\n /**\n * Get icon for category\n */\n function getCategoryIcon(categorySlug) {\n return config.categoryIcons[categorySlug] || config.categoryIcons['default'] || 'ph-file-text';\n }\n\n /**\n * Get excerpt from content\n */\n function getExcerpt(content, query) {\n const terms = query.toLowerCase().split(/\\s+/);\n const contentLower = content.toLowerCase();\n const excerptLength = 100;\n\n let matchPos = -1;\n for (let i = 0; i < terms.length; i++) {\n const pos = contentLower.indexOf(terms[i]);\n if (pos !== -1 && (matchPos === -1 || pos < matchPos)) {\n matchPos = pos;\n }\n }\n\n if (matchPos === -1) {\n return content.substring(0, excerptLength) + '...';\n }\n\n const start = Math.max(0, matchPos - 30);\n const end = Math.min(content.length, matchPos + excerptLength);\n let excerpt = content.substring(start, end);\n\n if (start > 0) {\n excerpt = '...' + excerpt;\n }\n if (end < content.length) {\n excerpt = excerpt + '...';\n }\n\n return excerpt;\n }\n\n /**\n * Highlight matched terms in text\n */\n function highlight(text, query) {\n if (!query) return escapeHtml(text);\n\n const terms = query.toLowerCase().split(/\\s+/).filter(function(t) {\n return t.length > 0;\n });\n let escaped = escapeHtml(text);\n\n terms.forEach(function(term) {\n // Skip overly long terms to prevent ReDoS\n if (term.length > 50) return;\n const regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi');\n escaped = escaped.replace(regex, '<' + config.highlightTag + '>$1');\n });\n\n return escaped;\n }\n\n /**\n * Escape HTML entities\n */\n function escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Navigate results with keyboard\n */\n function navigate(direction) {\n let newIndex = state.activeIndex + direction;\n\n if (newIndex < 0) {\n newIndex = state.results.length - 1;\n } else if (newIndex >= state.results.length) {\n newIndex = 0;\n }\n\n setActiveIndex(newIndex);\n }\n\n /**\n * Set active result index\n */\n function setActiveIndex(index) {\n const prevActive = state.resultsContainer.querySelector('.vd-doc-search-result.is-active');\n if (prevActive) {\n prevActive.classList.remove('is-active');\n prevActive.setAttribute('aria-selected', 'false');\n }\n\n state.activeIndex = index;\n\n const newActive = state.resultsContainer.querySelector('[data-index=\"' + index + '\"]');\n if (newActive) {\n newActive.classList.add('is-active');\n newActive.setAttribute('aria-selected', 'true');\n state.input.setAttribute('aria-activedescendant', 'vd-doc-search-result-' + index);\n newActive.scrollIntoView({ block: 'nearest' });\n }\n }\n\n /**\n * Select a result\n */\n function select(index) {\n const result = state.results[index];\n if (!result) return;\n\n // Close search\n close();\n state.input.value = '';\n state.query = '';\n\n // Custom callback\n if (typeof config.onSelect === 'function') {\n safeInvokeCallback('onSelect', config.onSelect, result);\n return;\n }\n\n // Default behavior: navigate to section\n const section = document.querySelector(result.url);\n if (section) {\n section.scrollIntoView({ behavior: 'smooth', block: 'start' });\n window.history.pushState(null, '', result.url);\n updateSidebarActive(result.id);\n }\n }\n\n /**\n * Update sidebar navigation active state\n */\n function updateSidebarActive(sectionId) {\n const navLinks = document.querySelectorAll(config.navSelector);\n navLinks.forEach(function(link) {\n link.classList.remove('active');\n if (link.getAttribute('href') === '#' + sectionId) {\n link.classList.add('active');\n }\n });\n }\n\n /**\n * Open results dropdown\n */\n function open() {\n if (state.isOpen) return;\n\n state.isOpen = true;\n state.resultsContainer.classList.add('is-open');\n state.input.setAttribute('aria-expanded', 'true');\n\n if (typeof config.onOpen === 'function') {\n safeInvokeCallback('onOpen', config.onOpen);\n }\n }\n\n /**\n * Close results dropdown\n */\n function close() {\n if (!state.isOpen) return;\n\n state.isOpen = false;\n state.activeIndex = -1;\n state.resultsContainer.classList.remove('is-open');\n state.input.setAttribute('aria-expanded', 'false');\n state.input.removeAttribute('aria-activedescendant');\n\n if (typeof config.onClose === 'function') {\n safeInvokeCallback('onClose', config.onClose);\n }\n }\n\n /**\n * Destroy the component\n */\n function destroy() {\n unbindEvents();\n \n state.initialized = false;\n state.index = [];\n state.results = [];\n state.isOpen = false;\n state.query = '';\n\n if (state.debounceTimer) {\n clearTimeout(state.debounceTimer);\n }\n\n if (state.resultsContainer) {\n setResultsHtml('');\n }\n }\n\n /**\n * Rebuild the search index\n */\n function rebuild() {\n buildIndex();\n }\n\n /**\n * Update configuration\n */\n function setConfig(newConfig) {\n Object.assign(config, newConfig);\n }\n\n /**\n * Get current configuration\n */\n function getConfig() {\n return Object.assign({}, config);\n }\n\n /**\n * Get search index\n */\n function getIndex() {\n return state.index.slice();\n }\n\n // Public instance API\n const instance = {\n init: init,\n destroy: destroy,\n rebuild: rebuild,\n search: search,\n open: open,\n close: close,\n setConfig: setConfig,\n getConfig: getConfig,\n getIndex: getIndex\n };\n\n return instance;\n }\n\n /**\n * Search Component (singleton for backward compatibility)\n */\n const Search = {\n // Factory method \u2014 creates and auto-initializes a new independent instance.\n // Always returns the instance so callers retain a reference even if the\n // DOM container is not yet available (they can retry init() later).\n create: function(options) {\n const instance = createSearch(options);\n if (instance) {\n instance.init();\n }\n return instance || null;\n },\n \n // Default instance\n _instance: null,\n \n // Configuration (for default instance)\n config: Object.assign({}, DEFAULTS),\n\n /**\n * Initialize the default search instance\n */\n init: function(options) {\n if (this._instance) {\n this._instance.destroy();\n }\n \n if (options) {\n Object.assign(this.config, options);\n }\n \n this._instance = createSearch(this.config);\n return this._instance ? this._instance.init() : null;\n },\n\n /**\n * Destroy the default instance\n */\n destroy: function() {\n if (this._instance) {\n this._instance.destroy();\n this._instance = null;\n }\n },\n\n destroyAll: function() {\n this.destroy();\n },\n\n /**\n * Rebuild the default instance index\n */\n rebuild: function() {\n if (this._instance) {\n this._instance.rebuild();\n }\n },\n\n /**\n * Search using the default instance\n */\n search: function(query) {\n return this._instance ? this._instance.search(query) : [];\n },\n\n /**\n * Open the default instance\n */\n open: function() {\n if (this._instance) {\n this._instance.open();\n }\n },\n\n /**\n * Close the default instance\n */\n close: function() {\n if (this._instance) {\n this._instance.close();\n }\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('docSearch', Search);\n }\n\n // Expose globally (both names for compatibility)\n window.Search = Search;\n window.DocSearch = Search; // Backward compatibility\n window.VanduoDocSearch = Search; // New name compatibility\n\n})();\n", "/**\n * Vanduo Framework - Draggable Component\n * JavaScript functionality for draggable elements and drop zones\n */\n\n(function () {\n 'use strict';\n\n /**\n * Draggable Component\n */\n const Draggable = {\n // Store initialized draggables and their cleanup functions\n instances: new Map(),\n // Store current drag state\n currentDrag: null,\n // Store touch state\n touchState: null,\n // Feedback element\n feedbackElement: null,\n // Shared selector used by init and touch reorder\n containerSelector: '.vd-draggable-container, .vd-draggable-container-vertical',\n\n /**\n * Initialize draggable components\n */\n init: function () {\n const draggables = document.querySelectorAll('.vd-draggable, [data-draggable]');\n\n draggables.forEach(element => {\n if (this.instances.has(element)) {\n return;\n }\n this.initDraggable(element);\n });\n\n const containers = document.querySelectorAll(this.containerSelector);\n containers.forEach(container => {\n if (!this.instances.has(container)) {\n this.initContainer(container);\n }\n });\n\n const dropZones = document.querySelectorAll('.vd-drop-zone');\n dropZones.forEach(zone => {\n if (!this.instances.has(zone)) {\n this.initDropZone(zone);\n }\n });\n\n this.createFeedbackElement();\n },\n\n /**\n * Initialize a single draggable element\n * @param {HTMLElement} element - Draggable element\n */\n initDraggable: function (element) {\n const cleanupFunctions = [];\n\n // Make element draggable if not already\n if (!element.hasAttribute('draggable')) {\n element.setAttribute('draggable', 'true');\n }\n\n // Accessibility: add ARIA attributes\n if (!element.hasAttribute('tabindex')) {\n element.setAttribute('tabindex', '0');\n }\n element.setAttribute('role', 'option');\n element.setAttribute('aria-roledescription', 'draggable item');\n element.setAttribute('aria-grabbed', 'false');\n\n // Handle drag start\n const dragStartHandler = (e) => {\n this.handleDragStart(e, element);\n };\n element.addEventListener('dragstart', dragStartHandler);\n cleanupFunctions.push(() => element.removeEventListener('dragstart', dragStartHandler));\n\n // Handle drag\n const dragHandler = (e) => {\n this.handleDrag(e, element);\n };\n element.addEventListener('drag', dragHandler);\n cleanupFunctions.push(() => element.removeEventListener('drag', dragHandler));\n\n // Handle drag end\n const dragEndHandler = (e) => {\n this.handleDragEnd(e, element);\n };\n element.addEventListener('dragend', dragEndHandler);\n cleanupFunctions.push(() => element.removeEventListener('dragend', dragEndHandler));\n\n // Handle touch start (for mobile)\n const touchStartHandler = (e) => {\n this.handleTouchStart(e, element);\n };\n element.addEventListener('touchstart', touchStartHandler);\n cleanupFunctions.push(() => element.removeEventListener('touchstart', touchStartHandler));\n\n // Handle touch move (for mobile)\n // { passive: false } is required so that e.preventDefault() works\n // once the drag threshold is reached \u2014 without it, modern browsers\n // treat the listener as passive and silently ignore preventDefault().\n const touchMoveHandler = (e) => {\n this.handleTouchMove(e, element);\n };\n element.addEventListener('touchmove', touchMoveHandler, { passive: false });\n cleanupFunctions.push(() => element.removeEventListener('touchmove', touchMoveHandler));\n\n // Handle touch end (for mobile)\n const touchEndHandler = (e) => {\n this.handleTouchEnd(e, element);\n };\n element.addEventListener('touchend', touchEndHandler, { passive: false });\n cleanupFunctions.push(() => element.removeEventListener('touchend', touchEndHandler));\n\n // Handle touch cancel (for mobile)\n const touchCancelHandler = (e) => {\n this.handleTouchEnd(e, element);\n };\n element.addEventListener('touchcancel', touchCancelHandler);\n cleanupFunctions.push(() => element.removeEventListener('touchcancel', touchCancelHandler));\n\n // Keyboard navigation\n const keydownHandler = (e) => {\n this.handleKeydown(e, element);\n };\n element.addEventListener('keydown', keydownHandler);\n cleanupFunctions.push(() => element.removeEventListener('keydown', keydownHandler));\n\n this.instances.set(element, { cleanup: cleanupFunctions });\n },\n\n /**\n * Initialize a draggable container\n * @param {HTMLElement} container - Draggable container\n */\n initContainer: function (container) {\n // Accessibility: add ARIA role to container\n container.setAttribute('role', 'listbox');\n container.setAttribute('aria-label', container.getAttribute('aria-label') || 'Draggable items');\n\n const items = container.querySelectorAll('.vd-draggable-item');\n items.forEach(item => {\n if (!this.instances.has(item)) {\n this.initDraggable(item);\n }\n });\n\n const cleanupFunctions = [];\n\n // Handle drag enter\n const dragEnterHandler = (e) => {\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n };\n\n // Handle drag over for auto-sorting\n const dragOverHandler = (e) => {\n e.preventDefault(); // Necessary to allow drop\n e.dataTransfer.dropEffect = 'move';\n\n if (!this.currentDrag) return;\n const draggingEl = this.currentDrag.element;\n\n // Only reorder if dragging an item that belongs to this container (or if we support cross-container drag, but keep simple)\n if (!container.contains(draggingEl)) return;\n\n // Prevent jumps when the dragging finalizes with 0,0 coordinates near the end\n if (e.clientX === 0 && e.clientY === 0) return;\n\n this.handleReorder(container, draggingEl, e.clientX, e.clientY);\n };\n\n // Handle drop\n const dropHandler = (e) => {\n e.preventDefault(); // crucial to prevent the browser's default handling and snapping back\n };\n\n container.addEventListener('dragenter', dragEnterHandler);\n container.addEventListener('dragover', dragOverHandler);\n container.addEventListener('drop', dropHandler);\n\n cleanupFunctions.push(() => {\n container.removeEventListener('dragenter', dragEnterHandler);\n container.removeEventListener('dragover', dragOverHandler);\n container.removeEventListener('drop', dropHandler);\n });\n\n this.instances.set(container, { cleanup: cleanupFunctions });\n },\n\n /**\n * Initialize a drop zone\n * @param {HTMLElement} zone - Drop zone element\n */\n initDropZone: function (zone) {\n const cleanupFunctions = [];\n\n // Accessibility: add ARIA role to drop zone\n zone.setAttribute('role', 'region');\n zone.setAttribute('aria-dropeffect', 'move');\n if (!zone.hasAttribute('aria-label')) {\n zone.setAttribute('aria-label', 'Drop zone');\n }\n\n // Handle drag over\n const dragOverHandler = (e) => {\n e.preventDefault();\n this.handleDragOver(e, zone);\n };\n zone.addEventListener('dragover', dragOverHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragover', dragOverHandler));\n\n // Handle drag enter\n const dragEnterHandler = (e) => {\n e.preventDefault();\n this.handleDragEnter(e, zone);\n };\n zone.addEventListener('dragenter', dragEnterHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragenter', dragEnterHandler));\n\n // Handle drag leave\n const dragLeaveHandler = (e) => {\n this.handleDragLeave(e, zone);\n };\n zone.addEventListener('dragleave', dragLeaveHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragleave', dragLeaveHandler));\n\n // Handle drop\n const dropHandler = (e) => {\n e.preventDefault();\n this.handleDrop(e, zone);\n };\n zone.addEventListener('drop', dropHandler);\n cleanupFunctions.push(() => zone.removeEventListener('drop', dropHandler));\n\n this.instances.set(zone, { cleanup: cleanupFunctions });\n },\n\n /**\n * Create feedback element for drag operations\n */\n createFeedbackElement: function () {\n if (!this.feedbackElement) {\n // Reuse existing element if present\n const existing = document.querySelector('.vd-drag-feedback');\n if (existing) {\n this.feedbackElement = existing;\n return;\n }\n\n this.feedbackElement = document.createElement('div');\n this.feedbackElement.className = 'vd-drag-feedback hidden';\n this.feedbackElement.setAttribute('role', 'presentation');\n document.body.appendChild(this.feedbackElement);\n }\n },\n\n /**\n * Handle drag start event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDragStart: function (e, element) {\n // Add dragging class\n element.classList.add('is-dragging');\n\n // Accessibility: update aria-grabbed\n element.setAttribute('aria-grabbed', 'true');\n\n // Store drag state\n this.currentDrag = {\n element: element,\n initialPosition: { x: e.clientX, y: e.clientY },\n initialBounds: element.getBoundingClientRect(),\n data: this.getData(element)\n };\n\n // Set drag data\n e.dataTransfer.effectAllowed = 'move';\n e.dataTransfer.setData('text/plain', this.currentDrag.data);\n\n // We no longer suppress the native ghost image or manually update feedback\n // for mouse drags, relying on the browser's native rendering instead.\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:start', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: e.clientX, y: e.clientY }\n }\n }));\n },\n\n /**\n * Handle drag event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDrag: function (e, element) {\n // Guard against null state (race condition on fast interactions)\n if (!this.currentDrag) return;\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:drag', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: e.clientX, y: e.clientY },\n delta: {\n x: e.clientX - this.currentDrag.initialPosition.x,\n y: e.clientY - this.currentDrag.initialPosition.y\n }\n }\n }));\n },\n\n /**\n * Handle drag end event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDragEnd: function (e, element) {\n // Remove dragging class\n element.classList.remove('is-dragging');\n element.classList.add('is-dropped');\n setTimeout(() => element.classList.remove('is-dropped'), 300);\n\n // Accessibility: update aria-grabbed\n element.setAttribute('aria-grabbed', 'false');\n\n // Hide feedback\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n\n // Guard against null state\n const data = this.currentDrag?.data || this.getData(element);\n const initialPos = this.currentDrag?.initialPosition || { x: 0, y: 0 };\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:end', {\n bubbles: true,\n detail: {\n element: element,\n data: data,\n position: { x: e.clientX, y: e.clientY },\n delta: {\n x: e.clientX - initialPos.x,\n y: e.clientY - initialPos.y\n }\n }\n }));\n\n // Reset drag state\n this.currentDrag = null;\n },\n\n /**\n * Handle touch start event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchStart: function (e, element) {\n // Don't prevent default here \u2014 it blocks scrolling.\n // We only prevent default in touchmove once drag threshold is reached.\n const touch = e.touches[0];\n const rect = element.getBoundingClientRect();\n this.touchState = {\n element: element,\n startX: touch.clientX,\n startY: touch.clientY,\n lastX: touch.clientX,\n lastY: touch.clientY,\n // Keep preview anchored to the original grab point.\n offsetX: touch.clientX - rect.left,\n offsetY: touch.clientY - rect.top,\n startTime: Date.now(),\n isDragging: false\n };\n },\n\n /**\n * Handle touch move event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchMove: function (e, element) {\n if (!this.touchState) return;\n\n const touch = e.touches[0];\n this.touchState.lastX = touch.clientX;\n this.touchState.lastY = touch.clientY;\n const deltaX = touch.clientX - this.touchState.startX;\n const deltaY = touch.clientY - this.touchState.startY;\n\n // Only start dragging if moved a minimum distance\n if (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10) {\n // Now we know it's a drag, not a scroll \u2014 prevent default\n if (e.cancelable) e.preventDefault();\n\n if (!this.touchState.isDragging) {\n this.touchState.isDragging = true;\n element.classList.add('is-dragging');\n element.setAttribute('aria-grabbed', 'true');\n\n // Store drag state\n this.currentDrag = {\n element: element,\n initialPosition: { x: this.touchState.startX, y: this.touchState.startY },\n initialBounds: element.getBoundingClientRect(),\n data: this.getData(element),\n // Preserve where inside the element the drag started for accurate ghost positioning.\n offsetX: this.touchState.offsetX,\n offsetY: this.touchState.offsetY\n };\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:start', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: touch.clientX, y: touch.clientY }\n }\n }));\n }\n\n // Update feedback\n this.updateFeedback(touch.clientX, touch.clientY);\n\n // Dispatch event\n if (this.currentDrag) {\n element.dispatchEvent(new CustomEvent('draggable:drag', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: touch.clientX, y: touch.clientY },\n delta: { x: deltaX, y: deltaY }\n }\n }));\n\n this.updateTouchDropZone(touch.clientX, touch.clientY);\n\n // Reorder for touch\n const container = element.closest(this.containerSelector);\n if (container && container.contains(element)) {\n this.handleReorder(container, element, touch.clientX, touch.clientY);\n }\n }\n }\n },\n\n /**\n * Handle touch end event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchEnd: function (e, element) {\n if (this.touchState && this.touchState.isDragging) {\n if (e.cancelable) e.preventDefault();\n const endTouch = e.changedTouches?.[0];\n const endPosition = {\n x: endTouch?.clientX ?? this.touchState.lastX ?? this.touchState.startX,\n y: endTouch?.clientY ?? this.touchState.lastY ?? this.touchState.startY\n };\n\n const dropZone = this.resolveDropZoneAtPoint(endPosition.x, endPosition.y) || this.touchState.overZone;\n if (dropZone) {\n this.dispatchDrop(dropZone, endPosition);\n } else if (this.touchState.overZone) {\n this.touchState.overZone.classList.remove('is-drag-over');\n }\n\n element.classList.remove('is-dragging');\n element.classList.add('is-dropped');\n element.setAttribute('aria-grabbed', 'false');\n setTimeout(() => element.classList.remove('is-dropped'), 300);\n\n // Hide feedback\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n\n // Dispatch event\n const data = this.currentDrag?.data || this.getData(element);\n const startX = this.touchState?.startX || 0;\n const startY = this.touchState?.startY || 0;\n\n element.dispatchEvent(new CustomEvent('draggable:end', {\n bubbles: true,\n detail: {\n element: element,\n data: data,\n position: endPosition,\n delta: {\n x: endPosition.x - startX,\n y: endPosition.y - startY\n }\n }\n }));\n }\n\n // Reset states\n this.touchState = null;\n this.currentDrag = null;\n },\n\n /**\n * Handle drag over event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} _zone - Drop zone element\n */\n handleDragOver: function (e, _zone) {\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n },\n\n /**\n * Handle drag enter event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDragEnter: function (e, zone) {\n e.preventDefault();\n zone.classList.add('is-drag-over');\n },\n\n /**\n * Handle drag leave event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDragLeave: function (e, zone) {\n zone.classList.remove('is-drag-over');\n },\n\n /**\n * Handle drop event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDrop: function (e, zone) {\n e.preventDefault();\n this.dispatchDrop(zone, { x: e.clientX, y: e.clientY });\n },\n\n /**\n * Resolve a drop zone from viewport coordinates\n * @param {number} x\n * @param {number} y\n * @returns {HTMLElement|null}\n */\n resolveDropZoneAtPoint: function (x, y) {\n if (!Number.isFinite(x) || !Number.isFinite(y)) return null;\n\n // Prefer the full stacking list so overlays/top elements don't hide real drop targets.\n if (typeof document.elementsFromPoint === 'function') {\n const stacked = document.elementsFromPoint(x, y);\n for (const element of stacked) {\n const zone = element.closest('.vd-drop-zone');\n if (zone) return zone;\n }\n }\n\n const target = document.elementFromPoint(x, y);\n const targetZone = target ? target.closest('.vd-drop-zone') : null;\n if (targetZone) return targetZone;\n\n // Last-resort fallback for mobile emulation edge cases.\n const zones = document.querySelectorAll('.vd-drop-zone');\n for (const zone of zones) {\n const rect = zone.getBoundingClientRect();\n if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {\n return zone;\n }\n }\n\n return null;\n },\n\n /**\n * Track and update active drop-zone hover state on touch devices\n * @param {number} x\n * @param {number} y\n */\n updateTouchDropZone: function (x, y) {\n if (!this.touchState) return;\n\n const nextZone = this.resolveDropZoneAtPoint(x, y);\n const prevZone = this.touchState.overZone || null;\n\n if (prevZone && prevZone !== nextZone) {\n prevZone.classList.remove('is-drag-over');\n }\n\n if (nextZone && nextZone !== prevZone) {\n nextZone.classList.add('is-drag-over');\n }\n\n this.touchState.overZone = nextZone || null;\n },\n\n /**\n * Dispatch a normalized drop event for mouse and touch flows\n * @param {HTMLElement} zone\n * @param {{x:number, y:number}} position\n */\n dispatchDrop: function (zone, position) {\n zone.classList.remove('is-drag-over');\n zone.dispatchEvent(new CustomEvent('draggable:drop', {\n bubbles: true,\n detail: {\n zone: zone,\n element: this.currentDrag?.element,\n data: this.currentDrag?.data,\n position: position\n }\n }));\n },\n\n /**\n * Reorder elements in container based on cursor position\n * @param {HTMLElement} container \n * @param {HTMLElement} element \n * @param {number} clientX \n * @param {number} clientY \n */\n handleReorder: function (container, element, clientX, clientY) {\n const isVertical = container.classList.contains('vd-draggable-container-vertical');\n const siblings = [...container.querySelectorAll('.vd-draggable-item:not(.is-dragging), .vd-draggable:not(.is-dragging)')];\n\n const nextSibling = siblings.reduce((closest, child) => {\n const box = child.getBoundingClientRect();\n const offset = isVertical\n ? clientY - box.top - box.height / 2\n : clientX - box.left - box.width / 2;\n\n if (offset < 0 && offset > closest.offset) {\n return { offset: offset, element: child };\n } else {\n return closest;\n }\n }, { offset: Number.NEGATIVE_INFINITY }).element;\n\n if (nextSibling == null) {\n container.appendChild(element);\n } else {\n container.insertBefore(element, nextSibling);\n }\n },\n\n /**\n * Handle keyboard events\n * @param {KeyboardEvent} e - Keyboard event\n * @param {HTMLElement} element - Draggable element\n */\n handleKeydown: function (e, element) {\n switch (e.key) {\n case 'Enter':\n case ' ':\n e.preventDefault();\n // Trigger click or custom action\n element.click();\n break;\n case 'Escape':\n // Cancel drag if in progress\n if (element.classList.contains('is-dragging')) {\n element.classList.remove('is-dragging');\n element.setAttribute('aria-grabbed', 'false');\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n this.currentDrag = null;\n }\n break;\n case 'ArrowUp':\n case 'ArrowLeft': {\n e.preventDefault();\n const prev = element.previousElementSibling;\n if (prev && (prev.classList.contains('vd-draggable') || prev.classList.contains('vd-draggable-item'))) {\n element.parentNode.insertBefore(element, prev);\n element.focus();\n element.dispatchEvent(new CustomEvent('draggable:reorder', {\n bubbles: true,\n detail: { element: element, direction: 'up' }\n }));\n }\n break;\n }\n case 'ArrowDown':\n case 'ArrowRight': {\n e.preventDefault();\n const next = element.nextElementSibling;\n if (next && (next.classList.contains('vd-draggable') || next.classList.contains('vd-draggable-item'))) {\n element.parentNode.insertBefore(next, element);\n element.focus();\n element.dispatchEvent(new CustomEvent('draggable:reorder', {\n bubbles: true,\n detail: { element: element, direction: 'down' }\n }));\n }\n break;\n }\n }\n },\n\n /**\n * Get data from draggable element\n * @param {HTMLElement} element - Draggable element\n * @returns {string} Data associated with the element\n */\n getData: function (element) {\n return element.dataset.draggable || element.textContent.trim();\n },\n\n /**\n * Update drag feedback element\n * @param {number} x - Current X coordinate\n * @param {number} y - Current Y coordinate\n */\n updateFeedback: function (x, y) {\n if (!this.currentDrag) return;\n\n // Show feedback\n this.feedbackElement.classList.remove('hidden');\n\n // Update feedback content\n const rect = this.currentDrag.initialBounds;\n this.feedbackElement.innerHTML = '';\n const clone = this.currentDrag.element.cloneNode(true);\n this.feedbackElement.appendChild(clone);\n\n // Set styles\n const offsetX = this.currentDrag.offsetX ?? 20;\n const offsetY = this.currentDrag.offsetY ?? 20;\n Object.assign(this.feedbackElement.style, {\n left: (x - offsetX) + 'px',\n top: (y - offsetY) + 'px',\n width: rect.width + 'px',\n height: rect.height + 'px'\n });\n },\n\n /**\n * Make an element draggable programmatically\n * @param {HTMLElement|string} element - Element or selector\n * @param {Object} options - Configuration options\n */\n makeDraggable: function (element, options = {}) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n\n if (el && !this.instances.has(el)) {\n // Add classes and attributes\n el.classList.add('vd-draggable');\n el.setAttribute('draggable', 'true');\n\n // Set options\n if (options.data) {\n el.dataset.draggable = options.data;\n }\n\n // Initialize\n this.initDraggable(el);\n }\n },\n\n /**\n * Remove draggable functionality from an element\n * @param {HTMLElement|string} element - Element or selector\n */\n removeDraggable: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n\n if (el && this.instances.has(el)) {\n // Clean up\n const instance = this.instances.get(el);\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n\n // Remove classes and attributes\n el.classList.remove('vd-draggable');\n el.removeAttribute('draggable');\n el.removeAttribute('data-draggable');\n }\n },\n\n /**\n * Destroy a draggable instance and clean up event listeners\n * @param {HTMLElement} element - Draggable element\n */\n destroy: function (element) {\n this.removeDraggable(element);\n },\n\n /**\n * Destroy all draggable instances\n */\n destroyAll: function () {\n const instances = Array.from(this.instances.keys());\n instances.forEach(element => this.destroy(element));\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('draggable', Draggable);\n }\n\n // Expose globally\n window.VanduoDraggable = Draggable;\n})();\n", "/**\n * Vanduo Framework \u2013 LazyLoad Component\n * v1.2.1\n *\n * Provides two levels of API:\n *\n * LOW-LEVEL (generic IntersectionObserver wrapper)\n * VanduoLazyLoad.observe(element, callback, options?)\n * VanduoLazyLoad.unobserve(element)\n * VanduoLazyLoad.unobserveAll()\n *\n * HIGH-LEVEL (HTML-section fetcher, mirrors vanduo-docs SPA behaviour)\n * VanduoLazyLoad.loadSection(url, containerEl, options?)\n * options: { placeholder, threshold, rootMargin, onLoaded, onError }\n * placeholder: 'skeleton' | 'spinner' | HTMLString (default: 'skeleton')\n *\n * ATTRIBUTE-DRIVEN (zero-JS usage, wired by init())\n *
    \u2026
    \n *\n * EVENTS dispatched on the host element:\n * lazysection:loading \u2014 fetch started\n * lazysection:loaded \u2014 content injected\n * lazysection:error \u2014 fetch failed\n */\n\n(function () {\n 'use strict';\n\n /* \u2500\u2500 Private state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /** @type {Map} */\n const _observerMap = new Map();\n\n /* \u2500\u2500 Security helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Returns true only if `url` resolves to the same origin as the page\n * (relative paths and same-origin absolute URLs are allowed).\n * Cross-origin URLs are rejected to prevent SSRF-style fetch abuse.\n * @param {string} url\n * @returns {boolean}\n */\n function _isSafeUrl(url) {\n try {\n // Relative URLs (no origin) are always safe\n const resolved = new URL(url, window.location.href);\n return resolved.origin === window.location.origin;\n } catch (_) {\n return false;\n }\n }\n\n /**\n * Safely inject fetched HTML into a container by parsing it with\n * DOMParser (avoids script execution) and replacing children via\n * standard DOM APIs instead of raw innerHTML assignment.\n * @param {Element} containerEl\n * @param {string} html\n */\n function _safeInjectHtml(containerEl, html) {\n const parser = new DOMParser();\n const doc = parser.parseFromString(html.trim(), 'text/html'); // CodeQL [js/xss-through-dom] Intentional: parsed nodes are sanitized below before adoption into live DOM\n\n const DANGEROUS_TAGS = ['SCRIPT', 'IFRAME', 'OBJECT', 'EMBED', 'FORM', 'BASE', 'LINK', 'META', 'STYLE'];\n for (const tag of DANGEROUS_TAGS) {\n const els = doc.querySelectorAll(tag);\n for (let i = els.length - 1; i >= 0; i--) {\n els[i].parentNode.removeChild(els[i]);\n }\n }\n\n // Strip dangerous attributes (e.g. onerror, javascript: urls) from all elements\n function _sanitizeNode(node) {\n if (node.nodeType === Node.ELEMENT_NODE) {\n const attrs = node.attributes;\n for (let i = attrs.length - 1; i >= 0; i--) {\n const attrName = attrs[i].name.toLowerCase();\n const attrValue = attrs[i].value.toLowerCase();\n const trimmedValue = attrValue.trim();\n if (\n attrName.startsWith('on') ||\n trimmedValue.startsWith('javascript:') ||\n trimmedValue.startsWith('data:') ||\n trimmedValue.startsWith('vbscript:')\n ) {\n node.removeAttribute(attrs[i].name);\n }\n }\n const children = node.childNodes;\n for (let i = 0; i < children.length; i++) {\n _sanitizeNode(children[i]);\n }\n }\n }\n _sanitizeNode(doc.body);\n\n // Collect all top-level body children\n const nodes = Array.from(doc.body.childNodes);\n // Clear container and append parsed nodes\n while (containerEl.firstChild) {\n containerEl.removeChild(containerEl.firstChild);\n }\n nodes.forEach(function (node) {\n containerEl.appendChild(document.adoptNode(node));\n });\n }\n\n /* \u2500\u2500 Placeholder HTML \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n function _skeletonHtml() {\n return '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + 'Loading\u2026
    ';\n }\n\n function _spinnerHtml() {\n return '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + 'Loading\u2026
    ';\n }\n\n function _resolvePlaceholder(placeholder) {\n if (!placeholder || placeholder === 'skeleton') return _skeletonHtml();\n if (placeholder === 'spinner') return _spinnerHtml();\n // Caller-supplied HTML string\n return placeholder;\n }\n\n /* \u2500\u2500 Dispatch helper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n function _dispatch(el, eventName, detail) {\n el.dispatchEvent(new CustomEvent(eventName, { bubbles: true, detail: detail || {} }));\n }\n\n /* \u2500\u2500 VanduoLazyLoad \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const VanduoLazyLoad = {\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * LOW-LEVEL API\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Observe an element. `callback` is invoked once when the element\n * enters the viewport, then the element is automatically unobserved.\n *\n * @param {Element} element\n * @param {function(Element): void} callback\n * @param {{ threshold?: number, rootMargin?: string }} [options]\n */\n observe: function (element, callback, options) {\n if (!(element instanceof Element)) {\n console.warn('[VanduoLazyLoad] observe() requires a DOM Element.');\n return;\n }\n if (typeof callback !== 'function') {\n console.warn('[VanduoLazyLoad] observe() requires a callback function.');\n return;\n }\n // Already observed \u2014 ignore\n if (_observerMap.has(element)) return;\n\n const threshold = (options && options.threshold != null) ? options.threshold : 0;\n const rootMargin = (options && options.rootMargin) ? options.rootMargin : '0px';\n\n const observer = new IntersectionObserver(function (entries, obs) {\n entries.forEach(function (entry) {\n if (entry.isIntersecting) {\n obs.unobserve(entry.target);\n _observerMap.delete(entry.target);\n try {\n callback(entry.target);\n } catch (e) {\n console.error('[VanduoLazyLoad] Callback threw:', e);\n }\n }\n });\n }, { threshold: threshold, rootMargin: rootMargin });\n\n _observerMap.set(element, observer);\n observer.observe(element);\n },\n\n /**\n * Stop observing an element that was previously passed to observe().\n * @param {Element} element\n */\n unobserve: function (element) {\n const observer = _observerMap.get(element);\n if (observer) {\n observer.unobserve(element);\n _observerMap.delete(element);\n }\n },\n\n /**\n * Stop observing ALL currently observed elements.\n */\n unobserveAll: function () {\n _observerMap.forEach(function (observer, element) {\n observer.unobserve(element);\n });\n _observerMap.clear();\n },\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * HIGH-LEVEL API\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Fetch an HTML partial and inject it into `containerEl` when the\n * container enters the viewport. A placeholder is shown immediately.\n *\n * @param {string} url URL of the HTML partial to fetch\n * @param {Element} containerEl Target element whose content will be replaced\n * @param {{\n * placeholder?: 'skeleton'|'spinner'|string,\n * threshold?: number,\n * rootMargin?: string,\n * onLoaded?: function(Element): void,\n * onError?: function(Error): void\n * }} [options]\n */\n loadSection: function (url, containerEl, options) {\n if (typeof url !== 'string' || !url) {\n console.warn('[VanduoLazyLoad] loadSection() requires a non-empty URL string.');\n return;\n }\n if (!(containerEl instanceof Element)) {\n console.warn('[VanduoLazyLoad] loadSection() requires a DOM Element as containerEl.');\n return;\n }\n // Reject cross-origin URLs to prevent SSRF-style fetch abuse\n if (!_isSafeUrl(url)) {\n console.error('[VanduoLazyLoad] loadSection() blocked cross-origin URL:', url);\n return;\n }\n\n const opts = options || {};\n // Placeholders are known-safe static HTML strings built internally\n const placeholderHtml = _resolvePlaceholder(opts.placeholder);\n\n // Use _safeInjectHtml instead of innerHTML to prevent XSS from caller-supplied placeholders\n _safeInjectHtml(containerEl, placeholderHtml);\n _dispatch(containerEl, 'lazysection:loading', { url: url });\n\n // Fetch when visible\n this.observe(containerEl, function () {\n const controller = new window.AbortController();\n const timeoutId = setTimeout(function () { controller.abort(); }, 10000);\n\n window.fetch(url, { signal: controller.signal })\n .then(function (res) {\n clearTimeout(timeoutId);\n if (!res.ok) throw new Error('HTTP ' + res.status);\n return res.text();\n })\n .then(function (html) {\n // Use DOMParser to parse fetched content safely, avoiding\n // raw innerHTML assignment of externally-sourced strings\n _safeInjectHtml(containerEl, html);\n _dispatch(containerEl, 'lazysection:loaded', { url: url });\n // Re-init Vanduo components inside the new content\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.init();\n }\n if (typeof opts.onLoaded === 'function') {\n opts.onLoaded(containerEl);\n }\n })\n .catch(function (err) {\n // Build error node via DOM APIs \u2014 no dynamic HTML strings\n const alertEl = document.createElement('div');\n alertEl.className = 'vd-alert vd-alert-error';\n alertEl.setAttribute('role', 'alert');\n const msgEl = document.createElement('span');\n msgEl.textContent = 'Failed to load content. ';\n const detailEl = document.createElement('small');\n detailEl.style.opacity = '0.7';\n detailEl.textContent = err.message;\n alertEl.appendChild(msgEl);\n alertEl.appendChild(detailEl);\n while (containerEl.firstChild) {\n containerEl.removeChild(containerEl.firstChild);\n }\n containerEl.appendChild(alertEl);\n _dispatch(containerEl, 'lazysection:error', { url: url, error: err });\n console.error('[VanduoLazyLoad] loadSection failed:', err);\n if (typeof opts.onError === 'function') {\n opts.onError(err);\n }\n });\n }, { threshold: opts.threshold, rootMargin: opts.rootMargin });\n },\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * ATTRIBUTE-DRIVEN INIT\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Scan the DOM for [data-vd-lazy] elements and wire them up.\n * Safe to call multiple times \u2014 already-observed elements are skipped.\n */\n init: function () {\n const self = this;\n const elements = document.querySelectorAll('[data-vd-lazy]');\n elements.forEach(function (el) {\n // Skip already-observed or already-loaded elements\n if (_observerMap.has(el) || el.dataset.vdLazyState === 'loading' || el.dataset.vdLazyState === 'loaded') return;\n\n const url = el.getAttribute('data-vd-lazy');\n if (!url) return;\n\n el.dataset.vdLazyState = 'loading';\n const placeholder = el.getAttribute('data-vd-lazy-placeholder') || 'skeleton';\n\n self.loadSection(url, el, {\n placeholder: placeholder,\n onLoaded: function () {\n el.dataset.vdLazyState = 'loaded';\n },\n onError: function () {\n el.dataset.vdLazyState = 'error';\n }\n });\n });\n }\n };\n\n /* \u2500\u2500 Register with Vanduo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('LazyLoad', VanduoLazyLoad);\n }\n\n /* \u2500\u2500 Global convenience alias \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n window.VanduoLazyLoad = VanduoLazyLoad;\n\n})();\n", "/**\n * Vanduo Framework - Glass Scroll Activation\n * Generic scroll-aware glass activation via IntersectionObserver.\n *\n * Usage:\n * Add `data-glass-scroll` to any element carrying a `.vd-glass*` class.\n * By default the previous sibling is used as the sentinel.\n * Point to a custom sentinel via `data-glass-sentinel=\"\"`.\n *\n * Behaviour:\n * - Adds `.is-glass-active` when the sentinel leaves the viewport (scrolled past).\n * - Removes `.is-glass-active` when the sentinel re-enters the viewport.\n */\n(function () {\n 'use strict';\n\n const GlassScroll = {\n /** @type {Map} */\n observers: new Map(),\n\n init: function () {\n document.querySelectorAll('[data-glass-scroll]').forEach(el => {\n if (this.observers.has(el)) return;\n this.initElement(el);\n });\n },\n\n /**\n * Wire up a single scroll-activated glass element.\n * @param {HTMLElement} el\n */\n initElement: function (el) {\n const sentinelSelector = el.dataset.glassSentinel;\n let sentinel;\n\n if (sentinelSelector) {\n sentinel = document.querySelector(sentinelSelector);\n }\n\n if (!sentinel) {\n // Fall back to the previous sibling element\n sentinel = el.previousElementSibling;\n }\n\n if (!sentinel) {\n // No sentinel available \u2014 activate immediately so glass is always shown\n el.classList.add('is-glass-active');\n return;\n }\n\n const observer = new IntersectionObserver(\n (entries) => {\n entries.forEach(entry => {\n // Active when sentinel is NOT intersecting (scrolled past it)\n el.classList.toggle('is-glass-active', !entry.isIntersecting);\n });\n },\n { threshold: 0, rootMargin: '0px' }\n );\n\n observer.observe(sentinel);\n this.observers.set(el, observer);\n },\n\n /**\n * Disconnect and remove a single element's observer.\n * @param {HTMLElement} el\n */\n destroy: function (el) {\n const observer = this.observers.get(el);\n if (observer) {\n observer.disconnect();\n this.observers.delete(el);\n }\n },\n\n destroyAll: function () {\n this.observers.forEach((observer, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('glassScroll', GlassScroll);\n }\n\n window.VanduoGlassScroll = GlassScroll;\n})();\n", "/**\n * Vanduo Framework - Water Morph Effect Component\n * Liquid wave content-swap animation on click\n *\n * Usage:\n * Add .vd-morph or [data-vd-morph] to any element.\n * Provide .vd-morph-content.vd-morph-current and .vd-morph-content.vd-morph-next children.\n * Wave and shine layers are auto-created if absent.\n *\n * JS API:\n * VanduoMorph.morph(el) \u2014 trigger morph programmatically (from center)\n * VanduoMorph.destroy(el) \u2014 tear down listeners for one element\n * VanduoMorph.destroyAll() \u2014 tear down all instances\n */\n\n(function () {\n 'use strict';\n\n const MORPH_DURATION_MS = 750;\n\n const Morph = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-morph, [data-vd-morph]');\n elements.forEach(function (el) {\n if (Morph.instances.has(el)) return;\n if (el.getAttribute('data-vd-morph') === 'manual') return;\n Morph.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n Morph._ensureLayers(el);\n\n const cleanup = [];\n let morphing = false;\n\n const handleClick = function (e) {\n if (morphing) return;\n Morph._runMorph(el, e, function () { morphing = false; });\n morphing = true;\n };\n\n el.addEventListener('click', handleClick);\n cleanup.push(function () { el.removeEventListener('click', handleClick); });\n\n this.instances.set(el, { cleanup: cleanup });\n },\n\n morph: function (el) {\n if (!el) return;\n if (!this.instances.has(el)) this.initInstance(el);\n this._runMorph(el, null, null);\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(function (fn) { fn(); });\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) { Morph.destroy(el); });\n },\n\n /* \u2500\u2500 Internal helpers \u2500\u2500 */\n\n _ensureLayers: function (el) {\n if (!el.querySelector('.vd-morph-wave')) {\n const wave = document.createElement('span');\n wave.className = 'vd-morph-wave';\n wave.setAttribute('aria-hidden', 'true');\n el.insertBefore(wave, el.firstChild);\n }\n if (!el.querySelector('.vd-morph-shine')) {\n const shine = document.createElement('span');\n shine.className = 'vd-morph-shine';\n shine.setAttribute('aria-hidden', 'true');\n const waveEl = el.querySelector('.vd-morph-wave');\n if (waveEl && waveEl.nextSibling) {\n el.insertBefore(shine, waveEl.nextSibling);\n } else {\n el.insertBefore(shine, el.firstChild);\n }\n }\n },\n\n _runMorph: function (el, pointerEvent, onComplete) {\n const wave = el.querySelector('.vd-morph-wave');\n if (wave) {\n const rect = el.getBoundingClientRect();\n const cx = rect.left + rect.width / 2;\n const cy = rect.top + rect.height / 2;\n const px = pointerEvent ? (pointerEvent.clientX || cx) : cx;\n const py = pointerEvent ? (pointerEvent.clientY || cy) : cy;\n wave.style.left = (px - rect.left) + 'px';\n wave.style.top = (py - rect.top) + 'px';\n }\n\n el.classList.add('is-morphing');\n\n let duration = MORPH_DURATION_MS;\n const custom = getComputedStyle(el).getPropertyValue('--morph-duration');\n if (custom) {\n const parsed = parseFloat(custom);\n if (!isNaN(parsed)) duration = parsed * (custom.indexOf('ms') !== -1 ? 1 : 1000);\n }\n\n setTimeout(function () {\n el.classList.remove('is-morphing');\n\n const current = el.querySelector('.vd-morph-current');\n const next = el.querySelector('.vd-morph-next');\n if (current && next) {\n current.classList.remove('vd-morph-current');\n current.classList.add('vd-morph-next');\n next.classList.remove('vd-morph-next');\n next.classList.add('vd-morph-current');\n }\n\n if (typeof onComplete === 'function') onComplete();\n }, duration);\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('morph', Morph);\n }\n\n window.VanduoMorph = Morph;\n\n})();\n", "/**\n * Vanduo Framework \u2014 Expanding flex cards\n * Click / Enter / Space / Arrow keys to change the active card.\n *\n * Usage:\n *
    \n * Use data-vd-expanding-cards=\"manual\" to skip auto-init.\n */\n\n(function () {\n 'use strict';\n\n const ExpandingCards = {\n instances: new Map(),\n\n init: function () {\n document.querySelectorAll('.vd-expanding-cards').forEach(function (el) {\n if (el.getAttribute('data-vd-expanding-cards') === 'manual') return;\n if (ExpandingCards.instances.has(el)) return;\n ExpandingCards.initContainer(el);\n });\n },\n\n initContainer: function (container) {\n const cleanup = [];\n\n const getCards = function () {\n return Array.prototype.slice.call(container.querySelectorAll('.vd-expanding-card'));\n };\n\n const setActive = function (card) {\n const cards = getCards();\n if (!card || cards.indexOf(card) === -1) return;\n cards.forEach(function (c) {\n c.classList.toggle('is-active', c === card);\n });\n card.focus({ preventScroll: true });\n };\n\n const onClick = function (e) {\n const t = e.target;\n const card = t.closest ? t.closest('.vd-expanding-card') : null;\n if (!card || !container.contains(card)) return;\n setActive(card);\n };\n\n const onKeydown = function (e) {\n if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'Home' && e.key !== 'End') {\n return;\n }\n const cards = getCards().filter(function (c) {\n return c.offsetParent !== null || c.getClientRects().length > 0;\n });\n if (!cards.length) return;\n const activeEl = document.activeElement;\n let idx = cards.indexOf(activeEl);\n if (idx < 0) {\n idx = cards.findIndex(function (c) {\n return c.classList.contains('is-active');\n });\n }\n if (idx < 0) idx = 0;\n\n if (e.key === 'ArrowLeft') {\n e.preventDefault();\n setActive(cards[Math.max(0, idx - 1)]);\n } else if (e.key === 'ArrowRight') {\n e.preventDefault();\n setActive(cards[Math.min(cards.length - 1, idx + 1)]);\n } else if (e.key === 'Home') {\n e.preventDefault();\n setActive(cards[0]);\n } else if (e.key === 'End') {\n e.preventDefault();\n setActive(cards[cards.length - 1]);\n }\n };\n\n container.addEventListener('click', onClick);\n cleanup.push(function () {\n container.removeEventListener('click', onClick);\n });\n\n container.addEventListener('keydown', onKeydown);\n cleanup.push(function () {\n container.removeEventListener('keydown', onKeydown);\n });\n\n getCards().forEach(function (card) {\n if (!card.hasAttribute('tabindex')) {\n card.setAttribute('tabindex', '0');\n }\n card.setAttribute('role', 'button');\n if (!card.hasAttribute('aria-pressed')) {\n card.setAttribute('aria-pressed', card.classList.contains('is-active') ? 'true' : 'false');\n }\n });\n\n const syncAria = function () {\n getCards().forEach(function (card) {\n card.setAttribute('aria-pressed', card.classList.contains('is-active') ? 'true' : 'false');\n });\n };\n\n const mo = new MutationObserver(syncAria);\n mo.observe(container, { attributes: true, subtree: true, attributeFilter: ['class'] });\n cleanup.push(function () {\n mo.disconnect();\n });\n syncAria();\n\n ExpandingCards.instances.set(container, { cleanup: cleanup });\n },\n\n destroy: function (container) {\n const inst = this.instances.get(container);\n if (!inst) return;\n inst.cleanup.forEach(function (fn) {\n fn();\n });\n this.instances.delete(container);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) {\n ExpandingCards.destroy(el);\n });\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('expandingCards', ExpandingCards);\n }\n\n window.VanduoExpandingCards = ExpandingCards;\n})();\n", "/**\n * Vanduo Framework \u2014 Timeline animated reveal\n * Opt-in: add `.vd-timeline-animated` to a `.vd-timeline` container.\n * Uses IntersectionObserver to add `.is-revealed` per item with staggered delays.\n * Optional: `.vd-timeline-playback` with `[data-vd-timeline-prev|next|play|pause]` for stepped control.\n */\n\n(function () {\n 'use strict';\n\n const STAGGER_MS = 140;\n const MAX_STAGGER_INDEX = 7;\n const PLAY_INTERVAL_MS = 800;\n\n function countRevealedPrefix(items) {\n let count = 0;\n for (let i = 0; i < items.length; i++) {\n if (!items[i].classList.contains('is-revealed')) break;\n count++;\n }\n return count;\n }\n\n function findPlaybackControls(container) {\n return container.parentElement || document.body;\n }\n\n function initPlayback(container, items, cleanup) {\n items.forEach(function (item) {\n item.classList.remove('is-revealed');\n });\n\n const scope = findPlaybackControls(container);\n const prevBtn = scope.querySelector('[data-vd-timeline-prev]');\n const nextBtn = scope.querySelector('[data-vd-timeline-next]');\n const playBtn = scope.querySelector('[data-vd-timeline-play]');\n const pauseBtn = scope.querySelector('[data-vd-timeline-pause]');\n\n let playTimer = null;\n\n function updateNavButtons() {\n const k = countRevealedPrefix(items);\n const n = items.length;\n if (prevBtn) {\n const atStart = k === 0;\n prevBtn.disabled = atStart;\n prevBtn.setAttribute('aria-disabled', atStart ? 'true' : 'false');\n }\n if (nextBtn) {\n const atEnd = k >= n;\n nextBtn.disabled = atEnd;\n nextBtn.setAttribute('aria-disabled', atEnd ? 'true' : 'false');\n }\n if (playBtn) {\n playBtn.setAttribute('aria-pressed', playTimer ? 'true' : 'false');\n }\n if (pauseBtn) {\n pauseBtn.disabled = !playTimer;\n }\n }\n\n function stepNext() {\n const k = countRevealedPrefix(items);\n if (k < items.length) {\n items[k].classList.add('is-revealed');\n }\n updateNavButtons();\n }\n\n function stepPrev() {\n const k = countRevealedPrefix(items);\n if (k > 0) {\n items[k - 1].classList.remove('is-revealed');\n }\n updateNavButtons();\n }\n\n function play() {\n if (playTimer) return;\n playTimer = setInterval(function () {\n if (countRevealedPrefix(items) >= items.length) {\n pause();\n return;\n }\n stepNext();\n }, PLAY_INTERVAL_MS);\n updateNavButtons();\n }\n\n function pause() {\n if (playTimer) {\n clearInterval(playTimer);\n playTimer = null;\n }\n updateNavButtons();\n }\n\n function addClick(el, fn) {\n if (!el) return;\n const handler = function (e) {\n e.preventDefault();\n fn();\n };\n el.addEventListener('click', handler);\n cleanup.push(function () {\n el.removeEventListener('click', handler);\n });\n }\n\n addClick(prevBtn, stepPrev);\n addClick(nextBtn, stepNext);\n addClick(playBtn, play);\n addClick(pauseBtn, pause);\n\n cleanup.push(function () {\n pause();\n });\n\n updateNavButtons();\n\n return {\n stepNext: stepNext,\n stepPrev: stepPrev,\n play: play,\n pause: pause\n };\n }\n\n const Timeline = {\n instances: new Map(),\n\n init: function () {\n document.querySelectorAll('.vd-timeline.vd-timeline-animated').forEach(function (el) {\n if (Timeline.instances.has(el)) return;\n Timeline.initInstance(el);\n });\n },\n\n reinit: function () {\n Timeline.destroyAll();\n Timeline.init();\n },\n\n initInstance: function (container) {\n const cleanup = [];\n const items = Array.prototype.filter.call(container.children, function (child) {\n return child.classList && child.classList.contains('vd-timeline-item');\n });\n\n items.forEach(function (item, i) {\n const idx = Math.min(i, MAX_STAGGER_INDEX);\n item.style.setProperty('--vd-timeline-reveal-delay', (idx * STAGGER_MS) + 'ms');\n });\n\n const reducedMotion = typeof window.matchMedia === 'function'\n && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n if (reducedMotion) {\n items.forEach(function (item) {\n item.classList.add('is-revealed');\n });\n Timeline.instances.set(container, { cleanup: cleanup });\n return;\n }\n\n const playback = container.classList && container.classList.contains('vd-timeline-playback');\n\n if (playback) {\n const playbackApi = initPlayback(container, items, cleanup);\n Timeline.instances.set(container, { cleanup: cleanup, playback: playbackApi });\n return;\n }\n\n if (typeof IntersectionObserver === 'undefined') {\n items.forEach(function (item) {\n item.classList.add('is-revealed');\n });\n Timeline.instances.set(container, { cleanup: cleanup });\n return;\n }\n\n const observer = new IntersectionObserver(function (entries) {\n entries.forEach(function (entry) {\n if (!entry.isIntersecting) return;\n entry.target.classList.add('is-revealed');\n observer.unobserve(entry.target);\n });\n }, {\n root: null,\n rootMargin: '0px 0px -10% 0px',\n threshold: 0.15\n });\n\n items.forEach(function (item) {\n observer.observe(item);\n });\n\n cleanup.push(function () {\n observer.disconnect();\n });\n\n Timeline.instances.set(container, { cleanup: cleanup });\n },\n\n destroy: function (container) {\n const inst = this.instances.get(container);\n if (!inst) return;\n inst.cleanup.forEach(function (fn) {\n fn();\n });\n this.instances.delete(container);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) {\n Timeline.destroy(el);\n });\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('timeline', Timeline);\n }\n\n window.VanduoTimeline = Timeline;\n})();\n", "/**\n * Vanduo Framework - Flow (Carousel/Slider) Component\n * Touch-enabled carousel with slide/fade transitions, autoplay, indicators\n */\n\n(function () {\n 'use strict';\n\n const Flow = {\n instances: new Map(),\n\n init: function () {\n const carousels = document.querySelectorAll('.vd-flow, .vd-carousel');\n carousels.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const track = el.querySelector('.vd-flow-track');\n if (!track) return;\n\n const slides = Array.from(track.querySelectorAll('.vd-flow-slide'));\n if (slides.length === 0) return;\n\n const isFade = el.classList.contains('vd-flow-fade');\n const autoplay = el.hasAttribute('data-vd-autoplay');\n const interval = parseInt(el.getAttribute('data-vd-interval'), 10) || 5000;\n const loop = el.getAttribute('data-vd-loop') !== 'false';\n\n const state = {\n current: 0,\n total: slides.length,\n autoplayTimer: null,\n isFade: isFade,\n loop: loop,\n isDragging: false,\n startX: 0,\n currentX: 0,\n threshold: 50\n };\n\n const cleanup = [];\n\n // Set initial active slide\n slides.forEach((slide, i) => {\n slide.setAttribute('role', 'group');\n slide.setAttribute('aria-roledescription', 'slide');\n slide.setAttribute('aria-label', 'Slide ' + (i + 1) + ' of ' + slides.length);\n if (i === 0) slide.classList.add('is-active');\n });\n\n el.setAttribute('role', 'region');\n el.setAttribute('aria-roledescription', 'carousel');\n if (!el.getAttribute('aria-label')) {\n el.setAttribute('aria-label', 'Carousel');\n }\n\n // Live region for announcements\n const liveRegion = document.createElement('div');\n liveRegion.setAttribute('aria-live', 'polite');\n liveRegion.setAttribute('aria-atomic', 'true');\n liveRegion.className = 'sr-only';\n liveRegion.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);';\n el.appendChild(liveRegion);\n\n const goTo = (index, announce) => {\n if (announce === undefined) announce = true;\n let target = index;\n if (state.loop) {\n target = ((index % state.total) + state.total) % state.total;\n } else {\n target = Math.max(0, Math.min(index, state.total - 1));\n }\n\n const prev = state.current;\n state.current = target;\n\n if (state.isFade) {\n slides.forEach((s, i) => {\n s.classList.toggle('is-active', i === target);\n });\n } else {\n track.style.transform = 'translateX(-' + (target * 100) + '%)';\n }\n\n // Update indicators\n const indicators = el.querySelectorAll('.vd-flow-indicator');\n indicators.forEach((ind, i) => {\n ind.classList.toggle('is-active', i === target);\n ind.setAttribute('aria-selected', i === target ? 'true' : 'false');\n });\n\n // Update slide ARIA\n slides.forEach((s, i) => {\n s.setAttribute('aria-hidden', i !== target ? 'true' : 'false');\n });\n\n if (announce) {\n liveRegion.textContent = 'Slide ' + (target + 1) + ' of ' + state.total;\n }\n\n el.dispatchEvent(new CustomEvent('flow:change', {\n detail: { current: target, previous: prev, total: state.total }\n }));\n };\n\n const next = () => goTo(state.current + 1);\n const prev = () => goTo(state.current - 1);\n\n // Controls\n const prevBtn = el.querySelector('.vd-flow-prev');\n const nextBtn = el.querySelector('.vd-flow-next');\n\n if (prevBtn) {\n const h = () => prev();\n prevBtn.addEventListener('click', h);\n cleanup.push(() => prevBtn.removeEventListener('click', h));\n }\n if (nextBtn) {\n const h = () => next();\n nextBtn.addEventListener('click', h);\n cleanup.push(() => nextBtn.removeEventListener('click', h));\n }\n\n // Indicators\n const indicators = el.querySelectorAll('.vd-flow-indicator');\n indicators.forEach((ind, i) => {\n ind.setAttribute('role', 'tab');\n ind.setAttribute('aria-selected', i === 0 ? 'true' : 'false');\n ind.setAttribute('aria-label', 'Go to slide ' + (i + 1));\n const h = () => goTo(i);\n ind.addEventListener('click', h);\n cleanup.push(() => ind.removeEventListener('click', h));\n });\n\n // Keyboard navigation\n const keyHandler = (e) => {\n if (e.key === 'ArrowLeft') { prev(); e.preventDefault(); }\n if (e.key === 'ArrowRight') { next(); e.preventDefault(); }\n };\n el.setAttribute('tabindex', '0');\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n\n // Touch / pointer support\n const pointerDown = (e) => {\n state.isDragging = true;\n state.startX = e.clientX || (e.touches && e.touches[0].clientX) || 0;\n state.currentX = state.startX;\n el.classList.add('is-dragging');\n };\n\n const pointerMove = (e) => {\n if (!state.isDragging) return;\n state.currentX = e.clientX || (e.touches && e.touches[0].clientX) || 0;\n };\n\n const pointerUp = () => {\n if (!state.isDragging) return;\n state.isDragging = false;\n el.classList.remove('is-dragging');\n const diff = state.startX - state.currentX;\n if (Math.abs(diff) > state.threshold) {\n if (diff > 0) next();\n else prev();\n }\n };\n\n el.addEventListener('mousedown', pointerDown);\n el.addEventListener('mousemove', pointerMove);\n el.addEventListener('mouseup', pointerUp);\n el.addEventListener('mouseleave', pointerUp);\n el.addEventListener('touchstart', pointerDown, { passive: true });\n el.addEventListener('touchmove', pointerMove, { passive: true });\n el.addEventListener('touchend', pointerUp);\n\n cleanup.push(\n () => el.removeEventListener('mousedown', pointerDown),\n () => el.removeEventListener('mousemove', pointerMove),\n () => el.removeEventListener('mouseup', pointerUp),\n () => el.removeEventListener('mouseleave', pointerUp),\n () => el.removeEventListener('touchstart', pointerDown),\n () => el.removeEventListener('touchmove', pointerMove),\n () => el.removeEventListener('touchend', pointerUp)\n );\n\n // Autoplay\n const startAutoplay = () => {\n stopAutoplay();\n state.autoplayTimer = setInterval(next, interval);\n };\n\n const stopAutoplay = () => {\n if (state.autoplayTimer) {\n clearInterval(state.autoplayTimer);\n state.autoplayTimer = null;\n }\n };\n\n if (autoplay) {\n startAutoplay();\n const pauseHandler = () => stopAutoplay();\n const resumeHandler = () => startAutoplay();\n el.addEventListener('mouseenter', pauseHandler);\n el.addEventListener('mouseleave', resumeHandler);\n el.addEventListener('focusin', pauseHandler);\n el.addEventListener('focusout', resumeHandler);\n cleanup.push(\n () => el.removeEventListener('mouseenter', pauseHandler),\n () => el.removeEventListener('mouseleave', resumeHandler),\n () => el.removeEventListener('focusin', pauseHandler),\n () => el.removeEventListener('focusout', resumeHandler),\n () => stopAutoplay()\n );\n }\n\n // Initial ARIA state\n goTo(0, false);\n\n this.instances.set(el, {\n cleanup: cleanup,\n goTo: goTo,\n next: next,\n prev: prev,\n getState: () => ({ ...state })\n });\n },\n\n goTo: function (el, index) {\n const instance = this.instances.get(el);\n if (instance) instance.goTo(index);\n },\n\n next: function (el) {\n const instance = this.instances.get(el);\n if (instance) instance.next();\n },\n\n prev: function (el) {\n const instance = this.instances.get(el);\n if (instance) instance.prev();\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('flow', Flow);\n }\n\n window.VanduoFlow = Flow;\n\n})();\n", "/**\n * Vanduo Framework - Bubble (Popover) Component\n * Click-triggered rich HTML popover, reuses tooltip positioning concepts\n */\n\n(function () {\n 'use strict';\n\n const Bubble = {\n instances: new Map(),\n _globalCleanups: [],\n\n init: function () {\n const triggers = document.querySelectorAll('[data-vd-bubble], [data-vd-popover]');\n triggers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n\n if (this._globalCleanups.length === 0) {\n const outsideClick = (e) => {\n this.instances.forEach((inst, trigger) => {\n if (!inst.popover.contains(e.target) && !trigger.contains(e.target)) {\n this.hide(trigger);\n }\n });\n };\n const escHandler = (e) => {\n if (e.key === 'Escape') {\n this.instances.forEach((_, trigger) => this.hide(trigger));\n }\n };\n document.addEventListener('click', outsideClick, true);\n document.addEventListener('keydown', escHandler);\n this._globalCleanups.push(\n () => document.removeEventListener('click', outsideClick, true),\n () => document.removeEventListener('keydown', escHandler)\n );\n }\n },\n\n initInstance: function (trigger) {\n const cleanup = [];\n const placement = trigger.getAttribute('data-vd-bubble-placement') ||\n trigger.getAttribute('data-vd-popover-placement') || 'bottom';\n\n // Build popover element\n const popover = document.createElement('div');\n popover.className = 'vd-bubble-content';\n popover.setAttribute('role', 'dialog');\n popover.setAttribute('aria-modal', 'false');\n popover.setAttribute('data-placement', placement);\n\n const title = trigger.getAttribute('data-vd-bubble-title') ||\n trigger.getAttribute('data-vd-popover-title');\n const content = trigger.getAttribute('data-vd-bubble') ||\n trigger.getAttribute('data-vd-popover') || '';\n const htmlContent = trigger.getAttribute('data-vd-bubble-html') ||\n trigger.getAttribute('data-vd-popover-html');\n const allowSvg = trigger.hasAttribute('data-vd-bubble-allow-svg') ||\n trigger.hasAttribute('data-vd-popover-allow-svg');\n\n if (title) {\n const header = document.createElement('div');\n header.className = 'vd-bubble-header';\n const titleSpan = document.createElement('span');\n titleSpan.textContent = title;\n const closeBtn = document.createElement('button');\n closeBtn.className = 'vd-bubble-close';\n closeBtn.setAttribute('aria-label', 'Close');\n closeBtn.innerHTML = '×';\n header.appendChild(titleSpan);\n header.appendChild(closeBtn);\n popover.appendChild(header);\n\n const closeHandler = (e) => { e.stopPropagation(); this.hide(trigger); };\n closeBtn.addEventListener('click', closeHandler);\n cleanup.push(() => closeBtn.removeEventListener('click', closeHandler));\n }\n\n const body = document.createElement('div');\n body.className = 'vd-bubble-body';\n if (htmlContent) {\n if (typeof sanitizeHtml === 'function') {\n body.innerHTML = sanitizeHtml(htmlContent, { allowSvg });\n } else {\n body.textContent = htmlContent;\n }\n } else {\n body.textContent = content;\n }\n popover.appendChild(body);\n\n document.body.appendChild(popover);\n\n // ARIA on trigger\n const popId = 'vd-bubble-' + Math.random().toString(36).slice(2, 9);\n popover.id = popId;\n trigger.setAttribute('aria-haspopup', 'dialog');\n trigger.setAttribute('aria-expanded', 'false');\n trigger.setAttribute('aria-controls', popId);\n\n // Toggle on click\n const toggleHandler = (e) => {\n e.stopPropagation();\n if (popover.classList.contains('is-visible')) {\n this.hide(trigger);\n } else {\n this.hideAll();\n this.show(trigger);\n }\n };\n trigger.addEventListener('click', toggleHandler);\n cleanup.push(() => trigger.removeEventListener('click', toggleHandler));\n\n this.instances.set(trigger, { popover, cleanup, placement });\n },\n\n position: function (trigger, popover, placement) {\n const rect = trigger.getBoundingClientRect();\n const popRect = popover.getBoundingClientRect();\n const gap = 10;\n let top, left;\n\n switch (placement) {\n case 'top':\n top = rect.top - popRect.height - gap + window.scrollY;\n left = rect.left + (rect.width - popRect.width) / 2 + window.scrollX;\n break;\n case 'left':\n top = rect.top + (rect.height - popRect.height) / 2 + window.scrollY;\n left = rect.left - popRect.width - gap + window.scrollX;\n break;\n case 'right':\n top = rect.top + (rect.height - popRect.height) / 2 + window.scrollY;\n left = rect.right + gap + window.scrollX;\n break;\n default: // bottom\n top = rect.bottom + gap + window.scrollY;\n left = rect.left + (rect.width - popRect.width) / 2 + window.scrollX;\n }\n\n // Clamp to viewport\n left = Math.max(8, Math.min(left, window.innerWidth - popRect.width - 8));\n top = Math.max(8, top);\n\n popover.style.top = top + 'px';\n popover.style.left = left + 'px';\n },\n\n show: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n const { popover, placement } = instance;\n\n popover.style.display = 'block';\n popover.classList.add('is-visible');\n trigger.setAttribute('aria-expanded', 'true');\n\n requestAnimationFrame(() => {\n this.position(trigger, popover, placement);\n });\n\n trigger.dispatchEvent(new CustomEvent('bubble:show', {\n bubbles: true,\n detail: { trigger: trigger, placement: placement }\n }));\n },\n\n hide: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n instance.popover.classList.remove('is-visible');\n trigger.setAttribute('aria-expanded', 'false');\n trigger.dispatchEvent(new CustomEvent('bubble:hide', {\n bubbles: true,\n detail: { trigger: trigger }\n }));\n },\n\n hideAll: function () {\n this.instances.forEach((_, trigger) => this.hide(trigger));\n },\n\n destroy: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n if (instance.popover.parentNode) {\n instance.popover.parentNode.removeChild(instance.popover);\n }\n trigger.removeAttribute('aria-haspopup');\n trigger.removeAttribute('aria-expanded');\n trigger.removeAttribute('aria-controls');\n this.instances.delete(trigger);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, trigger) => this.destroy(trigger));\n this._globalCleanups.forEach(fn => fn());\n this._globalCleanups = [];\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('bubble', Bubble);\n }\n\n window.VanduoBubble = Bubble;\n\n})();\n", "/**\n * Vanduo Framework - Waypoint (Scrollspy) Component\n * Highlights navigation links based on scroll position using IntersectionObserver\n */\n\n(function () {\n 'use strict';\n\n const Waypoint = {\n instances: new Map(),\n\n init: function () {\n const navs = document.querySelectorAll('[data-vd-waypoint-nav], [data-vd-scrollspy-nav]');\n navs.forEach(nav => {\n if (this.instances.has(nav)) return;\n this.initInstance(nav);\n });\n },\n\n initInstance: function (nav) {\n const links = Array.from(nav.querySelectorAll('a[href^=\"#\"]'));\n if (links.length === 0) return;\n\n const cleanup = [];\n const offset = parseInt(nav.getAttribute('data-vd-waypoint-offset') || '80', 10);\n const sections = [];\n\n links.forEach(link => {\n const id = link.getAttribute('href').slice(1);\n const section = document.getElementById(id);\n if (section) {\n section.setAttribute('data-vd-waypoint-section', '');\n sections.push({ id, link, section });\n }\n });\n\n if (sections.length === 0) return;\n\n const activeSections = new Set();\n\n const setActive = (id) => {\n links.forEach(l => l.classList.remove('is-active'));\n const target = links.find(l => l.getAttribute('href') === '#' + id);\n if (target) {\n target.classList.add('is-active');\n nav.dispatchEvent(new CustomEvent('waypoint:change', {\n detail: { activeId: id, link: target }\n }));\n }\n };\n\n const rootMargin = '-' + offset + 'px 0px -40% 0px';\n\n const observer = new IntersectionObserver((entries) => {\n entries.forEach(entry => {\n if (entry.isIntersecting) {\n activeSections.add(entry.target.id);\n } else {\n activeSections.delete(entry.target.id);\n }\n });\n\n // Pick the topmost visible section\n for (let i = 0; i < sections.length; i++) {\n if (activeSections.has(sections[i].id)) {\n setActive(sections[i].id);\n return;\n }\n }\n }, {\n rootMargin: rootMargin,\n threshold: 0\n });\n\n sections.forEach(s => observer.observe(s.section));\n\n // Smooth scroll on click\n links.forEach(link => {\n const clickHandler = (e) => {\n e.preventDefault();\n const id = link.getAttribute('href').slice(1);\n const section = document.getElementById(id);\n if (section) {\n section.scrollIntoView({ behavior: 'smooth' });\n setActive(id);\n }\n };\n link.addEventListener('click', clickHandler);\n cleanup.push(() => link.removeEventListener('click', clickHandler));\n });\n\n cleanup.push(() => observer.disconnect());\n\n this.instances.set(nav, { observer, cleanup, sections, setActive });\n },\n\n refresh: function (nav) {\n this.destroy(nav);\n this.initInstance(nav);\n },\n\n destroy: function (nav) {\n const instance = this.instances.get(nav);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(nav);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, nav) => this.destroy(nav));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('waypoint', Waypoint);\n }\n\n window.VanduoWaypoint = Waypoint;\n\n})();\n", "/**\n * Vanduo Framework - Ripple (Waves Effect) Component\n * Adds expanding circle animation on click at pointer position\n */\n\n(function () {\n 'use strict';\n\n const Ripple = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-ripple, [data-vd-ripple]');\n elements.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n\n const createWave = (e) => {\n const rect = el.getBoundingClientRect();\n const size = Math.max(rect.width, rect.height);\n const x = (e.clientX || (e.touches && e.touches[0].clientX) || rect.left + rect.width / 2) - rect.left - size / 2;\n const y = (e.clientY || (e.touches && e.touches[0].clientY) || rect.top + rect.height / 2) - rect.top - size / 2;\n\n const wave = document.createElement('span');\n wave.className = 'vd-ripple-wave';\n wave.style.width = size + 'px';\n wave.style.height = size + 'px';\n wave.style.left = x + 'px';\n wave.style.top = y + 'px';\n\n el.appendChild(wave);\n\n wave.addEventListener('animationend', () => {\n if (wave.parentNode) wave.parentNode.removeChild(wave);\n });\n };\n\n el.addEventListener('mousedown', createWave);\n el.addEventListener('touchstart', createWave, { passive: true });\n\n cleanup.push(\n () => el.removeEventListener('mousedown', createWave),\n () => el.removeEventListener('touchstart', createWave)\n );\n\n this.instances.set(el, { cleanup });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n // Remove any lingering wave elements\n el.querySelectorAll('.vd-ripple-wave').forEach(w => w.remove());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('ripple', Ripple);\n }\n\n window.VanduoRipple = Ripple;\n\n})();\n", "/**\n * Vanduo Framework - Affix (Sticky) Component\n * Uses IntersectionObserver to toggle .is-stuck within the nearest scrollable parent\n */\n\n(function () {\n 'use strict';\n\n function isScrollable(element) {\n if (!element || element === document.body) return false;\n\n const style = window.getComputedStyle(element);\n const overflowY = style.overflowY;\n const overflowX = style.overflowX;\n const canScrollY = /(auto|scroll|overlay)/.test(overflowY) && element.scrollHeight > element.clientHeight;\n const canScrollX = /(auto|scroll|overlay)/.test(overflowX) && element.scrollWidth > element.clientWidth;\n\n return canScrollY || canScrollX;\n }\n\n function getScrollParent(element) {\n let parent = element.parentElement;\n\n while (parent && parent !== document.body && parent !== document.documentElement) {\n if (isScrollable(parent)) return parent;\n parent = parent.parentElement;\n }\n\n return null;\n }\n\n const Affix = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-affix, .vd-sticky, [data-vd-affix]');\n elements.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const parsedOffset = parseInt(el.getAttribute('data-vd-affix-offset') || '0', 10);\n const offset = Number.isNaN(parsedOffset) ? 0 : parsedOffset;\n const scrollParent = getScrollParent(el);\n let isStuck = false;\n\n const sentinel = document.createElement('div');\n sentinel.style.cssText = 'display:block;height:1px;margin-bottom:-1px;visibility:hidden;pointer-events:none;';\n el.parentNode.insertBefore(sentinel, el);\n\n el.style.setProperty('--affix-top-offset', offset + 'px');\n\n function stick() {\n if (isStuck) return;\n isStuck = true;\n el.classList.add('is-stuck');\n el.dispatchEvent(new CustomEvent('affix:stuck', {\n bubbles: true,\n detail: {\n offset: offset,\n root: scrollParent || window\n }\n }));\n }\n\n function unstick() {\n if (!isStuck) return;\n isStuck = false;\n el.classList.remove('is-stuck');\n el.dispatchEvent(new CustomEvent('affix:unstuck', {\n bubbles: true,\n detail: {\n offset: offset,\n root: scrollParent || window\n }\n }));\n }\n\n const observer = new IntersectionObserver(function (entries) {\n entries.forEach(entry => {\n if (!entry.isIntersecting) {\n stick();\n } else {\n unstick();\n }\n });\n }, {\n root: scrollParent,\n rootMargin: '-' + offset + 'px 0px 0px 0px',\n threshold: 0\n });\n\n observer.observe(sentinel);\n\n cleanup.push(\n () => observer.disconnect(),\n () => { if (sentinel.parentNode) sentinel.parentNode.removeChild(sentinel); },\n () => {\n el.classList.remove('is-stuck');\n el.style.removeProperty('--affix-top-offset');\n }\n );\n\n this.instances.set(el, { cleanup, observer, sentinel, scrollParent });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n el.classList.remove('is-stuck');\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('affix', Affix);\n }\n\n window.VanduoAffix = Affix;\n\n})();\n", "/**\n * Vanduo Framework - Suggest (Autocomplete) Component\n * Dropdown suggestion list with keyboard navigation, static/async data sources\n */\n\n(function () {\n 'use strict';\n\n /**\n * Escape HTML entities to prevent XSS when inserting into innerHTML\n * @param {string} text\n * @returns {string}\n */\n function _escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Allow same-origin URLs by default, or an explicit allowlist of origins.\n * @param {string} url\n * @param {string[]} allowlist\n * @returns {boolean}\n */\n function _isSafeUrl(url, allowlist) {\n try {\n const resolved = new URL(url, window.location.href);\n if (resolved.origin === window.location.origin) return true;\n return allowlist.includes(resolved.origin);\n } catch (_e) {\n return false;\n }\n }\n\n const Suggest = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-suggest], [data-vd-autocomplete]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const minChars = parseInt(input.getAttribute('data-vd-suggest-min-chars') || '1', 10);\n const url = input.getAttribute('data-vd-suggest-url') || '';\n const allowlistAttr = input.getAttribute('data-vd-suggest-allowlist') || '';\n const allowlist = allowlistAttr\n .split(',')\n .map(value => value.trim())\n .filter(Boolean);\n const staticData = input.getAttribute('data-vd-suggest') || input.getAttribute('data-vd-autocomplete') || '';\n let items = [];\n\n try { items = JSON.parse(staticData); } catch (_e) {\n items = staticData.split(',').map(s => s.trim()).filter(Boolean);\n }\n\n // Wrap input if not already wrapped\n let wrapper = input.closest('.vd-suggest-wrapper, .vd-autocomplete-wrapper');\n if (!wrapper) {\n wrapper = document.createElement('div');\n wrapper.className = 'vd-suggest-wrapper';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n }\n\n // Create dropdown list\n const list = document.createElement('ul');\n list.className = 'vd-suggest-list';\n list.setAttribute('role', 'listbox');\n const listId = 'vd-suggest-' + Math.random().toString(36).slice(2, 9);\n list.id = listId;\n wrapper.appendChild(list);\n\n // ARIA\n input.setAttribute('role', 'combobox');\n input.setAttribute('aria-autocomplete', 'list');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('aria-controls', listId);\n input.setAttribute('autocomplete', 'off');\n\n let highlighted = -1;\n let currentItems = [];\n let debounceTimer = null;\n\n const renderItems = (filtered, query) => {\n list.innerHTML = '';\n currentItems = filtered;\n highlighted = -1;\n\n if (filtered.length === 0) {\n const empty = document.createElement('li');\n empty.className = 'vd-suggest-empty';\n empty.textContent = 'No results';\n list.appendChild(empty);\n return;\n }\n\n filtered.forEach((item, i) => {\n const li = document.createElement('li');\n li.className = 'vd-suggest-item';\n li.setAttribute('role', 'option');\n li.id = listId + '-item-' + i;\n\n const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);\n if (query) {\n // Escape HTML first to prevent XSS, then highlight matches in the safe string\n const escaped = _escapeHtml(text);\n const re = new RegExp('(' + query.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi');\n li.innerHTML = escaped.replace(re, '$1');\n } else {\n li.textContent = text;\n }\n\n li.addEventListener('click', () => selectItem(i));\n list.appendChild(li);\n });\n };\n\n const open = () => {\n list.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n };\n\n const close = () => {\n list.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n highlighted = -1;\n input.removeAttribute('aria-activedescendant');\n };\n\n const selectItem = (index) => {\n const item = currentItems[index];\n const value = typeof item === 'object' ? (item.value || item.label || String(item)) : String(item);\n input.value = value;\n close();\n input.dispatchEvent(new CustomEvent('suggest:select', {\n detail: { value, item, index },\n bubbles: true\n }));\n };\n\n const highlight = (index) => {\n const listItems = list.querySelectorAll('.vd-suggest-item');\n listItems.forEach(li => li.classList.remove('is-highlighted'));\n if (index >= 0 && index < listItems.length) {\n highlighted = index;\n listItems[index].classList.add('is-highlighted');\n input.setAttribute('aria-activedescendant', listItems[index].id);\n listItems[index].scrollIntoView({ block: 'nearest' });\n }\n };\n\n const doSearch = async (query) => {\n if (query.length < minChars) { close(); return; }\n\n let filtered;\n if (url) {\n try {\n if (!_isSafeUrl(url, allowlist)) {\n console.warn('[VanduoSuggest] Blocked non-allowlisted URL:', url);\n filtered = [];\n } else {\n const separator = url.includes('?') ? '&' : '?';\n const res = await window.fetch(url + separator + 'q=' + encodeURIComponent(query));\n filtered = await res.json();\n }\n } catch (_e) {\n filtered = [];\n }\n } else {\n const lower = query.toLowerCase();\n filtered = items.filter(item => {\n const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);\n return text.toLowerCase().includes(lower);\n });\n }\n\n renderItems(filtered, query);\n if (filtered.length > 0) open();\n else open(); // show \"no results\"\n };\n\n const inputHandler = () => {\n clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => doSearch(input.value), 200);\n };\n\n const keyHandler = (e) => {\n if (!list.classList.contains('is-open')) {\n if (e.key === 'ArrowDown') { doSearch(input.value); e.preventDefault(); }\n return;\n }\n\n const total = currentItems.length;\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n highlight(highlighted < total - 1 ? highlighted + 1 : 0);\n break;\n case 'ArrowUp':\n e.preventDefault();\n highlight(highlighted > 0 ? highlighted - 1 : total - 1);\n break;\n case 'Enter':\n e.preventDefault();\n if (highlighted >= 0) selectItem(highlighted);\n break;\n case 'Escape':\n close();\n break;\n }\n };\n\n const blurHandler = () => {\n setTimeout(close, 200);\n };\n\n input.addEventListener('input', inputHandler);\n input.addEventListener('keydown', keyHandler);\n input.addEventListener('blur', blurHandler);\n input.addEventListener('focus', () => { if (input.value.length >= minChars) doSearch(input.value); });\n\n cleanup.push(\n () => input.removeEventListener('input', inputHandler),\n () => input.removeEventListener('keydown', keyHandler),\n () => input.removeEventListener('blur', blurHandler),\n () => clearTimeout(debounceTimer),\n () => { if (list.parentNode) list.parentNode.removeChild(list); }\n );\n\n this.instances.set(input, { cleanup, list, close });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('suggest', Suggest);\n }\n\n window.VanduoSuggest = Suggest;\n\n})();\n", "/**\n * Vanduo Framework - Validate (Form Validation) Component\n * Declarative validation via data attributes with real-time and on-submit modes\n */\n\n(function () {\n 'use strict';\n\n const Validate = {\n instances: new Map(),\n\n rules: {\n required: (value) => value.trim().length > 0,\n email: (value) => /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(value),\n url: (value) => { try { new URL(value); return true; } catch (_e) { return false; } },\n number: (value) => !isNaN(parseFloat(value)) && isFinite(value),\n min: (value, param) => value.length >= parseInt(param, 10),\n max: (value, param) => value.length <= parseInt(param, 10),\n minVal: (value, param) => parseFloat(value) >= parseFloat(param),\n maxVal: (value, param) => parseFloat(value) <= parseFloat(param),\n pattern: (value, param) => {\n try {\n // Cap regex length to prevent ReDoS from excessively complex patterns\n if (param.length > 100) return false;\n return new RegExp(param).test(value);\n } catch (_e) { return false; }\n },\n match: (value, param) => {\n try {\n const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(param) : param;\n const other = document.querySelector('[name=\"' + escaped + '\"]');\n return other ? value === other.value : false;\n } catch (_e) {\n return false;\n }\n }\n },\n\n messages: {\n required: 'This field is required',\n email: 'Please enter a valid email address',\n url: 'Please enter a valid URL',\n number: 'Please enter a valid number',\n min: 'Minimum {0} characters required',\n max: 'Maximum {0} characters allowed',\n minVal: 'Value must be at least {0}',\n maxVal: 'Value must be at most {0}',\n pattern: 'Invalid format',\n match: 'Fields do not match'\n },\n\n init: function () {\n const forms = document.querySelectorAll('[data-vd-validate], .vd-validate');\n forms.forEach(form => {\n if (this.instances.has(form)) return;\n this.initInstance(form);\n });\n },\n\n initInstance: function (form) {\n const cleanup = [];\n const mode = form.getAttribute('data-vd-validate-mode') || 'blur'; // blur | input | submit\n const fields = form.querySelectorAll('[data-vd-rules]');\n\n const validateField = (field) => {\n const rulesStr = field.getAttribute('data-vd-rules') || '';\n const rules = rulesStr.split('|').map(r => r.trim()).filter(Boolean);\n const value = field.value;\n const errors = [];\n\n for (const rule of rules) {\n const [name, ...params] = rule.split(':');\n const param = params.join(':');\n const validator = this.rules[name];\n\n if (validator && !validator(value, param)) {\n const customMsg = field.getAttribute('data-vd-msg-' + name);\n let msg = customMsg || this.messages[name] || 'Invalid';\n if (param) msg = msg.replace('{0}', param);\n errors.push(msg);\n break; // one error at a time\n }\n }\n\n this.setFieldState(field, errors);\n return errors.length === 0;\n };\n\n const validateAll = () => {\n let valid = true;\n fields.forEach(field => {\n if (!validateField(field)) valid = false;\n });\n return valid;\n };\n\n // Per-field listeners\n fields.forEach(field => {\n if (mode === 'input' || mode === 'blur') {\n const eventType = mode === 'input' ? 'input' : 'blur';\n const handler = () => validateField(field);\n field.addEventListener(eventType, handler);\n cleanup.push(() => field.removeEventListener(eventType, handler));\n\n if (mode === 'blur') {\n const inputClear = () => {\n if (field.classList.contains('is-invalid') || field.classList.contains('is-valid')) {\n validateField(field);\n }\n };\n field.addEventListener('input', inputClear);\n cleanup.push(() => field.removeEventListener('input', inputClear));\n }\n }\n });\n\n // Form submit\n const submitHandler = (e) => {\n const valid = validateAll();\n if (!valid) {\n e.preventDefault();\n e.stopPropagation();\n // Focus first invalid field\n const firstInvalid = form.querySelector('.is-invalid');\n if (firstInvalid) firstInvalid.focus();\n }\n form.dispatchEvent(new CustomEvent('validate:submit', {\n detail: { valid },\n bubbles: true\n }));\n };\n\n form.addEventListener('submit', submitHandler);\n cleanup.push(() => form.removeEventListener('submit', submitHandler));\n\n this.instances.set(form, { cleanup, validateAll, validateField });\n },\n\n setFieldState: function (field, errors) {\n const wrapper = field.closest('.vd-form-group') || field.parentElement;\n let errorEl = wrapper.querySelector('.vd-validate-error');\n\n field.classList.remove('is-valid', 'is-invalid');\n\n if (errors.length > 0) {\n field.classList.add('is-invalid');\n field.setAttribute('aria-invalid', 'true');\n if (!errorEl) {\n errorEl = document.createElement('div');\n errorEl.className = 'vd-validate-error';\n errorEl.id = 'vd-err-' + Math.random().toString(36).slice(2, 9);\n errorEl.setAttribute('role', 'alert');\n wrapper.appendChild(errorEl);\n }\n errorEl.textContent = errors[0];\n errorEl.style.display = '';\n field.setAttribute('aria-describedby', errorEl.id);\n } else if (field.value.trim()) {\n field.classList.add('is-valid');\n field.removeAttribute('aria-invalid');\n if (errorEl) errorEl.style.display = 'none';\n } else {\n field.removeAttribute('aria-invalid');\n if (errorEl) errorEl.style.display = 'none';\n }\n },\n\n validateForm: function (form) {\n const instance = this.instances.get(form);\n return instance ? instance.validateAll() : false;\n },\n\n addRule: function (name, validator, message) {\n this.rules[name] = validator;\n if (message) this.messages[name] = message;\n },\n\n destroy: function (form) {\n const instance = this.instances.get(form);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(form);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, form) => this.destroy(form));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('validate', Validate);\n }\n\n window.VanduoValidate = Validate;\n\n})();\n", "/**\n * Vanduo Framework - Datepicker Component\n * Calendar popup attached to input field with month/year navigation\n */\n\n(function () {\n 'use strict';\n\n const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];\n const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];\n\n function escapeRegexChar(c) {\n return c.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n }\n\n function buildParseFormat(format) {\n let regex = '^';\n const order = [];\n let i = 0;\n while (i < format.length) {\n const slice = format.slice(i);\n if (slice.toLowerCase().startsWith('yyyy')) {\n regex += '(\\\\d{4})';\n order.push('y');\n i += 4;\n } else if (slice.toLowerCase().startsWith('mm')) {\n regex += '(\\\\d{2})';\n order.push('m');\n i += 2;\n } else if (slice.toLowerCase().startsWith('dd')) {\n regex += '(\\\\d{2})';\n order.push('d');\n i += 2;\n } else {\n regex += escapeRegexChar(format[i]);\n i++;\n }\n }\n regex += '$';\n return { regex: new RegExp(regex), order };\n }\n\n function parseDateFromFormat(value, format) {\n if (!value || !format) return null;\n const { regex, order } = buildParseFormat(format);\n const m = value.trim().match(regex);\n if (!m) return null;\n let y;\n let mo;\n let d;\n let ci = 1;\n for (let k = 0; k < order.length; k++) {\n const part = order[k];\n const v = parseInt(m[ci++], 10);\n if (Number.isNaN(v)) return null;\n if (part === 'y') y = v;\n else if (part === 'm') mo = v - 1;\n else if (part === 'd') d = v;\n }\n if (y === undefined || mo === undefined || d === undefined) return null;\n const dt = new Date(y, mo, d);\n if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== d) return null;\n return dt;\n }\n\n function formatDate(d, format) {\n const yyyy = String(d.getFullYear());\n const mm = String(d.getMonth() + 1).padStart(2, '0');\n const dd = String(d.getDate()).padStart(2, '0');\n let out = '';\n let i = 0;\n while (i < format.length) {\n const slice = format.slice(i);\n if (slice.toLowerCase().startsWith('yyyy')) {\n out += yyyy;\n i += 4;\n } else if (slice.toLowerCase().startsWith('mm')) {\n out += mm;\n i += 2;\n } else if (slice.toLowerCase().startsWith('dd')) {\n out += dd;\n i += 2;\n } else {\n out += format[i];\n i++;\n }\n }\n return out;\n }\n\n function dateKey(d) {\n return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');\n }\n\n function addDays(d, n) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n x.setDate(x.getDate() + n);\n return x;\n }\n\n function addMonthsClamped(d, n) {\n return new Date(d.getFullYear(), d.getMonth() + n, d.getDate());\n }\n\n function parseYmdLocal(ymd) {\n if (!ymd || typeof ymd !== 'string') return null;\n const m = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(ymd.trim());\n if (!m) return null;\n const y = +m[1];\n const mo = +m[2] - 1;\n const day = +m[3];\n const dt = new Date(y, mo, day);\n if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== day) return null;\n return dt;\n }\n\n function startOfWeekSunday(d) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const day = x.getDay();\n x.setDate(x.getDate() - day);\n return x;\n }\n\n function endOfWeekSunday(d) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const day = x.getDay();\n x.setDate(x.getDate() + (6 - day));\n return x;\n }\n\n const Datepicker = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-datepicker]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const format = input.getAttribute('data-vd-datepicker-format') || 'YYYY-MM-DD';\n const minStr = input.getAttribute('data-vd-datepicker-min');\n const maxStr = input.getAttribute('data-vd-datepicker-max');\n const minDate = minStr ? parseYmdLocal(minStr) : null;\n const maxDate = maxStr ? parseYmdLocal(maxStr) : null;\n\n const today = new Date();\n let viewYear = today.getFullYear();\n let viewMonth = today.getMonth();\n let selectedDate = null;\n let viewMode = 'days'; // days | months | years\n let focusedDate = null;\n /** Prevents focus() after close from immediately re-opening the popup */\n let skipNextFocusOpen = false;\n\n const isDisabled = (d) => {\n if (minDate) {\n const t = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n if (t < minDate.getTime()) return true;\n }\n if (maxDate) {\n const t = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n if (t > maxDate.getTime()) return true;\n }\n return false;\n };\n\n const ensureMonthInRange = (y, m) => {\n if (!minDate && !maxDate) return { y: y, m: m };\n const first = new Date(y, m, 1);\n const last = new Date(y, m + 1, 0);\n if (minDate && last.getTime() < minDate.getTime()) {\n return { y: minDate.getFullYear(), m: minDate.getMonth() };\n }\n if (maxDate && first.getTime() > maxDate.getTime()) {\n return { y: maxDate.getFullYear(), m: maxDate.getMonth() };\n }\n return { y: y, m: m };\n };\n\n const firstSelectableInMonth = (y, m) => {\n const last = new Date(y, m + 1, 0).getDate();\n for (let day = 1; day <= last; day++) {\n const dt = new Date(y, m, day);\n if (!isDisabled(dt)) return dt;\n }\n return new Date(y, m, 1);\n };\n\n if (input.value) {\n const trimmed = input.value.trim();\n let parsed = parseDateFromFormat(trimmed, format);\n if (!parsed) {\n const fallback = new Date(trimmed);\n if (!isNaN(fallback.getTime())) parsed = fallback;\n }\n if (parsed) {\n selectedDate = parsed;\n viewYear = parsed.getFullYear();\n viewMonth = parsed.getMonth();\n }\n }\n\n const clampedInit = ensureMonthInRange(viewYear, viewMonth);\n viewYear = clampedInit.y;\n viewMonth = clampedInit.m;\n\n // Create popup\n const popup = document.createElement('div');\n popup.className = 'vd-datepicker-popup';\n popup.setAttribute('role', 'dialog');\n popup.setAttribute('aria-label', 'Choose date');\n popup.tabIndex = -1;\n\n const wrapper = document.createElement('div');\n wrapper.className = 'vd-suggest-wrapper';\n wrapper.style.position = 'relative';\n wrapper.style.display = 'inline-block';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n wrapper.appendChild(popup);\n\n const isSameDay = (a, b) => a && b &&\n a.getFullYear() === b.getFullYear() &&\n a.getMonth() === b.getMonth() &&\n a.getDate() === b.getDate();\n\n const selectDate = (date) => {\n selectedDate = date;\n viewYear = date.getFullYear();\n viewMonth = date.getMonth();\n input.value = formatDate(date, format);\n skipNextFocusOpen = true;\n close();\n input.dispatchEvent(new CustomEvent('datepicker:select', {\n detail: { date: date, formatted: input.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n input.focus();\n };\n\n const focusFocusedDay = () => {\n if (viewMode !== 'days' || !focusedDate) return;\n const key = dateKey(focusedDate);\n const btn = popup.querySelector('[data-vd-date=\"' + key + '\"]');\n if (btn && !btn.classList.contains('is-outside') && btn.getAttribute('aria-disabled') !== 'true') {\n btn.focus();\n }\n };\n\n const skipDisabled = (d, stepDir, maxSteps) => {\n let x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const step = stepDir > 0 ? 1 : -1;\n for (let i = 0; i < maxSteps; i++) {\n if (!isDisabled(x)) return x;\n x = addDays(x, step);\n }\n return d;\n };\n\n const createDayBtn = (day, outside, date) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-day';\n btn.textContent = day;\n btn.setAttribute('role', 'gridcell');\n\n if (outside) {\n btn.classList.add('is-outside');\n btn.tabIndex = -1;\n btn.setAttribute('aria-disabled', 'true');\n return btn;\n }\n\n btn.setAttribute('data-vd-date', dateKey(date));\n\n if (date && isSameDay(date, today)) btn.classList.add('is-today');\n if (date && isSameDay(date, selectedDate)) btn.classList.add('is-selected');\n if (date && isDisabled(date)) {\n btn.classList.add('is-disabled');\n btn.setAttribute('aria-disabled', 'true');\n btn.tabIndex = -1;\n return btn;\n }\n\n if (date) {\n const isFocused = focusedDate && isSameDay(date, focusedDate);\n btn.tabIndex = isFocused ? 0 : -1;\n\n btn.addEventListener('click', () => {\n selectedDate = date;\n viewYear = date.getFullYear();\n viewMonth = date.getMonth();\n focusedDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());\n input.value = formatDate(date, format);\n skipNextFocusOpen = true;\n close();\n input.dispatchEvent(new CustomEvent('datepicker:select', {\n detail: { date: date, formatted: input.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n input.focus();\n });\n }\n\n return btn;\n };\n\n const render = () => {\n popup.innerHTML = '';\n\n // Header\n const header = document.createElement('div');\n header.className = 'vd-datepicker-header';\n\n const prevBtn = document.createElement('button');\n prevBtn.type = 'button';\n prevBtn.className = 'vd-datepicker-prev';\n prevBtn.innerHTML = '‹';\n prevBtn.setAttribute('aria-label', 'Previous');\n\n const nextBtn = document.createElement('button');\n nextBtn.type = 'button';\n nextBtn.className = 'vd-datepicker-next';\n nextBtn.innerHTML = '›';\n nextBtn.setAttribute('aria-label', 'Next');\n\n const title = document.createElement('span');\n title.className = 'vd-datepicker-title';\n\n if (viewMode === 'days') {\n title.textContent = MONTHS[viewMonth] + ' ' + viewYear;\n title.addEventListener('click', () => { viewMode = 'months'; render(); });\n prevBtn.addEventListener('click', () => { viewMonth--; if (viewMonth < 0) { viewMonth = 11; viewYear--; } render(); });\n nextBtn.addEventListener('click', () => { viewMonth++; if (viewMonth > 11) { viewMonth = 0; viewYear++; } render(); });\n } else if (viewMode === 'months') {\n title.textContent = String(viewYear);\n title.addEventListener('click', () => { viewMode = 'years'; render(); });\n prevBtn.addEventListener('click', () => { viewYear--; render(); });\n nextBtn.addEventListener('click', () => { viewYear++; render(); });\n } else {\n const decadeStart = Math.floor(viewYear / 10) * 10;\n title.textContent = decadeStart + ' - ' + (decadeStart + 9);\n prevBtn.addEventListener('click', () => { viewYear -= 10; render(); });\n nextBtn.addEventListener('click', () => { viewYear += 10; render(); });\n }\n\n header.appendChild(prevBtn);\n header.appendChild(title);\n header.appendChild(nextBtn);\n popup.appendChild(header);\n\n if (viewMode === 'days') {\n const gridWrap = document.createElement('div');\n gridWrap.className = 'vd-datepicker-grid';\n gridWrap.setAttribute('role', 'grid');\n gridWrap.setAttribute('aria-label', 'Calendar');\n\n const weekdays = document.createElement('div');\n weekdays.className = 'vd-datepicker-weekdays';\n weekdays.setAttribute('role', 'row');\n DAYS.forEach(function (d) {\n const span = document.createElement('span');\n span.setAttribute('role', 'columnheader');\n span.setAttribute('aria-label', d);\n span.textContent = d;\n weekdays.appendChild(span);\n });\n gridWrap.appendChild(weekdays);\n\n const firstDay = new Date(viewYear, viewMonth, 1).getDay();\n const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();\n const daysInPrev = new Date(viewYear, viewMonth, 0).getDate();\n\n const cells = [];\n\n for (let i = firstDay - 1; i >= 0; i--) {\n const dayNum = daysInPrev - i;\n const prevMonth = viewMonth === 0 ? 11 : viewMonth - 1;\n const prevYear = viewMonth === 0 ? viewYear - 1 : viewYear;\n const date = new Date(prevYear, prevMonth, dayNum);\n cells.push({ day: dayNum, outside: true, date: date });\n }\n\n for (let d = 1; d <= daysInMonth; d++) {\n const date = new Date(viewYear, viewMonth, d);\n cells.push({ day: d, outside: false, date: date });\n }\n\n const totalCells = firstDay + daysInMonth;\n const remaining = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7);\n for (let i = 1; i <= remaining; i++) {\n const date = new Date(viewYear, viewMonth + 1, i);\n cells.push({ day: i, outside: true, date: date });\n }\n\n for (let r = 0; r < cells.length; r += 7) {\n const row = document.createElement('div');\n row.className = 'vd-datepicker-row';\n row.setAttribute('role', 'row');\n for (let c = 0; c < 7; c++) {\n const cell = cells[r + c];\n row.appendChild(createDayBtn(cell.day, cell.outside, cell.date));\n }\n gridWrap.appendChild(row);\n }\n\n popup.appendChild(gridWrap);\n } else if (viewMode === 'months') {\n const grid = document.createElement('div');\n grid.className = 'vd-datepicker-months';\n MONTHS.forEach((name, i) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-month-btn';\n btn.textContent = name.slice(0, 3);\n if (selectedDate && selectedDate.getFullYear() === viewYear && selectedDate.getMonth() === i) {\n btn.classList.add('is-selected');\n }\n btn.addEventListener('click', () => { viewMonth = i; viewMode = 'days'; render(); });\n grid.appendChild(btn);\n });\n popup.appendChild(grid);\n } else {\n const grid = document.createElement('div');\n grid.className = 'vd-datepicker-years';\n const decadeStart = Math.floor(viewYear / 10) * 10;\n for (let y = decadeStart - 1; y <= decadeStart + 10; y++) {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-year-btn';\n btn.textContent = y;\n if (selectedDate && selectedDate.getFullYear() === y) btn.classList.add('is-selected');\n if (y < decadeStart || y > decadeStart + 9) btn.style.opacity = '0.4';\n btn.addEventListener('click', () => { viewYear = y; viewMode = 'months'; render(); });\n grid.appendChild(btn);\n }\n popup.appendChild(grid);\n }\n };\n\n const handleGridKeydown = (e) => {\n if (!popup.classList.contains('is-open') || viewMode !== 'days') return;\n const grid = popup.querySelector('.vd-datepicker-grid');\n if (!grid || !grid.contains(e.target)) return;\n\n const key = e.key;\n if (key !== 'ArrowLeft' && key !== 'ArrowRight' && key !== 'ArrowUp' && key !== 'ArrowDown' &&\n key !== 'Home' && key !== 'End' && key !== 'PageUp' && key !== 'PageDown' &&\n key !== 'Enter' && key !== ' ' && key !== 'Escape') {\n return;\n }\n\n if (key === 'Escape') {\n e.preventDefault();\n e.stopPropagation();\n skipNextFocusOpen = true;\n close();\n input.focus();\n return;\n }\n\n if (!focusedDate) {\n focusedDate = firstSelectableInMonth(viewYear, viewMonth);\n }\n\n if (key === 'Enter' || key === ' ') {\n e.preventDefault();\n if (focusedDate && !isDisabled(focusedDate)) {\n selectDate(new Date(focusedDate.getFullYear(), focusedDate.getMonth(), focusedDate.getDate()));\n }\n return;\n }\n\n e.preventDefault();\n\n let next = new Date(focusedDate.getFullYear(), focusedDate.getMonth(), focusedDate.getDate());\n let skipDir = 1;\n\n if (key === 'ArrowLeft') {\n next = addDays(next, -1);\n skipDir = -1;\n } else if (key === 'ArrowRight') {\n next = addDays(next, 1);\n skipDir = 1;\n } else if (key === 'ArrowUp') {\n next = addDays(next, -7);\n skipDir = -1;\n } else if (key === 'ArrowDown') {\n next = addDays(next, 7);\n skipDir = 1;\n } else if (key === 'Home') {\n next = startOfWeekSunday(next);\n skipDir = 1;\n } else if (key === 'End') {\n next = endOfWeekSunday(next);\n skipDir = -1;\n } else if (key === 'PageUp') {\n next = addMonthsClamped(next, -1);\n skipDir = -1;\n } else if (key === 'PageDown') {\n next = addMonthsClamped(next, 1);\n skipDir = 1;\n }\n\n next = skipDisabled(next, skipDir, 400);\n\n if (next.getMonth() !== viewMonth || next.getFullYear() !== viewYear) {\n viewYear = next.getFullYear();\n viewMonth = next.getMonth();\n const cl = ensureMonthInRange(viewYear, viewMonth);\n viewYear = cl.y;\n viewMonth = cl.m;\n }\n\n focusedDate = next;\n render();\n requestAnimationFrame(focusFocusedDay);\n };\n\n const open = () => {\n viewMode = 'days';\n if (selectedDate) {\n viewYear = selectedDate.getFullYear();\n viewMonth = selectedDate.getMonth();\n }\n const cl = ensureMonthInRange(viewYear, viewMonth);\n viewYear = cl.y;\n viewMonth = cl.m;\n\n if (selectedDate) {\n focusedDate = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate());\n } else {\n focusedDate = firstSelectableInMonth(viewYear, viewMonth);\n }\n\n render();\n popup.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n requestAnimationFrame(focusFocusedDay);\n };\n\n const close = () => {\n popup.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n viewMode = 'days';\n };\n\n const focusHandler = () => {\n if (skipNextFocusOpen) {\n skipNextFocusOpen = false;\n return;\n }\n open();\n };\n const outsideHandler = (e) => {\n if (!wrapper.contains(e.target)) close();\n };\n const escHandler = (e) => {\n if (e.key === 'Escape' && popup.classList.contains('is-open')) {\n skipNextFocusOpen = true;\n close();\n input.focus();\n }\n };\n\n input.addEventListener('focus', focusHandler);\n document.addEventListener('click', outsideHandler, true);\n document.addEventListener('keydown', escHandler);\n popup.addEventListener('keydown', handleGridKeydown);\n\n input.setAttribute('aria-haspopup', 'dialog');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('autocomplete', 'off');\n\n cleanup.push(\n () => input.removeEventListener('focus', focusHandler),\n () => document.removeEventListener('click', outsideHandler, true),\n () => document.removeEventListener('keydown', escHandler),\n () => popup.removeEventListener('keydown', handleGridKeydown)\n );\n\n this.instances.set(input, { cleanup: cleanup, open: open, close: close, popup: popup });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('datepicker', Datepicker);\n }\n\n window.VanduoDatepicker = Datepicker;\n\n})();\n", "/**\n * Vanduo Framework - Timepicker Component\n * Dropdown time selection with 12h/24h format and configurable step intervals\n */\n\n(function () {\n 'use strict';\n\n const Timepicker = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-timepicker]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const is24h = input.getAttribute('data-vd-timepicker-format') === '24h';\n const step = parseInt(input.getAttribute('data-vd-timepicker-step') || '30', 10);\n\n // Create wrapper\n let wrapper = input.closest('.vd-suggest-wrapper');\n if (!wrapper) {\n wrapper = document.createElement('div');\n wrapper.style.position = 'relative';\n wrapper.style.display = 'inline-block';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n }\n\n // Create popup\n const popup = document.createElement('div');\n popup.className = 'vd-timepicker-popup';\n popup.setAttribute('role', 'listbox');\n wrapper.appendChild(popup);\n\n // Generate time slots\n const times = [];\n for (let h = 0; h < 24; h++) {\n for (let m = 0; m < 60; m += step) {\n const hh24 = String(h).padStart(2, '0');\n const mm = String(m).padStart(2, '0');\n\n if (is24h) {\n times.push({ display: hh24 + ':' + mm, value: hh24 + ':' + mm });\n } else {\n const period = h < 12 ? 'AM' : 'PM';\n const h12 = h === 0 ? 12 : (h > 12 ? h - 12 : h);\n const display = h12 + ':' + mm + ' ' + period;\n times.push({ display, value: hh24 + ':' + mm });\n }\n }\n }\n\n const render = () => {\n popup.innerHTML = '';\n times.forEach(t => {\n const item = document.createElement('div');\n item.className = 'vd-timepicker-item';\n item.setAttribute('role', 'option');\n item.textContent = t.display;\n\n if (input.value === t.value || input.value === t.display) {\n item.classList.add('is-selected');\n }\n\n item.addEventListener('click', () => {\n input.value = t.display;\n popup.querySelectorAll('.vd-timepicker-item').forEach(i => i.classList.remove('is-selected'));\n item.classList.add('is-selected');\n close();\n input.dispatchEvent(new CustomEvent('timepicker:select', {\n detail: { display: t.display, value: t.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n });\n\n popup.appendChild(item);\n });\n };\n\n const open = () => {\n render();\n popup.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n // Scroll to selected\n const selected = popup.querySelector('.is-selected');\n if (selected) selected.scrollIntoView({ block: 'center' });\n };\n\n const close = () => {\n popup.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n };\n\n const focusHandler = () => open();\n const outsideHandler = (e) => {\n if (!wrapper.contains(e.target)) close();\n };\n const escHandler = (e) => { if (e.key === 'Escape') close(); };\n\n input.addEventListener('focus', focusHandler);\n document.addEventListener('click', outsideHandler, true);\n document.addEventListener('keydown', escHandler);\n input.setAttribute('aria-haspopup', 'listbox');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('autocomplete', 'off');\n input.readOnly = true;\n\n cleanup.push(\n () => input.removeEventListener('focus', focusHandler),\n () => document.removeEventListener('click', outsideHandler, true),\n () => document.removeEventListener('keydown', escHandler)\n );\n\n this.instances.set(input, { cleanup, open, close });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('timepicker', Timepicker);\n }\n\n window.VanduoTimepicker = Timepicker;\n\n})();\n", "/**\n * Vanduo Framework - Stepper Component\n * Multi-step progress indicator with state management\n */\n\n(function () {\n 'use strict';\n\n const Stepper = {\n instances: new Map(),\n\n init: function () {\n const steppers = document.querySelectorAll('.vd-stepper');\n steppers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const items = Array.from(el.querySelectorAll('.vd-stepper-item'));\n const isClickable = el.classList.contains('vd-stepper-clickable');\n let currentIndex = items.findIndex(i => i.classList.contains('is-active'));\n if (currentIndex === -1) currentIndex = 0;\n\n const setStep = (index) => {\n if (index < 0 || index >= items.length) return;\n const prev = currentIndex;\n currentIndex = index;\n\n items.forEach((item, i) => {\n item.classList.remove('is-active', 'is-completed');\n if (i < index) item.classList.add('is-completed');\n else if (i === index) item.classList.add('is-active');\n });\n\n el.dispatchEvent(new CustomEvent('stepper:change', {\n detail: { current: index, previous: prev, total: items.length },\n bubbles: true\n }));\n };\n\n if (isClickable) {\n items.forEach((item, i) => {\n const handler = () => setStep(i);\n item.addEventListener('click', handler);\n cleanup.push(() => item.removeEventListener('click', handler));\n });\n }\n\n // Initialize current state\n setStep(currentIndex);\n\n this.instances.set(el, {\n cleanup,\n setStep,\n next: () => setStep(currentIndex + 1),\n prev: () => setStep(currentIndex - 1),\n getCurrent: () => currentIndex\n });\n },\n\n setStep: function (el, index) {\n const inst = this.instances.get(el);\n if (inst) inst.setStep(index);\n },\n\n next: function (el) {\n const inst = this.instances.get(el);\n if (inst) inst.next();\n },\n\n prev: function (el) {\n const inst = this.instances.get(el);\n if (inst) inst.prev();\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('stepper', Stepper);\n }\n\n window.VanduoStepper = Stepper;\n\n})();\n", "/**\n * Vanduo Framework - Rating Component\n * Star-based rating input with hover preview and read-only mode\n */\n\n(function () {\n 'use strict';\n\n const Rating = {\n instances: new Map(),\n\n init: function () {\n const ratings = document.querySelectorAll('[data-vd-rating]');\n ratings.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const max = parseInt(el.getAttribute('data-vd-rating-max') || '5', 10);\n const initialValue = parseFloat(el.getAttribute('data-vd-rating-value') || '0');\n const readonly = el.classList.contains('vd-rating-readonly') || el.hasAttribute('data-vd-rating-readonly');\n let currentValue = initialValue;\n\n el.classList.add('vd-rating');\n el.setAttribute('role', 'radiogroup');\n el.setAttribute('aria-label', el.getAttribute('aria-label') || 'Rating');\n\n // Clear existing stars\n el.innerHTML = '';\n\n // Create stars\n const stars = [];\n for (let i = 1; i <= max; i++) {\n const star = document.createElement('button');\n star.type = 'button';\n star.className = 'vd-rating-star';\n star.setAttribute('role', 'radio');\n star.setAttribute('aria-label', i + ' star' + (i > 1 ? 's' : ''));\n star.setAttribute('aria-checked', i <= currentValue ? 'true' : 'false');\n if (readonly) star.tabIndex = -1;\n stars.push(star);\n el.appendChild(star);\n }\n\n // Value display\n const valueDisplay = document.createElement('span');\n valueDisplay.className = 'vd-rating-value';\n valueDisplay.textContent = currentValue > 0 ? currentValue.toString() : '';\n el.appendChild(valueDisplay);\n\n const updateStars = (value) => {\n stars.forEach((star, i) => {\n star.classList.remove('is-active', 'is-half');\n const starNum = i + 1;\n if (starNum <= Math.floor(value)) {\n star.classList.add('is-active');\n } else if (starNum - 0.5 <= value) {\n star.classList.add('is-half');\n }\n star.setAttribute('aria-checked', starNum <= value ? 'true' : 'false');\n });\n valueDisplay.textContent = value > 0 ? value.toString() : '';\n };\n\n updateStars(currentValue);\n\n if (!readonly) {\n stars.forEach((star, i) => {\n const enterHandler = () => {\n stars.forEach((s, j) => {\n s.classList.toggle('is-hovered', j <= i);\n });\n };\n const leaveHandler = () => {\n stars.forEach(s => s.classList.remove('is-hovered'));\n };\n const clickHandler = () => {\n currentValue = i + 1;\n el.setAttribute('data-vd-rating-value', currentValue);\n updateStars(currentValue);\n el.dispatchEvent(new CustomEvent('rating:change', {\n detail: { value: currentValue, max },\n bubbles: true\n }));\n };\n\n star.addEventListener('mouseenter', enterHandler);\n star.addEventListener('mouseleave', leaveHandler);\n star.addEventListener('click', clickHandler);\n\n cleanup.push(\n () => star.removeEventListener('mouseenter', enterHandler),\n () => star.removeEventListener('mouseleave', leaveHandler),\n () => star.removeEventListener('click', clickHandler)\n );\n });\n\n // Keyboard\n const keyHandler = (e) => {\n if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {\n e.preventDefault();\n if (currentValue < max) {\n currentValue++;\n updateStars(currentValue);\n stars[currentValue - 1].focus();\n el.dispatchEvent(new CustomEvent('rating:change', { detail: { value: currentValue, max }, bubbles: true }));\n }\n } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {\n e.preventDefault();\n if (currentValue > 1) {\n currentValue--;\n updateStars(currentValue);\n stars[currentValue - 1].focus();\n el.dispatchEvent(new CustomEvent('rating:change', { detail: { value: currentValue, max }, bubbles: true }));\n }\n }\n };\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n }\n\n this.instances.set(el, {\n cleanup,\n getValue: () => currentValue,\n setValue: (v) => { currentValue = v; updateStars(v); }\n });\n },\n\n getValue: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getValue() : 0;\n },\n\n setValue: function (el, value) {\n const inst = this.instances.get(el);\n if (inst) inst.setValue(value);\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('rating', Rating);\n }\n\n window.VanduoRating = Rating;\n\n})();\n", "/**\n * Vanduo Framework - Transfer (Dual-list) Component\n * Source-to-target list transfer with search, select-all, and move actions\n */\n\n(function () {\n 'use strict';\n\n const Transfer = {\n instances: new Map(),\n\n init: function () {\n const transfers = document.querySelectorAll('[data-vd-transfer]');\n transfers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n el.classList.add('vd-transfer');\n\n let sourceData, targetData;\n try {\n const raw = JSON.parse(el.getAttribute('data-vd-transfer') || '[]');\n sourceData = raw.map((item, i) => ({\n id: item.id || 'item-' + i,\n label: item.label || item.text || String(item),\n selected: false\n }));\n } catch (_e) {\n sourceData = [];\n }\n targetData = [];\n\n const sourceSelected = new Set();\n const targetSelected = new Set();\n\n const render = () => {\n el.innerHTML = '';\n\n // Source panel\n const sourcePanel = createPanel('Source', sourceData, sourceSelected, 'source');\n // Actions\n const actions = document.createElement('div');\n actions.className = 'vd-transfer-actions';\n\n const moveRightBtn = document.createElement('button');\n moveRightBtn.type = 'button';\n moveRightBtn.className = 'vd-transfer-btn';\n moveRightBtn.innerHTML = '›';\n moveRightBtn.setAttribute('aria-label', 'Move to target');\n moveRightBtn.disabled = sourceSelected.size === 0;\n moveRightBtn.addEventListener('click', () => moveRight());\n\n const moveLeftBtn = document.createElement('button');\n moveLeftBtn.type = 'button';\n moveLeftBtn.className = 'vd-transfer-btn';\n moveLeftBtn.innerHTML = '‹';\n moveLeftBtn.setAttribute('aria-label', 'Move to source');\n moveLeftBtn.disabled = targetSelected.size === 0;\n moveLeftBtn.addEventListener('click', () => moveLeft());\n\n actions.appendChild(moveRightBtn);\n actions.appendChild(moveLeftBtn);\n\n // Target panel\n const targetPanel = createPanel('Target', targetData, targetSelected, 'target');\n\n el.appendChild(sourcePanel);\n el.appendChild(actions);\n el.appendChild(targetPanel);\n };\n\n const createPanel = (title, data, selected, _side) => {\n const panel = document.createElement('div');\n panel.className = 'vd-transfer-panel';\n\n const header = document.createElement('div');\n header.className = 'vd-transfer-header';\n const titleSpan = document.createElement('span');\n titleSpan.textContent = title;\n const count = document.createElement('span');\n count.className = 'vd-transfer-count';\n count.textContent = selected.size + '/' + data.length;\n header.appendChild(titleSpan);\n header.appendChild(count);\n panel.appendChild(header);\n\n // Search\n const searchDiv = document.createElement('div');\n searchDiv.className = 'vd-transfer-search';\n const searchInput = document.createElement('input');\n searchInput.type = 'text';\n searchInput.placeholder = 'Search...';\n searchInput.setAttribute('aria-label', 'Search ' + title.toLowerCase());\n searchDiv.appendChild(searchInput);\n panel.appendChild(searchDiv);\n\n // List\n const list = document.createElement('ul');\n list.className = 'vd-transfer-list';\n list.setAttribute('role', 'listbox');\n\n const renderList = (filter) => {\n list.innerHTML = '';\n const filtered = filter ? data.filter(d => {\n const label = (d.label || d.text || String(d)).toLowerCase();\n return label.includes(filter.toLowerCase());\n }) : data;\n filtered.forEach(item => {\n const li = document.createElement('li');\n li.className = 'vd-transfer-item';\n li.setAttribute('role', 'option');\n if (selected.has(item.id)) li.classList.add('is-selected');\n\n const checkbox = document.createElement('input');\n checkbox.type = 'checkbox';\n checkbox.checked = selected.has(item.id);\n checkbox.setAttribute('aria-label', item.label);\n\n const label = document.createElement('span');\n label.textContent = item.label;\n\n li.addEventListener('click', () => {\n if (selected.has(item.id)) selected.delete(item.id);\n else selected.add(item.id);\n render();\n });\n\n li.appendChild(checkbox);\n li.appendChild(label);\n list.appendChild(li);\n });\n };\n\n searchInput.addEventListener('input', () => renderList(searchInput.value));\n renderList('');\n\n panel.appendChild(list);\n return panel;\n };\n\n const moveRight = () => {\n const toMove = sourceData.filter(d => sourceSelected.has(d.id));\n sourceData = sourceData.filter(d => !sourceSelected.has(d.id));\n targetData = targetData.concat(toMove);\n sourceSelected.clear();\n render();\n fireChange();\n };\n\n const moveLeft = () => {\n const toMove = targetData.filter(d => targetSelected.has(d.id));\n targetData = targetData.filter(d => !targetSelected.has(d.id));\n sourceData = sourceData.concat(toMove);\n targetSelected.clear();\n render();\n fireChange();\n };\n\n const fireChange = () => {\n el.dispatchEvent(new CustomEvent('transfer:change', {\n detail: {\n source: sourceData.map(d => d.id),\n target: targetData.map(d => d.id)\n },\n bubbles: true\n }));\n };\n\n render();\n\n this.instances.set(el, {\n cleanup,\n getTarget: () => targetData.map(d => d.id),\n getSource: () => sourceData.map(d => d.id)\n });\n },\n\n getSelected: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getTarget() : [];\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n el.innerHTML = '';\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('transfer', Transfer);\n }\n\n window.VanduoTransfer = Transfer;\n\n})();\n", "/**\n * Vanduo Framework - Tree View Component\n * Hierarchical collapsible tree with checkbox selection and keyboard navigation\n */\n\n(function () {\n 'use strict';\n\n const Tree = {\n instances: new Map(),\n\n init: function () {\n const trees = document.querySelectorAll('[data-vd-tree]');\n trees.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const cascade = el.getAttribute('data-vd-tree-cascade') !== 'false';\n\n let data;\n try { data = JSON.parse(el.getAttribute('data-vd-tree') || '[]'); } catch (_e) { data = []; }\n\n el.classList.add('vd-tree');\n el.setAttribute('role', 'tree');\n\n const render = (items, parent) => {\n parent.innerHTML = '';\n items.forEach(item => {\n const node = document.createElement('li');\n node.className = 'vd-tree-node';\n node.setAttribute('role', 'treeitem');\n node.setAttribute('aria-expanded', item.open ? 'true' : 'false');\n if (item.open) node.classList.add('is-open');\n\n const content = document.createElement('div');\n content.className = 'vd-tree-node-content';\n\n // Toggle\n if (item.children && item.children.length > 0) {\n const toggle = document.createElement('button');\n toggle.type = 'button';\n toggle.className = 'vd-tree-toggle';\n toggle.setAttribute('aria-label', 'Toggle');\n toggle.addEventListener('click', (e) => {\n e.stopPropagation();\n item.open = !item.open;\n node.classList.toggle('is-open');\n node.setAttribute('aria-expanded', item.open ? 'true' : 'false');\n });\n content.appendChild(toggle);\n } else {\n const ph = document.createElement('span');\n ph.className = 'vd-tree-toggle-placeholder';\n content.appendChild(ph);\n }\n\n // Checkbox\n if (el.hasAttribute('data-vd-tree-checkbox')) {\n const cb = document.createElement('input');\n cb.type = 'checkbox';\n cb.className = 'vd-tree-checkbox';\n cb.checked = !!item.checked;\n cb.setAttribute('aria-label', item.label);\n cb.addEventListener('change', (e) => {\n e.stopPropagation();\n item.checked = cb.checked;\n if (cascade && item.children) {\n setChildChecked(item.children, cb.checked);\n render(data, el);\n }\n el.dispatchEvent(new CustomEvent('tree:check', {\n detail: { id: item.id, checked: cb.checked, label: item.label },\n bubbles: true\n }));\n });\n content.appendChild(cb);\n }\n\n // Icon\n if (item.icon) {\n const icon = document.createElement('span');\n icon.className = 'vd-tree-icon ' + item.icon;\n content.appendChild(icon);\n }\n\n // Label\n const label = document.createElement('span');\n label.className = 'vd-tree-label';\n label.textContent = item.label || '';\n content.appendChild(label);\n\n node.appendChild(content);\n\n // Children\n if (item.children && item.children.length > 0) {\n const childList = document.createElement('ul');\n childList.className = 'vd-tree-children';\n childList.setAttribute('role', 'group');\n render(item.children, childList);\n node.appendChild(childList);\n }\n\n parent.appendChild(node);\n });\n };\n\n const setChildChecked = (items, checked) => {\n items.forEach(item => {\n item.checked = checked;\n if (item.children) setChildChecked(item.children, checked);\n });\n };\n\n // Keyboard\n const keyHandler = (e) => {\n const focused = document.activeElement;\n if (!el.contains(focused)) return;\n\n const nodes = Array.from(el.querySelectorAll('.vd-tree-node-content'));\n const idx = nodes.indexOf(focused.closest('.vd-tree-node-content'));\n if (idx === -1) return;\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n if (idx < nodes.length - 1) {\n const next = nodes[idx + 1].querySelector('.vd-tree-toggle, .vd-tree-label');\n if (next) next.focus();\n }\n break;\n case 'ArrowUp':\n e.preventDefault();\n if (idx > 0) {\n const prev = nodes[idx - 1].querySelector('.vd-tree-toggle, .vd-tree-label');\n if (prev) prev.focus();\n }\n break;\n }\n };\n\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n\n render(data, el);\n\n this.instances.set(el, {\n cleanup,\n getData: () => data,\n getChecked: () => {\n const checked = [];\n const collect = (items) => {\n items.forEach(i => {\n if (i.checked) checked.push(i.id || i.label);\n if (i.children) collect(i.children);\n });\n };\n collect(data);\n return checked;\n }\n });\n },\n\n getChecked: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getChecked() : [];\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n el.innerHTML = '';\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('tree', Tree);\n }\n\n window.VanduoTree = Tree;\n\n})();\n", "/**\n * Vanduo Framework - Spotlight (Feature Discovery) Component\n * Guided tour with overlay highlight and step-through tooltip\n */\n\n(function () {\n 'use strict';\n\n const Spotlight = {\n _active: false,\n _steps: [],\n _currentStep: 0,\n _elements: {},\n _cleanup: [],\n _boundTriggers: new WeakMap(),\n _triggerElement: null,\n\n init: function () {\n const triggers = document.querySelectorAll('[data-vd-spotlight]');\n\n triggers.forEach(trigger => {\n if (this._boundTriggers.has(trigger)) return;\n\n const clickHandler = (event) => {\n event.preventDefault();\n\n const steps = this._parseSteps(trigger.getAttribute('data-vd-spotlight'));\n if (steps.length === 0) return;\n\n this.start(steps, { trigger });\n };\n\n trigger.addEventListener('click', clickHandler);\n this._boundTriggers.set(trigger, clickHandler);\n });\n },\n\n _parseSteps: function (raw) {\n if (typeof raw !== 'string' || raw.trim() === '') return [];\n\n try {\n const parsed = JSON.parse(raw);\n return this._normalizeSteps(parsed);\n } catch (error) {\n console.error('VanduoSpotlight: invalid data-vd-spotlight payload.', error);\n return [];\n }\n },\n\n _normalizeStep: function (step) {\n if (!step || typeof step !== 'object') return null;\n\n const target = step.target;\n const hasSelectorTarget = typeof target === 'string' && target.trim() !== '';\n const hasElementTarget = typeof Element !== 'undefined' && target instanceof Element;\n\n if (!hasSelectorTarget && !hasElementTarget) return null;\n\n const title = typeof step.title === 'string' ? step.title : '';\n const description = typeof step.description === 'string'\n ? step.description\n : (typeof step.content === 'string' ? step.content : '');\n\n return {\n target,\n title,\n description\n };\n },\n\n _normalizeSteps: function (steps) {\n if (!Array.isArray(steps)) return [];\n\n return steps\n .map(step => this._normalizeStep(step))\n .filter(Boolean);\n },\n\n start: function (steps, options) {\n if (this._active) this.stop();\n\n const normalizedSteps = this._normalizeSteps(steps);\n if (normalizedSteps.length === 0) return;\n\n const startOptions = options || {};\n\n this._steps = normalizedSteps;\n this._currentStep = 0;\n this._active = true;\n this._triggerElement = startOptions.trigger || (document.activeElement instanceof HTMLElement ? document.activeElement : null);\n\n // Create overlay\n const overlay = document.createElement('div');\n overlay.className = 'vd-spotlight-overlay';\n overlay.setAttribute('aria-hidden', 'true');\n document.body.appendChild(overlay);\n\n // Create tooltip\n const tooltip = document.createElement('div');\n tooltip.className = 'vd-spotlight-tooltip';\n tooltip.setAttribute('role', 'dialog');\n tooltip.setAttribute('aria-modal', 'true');\n tooltip.tabIndex = -1;\n document.body.appendChild(tooltip);\n\n this._elements = { overlay, tooltip };\n\n // ESC to close\n const escHandler = (e) => { if (e.key === 'Escape') this.stop(); };\n document.addEventListener('keydown', escHandler);\n this._cleanup.push(() => document.removeEventListener('keydown', escHandler));\n\n // Overlay click to close\n overlay.addEventListener('click', () => this.stop());\n\n this._showStep(this._currentStep);\n },\n\n _showStep: function (index) {\n const step = this._steps[index];\n if (!step) return;\n\n const target = typeof step.target === 'string' ? document.querySelector(step.target) : step.target;\n const { tooltip } = this._elements;\n\n // Remove previous highlight\n document.querySelectorAll('.vd-spotlight-target').forEach(el => {\n el.classList.remove('vd-spotlight-target');\n });\n\n // Highlight target\n if (target) {\n target.classList.add('vd-spotlight-target');\n target.scrollIntoView({ behavior: 'smooth', block: 'center' });\n }\n\n // Build tooltip content\n const total = this._steps.length;\n tooltip.innerHTML = '';\n tooltip.removeAttribute('aria-labelledby');\n tooltip.removeAttribute('aria-describedby');\n\n if (step.title) {\n const title = document.createElement('h4');\n title.className = 'vd-spotlight-title';\n title.id = 'vd-spotlight-title-' + index + '-' + Date.now();\n title.textContent = step.title;\n tooltip.appendChild(title);\n tooltip.setAttribute('aria-labelledby', title.id);\n }\n\n if (step.description) {\n const desc = document.createElement('p');\n desc.className = 'vd-spotlight-description';\n desc.id = 'vd-spotlight-description-' + index + '-' + Date.now();\n desc.textContent = step.description;\n tooltip.appendChild(desc);\n tooltip.setAttribute('aria-describedby', desc.id);\n }\n\n // Footer\n const footer = document.createElement('div');\n footer.className = 'vd-spotlight-footer';\n footer.setAttribute('aria-label', 'Step ' + (index + 1) + ' of ' + total);\n\n const counter = document.createElement('span');\n counter.className = 'vd-spotlight-counter';\n counter.textContent = (index + 1) + ' / ' + total;\n\n const actions = document.createElement('div');\n actions.className = 'vd-spotlight-actions';\n\n if (index > 0) {\n const prevBtn = document.createElement('button');\n prevBtn.type = 'button';\n prevBtn.className = 'vd-spotlight-btn';\n prevBtn.textContent = 'Back';\n prevBtn.addEventListener('click', () => this.prev());\n actions.appendChild(prevBtn);\n }\n\n const skipBtn = document.createElement('button');\n skipBtn.type = 'button';\n skipBtn.className = 'vd-spotlight-btn';\n skipBtn.textContent = 'Skip';\n skipBtn.addEventListener('click', () => this.stop());\n actions.appendChild(skipBtn);\n\n if (index < total - 1) {\n const nextBtn = document.createElement('button');\n nextBtn.type = 'button';\n nextBtn.className = 'vd-spotlight-btn vd-spotlight-btn-primary';\n nextBtn.textContent = 'Next';\n nextBtn.addEventListener('click', () => this.next());\n actions.appendChild(nextBtn);\n } else {\n const doneBtn = document.createElement('button');\n doneBtn.type = 'button';\n doneBtn.className = 'vd-spotlight-btn vd-spotlight-btn-primary';\n doneBtn.textContent = 'Done';\n doneBtn.addEventListener('click', () => this.stop());\n actions.appendChild(doneBtn);\n }\n\n footer.appendChild(counter);\n footer.appendChild(actions);\n tooltip.appendChild(footer);\n\n // Position tooltip near target\n if (target) {\n requestAnimationFrame(() => {\n const rect = target.getBoundingClientRect();\n const tRect = tooltip.getBoundingClientRect();\n let top = rect.bottom + 12 + window.scrollY;\n let left = rect.left + (rect.width - tRect.width) / 2 + window.scrollX;\n\n // Keep in viewport\n left = Math.max(8, Math.min(left, window.innerWidth - tRect.width - 8));\n if (top + tRect.height > window.innerHeight + window.scrollY) {\n top = rect.top - tRect.height - 12 + window.scrollY;\n }\n\n tooltip.style.top = top + 'px';\n tooltip.style.left = left + 'px';\n });\n }\n\n document.dispatchEvent(new CustomEvent('spotlight:step', {\n detail: { index, step: index, total, data: step }\n }));\n },\n\n next: function () {\n if (this._currentStep < this._steps.length - 1) {\n this._currentStep++;\n this._showStep(this._currentStep);\n }\n },\n\n prev: function () {\n if (this._currentStep > 0) {\n this._currentStep--;\n this._showStep(this._currentStep);\n }\n },\n\n stop: function () {\n if (!this._active) return;\n\n const total = this._steps.length;\n const detail = {\n completedSteps: total === 0 ? 0 : Math.min(this._currentStep + 1, total),\n total,\n completed: total > 0 && this._currentStep >= total - 1\n };\n\n this._active = false;\n\n document.querySelectorAll('.vd-spotlight-target').forEach(el => {\n el.classList.remove('vd-spotlight-target');\n });\n\n if (this._elements.overlay && this._elements.overlay.parentNode) {\n this._elements.overlay.parentNode.removeChild(this._elements.overlay);\n }\n if (this._elements.tooltip && this._elements.tooltip.parentNode) {\n this._elements.tooltip.parentNode.removeChild(this._elements.tooltip);\n }\n\n this._cleanup.forEach(fn => fn());\n this._cleanup = [];\n this._elements = {};\n this._steps = [];\n this._currentStep = 0;\n\n if (this._triggerElement && this._triggerElement.isConnected && typeof this._triggerElement.focus === 'function') {\n this._triggerElement.focus();\n }\n this._triggerElement = null;\n\n document.dispatchEvent(new CustomEvent('spotlight:end', { detail }));\n },\n\n destroyAll: function () {\n this.stop();\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('spotlight', Spotlight);\n }\n\n window.VanduoSpotlight = Spotlight;\n\n})();\n", "/**\n * Vanduo Framework - Music Player Component\n * HTML5 Audio-based music player with transport controls, volume,\n * and optional shuffle, seek bar, and playlist features.\n *\n * Options (passed to MusicPlayer.init or data-music-player-options):\n * tracks {Array} - [{name, url}] \u2014 required\n * volume {number} - Initial volume 0\u20131 (default 0.5)\n * shuffle {boolean} - Shuffle on init (default false)\n * showProgress {boolean} - Show seek/progress bar (default false)\n * showPlaylist {boolean} - Show expandable playlist panel (default false)\n * autoAdvance {boolean} - Auto-play next track on end (default true)\n * glass {boolean} - Frosted-glass surface styling (default false)\n * detachable {boolean} - Show detach/attach; float above page when detached (default false)\n * floatingPosition {string|null} - After detach: 'bottom-left' or 'bottom-right' (default null = bottom-right)\n * draggable {boolean} - When detached+detachable, drag by handle (default false)\n * minimizable {boolean} - Minimize/expand control (default false)\n * startMinimized {boolean} - On first detach, start minimized (default false)\n * persistPosition {boolean} - Save floating x/y in localStorage (default false)\n * persistKey {string} - Storage key for persist (default from element id)\n *\n * Custom events (all bubble, dispatched on container):\n * musicplayer:play \u2014 playback started\n * musicplayer:pause \u2014 playback paused\n * musicplayer:trackchange \u2014 detail: { index, name, url }\n * musicplayer:volumechange \u2014 detail: { volume }\n * musicplayer:ended \u2014 last track ended (autoAdvance=false only)\n * musicplayer:detach \u2014 after floating player is created\n * musicplayer:attach \u2014 after returned to document flow\n * musicplayer:minimize \u2014 collapsed to minimal controls\n * musicplayer:expand \u2014 full controls visible again\n *\n * Programmatic API:\n * MusicPlayer.init(container?, options?)\n * MusicPlayer.play(container)\n * MusicPlayer.pause(container)\n * MusicPlayer.toggle(container)\n * MusicPlayer.next(container)\n * MusicPlayer.previous(container)\n * MusicPlayer.setVolume(container, value)\n * MusicPlayer.setTrack(container, index)\n * MusicPlayer.shuffle(container)\n * MusicPlayer.detach(container, position?)\n * MusicPlayer.attach(container)\n * MusicPlayer.minimize(container)\n * MusicPlayer.expand(container)\n * MusicPlayer.toggleMinimize(container)\n * MusicPlayer.setPosition(container, 'bottom-left'|'bottom-right'|{x,y})\n * MusicPlayer.getState(container)\n * MusicPlayer.destroy(container)\n * MusicPlayer.destroyAll()\n */\n\n(function () {\n 'use strict';\n\n /* \u2500\u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Fisher-Yates shuffle (returns new array).\n * @param {Array} arr\n * @returns {Array}\n */\n function shuffleArray(arr) {\n const shuffled = arr.slice();\n for (let i = shuffled.length - 1; i > 0; i--) {\n const j = Math.floor(Math.random() * (i + 1));\n const tmp = shuffled[i];\n shuffled[i] = shuffled[j];\n shuffled[j] = tmp;\n }\n return shuffled;\n }\n\n /**\n * Format seconds as m:ss.\n * @param {number} seconds\n * @returns {string}\n */\n function formatTime(seconds) {\n if (!isFinite(seconds) || seconds < 0) return '0:00';\n const m = Math.floor(seconds / 60);\n const s = Math.floor(seconds % 60);\n return m + ':' + (s < 10 ? '0' : '') + s;\n }\n\n /**\n * Set CSS background-size on a range input to visually fill the track.\n * @param {HTMLInputElement} input\n */\n /**\n * @param {string|undefined} id\n * @returns {string}\n */\n function persistStorageKey(id) {\n return 'vanduo:music-player:' + (id && id.trim() ? id.trim() : 'default') + ':pos';\n }\n\n function updateRangeFill(input) {\n const min = parseFloat(input.min) || 0;\n const max = parseFloat(input.max) || 1;\n const val = parseFloat(input.value) || 0;\n const pct = ((val - min) / (max - min)) * 100;\n input.style.setProperty('--fill', pct + '%');\n // Fallback inline gradient for browsers without ::-moz-range-progress\n input.style.backgroundImage =\n 'linear-gradient(to right, var(--music-player-track-fill, currentColor) 0%, ' +\n 'var(--music-player-track-fill, currentColor) ' + pct + '%, ' +\n 'var(--music-player-track-bg, #ccc) ' + pct + '%, ' +\n 'var(--music-player-track-bg, #ccc) 100%)';\n }\n\n /* \u2500\u2500\u2500 Phosphor icon helper (matches framework icon style) \u2500 */\n\n /**\n * Return an element (Phosphor icon).\n * @param {string} name\n * @returns {HTMLElement}\n */\n function icon(name) {\n const el = document.createElement('i');\n el.className = 'ph ph-' + name;\n el.setAttribute('aria-hidden', 'true');\n return el;\n }\n\n /* \u2500\u2500\u2500 Component \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const MusicPlayer = {\n /** @type {Map} */\n instances: new Map(),\n\n /**\n * Default options.\n */\n defaults: {\n tracks: [],\n volume: 0.5,\n shuffle: false,\n showProgress: false,\n showPlaylist: false,\n autoAdvance: true,\n glass: false,\n detachable: false,\n /** @type {null|string} */\n floatingPosition: null,\n draggable: false,\n minimizable: false,\n startMinimized: false,\n persistPosition: false,\n persistKey: '',\n },\n\n /**\n * Auto-initialize all .vd-music-player / [data-music-player] elements.\n * Options can be provided via data-music-player-options (JSON string).\n */\n init: function () {\n document.querySelectorAll('.vd-music-player, [data-music-player]').forEach((el) => {\n if (this.instances.has(el)) return;\n\n let opts = {};\n const attr = el.getAttribute('data-music-player-options');\n if (attr) {\n try { opts = JSON.parse(attr); } catch (_) { /* ignore malformed JSON */ }\n }\n this.initPlayer(el, opts);\n });\n },\n\n /**\n * Initialize a single player element.\n * @param {HTMLElement} container\n * @param {Object} [options]\n */\n initPlayer: function (container, options) {\n const opts = Object.assign({}, this.defaults, options || {});\n\n // Validate and normalise tracks\n const rawTracks = Array.isArray(opts.tracks) ? opts.tracks : [];\n const tracks = rawTracks.filter((t) => t && typeof t.url === 'string' && t.url.trim());\n\n // Build shuffled working copy without mutating opts\n const trackList = opts.shuffle ? shuffleArray(tracks) : tracks.slice();\n\n /* \u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const state = {\n tracks: trackList,\n originalTracks: tracks.slice(),\n currentIndex: 0,\n isPlaying: false,\n volume: Math.max(0, Math.min(1, opts.volume)),\n shuffle: opts.shuffle,\n showProgress: opts.showProgress,\n showPlaylist: opts.showPlaylist,\n autoAdvance: opts.autoAdvance,\n audio: null,\n glass: Boolean(opts.glass),\n detachable: Boolean(opts.detachable),\n floatingPosition: opts.floatingPosition || 'bottom-right',\n draggable: Boolean(opts.draggable) && Boolean(opts.detachable),\n minimizable: Boolean(opts.minimizable),\n startMinimized: Boolean(opts.startMinimized),\n persistPosition: Boolean(opts.persistPosition),\n persistKey: typeof opts.persistKey === 'string' ? opts.persistKey : '',\n isDetached: false,\n isMinimized: false,\n _startMinimizeApplied: false,\n };\n\n /* \u2500\u2500 Audio element \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const audio = new Audio();\n audio.volume = state.volume;\n audio.preload = 'metadata';\n state.audio = audio;\n\n /* \u2500\u2500 Build DOM \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n this._buildDOM(container, state);\n\n // Grab references after DOM build\n const refs = {\n btnPlay: container.querySelector('.vd-music-player-btn-play'),\n btnPrev: container.querySelector('.vd-music-player-btn-prev'),\n btnNext: container.querySelector('.vd-music-player-btn-next'),\n btnShuffle: container.querySelector('.vd-music-player-btn-shuffle'),\n btnPlaylist: container.querySelector('.vd-music-player-btn-playlist'),\n btnDetach: container.querySelector('.vd-music-player-btn-detach'),\n btnAttach: container.querySelector('.vd-music-player-btn-attach'),\n btnMinimize: container.querySelector('.vd-music-player-btn-minimize'),\n dragHandle: container.querySelector('.vd-music-player-drag-handle'),\n trackName: container.querySelector('.vd-music-player-track-name'),\n volumeSlider: container.querySelector('.vd-music-player-volume-slider'),\n volumeIcon: container.querySelector('.vd-music-player-volume-icon'),\n progressBar: container.querySelector('.vd-music-player-progress-bar'),\n timeElapsed: container.querySelector('.vd-music-player-time-elapsed'),\n timeDuration: container.querySelector('.vd-music-player-time-duration'),\n playlistPanel: container.querySelector('.vd-music-player-playlist'),\n };\n\n /* \u2500\u2500 Internal render helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const renderPlayIcon = () => {\n const btn = refs.btnPlay;\n if (!btn) return;\n btn.innerHTML = '';\n btn.appendChild(icon(state.isPlaying ? 'pause' : 'play'));\n btn.setAttribute('aria-label', state.isPlaying ? 'Pause' : 'Play');\n btn.classList.toggle('is-active', state.isPlaying);\n };\n\n const renderTrackName = () => {\n const el = refs.trackName;\n if (!el) return;\n const track = state.tracks[state.currentIndex];\n if (track) {\n el.textContent = track.name || 'Unknown Track';\n el.classList.remove('is-idle');\n } else {\n el.textContent = 'No tracks loaded';\n el.classList.add('is-idle');\n }\n };\n\n const renderVolumeIcon = () => {\n const el = refs.volumeIcon;\n if (!el) return;\n el.innerHTML = '';\n const v = state.volume;\n const name = v === 0 ? 'speaker-none' : v < 0.5 ? 'speaker-low' : 'speaker-high';\n el.appendChild(icon(name));\n };\n\n const renderShuffleBtn = () => {\n const btn = refs.btnShuffle;\n if (!btn) return;\n btn.classList.toggle('is-active', state.shuffle);\n btn.setAttribute('aria-pressed', state.shuffle ? 'true' : 'false');\n };\n\n const renderPlaylistItems = () => {\n const panel = refs.playlistPanel;\n if (!panel) return;\n panel.innerHTML = '';\n state.tracks.forEach((track, i) => {\n const item = document.createElement('button');\n item.className =\n 'vd-music-player-playlist-item' + (i === state.currentIndex ? ' is-active' : '');\n item.type = 'button';\n item.setAttribute('data-index', String(i));\n item.setAttribute('aria-current', i === state.currentIndex ? 'true' : 'false');\n\n const num = document.createElement('span');\n num.className = 'vd-music-player-playlist-num';\n num.textContent = String(i + 1);\n\n const name = document.createElement('span');\n name.className = 'vd-music-player-playlist-name';\n name.textContent = track.name || 'Track ' + (i + 1);\n\n item.appendChild(num);\n item.appendChild(name);\n panel.appendChild(item);\n });\n };\n\n const renderProgress = () => {\n const bar = refs.progressBar;\n if (!bar || !audio.duration) return;\n const pct = (audio.currentTime / audio.duration) * 100;\n bar.value = String(pct);\n updateRangeFill(bar);\n if (refs.timeElapsed) refs.timeElapsed.textContent = formatTime(audio.currentTime);\n if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);\n };\n\n /* \u2500\u2500 Load track \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const loadTrack = (index, autoPlay) => {\n const track = state.tracks[index];\n if (!track) return;\n state.currentIndex = index;\n audio.src = track.url;\n renderTrackName();\n renderPlaylistItems();\n\n // Reset progress\n if (refs.progressBar) {\n refs.progressBar.value = '0';\n updateRangeFill(refs.progressBar);\n }\n if (refs.timeElapsed) refs.timeElapsed.textContent = '0:00';\n if (refs.timeDuration) refs.timeDuration.textContent = '0:00';\n\n container.dispatchEvent(\n new CustomEvent('musicplayer:trackchange', {\n bubbles: true,\n detail: { index, name: track.name, url: track.url },\n })\n );\n\n if (autoPlay) {\n audio.play().catch(() => { /* browser may block autoplay */ });\n }\n };\n\n /* \u2500\u2500 Audio event listeners \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const cleanupFunctions = [];\n\n const onPlay = () => {\n state.isPlaying = true;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:play', { bubbles: true }));\n };\n\n const onPause = () => {\n state.isPlaying = false;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:pause', { bubbles: true }));\n };\n\n const onEnded = () => {\n if (state.autoAdvance && state.tracks.length > 1) {\n const next = (state.currentIndex + 1) % state.tracks.length;\n loadTrack(next, true);\n } else {\n state.isPlaying = false;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:ended', { bubbles: true }));\n }\n };\n\n const onTimeUpdate = () => {\n if (state.showProgress) renderProgress();\n };\n\n const onLoadedMetadata = () => {\n if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);\n if (refs.progressBar) {\n refs.progressBar.max = '100';\n updateRangeFill(refs.progressBar);\n }\n };\n\n audio.addEventListener('play', onPlay);\n audio.addEventListener('pause', onPause);\n audio.addEventListener('ended', onEnded);\n audio.addEventListener('timeupdate', onTimeUpdate);\n audio.addEventListener('loadedmetadata', onLoadedMetadata);\n cleanupFunctions.push(() => {\n audio.removeEventListener('play', onPlay);\n audio.removeEventListener('pause', onPause);\n audio.removeEventListener('ended', onEnded);\n audio.removeEventListener('timeupdate', onTimeUpdate);\n audio.removeEventListener('loadedmetadata', onLoadedMetadata);\n audio.pause();\n audio.src = '';\n });\n\n /* \u2500\u2500 Control button listeners \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n if (refs.btnPlay) {\n const handler = () => {\n if (!audio.src && state.tracks.length) loadTrack(state.currentIndex, false);\n if (state.isPlaying) {\n audio.pause();\n } else {\n audio.play().catch(() => {});\n }\n };\n refs.btnPlay.addEventListener('click', handler);\n cleanupFunctions.push(() => refs.btnPlay.removeEventListener('click', handler));\n\n // Keyboard: Space / Enter (already native for `;\n }\n\n // Generate neutral color swatches\n let neutralSwatches = '';\n for (const [key, value] of Object.entries(this.NEUTRAL_COLORS)) {\n neutralSwatches += ``;\n }\n\n // Generate radius buttons\n let radiusButtons = '';\n this.RADIUS_OPTIONS.forEach(r => {\n radiusButtons += ``;\n });\n\n // Generate font options\n let fontOptions = '';\n for (const [key, value] of Object.entries(this.FONT_OPTIONS)) {\n fontOptions += ``;\n }\n\n return `\n
    \n

    Customize Theme

    \n \n
    \n
    \n\n
    \n \n
    \n ${primarySwatches}\n
    \n
    \n
    \n \n
    \n ${neutralSwatches}\n
    \n
    \n
    \n \n
    \n ${radiusButtons}\n
    \n
    \n
    \n \n \n
    \n
    \n
    \n \n
    \n `;\n },\n\n /**\n * Bind event listeners\n */\n /**\n * Check whether the current primary color is one of the auto-defaults\n * (i.e. the user hasn't explicitly picked a non-default color).\n */\n isUsingDefaultPrimary: function () {\n return this.state.primary === this.DEFAULTS.PRIMARY_LIGHT ||\n this.state.primary === this.DEFAULTS.PRIMARY_DARK;\n },\n\n /**\n * When primary is still one of the auto-default palette keys (black/amber) but\n * localStorage was written under a different theme (or OS changed in system mode),\n * align in-memory state before applyAllPreferences runs \u2014 avoids amber+light / black+dark drift.\n */\n _normalizeDefaultPrimaryIfStaleWithStoredTheme: function () {\n if (!this.isUsingDefaultPrimary()) {\n return;\n }\n const expected = this.getDefaultPrimary(this.state.theme);\n if (this.state.primary !== expected) {\n this.state.primary = expected;\n }\n },\n\n bindEvents: function () {\n this.bindTriggerEvents();\n\n this.bindPanelEvents();\n\n // Listen for OS dark/light changes so we can swap the default primary\n if (window.matchMedia) {\n const mq = window.matchMedia('(prefers-color-scheme: dark)');\n const handler = () => {\n if (this.state.theme === 'system' && this.isUsingDefaultPrimary()) {\n const newDefault = this.getDefaultPrimary('system');\n if (newDefault !== this.state.primary) {\n this.applyPrimary(newDefault);\n this.updateUI();\n }\n }\n };\n mq.addEventListener('change', handler);\n this._cleanup.push(() => mq.removeEventListener('change', handler));\n }\n\n // Close on outside click\n this.addListener(document, 'click', (e) => {\n if (this.state.isOpen && this.elements.customizer && !this.elements.customizer.contains(e.target)) {\n this.close();\n }\n });\n\n // Close on Escape key\n this.addListener(document, 'keydown', (e) => {\n if (e.key === 'Escape' && this.state.isOpen) {\n this.close();\n }\n });\n },\n\n bindTriggerEvents: function () {\n this.elements.triggers.forEach((trigger) => {\n if (trigger.getAttribute('data-customizer-trigger-initialized') === 'true') {\n return;\n }\n this.addListener(trigger, 'click', (e) => {\n e.preventDefault();\n e.stopPropagation();\n this.elements.activeTrigger = trigger;\n this.elements.trigger = trigger;\n this.toggle();\n });\n trigger.setAttribute('data-customizer-trigger-initialized', 'true');\n });\n },\n\n /**\n * Toggle panel open/close\n */\n toggle: function () {\n if (this.state.isOpen) {\n this.close();\n } else {\n this.open();\n }\n },\n\n /**\n * Open the panel\n */\n open: function () {\n this.state.isOpen = true;\n\n // Ensure panel is positioned correctly before opening\n this.positionPanel();\n\n if (this.elements.panel) {\n this.elements.panel.classList.add('is-open');\n }\n this.elements.triggers.forEach((trigger) => trigger.setAttribute('aria-expanded', 'false'));\n if (this.elements.trigger) this.elements.trigger.setAttribute('aria-expanded', 'true');\n if (this.elements.overlay) {\n this.elements.overlay.classList.add('is-active');\n }\n\n this.dispatchEvent('panel-open', { isOpen: true });\n },\n\n /**\n * Close the panel\n */\n close: function () {\n this.state.isOpen = false;\n\n if (this.elements.panel) {\n this.elements.panel.classList.remove('is-open');\n }\n this.elements.triggers.forEach((trigger) => trigger.setAttribute('aria-expanded', 'false'));\n if (this.elements.overlay) {\n this.elements.overlay.classList.remove('is-active');\n }\n\n this.dispatchEvent('panel-close', { isOpen: false });\n },\n\n /**\n * Update UI to reflect current state\n */\n updateUI: function () {\n if (!this.elements.panel) return;\n\n // Update primary color swatches\n this.elements.panel.querySelectorAll('[data-color]').forEach(swatch => {\n swatch.classList.toggle('is-active', swatch.dataset.color === this.state.primary);\n });\n\n // Update neutral color swatches\n this.elements.panel.querySelectorAll('[data-neutral]').forEach(swatch => {\n swatch.classList.toggle('is-active', swatch.dataset.neutral === this.state.neutral);\n });\n\n // Update radius buttons\n this.elements.panel.querySelectorAll('[data-radius]').forEach(btn => {\n btn.classList.toggle('is-active', btn.dataset.radius === this.state.radius);\n });\n\n // Update font selector\n const fontSelect = this.elements.panel.querySelector('[data-customizer-font]');\n if (fontSelect) {\n fontSelect.value = this.state.font;\n }\n\n },\n\n /**\n * Reset all preferences to defaults\n */\n reset: function () {\n this.applyTheme(this.DEFAULTS.THEME);\n this.applyPrimary(this.getDefaultPrimary(this.DEFAULTS.THEME));\n this.applyNeutral(this.DEFAULTS.NEUTRAL);\n this.applyRadius(this.DEFAULTS.RADIUS);\n this.applyFont(this.DEFAULTS.FONT);\n this.updateUI();\n\n this.dispatchEvent('reset', { state: { ...this.state } });\n },\n\n /**\n * Get current state\n */\n getState: function () {\n return { ...this.state };\n },\n\n /**\n * Programmatically set preferences\n */\n setPreferences: function (prefs) {\n if (prefs.primary) this.applyPrimary(prefs.primary);\n if (prefs.neutral) this.applyNeutral(prefs.neutral);\n if (prefs.radius) this.applyRadius(prefs.radius);\n if (prefs.font) this.applyFont(prefs.font);\n if (prefs.theme) this.applyTheme(prefs.theme);\n this.updateUI();\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 destroyAll: function () {\n this._cleanup.forEach(fn => fn());\n this._cleanup = [];\n\n if (this.elements.panel) {\n this.elements.panel.removeAttribute('data-customizer-initialized');\n }\n\n this.close();\n this.isInitialized = false;\n }\n };\n\n // Register component\n if (window.Vanduo) {\n window.Vanduo.register('themeCustomizer', ThemeCustomizer);\n }\n\n // Expose globally for convenience\n window.ThemeCustomizer = ThemeCustomizer;\n})();\n", "/**\n * Vanduo Framework - Theme Switcher\n * Handles light/dark/system theme toggling and persistence\n */\n\n(function () {\n 'use strict';\n\n const ThemeSwitcher = {\n isInitialized: false,\n _mediaQuery: null,\n _onMediaChange: null,\n\n init: function () {\n this.STORAGE_KEY = 'vanduo-theme-preference';\n this.state = {\n preference: this.getPreference() // 'light', 'dark', or 'system'\n };\n\n if (this.isInitialized) {\n this.applyTheme();\n this.renderUI();\n this.updateUI();\n return;\n }\n\n this.isInitialized = true;\n\n this.applyTheme();\n this.listenForSystemChanges();\n this.renderUI();\n\n console.log('Vanduo Theme Switcher initialized');\n },\n\n getPreference: function () {\n return this.getStorageValue(this.STORAGE_KEY, 'system');\n },\n\n setPreference: function (pref) {\n this.state.preference = pref;\n this.setStorageValue(this.STORAGE_KEY, pref);\n this.applyTheme();\n \n // Coordinate with ThemeCustomizer for primary color swap if available\n // Check _isApplying flag to prevent circular updates\n if (window.ThemeCustomizer && window.ThemeCustomizer.applyTheme && !window.ThemeCustomizer._isApplying) {\n window.ThemeCustomizer.applyTheme(pref);\n }\n \n this.updateUI();\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 applyTheme: function () {\n const pref = this.state.preference;\n\n if (pref === 'system') {\n // When in system mode, we remove the data attribute to let the media query take over\n // or we can explicitly set it. Explicitly setting it ensures consistency if we rely on [data-theme]\n // But if we rely on @media in CSS, we might want to remove attributes.\n // However, our CSS strategy uses :root:not([data-theme=\"light\"]) inside media query for system dark fallback\n // which is a bit complex.\n\n // Simpler approach: \n // If preference is system, REMOVE data-theme attribute. Let CSS media queries handle it.\n document.documentElement.removeAttribute('data-theme');\n } else {\n document.documentElement.setAttribute('data-theme', pref);\n }\n },\n\n listenForSystemChanges: function () {\n if (this._mediaQuery && this._onMediaChange) {\n return;\n }\n\n this._mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n this._onMediaChange = _e => {\n if (this.state.preference === 'system') {\n // Re-apply (effectively just to ensure consistency, though removing attribute usually suffices)\n this.applyTheme();\n // Keep default primary (black/amber) aligned when OS scheme changes while in system mode\n if (window.ThemeCustomizer && typeof window.ThemeCustomizer.applyTheme === 'function' && !window.ThemeCustomizer._isApplying) {\n window.ThemeCustomizer.applyTheme('system');\n }\n }\n };\n this._mediaQuery.addEventListener('change', this._onMediaChange);\n },\n\n // Helper to facilitate UI creation if needed, though often UI is in HTML\n renderUI: function () {\n // Look for any uninitialized theme toggles\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"]');\n toggles.forEach(toggle => {\n if (toggle.getAttribute('data-theme-initialized') === 'true') {\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n }\n return;\n }\n\n // Simplified UI Binding - assumes a select or a button cycle\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n const onChange = (e) => {\n this.setPreference(e.target.value);\n };\n toggle.addEventListener('change', onChange);\n toggle._themeToggleHandler = onChange;\n } else {\n // Button implementation (cycle)\n const onClick = () => {\n const modes = ['system', 'light', 'dark'];\n const nextIndex = (modes.indexOf(this.state.preference) + 1) % modes.length;\n this.setPreference(modes[nextIndex]);\n };\n toggle.addEventListener('click', onClick);\n toggle._themeToggleHandler = onClick;\n }\n // Mark as initialized\n toggle.setAttribute('data-theme-initialized', 'true');\n });\n },\n\n updateUI: function () {\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"]');\n toggles.forEach(toggle => {\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n } else {\n // Update button text/icon if needed\n // e.g. toggle.textContent = this.state.preference;\n // For now, assume the user handles visual state or generic text\n\n // If there is an icon or text span inside, update it\n const span = toggle.querySelector('.theme-current-label');\n if (span) {\n span.textContent = this.state.preference.charAt(0).toUpperCase() + this.state.preference.slice(1);\n }\n }\n });\n },\n\n destroyAll: function () {\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"][data-theme-initialized=\"true\"]');\n toggles.forEach(toggle => {\n if (toggle._themeToggleHandler) {\n const eventName = toggle.tagName === 'SELECT' ? 'change' : 'click';\n toggle.removeEventListener(eventName, toggle._themeToggleHandler);\n delete toggle._themeToggleHandler;\n }\n toggle.removeAttribute('data-theme-initialized');\n });\n\n if (this._mediaQuery && this._onMediaChange) {\n this._mediaQuery.removeEventListener('change', this._onMediaChange);\n }\n\n this._mediaQuery = null;\n this._onMediaChange = null;\n this.isInitialized = false;\n }\n };\n\n // Register component\n if (window.Vanduo) {\n window.Vanduo.register('themeSwitcher', ThemeSwitcher);\n }\n})();\n", "/**\n * Vanduo Framework - Toast Component\n * Popup notifications for user feedback\n */\n\n(function() {\n 'use strict';\n\n /**\n * Toast Component\n */\n const Toast = {\n // Default options\n defaults: {\n position: 'top-right',\n duration: 5000,\n dismissible: true,\n showProgress: true,\n pauseOnHover: true\n },\n\n // Container cache\n containers: {},\n\n /**\n * Get or create a toast container for a position\n * @param {string} position - Container position\n * @returns {HTMLElement} Toast container element\n */\n getContainer: function(position) {\n if (this.containers[position]) {\n return this.containers[position];\n }\n\n const container = document.createElement('div');\n container.className = `vd-toast-container vd-toast-container-${position}`;\n container.setAttribute('role', 'status');\n container.setAttribute('aria-live', 'polite');\n container.setAttribute('aria-atomic', 'false');\n document.body.appendChild(container);\n this.containers[position] = container;\n\n return container;\n },\n\n /**\n * Show a toast notification\n * @param {Object|string} options - Toast options or message string\n * @param {string} [type] - Toast type (success, error, warning, info)\n * @param {number} [duration] - Auto-dismiss duration in ms\n * @returns {HTMLElement} Toast element\n */\n show: function(options, type, duration) {\n // Support simple API:\n // - Toast.show('Message', 'success', 3000)\n // - Toast.show('Message', { type: 'success', duration: 3000 })\n if (typeof options === 'string') {\n if (type && typeof type === 'object') {\n options = Object.assign({}, type, {\n message: options\n });\n } else {\n options = {\n message: options,\n type: type,\n duration: duration\n };\n }\n }\n\n const config = Object.assign({}, this.defaults, options);\n const container = this.getContainer(config.position);\n\n // Create toast element\n const toast = document.createElement('div');\n toast.className = 'vd-toast';\n\n if (config.type) {\n toast.classList.add(`vd-toast-${config.type}`);\n }\n\n if (config.solid) {\n toast.classList.add('vd-toast-solid');\n }\n\n if (config.showProgress && config.duration > 0) {\n toast.classList.add('vd-toast-with-progress');\n }\n\n // Build toast content\n let html = '';\n\n // Icon (sanitize custom icons, default icons are trusted SVG)\n if (config.icon) {\n const allowSvg = config.iconAllowSvg === true;\n const safeIcon = typeof sanitizeHtml === 'function'\n ? sanitizeHtml(config.icon, { allowSvg })\n : escapeHtml(config.icon);\n html += `${safeIcon}`;\n } else if (config.type) {\n html += `${this.getDefaultIcon(config.type)}`;\n }\n\n // Local escape helper \u2014 guarantees HTML-safe output even if the\n // global escapeHtml utility is not loaded in the current bundle.\n const _esc = typeof escapeHtml === 'function'\n ? escapeHtml\n : function (s) {\n const d = document.createElement('div');\n d.appendChild(document.createTextNode(s));\n return d.innerHTML;\n };\n\n // Content (escape text to prevent injection)\n html += '
    ';\n if (config.title) {\n html += `
    ${_esc(String(config.title))}
    `;\n }\n if (config.message) {\n html += `
    ${_esc(String(config.message))}
    `;\n }\n html += '
    ';\n\n // Close button\n if (config.dismissible) {\n html += '';\n }\n\n // Progress bar\n if (config.showProgress && config.duration > 0) {\n const safeDuration = parseInt(config.duration, 10) || 0;\n html += `
    `;\n }\n\n toast.innerHTML = html;\n\n // Add to container\n container.appendChild(toast);\n\n toast._toastCleanup = [];\n\n // Set up close button handler\n if (config.dismissible) {\n const closeBtn = toast.querySelector('.vd-toast-close');\n const onClose = () => {\n this.dismiss(toast);\n };\n closeBtn.addEventListener('click', onClose);\n toast._toastCleanup.push(() => closeBtn.removeEventListener('click', onClose));\n }\n\n // Pause on hover\n let timeoutId = null;\n let remainingTime = config.duration;\n let startTime = null;\n\n const startTimer = () => {\n if (config.duration > 0) {\n startTime = Date.now();\n timeoutId = setTimeout(() => {\n this.dismiss(toast);\n }, remainingTime);\n toast._toastTimeoutId = timeoutId;\n\n // Resume progress animation\n const progress = toast.querySelector('.vd-toast-progress');\n if (progress) {\n progress.style.animationPlayState = 'running';\n }\n }\n };\n\n const pauseTimer = () => {\n if (timeoutId) {\n clearTimeout(timeoutId);\n timeoutId = null;\n toast._toastTimeoutId = null;\n remainingTime -= Date.now() - startTime;\n\n // Pause progress animation\n const progress = toast.querySelector('.vd-toast-progress');\n if (progress) {\n progress.style.animationPlayState = 'paused';\n }\n }\n };\n\n if (config.pauseOnHover) {\n toast.addEventListener('mouseenter', pauseTimer);\n toast.addEventListener('mouseleave', startTimer);\n toast._toastCleanup.push(\n () => toast.removeEventListener('mouseenter', pauseTimer),\n () => toast.removeEventListener('mouseleave', startTimer)\n );\n }\n\n // Trigger enter animation\n requestAnimationFrame(() => {\n toast.classList.add('is-visible');\n startTimer();\n });\n\n // Store config on element for later access\n toast._toastConfig = config;\n\n // Dispatch show event\n const showEvent = new CustomEvent('toast:show', {\n bubbles: true,\n detail: { toast, config }\n });\n toast.dispatchEvent(showEvent);\n\n return toast;\n },\n\n /**\n * Dismiss a toast\n * @param {HTMLElement} toast - Toast element to dismiss\n */\n dismiss: function(toast) {\n if (!toast || toast.classList.contains('is-exiting')) return;\n\n if (toast._toastTimeoutId) {\n clearTimeout(toast._toastTimeoutId);\n toast._toastTimeoutId = null;\n }\n\n toast.classList.remove('is-visible');\n toast.classList.add('is-exiting');\n\n // Dispatch dismiss event\n const dismissEvent = new CustomEvent('toast:dismiss', {\n bubbles: true,\n detail: { toast }\n });\n toast.dispatchEvent(dismissEvent);\n\n // Remove after animation\n const handleTransitionEnd = () => {\n toast.removeEventListener('transitionend', handleTransitionEnd);\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n };\n\n toast.addEventListener('transitionend', handleTransitionEnd);\n\n // Fallback removal if transition doesn't fire\n setTimeout(() => {\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n }, 400);\n },\n\n /**\n * Destroy all toasts and containers\n */\n destroyAll: function() {\n Object.keys(this.containers).forEach(position => {\n const container = this.containers[position];\n if (!container) return;\n\n const toasts = container.querySelectorAll('.vd-toast');\n toasts.forEach(toast => {\n if (toast._toastTimeoutId) {\n clearTimeout(toast._toastTimeoutId);\n }\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n });\n\n if (container.parentElement) {\n container.parentElement.removeChild(container);\n }\n });\n\n this.containers = {};\n },\n\n /**\n * Dismiss all toasts\n * @param {string} [position] - Optional position to clear (clears all if not specified)\n */\n dismissAll: function(position) {\n if (position && this.containers[position]) {\n const toasts = this.containers[position].querySelectorAll('.vd-toast');\n toasts.forEach(toast => this.dismiss(toast));\n } else {\n Object.values(this.containers).forEach(container => {\n const toasts = container.querySelectorAll('.vd-toast');\n toasts.forEach(toast => this.dismiss(toast));\n });\n }\n },\n\n /**\n * Get default icon SVG for a type\n * @param {string} type - Toast type\n * @returns {string} SVG icon markup\n */\n getDefaultIcon: function(type) {\n const icons = {\n success: '',\n error: '',\n warning: '',\n info: ''\n };\n\n return icons[type] || '';\n },\n\n /**\n * Convenience methods for common toast types\n */\n success: function(message, options) {\n return this.show(Object.assign({ message, type: 'success' }, options));\n },\n\n error: function(message, options) {\n return this.show(Object.assign({ message, type: 'error' }, options));\n },\n\n warning: function(message, options) {\n return this.show(Object.assign({ message, type: 'warning' }, options));\n },\n\n info: function(message, options) {\n return this.show(Object.assign({ message, type: 'info' }, options));\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('toast', Toast);\n }\n\n // Also expose globally for convenience\n window.Toast = Toast;\n\n})();\n", "/**\n * Vanduo Framework - Tooltips Component\n * JavaScript functionality for tooltips\n */\n\n(function () {\n 'use strict';\n\n /**\n * Tooltips Component\n */\n const Tooltips = {\n tooltips: new Map(),\n delayTimers: new Map(),\n\n /**\n * Sanitize HTML \u2014 delegates to shared sanitizeHtml from helpers.js\n * @param {string} input\n * @param {{ allowSvg?: boolean }} [options]\n * @returns {string} sanitized HTML\n */\n sanitizeHtml: function (input, options) {\n if (typeof sanitizeHtml === 'function') {\n return sanitizeHtml(input, options);\n }\n // Fallback: content originates from developer-authored data attributes,\n // so returning it as-is is no worse than any other inline HTML.\n return input || '';\n },\n\n /**\n * Initialize tooltips\n */\n init: function () {\n const elements = document.querySelectorAll('[data-tooltip], [data-tooltip-html]');\n\n elements.forEach(element => {\n if (this.tooltips.has(element)) {\n return;\n }\n this.initTooltip(element);\n });\n },\n\n /**\n * Initialize a single tooltip\n * @param {HTMLElement} element - Element with tooltip\n */\n initTooltip: function (element) {\n const tooltip = this.createTooltip(element);\n const cleanupFunctions = [];\n\n // Show on hover/focus\n const enterHandler = () => { this.showTooltip(element, tooltip); };\n const leaveHandler = () => { this.hideTooltip(element, tooltip); };\n const focusHandler = () => { this.showTooltip(element, tooltip); };\n const blurHandler = () => { this.hideTooltip(element, tooltip); };\n\n element.addEventListener('mouseenter', enterHandler);\n element.addEventListener('mouseleave', leaveHandler);\n element.addEventListener('focus', focusHandler);\n element.addEventListener('blur', blurHandler);\n\n cleanupFunctions.push(\n () => element.removeEventListener('mouseenter', enterHandler),\n () => element.removeEventListener('mouseleave', leaveHandler),\n () => element.removeEventListener('focus', focusHandler),\n () => element.removeEventListener('blur', blurHandler)\n );\n\n this.tooltips.set(element, { tooltip, cleanup: cleanupFunctions });\n },\n\n /**\n * Create tooltip element\n * @param {HTMLElement} element - Target element\n * @returns {HTMLElement} Tooltip element\n */\n createTooltip: function (element) {\n const tooltip = document.createElement('div');\n tooltip.className = 'vd-tooltip';\n tooltip.setAttribute('role', 'tooltip');\n tooltip.setAttribute('aria-hidden', 'true');\n\n // Generate unique ID and link via aria-describedby\n const tooltipId = 'tooltip-' + Math.random().toString(36).substr(2, 9);\n tooltip.id = tooltipId;\n element.setAttribute('aria-describedby', tooltipId);\n\n // Get content\n const htmlContent = element.dataset.tooltipHtml;\n const textContent = element.dataset.tooltip;\n\n if (htmlContent) {\n const allowSvg = element.hasAttribute('data-tooltip-allow-svg');\n tooltip.innerHTML = this.sanitizeHtml(htmlContent, { allowSvg });\n tooltip.classList.add('vd-tooltip-html');\n } else if (textContent) {\n tooltip.textContent = textContent;\n }\n\n // Get placement\n const placement = element.dataset.tooltipPlacement || element.dataset.placement || 'top';\n tooltip.setAttribute('data-placement', placement);\n tooltip.classList.add(`vd-tooltip-${placement}`);\n\n // Get variant\n if (element.dataset.tooltipVariant) {\n tooltip.classList.add(`vd-tooltip-${element.dataset.tooltipVariant}`);\n }\n\n // Get size\n if (element.dataset.tooltipSize) {\n tooltip.classList.add(`vd-tooltip-${element.dataset.tooltipSize}`);\n }\n\n // Get delay\n const delay = parseInt(element.dataset.tooltipDelay) || 0;\n tooltip.dataset.delay = delay;\n\n document.body.appendChild(tooltip);\n\n return tooltip;\n },\n\n /**\n * Show tooltip\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n showTooltip: function (element, tooltip) {\n const delay = parseInt(tooltip.dataset.delay) || 0;\n\n if (delay > 0) {\n const timer = setTimeout(() => {\n this.positionTooltip(element, tooltip);\n tooltip.classList.add('is-visible');\n tooltip.setAttribute('aria-hidden', 'false');\n }, delay);\n this.delayTimers.set(element, timer);\n } else {\n this.positionTooltip(element, tooltip);\n tooltip.classList.add('is-visible');\n tooltip.setAttribute('aria-hidden', 'false');\n }\n },\n\n /**\n * Hide tooltip\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n hideTooltip: function (element, tooltip) {\n // Clear delay timer if exists\n const timer = this.delayTimers.get(element);\n if (timer) {\n clearTimeout(timer);\n this.delayTimers.delete(element);\n }\n\n tooltip.classList.remove('is-visible');\n tooltip.setAttribute('aria-hidden', 'true');\n },\n\n /**\n * Position tooltip relative to element\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n positionTooltip: function (element, tooltip) {\n const placement = tooltip.dataset.placement || 'top';\n const rect = element.getBoundingClientRect();\n const tooltipRect = tooltip.getBoundingClientRect();\n const scrollTop = window.pageYOffset || document.documentElement.scrollTop;\n const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;\n\n let top = 0;\n let left = 0;\n\n switch (placement) {\n case 'top':\n top = rect.top + scrollTop - tooltipRect.height - 8;\n left = rect.left + scrollLeft + (rect.width / 2) - (tooltipRect.width / 2);\n break;\n case 'bottom':\n top = rect.bottom + scrollTop + 8;\n left = rect.left + scrollLeft + (rect.width / 2) - (tooltipRect.width / 2);\n break;\n case 'left':\n top = rect.top + scrollTop + (rect.height / 2) - (tooltipRect.height / 2);\n left = rect.left + scrollLeft - tooltipRect.width - 8;\n break;\n case 'right':\n top = rect.top + scrollTop + (rect.height / 2) - (tooltipRect.height / 2);\n left = rect.right + scrollLeft + 8;\n break;\n }\n\n // Prevent overflow\n const viewportWidth = window.innerWidth;\n const viewportHeight = window.innerHeight;\n const padding = 8;\n\n if (left < padding) {\n left = padding;\n } else if (left + tooltipRect.width > viewportWidth - padding) {\n left = viewportWidth - tooltipRect.width - padding;\n }\n\n if (top < scrollTop + padding) {\n top = scrollTop + padding;\n } else if (top + tooltipRect.height > scrollTop + viewportHeight - padding) {\n top = scrollTop + viewportHeight - tooltipRect.height - padding;\n }\n\n // Use single style assignment with transform for better performance\n tooltip.style.cssText = `position: absolute; top: 0; left: 0; transform: translate(${left}px, ${top}px);`;\n },\n\n /**\n * Show tooltip programmatically\n * @param {HTMLElement|string} element - Target element or selector\n */\n show: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n this.showTooltip(el, tooltip);\n }\n },\n\n /**\n * Hide tooltip programmatically\n * @param {HTMLElement|string} element - Target element or selector\n */\n hide: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n this.hideTooltip(el, tooltip);\n }\n },\n\n /**\n * Update tooltip content\n * @param {HTMLElement|string} element - Target element or selector\n * @param {string} content - New content\n * @param {boolean} isHtml - Whether content is HTML\n */\n update: function (element, content, isHtml = false) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n if (isHtml) {\n const allowSvg = el.hasAttribute('data-tooltip-allow-svg');\n tooltip.innerHTML = this.sanitizeHtml(content, { allowSvg });\n tooltip.classList.add('vd-tooltip-html');\n } else {\n tooltip.textContent = content;\n tooltip.classList.remove('vd-tooltip-html');\n }\n }\n },\n\n /**\n * Destroy a tooltip instance and clean up\n * @param {HTMLElement} element - Element with tooltip\n */\n destroy: function (element) {\n const data = this.tooltips.get(element);\n if (!data) return;\n\n // Clear any pending timer\n const timer = this.delayTimers.get(element);\n if (timer) {\n clearTimeout(timer);\n this.delayTimers.delete(element);\n }\n\n data.cleanup.forEach(fn => fn());\n\n // Remove tooltip element from DOM\n if (data.tooltip && data.tooltip.parentNode) {\n data.tooltip.parentNode.removeChild(data.tooltip);\n }\n\n this.tooltips.delete(element);\n },\n\n /**\n * Destroy all tooltip instances\n */\n destroyAll: function () {\n this.tooltips.forEach((data, element) => {\n this.destroy(element);\n });\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('tooltips', Tooltips);\n }\n\n // Expose globally\n window.VanduoTooltips = Tooltips;\n\n})();\n", "/**\n * Vanduo Framework - Search Component\n * Client-side search functionality for content pages\n * \n * @example Basic usage (initialize with defaults)\n * // HTML:\n * //
    \n * // \n * //
    \n * //
    \n * \n * @example Custom configuration\n * var search = Search.create({\n * containerSelector: '.my-search',\n * contentSelector: 'article[id]',\n * titleSelector: 'h2, h3',\n * maxResults: 5,\n * onSelect: function(result) {\n * console.log('Selected:', result.title);\n * }\n * });\n * \n * @example With custom data source\n * var search = Search.create({\n * containerSelector: '.my-search',\n * data: [\n * { id: 'item1', title: 'First Item', content: 'Description...', category: 'Category A' },\n * { id: 'item2', title: 'Second Item', content: 'Description...', category: 'Category B' }\n * ]\n * });\n */\n\n(function() {\n 'use strict';\n\n /**\n * Default configuration\n */\n const DEFAULTS = {\n // Behavior\n minQueryLength: 2,\n maxResults: 10,\n debounceMs: 150,\n highlightTag: 'mark',\n keyboardShortcut: true, // Enable Cmd/Ctrl+K shortcut\n \n // Selectors (for DOM-based indexing)\n containerSelector: '.vd-doc-search',\n inputSelector: '.vd-doc-search-input',\n resultsSelector: '.vd-doc-search-results',\n contentSelector: '.doc-content section[id]',\n titleSelector: '.demo-title, h2, h3',\n navSelector: '.doc-nav-link',\n sectionSelector: '.doc-nav-section',\n \n // Content extraction\n excludeFromContent: 'pre, code, script, style',\n maxContentLength: 500,\n \n // Custom data source (alternative to DOM indexing)\n data: null,\n \n // Category icons mapping\n categoryIcons: {\n 'getting-started': 'ph-rocket-launch',\n 'core': 'ph-cube',\n 'components': 'ph-puzzle-piece',\n 'interactive': 'ph-cursor-click',\n 'data-display': 'ph-table',\n 'feedback': 'ph-bell',\n 'meta': 'ph-info',\n 'default': 'ph-file-text'\n },\n \n // Callbacks\n onSelect: null, // function(result) - called when result is selected\n onSearch: null, // function(query, results) - called after search\n onOpen: null, // function() - called when results open\n onClose: null, // function() - called when results close\n \n // Text customization\n emptyTitle: 'No results found',\n emptyText: 'Try different keywords or check spelling',\n placeholder: 'Search...'\n };\n\n /**\n * Search Component Factory\n * Creates a new search instance with the given configuration\n * \n * @param {Object} options - Configuration options\n * @returns {Object} Search instance\n */\n function createSearch(options) {\n const config = Object.assign({}, DEFAULTS, options || {});\n \n // Instance state\n const state = {\n initialized: false,\n index: [],\n results: [],\n activeIndex: -1,\n isOpen: false,\n query: '',\n container: null,\n input: null,\n resultsContainer: null,\n debounceTimer: null,\n boundHandlers: {}\n };\n\n function safeInvokeCallback(name, fn, ...args) {\n try {\n fn(...args);\n } catch (error) {\n console.warn('[Vanduo Search] Callback error in \"' + name + '\":', error);\n }\n }\n\n function setResultsHtml(html) {\n if (!state.resultsContainer) return;\n try {\n state.resultsContainer.innerHTML = html;\n } catch (error) {\n console.warn('[Vanduo Search] Failed to render results:', error);\n }\n }\n\n /**\n * Initialize the search component\n * Idempotent \u2014 safe to call more than once on the same instance.\n * Returns the instance on success, null if required DOM elements are missing.\n */\n function init() {\n if (state.initialized) {\n return instance;\n }\n\n state.container = document.querySelector(config.containerSelector);\n if (!state.container) {\n state.initialized = false;\n return null;\n }\n\n state.input = state.container.querySelector(config.inputSelector);\n state.resultsContainer = state.container.querySelector(config.resultsSelector);\n\n if (!state.input || !state.resultsContainer) {\n state.initialized = false;\n return null;\n }\n\n // Set placeholder if configured\n if (config.placeholder) {\n state.input.setAttribute('placeholder', config.placeholder);\n }\n\n // Build search index\n buildIndex();\n\n // Bind events\n bindEvents();\n\n // Set up ARIA attributes\n setupAria();\n\n state.initialized = true;\n return instance;\n }\n\n /**\n * Build search index from DOM or custom data\n */\n function buildIndex() {\n state.index = [];\n \n // Use custom data if provided\n if (config.data && Array.isArray(config.data)) {\n config.data.forEach(function(item) {\n state.index.push({\n id: item.id || slugify(item.title),\n title: item.title || '',\n category: item.category || '',\n categorySlug: slugify(item.category || ''),\n content: item.content || '',\n keywords: item.keywords || extractKeywordsFromText(item.title + ' ' + item.content),\n url: item.url || '#' + (item.id || slugify(item.title)),\n icon: item.icon || ''\n });\n });\n return;\n }\n\n // Build from DOM\n const sections = document.querySelectorAll(config.contentSelector);\n const categoryMap = buildCategoryMap();\n\n sections.forEach(function(section) {\n const id = section.id;\n if (!id) return;\n\n const titleEl = section.querySelector(config.titleSelector);\n const title = titleEl ? titleEl.textContent.replace(/v[\\d.]+/g, '').trim() : id;\n const category = categoryMap[id] || 'Documentation';\n const content = extractContent(section);\n const keywords = extractKeywords(section, title);\n const iconEl = titleEl ? titleEl.querySelector('i.ph') : null;\n let icon = '';\n if (iconEl && iconEl.classList) {\n for (let ci = 0; ci < iconEl.classList.length; ci++) {\n if (iconEl.classList[ci].indexOf('ph-') === 0) {\n icon = iconEl.classList[ci];\n break;\n }\n }\n }\n\n state.index.push({\n id: id,\n title: title,\n category: category,\n categorySlug: slugify(category),\n content: content,\n keywords: keywords,\n url: '#' + id,\n icon: icon\n });\n });\n }\n\n /**\n * Build a map of section IDs to their categories\n */\n function buildCategoryMap() {\n const map = {};\n let currentCategory = 'Documentation';\n const navItems = document.querySelectorAll(config.navSelector + ', ' + config.sectionSelector);\n\n navItems.forEach(function(item) {\n if (item.classList.contains('doc-nav-section')) {\n currentCategory = item.textContent.trim();\n } else {\n const href = item.getAttribute('href');\n if (href && href.startsWith('#')) {\n const id = href.substring(1);\n map[id] = currentCategory;\n }\n }\n });\n\n return map;\n }\n\n /**\n * Extract searchable content from a section\n */\n function extractContent(section) {\n const clone = section.cloneNode(true);\n \n const toRemove = clone.querySelectorAll(config.excludeFromContent);\n toRemove.forEach(function(el) {\n el.remove();\n });\n\n let text = clone.textContent || '';\n text = text.replace(/\\s+/g, ' ').trim();\n \n return text.substring(0, config.maxContentLength);\n }\n\n /**\n * Extract keywords from a section\n */\n function extractKeywords(section, title) {\n const keywords = [];\n \n // Add title words\n title.toLowerCase().split(/\\s+/).forEach(function(word) {\n if (word.length > 2) {\n keywords.push(word);\n }\n });\n\n // Add class names from code examples\n const codeBlocks = section.querySelectorAll('code');\n codeBlocks.forEach(function(code) {\n const text = code.textContent || '';\n const classMatches = text.match(/\\.([\\w-]+)/g);\n if (classMatches) {\n classMatches.forEach(function(match) {\n keywords.push(match.substring(1).toLowerCase());\n });\n }\n });\n\n // Add data attributes\n const dataAttrs = section.querySelectorAll('[data-tooltip], [data-modal]');\n dataAttrs.forEach(function(el) {\n const attrs = el.getAttributeNames().filter(function(name) {\n return name.startsWith('data-');\n });\n attrs.forEach(function(attr) {\n keywords.push(attr.replace('data-', ''));\n });\n });\n\n return Array.from(new Set(keywords));\n }\n\n /**\n * Extract keywords from text string\n */\n function extractKeywordsFromText(text) {\n const words = text.toLowerCase().split(/\\s+/);\n return words.filter(function(word) {\n return word.length > 2;\n });\n }\n\n /**\n * Convert string to slug\n */\n function slugify(str) {\n return str.toLowerCase().replace(/\\s+/g, '-').replace(/[^a-z0-9-]/g, '');\n }\n\n /**\n * Bind event listeners\n */\n function bindEvents() {\n // Store bound handlers for cleanup\n state.boundHandlers.handleInput = function(e) {\n handleInput(e);\n };\n state.boundHandlers.handleFocus = function() {\n if (state.query.length >= config.minQueryLength) {\n open();\n }\n };\n state.boundHandlers.handleKeydown = function(e) {\n handleKeydown(e);\n };\n state.boundHandlers.handleOutsideClick = function(e) {\n if (!state.container.contains(e.target)) {\n close();\n }\n };\n state.boundHandlers.handleGlobalKeydown = function(e) {\n if (config.keyboardShortcut && (e.metaKey || e.ctrlKey) && e.key === 'k') {\n e.preventDefault();\n state.input.focus();\n state.input.select();\n }\n };\n state.boundHandlers.handleResultClick = function(e) {\n const result = e.target.closest('.vd-doc-search-result');\n if (result) {\n const index = parseInt(result.dataset.index, 10);\n select(index);\n }\n };\n\n // Input events\n state.input.addEventListener('input', state.boundHandlers.handleInput);\n state.input.addEventListener('focus', state.boundHandlers.handleFocus);\n state.input.addEventListener('keydown', state.boundHandlers.handleKeydown);\n\n // Close on outside click\n document.addEventListener('click', state.boundHandlers.handleOutsideClick);\n\n // Global keyboard shortcut\n document.addEventListener('keydown', state.boundHandlers.handleGlobalKeydown);\n\n // Result click handling\n state.resultsContainer.addEventListener('click', state.boundHandlers.handleResultClick);\n }\n\n /**\n * Unbind event listeners\n */\n function unbindEvents() {\n if (state.input) {\n state.input.removeEventListener('input', state.boundHandlers.handleInput);\n state.input.removeEventListener('focus', state.boundHandlers.handleFocus);\n state.input.removeEventListener('keydown', state.boundHandlers.handleKeydown);\n }\n document.removeEventListener('click', state.boundHandlers.handleOutsideClick);\n document.removeEventListener('keydown', state.boundHandlers.handleGlobalKeydown);\n if (state.resultsContainer) {\n state.resultsContainer.removeEventListener('click', state.boundHandlers.handleResultClick);\n }\n }\n\n /**\n * Set up ARIA attributes\n */\n function setupAria() {\n const resultsId = state.resultsContainer.id || 'search-results-' + Math.random().toString(36).substr(2, 9);\n state.resultsContainer.id = resultsId;\n \n state.input.setAttribute('role', 'combobox');\n state.input.setAttribute('aria-autocomplete', 'list');\n state.input.setAttribute('aria-controls', resultsId);\n state.input.setAttribute('aria-expanded', 'false');\n \n state.resultsContainer.setAttribute('role', 'listbox');\n state.resultsContainer.setAttribute('aria-label', 'Search results');\n }\n\n /**\n * Handle input changes\n */\n function handleInput(e) {\n const query = e.target.value.trim();\n\n if (state.debounceTimer) {\n clearTimeout(state.debounceTimer);\n }\n\n state.debounceTimer = setTimeout(function() {\n state.query = query;\n\n if (query.length < config.minQueryLength) {\n close();\n return;\n }\n\n state.results = search(query);\n state.activeIndex = -1;\n render();\n open();\n\n // Callback\n if (typeof config.onSearch === 'function') {\n safeInvokeCallback('onSearch', config.onSearch, query, state.results);\n }\n }, config.debounceMs);\n }\n\n /**\n * Handle keyboard navigation\n */\n function handleKeydown(e) {\n if (!state.isOpen) {\n if (e.key === 'ArrowDown' && state.query.length >= config.minQueryLength) {\n e.preventDefault();\n state.results = search(state.query);\n render();\n open();\n }\n return;\n }\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n navigate(1);\n break;\n\n case 'ArrowUp':\n e.preventDefault();\n navigate(-1);\n break;\n\n case 'Enter':\n e.preventDefault();\n if (state.activeIndex >= 0) {\n select(state.activeIndex);\n } else if (state.results.length > 0) {\n select(0);\n }\n break;\n\n case 'Escape':\n e.preventDefault();\n close();\n break;\n\n case 'Tab':\n close();\n break;\n }\n }\n\n /**\n * Perform search\n */\n function search(query) {\n const terms = query.toLowerCase().split(/\\s+/).filter(function(t) {\n return t.length > 0;\n });\n const scored = [];\n\n state.index.forEach(function(entry) {\n let score = 0;\n const titleLower = entry.title.toLowerCase();\n const categoryLower = entry.category.toLowerCase();\n const contentLower = entry.content.toLowerCase();\n\n terms.forEach(function(term) {\n // Title match - highest priority\n if (titleLower.includes(term)) {\n score += 100;\n if (titleLower === term) {\n score += 50;\n } else if (titleLower.startsWith(term)) {\n score += 25;\n }\n }\n\n // Category match\n if (categoryLower.includes(term)) {\n score += 50;\n }\n\n // Keyword match\n const keywordMatch = entry.keywords.some(function(k) {\n return k.includes(term);\n });\n if (keywordMatch) {\n score += 30;\n }\n\n // Content match\n if (contentLower.includes(term)) {\n score += 10;\n }\n });\n\n if (score > 0) {\n scored.push({\n id: entry.id,\n title: entry.title,\n category: entry.category,\n categorySlug: entry.categorySlug,\n content: entry.content,\n url: entry.url,\n icon: entry.icon,\n score: score\n });\n }\n });\n\n scored.sort(function(a, b) {\n return b.score - a.score;\n });\n\n return scored.slice(0, config.maxResults);\n }\n\n /**\n * Render search results\n */\n function render() {\n if (state.results.length === 0) {\n setResultsHtml(renderEmpty());\n return;\n }\n\n let html = '
      ';\n\n state.results.forEach(function(result, index) {\n const isActive = index === state.activeIndex;\n const icon = result.icon || getCategoryIcon(result.categorySlug);\n const excerpt = getExcerpt(result.content, state.query);\n\n html += '
    • ' +\n '
      ' +\n '' +\n '
      ' +\n '
      ' +\n '
      ' + highlight(result.title, state.query) + '
      ' +\n '
      ' + escapeHtml(result.category) + '
      ' +\n '
      ' + highlight(excerpt, state.query) + '
      ' +\n '
      ' +\n '
    • ';\n });\n\n html += '
    ';\n html += renderFooter();\n\n setResultsHtml(html);\n }\n\n /**\n * Render empty state\n */\n function renderEmpty() {\n return '
    ' +\n '
    ' +\n '
    ' + escapeHtml(config.emptyTitle) + '
    ' +\n '
    ' + escapeHtml(config.emptyText) + '
    ' +\n '
    ';\n }\n\n /**\n * Render footer with keyboard hints\n */\n function renderFooter() {\n return '
    ' +\n '\u2191\u2193 to navigate' +\n '\u21B5 to select' +\n 'esc to close' +\n '
    ';\n }\n\n /**\n * Get icon for category\n */\n function getCategoryIcon(categorySlug) {\n return config.categoryIcons[categorySlug] || config.categoryIcons['default'] || 'ph-file-text';\n }\n\n /**\n * Get excerpt from content\n */\n function getExcerpt(content, query) {\n const terms = query.toLowerCase().split(/\\s+/);\n const contentLower = content.toLowerCase();\n const excerptLength = 100;\n\n let matchPos = -1;\n for (let i = 0; i < terms.length; i++) {\n const pos = contentLower.indexOf(terms[i]);\n if (pos !== -1 && (matchPos === -1 || pos < matchPos)) {\n matchPos = pos;\n }\n }\n\n if (matchPos === -1) {\n return content.substring(0, excerptLength) + '...';\n }\n\n const start = Math.max(0, matchPos - 30);\n const end = Math.min(content.length, matchPos + excerptLength);\n let excerpt = content.substring(start, end);\n\n if (start > 0) {\n excerpt = '...' + excerpt;\n }\n if (end < content.length) {\n excerpt = excerpt + '...';\n }\n\n return excerpt;\n }\n\n /**\n * Highlight matched terms in text\n */\n function highlight(text, query) {\n if (!query) return escapeHtml(text);\n\n const terms = query.toLowerCase().split(/\\s+/).filter(function(t) {\n return t.length > 0;\n });\n let escaped = escapeHtml(text);\n\n terms.forEach(function(term) {\n // Skip overly long terms to prevent ReDoS\n if (term.length > 50) return;\n const regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi');\n escaped = escaped.replace(regex, '<' + config.highlightTag + '>$1');\n });\n\n return escaped;\n }\n\n /**\n * Escape HTML entities\n */\n function escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Navigate results with keyboard\n */\n function navigate(direction) {\n let newIndex = state.activeIndex + direction;\n\n if (newIndex < 0) {\n newIndex = state.results.length - 1;\n } else if (newIndex >= state.results.length) {\n newIndex = 0;\n }\n\n setActiveIndex(newIndex);\n }\n\n /**\n * Set active result index\n */\n function setActiveIndex(index) {\n const prevActive = state.resultsContainer.querySelector('.vd-doc-search-result.is-active');\n if (prevActive) {\n prevActive.classList.remove('is-active');\n prevActive.setAttribute('aria-selected', 'false');\n }\n\n state.activeIndex = index;\n\n const newActive = state.resultsContainer.querySelector('[data-index=\"' + index + '\"]');\n if (newActive) {\n newActive.classList.add('is-active');\n newActive.setAttribute('aria-selected', 'true');\n state.input.setAttribute('aria-activedescendant', 'vd-doc-search-result-' + index);\n newActive.scrollIntoView({ block: 'nearest' });\n }\n }\n\n /**\n * Select a result\n */\n function select(index) {\n const result = state.results[index];\n if (!result) return;\n\n // Close search\n close();\n state.input.value = '';\n state.query = '';\n\n // Custom callback\n if (typeof config.onSelect === 'function') {\n safeInvokeCallback('onSelect', config.onSelect, result);\n return;\n }\n\n // Default behavior: navigate to section\n const section = document.querySelector(result.url);\n if (section) {\n section.scrollIntoView({ behavior: 'smooth', block: 'start' });\n window.history.pushState(null, '', result.url);\n updateSidebarActive(result.id);\n }\n }\n\n /**\n * Update sidebar navigation active state\n */\n function updateSidebarActive(sectionId) {\n const navLinks = document.querySelectorAll(config.navSelector);\n navLinks.forEach(function(link) {\n link.classList.remove('active');\n if (link.getAttribute('href') === '#' + sectionId) {\n link.classList.add('active');\n }\n });\n }\n\n /**\n * Open results dropdown\n */\n function open() {\n if (state.isOpen) return;\n\n state.isOpen = true;\n state.resultsContainer.classList.add('is-open');\n state.input.setAttribute('aria-expanded', 'true');\n\n if (typeof config.onOpen === 'function') {\n safeInvokeCallback('onOpen', config.onOpen);\n }\n }\n\n /**\n * Close results dropdown\n */\n function close() {\n if (!state.isOpen) return;\n\n state.isOpen = false;\n state.activeIndex = -1;\n state.resultsContainer.classList.remove('is-open');\n state.input.setAttribute('aria-expanded', 'false');\n state.input.removeAttribute('aria-activedescendant');\n\n if (typeof config.onClose === 'function') {\n safeInvokeCallback('onClose', config.onClose);\n }\n }\n\n /**\n * Destroy the component\n */\n function destroy() {\n unbindEvents();\n \n state.initialized = false;\n state.index = [];\n state.results = [];\n state.isOpen = false;\n state.query = '';\n\n if (state.debounceTimer) {\n clearTimeout(state.debounceTimer);\n }\n\n if (state.resultsContainer) {\n setResultsHtml('');\n }\n }\n\n /**\n * Rebuild the search index\n */\n function rebuild() {\n buildIndex();\n }\n\n /**\n * Update configuration\n */\n function setConfig(newConfig) {\n Object.assign(config, newConfig);\n }\n\n /**\n * Get current configuration\n */\n function getConfig() {\n return Object.assign({}, config);\n }\n\n /**\n * Get search index\n */\n function getIndex() {\n return state.index.slice();\n }\n\n // Public instance API\n const instance = {\n init: init,\n destroy: destroy,\n rebuild: rebuild,\n search: search,\n open: open,\n close: close,\n setConfig: setConfig,\n getConfig: getConfig,\n getIndex: getIndex\n };\n\n return instance;\n }\n\n /**\n * Search Component (singleton for backward compatibility)\n */\n const Search = {\n // Factory method \u2014 creates and auto-initializes a new independent instance.\n // Always returns the instance so callers retain a reference even if the\n // DOM container is not yet available (they can retry init() later).\n create: function(options) {\n const instance = createSearch(options);\n if (instance) {\n instance.init();\n }\n return instance || null;\n },\n \n // Default instance\n _instance: null,\n \n // Configuration (for default instance)\n config: Object.assign({}, DEFAULTS),\n\n /**\n * Initialize the default search instance\n */\n init: function(options) {\n if (this._instance) {\n this._instance.destroy();\n }\n \n if (options) {\n Object.assign(this.config, options);\n }\n \n this._instance = createSearch(this.config);\n return this._instance ? this._instance.init() : null;\n },\n\n /**\n * Destroy the default instance\n */\n destroy: function() {\n if (this._instance) {\n this._instance.destroy();\n this._instance = null;\n }\n },\n\n destroyAll: function() {\n this.destroy();\n },\n\n /**\n * Rebuild the default instance index\n */\n rebuild: function() {\n if (this._instance) {\n this._instance.rebuild();\n }\n },\n\n /**\n * Search using the default instance\n */\n search: function(query) {\n return this._instance ? this._instance.search(query) : [];\n },\n\n /**\n * Open the default instance\n */\n open: function() {\n if (this._instance) {\n this._instance.open();\n }\n },\n\n /**\n * Close the default instance\n */\n close: function() {\n if (this._instance) {\n this._instance.close();\n }\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('docSearch', Search);\n }\n\n // Expose globally (both names for compatibility)\n window.Search = Search;\n window.DocSearch = Search; // Backward compatibility\n window.VanduoDocSearch = Search; // New name compatibility\n\n})();\n", "/**\n * Vanduo Framework - Draggable Component\n * JavaScript functionality for draggable elements and drop zones\n */\n\n(function () {\n 'use strict';\n\n /**\n * Draggable Component\n */\n const Draggable = {\n // Store initialized draggables and their cleanup functions\n instances: new Map(),\n // Store current drag state\n currentDrag: null,\n // Store touch state\n touchState: null,\n // Feedback element\n feedbackElement: null,\n // Shared selector used by init and touch reorder\n containerSelector: '.vd-draggable-container, .vd-draggable-container-vertical',\n\n /**\n * Initialize draggable components\n */\n init: function () {\n const draggables = document.querySelectorAll('.vd-draggable, [data-draggable]');\n\n draggables.forEach(element => {\n if (this.instances.has(element)) {\n return;\n }\n this.initDraggable(element);\n });\n\n const containers = document.querySelectorAll(this.containerSelector);\n containers.forEach(container => {\n if (!this.instances.has(container)) {\n this.initContainer(container);\n }\n });\n\n const dropZones = document.querySelectorAll('.vd-drop-zone');\n dropZones.forEach(zone => {\n if (!this.instances.has(zone)) {\n this.initDropZone(zone);\n }\n });\n\n this.createFeedbackElement();\n },\n\n /**\n * Initialize a single draggable element\n * @param {HTMLElement} element - Draggable element\n */\n initDraggable: function (element) {\n const cleanupFunctions = [];\n\n // Make element draggable if not already\n if (!element.hasAttribute('draggable')) {\n element.setAttribute('draggable', 'true');\n }\n\n // Accessibility: add ARIA attributes\n if (!element.hasAttribute('tabindex')) {\n element.setAttribute('tabindex', '0');\n }\n element.setAttribute('role', 'option');\n element.setAttribute('aria-roledescription', 'draggable item');\n element.setAttribute('aria-grabbed', 'false');\n\n // Handle drag start\n const dragStartHandler = (e) => {\n this.handleDragStart(e, element);\n };\n element.addEventListener('dragstart', dragStartHandler);\n cleanupFunctions.push(() => element.removeEventListener('dragstart', dragStartHandler));\n\n // Handle drag\n const dragHandler = (e) => {\n this.handleDrag(e, element);\n };\n element.addEventListener('drag', dragHandler);\n cleanupFunctions.push(() => element.removeEventListener('drag', dragHandler));\n\n // Handle drag end\n const dragEndHandler = (e) => {\n this.handleDragEnd(e, element);\n };\n element.addEventListener('dragend', dragEndHandler);\n cleanupFunctions.push(() => element.removeEventListener('dragend', dragEndHandler));\n\n // Handle touch start (for mobile)\n const touchStartHandler = (e) => {\n this.handleTouchStart(e, element);\n };\n element.addEventListener('touchstart', touchStartHandler);\n cleanupFunctions.push(() => element.removeEventListener('touchstart', touchStartHandler));\n\n // Handle touch move (for mobile)\n // { passive: false } is required so that e.preventDefault() works\n // once the drag threshold is reached \u2014 without it, modern browsers\n // treat the listener as passive and silently ignore preventDefault().\n const touchMoveHandler = (e) => {\n this.handleTouchMove(e, element);\n };\n element.addEventListener('touchmove', touchMoveHandler, { passive: false });\n cleanupFunctions.push(() => element.removeEventListener('touchmove', touchMoveHandler));\n\n // Handle touch end (for mobile)\n const touchEndHandler = (e) => {\n this.handleTouchEnd(e, element);\n };\n element.addEventListener('touchend', touchEndHandler, { passive: false });\n cleanupFunctions.push(() => element.removeEventListener('touchend', touchEndHandler));\n\n // Handle touch cancel (for mobile)\n const touchCancelHandler = (e) => {\n this.handleTouchEnd(e, element);\n };\n element.addEventListener('touchcancel', touchCancelHandler);\n cleanupFunctions.push(() => element.removeEventListener('touchcancel', touchCancelHandler));\n\n // Keyboard navigation\n const keydownHandler = (e) => {\n this.handleKeydown(e, element);\n };\n element.addEventListener('keydown', keydownHandler);\n cleanupFunctions.push(() => element.removeEventListener('keydown', keydownHandler));\n\n this.instances.set(element, { cleanup: cleanupFunctions });\n },\n\n /**\n * Initialize a draggable container\n * @param {HTMLElement} container - Draggable container\n */\n initContainer: function (container) {\n // Accessibility: add ARIA role to container\n container.setAttribute('role', 'listbox');\n container.setAttribute('aria-label', container.getAttribute('aria-label') || 'Draggable items');\n\n const items = container.querySelectorAll('.vd-draggable-item');\n items.forEach(item => {\n if (!this.instances.has(item)) {\n this.initDraggable(item);\n }\n });\n\n const cleanupFunctions = [];\n\n // Handle drag enter\n const dragEnterHandler = (e) => {\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n };\n\n // Handle drag over for auto-sorting\n const dragOverHandler = (e) => {\n e.preventDefault(); // Necessary to allow drop\n e.dataTransfer.dropEffect = 'move';\n\n if (!this.currentDrag) return;\n const draggingEl = this.currentDrag.element;\n\n // Only reorder if dragging an item that belongs to this container (or if we support cross-container drag, but keep simple)\n if (!container.contains(draggingEl)) return;\n\n // Prevent jumps when the dragging finalizes with 0,0 coordinates near the end\n if (e.clientX === 0 && e.clientY === 0) return;\n\n this.handleReorder(container, draggingEl, e.clientX, e.clientY);\n };\n\n // Handle drop\n const dropHandler = (e) => {\n e.preventDefault(); // crucial to prevent the browser's default handling and snapping back\n };\n\n container.addEventListener('dragenter', dragEnterHandler);\n container.addEventListener('dragover', dragOverHandler);\n container.addEventListener('drop', dropHandler);\n\n cleanupFunctions.push(() => {\n container.removeEventListener('dragenter', dragEnterHandler);\n container.removeEventListener('dragover', dragOverHandler);\n container.removeEventListener('drop', dropHandler);\n });\n\n this.instances.set(container, { cleanup: cleanupFunctions });\n },\n\n /**\n * Initialize a drop zone\n * @param {HTMLElement} zone - Drop zone element\n */\n initDropZone: function (zone) {\n const cleanupFunctions = [];\n\n // Accessibility: add ARIA role to drop zone\n zone.setAttribute('role', 'region');\n zone.setAttribute('aria-dropeffect', 'move');\n if (!zone.hasAttribute('aria-label')) {\n zone.setAttribute('aria-label', 'Drop zone');\n }\n\n // Handle drag over\n const dragOverHandler = (e) => {\n e.preventDefault();\n this.handleDragOver(e, zone);\n };\n zone.addEventListener('dragover', dragOverHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragover', dragOverHandler));\n\n // Handle drag enter\n const dragEnterHandler = (e) => {\n e.preventDefault();\n this.handleDragEnter(e, zone);\n };\n zone.addEventListener('dragenter', dragEnterHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragenter', dragEnterHandler));\n\n // Handle drag leave\n const dragLeaveHandler = (e) => {\n this.handleDragLeave(e, zone);\n };\n zone.addEventListener('dragleave', dragLeaveHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragleave', dragLeaveHandler));\n\n // Handle drop\n const dropHandler = (e) => {\n e.preventDefault();\n this.handleDrop(e, zone);\n };\n zone.addEventListener('drop', dropHandler);\n cleanupFunctions.push(() => zone.removeEventListener('drop', dropHandler));\n\n this.instances.set(zone, { cleanup: cleanupFunctions });\n },\n\n /**\n * Create feedback element for drag operations\n */\n createFeedbackElement: function () {\n if (!this.feedbackElement) {\n // Reuse existing element if present\n const existing = document.querySelector('.vd-drag-feedback');\n if (existing) {\n this.feedbackElement = existing;\n return;\n }\n\n this.feedbackElement = document.createElement('div');\n this.feedbackElement.className = 'vd-drag-feedback hidden';\n this.feedbackElement.setAttribute('role', 'presentation');\n document.body.appendChild(this.feedbackElement);\n }\n },\n\n /**\n * Handle drag start event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDragStart: function (e, element) {\n // Add dragging class\n element.classList.add('is-dragging');\n\n // Accessibility: update aria-grabbed\n element.setAttribute('aria-grabbed', 'true');\n\n // Store drag state\n this.currentDrag = {\n element: element,\n initialPosition: { x: e.clientX, y: e.clientY },\n initialBounds: element.getBoundingClientRect(),\n data: this.getData(element)\n };\n\n // Set drag data\n e.dataTransfer.effectAllowed = 'move';\n e.dataTransfer.setData('text/plain', this.currentDrag.data);\n\n // We no longer suppress the native ghost image or manually update feedback\n // for mouse drags, relying on the browser's native rendering instead.\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:start', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: e.clientX, y: e.clientY }\n }\n }));\n },\n\n /**\n * Handle drag event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDrag: function (e, element) {\n // Guard against null state (race condition on fast interactions)\n if (!this.currentDrag) return;\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:drag', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: e.clientX, y: e.clientY },\n delta: {\n x: e.clientX - this.currentDrag.initialPosition.x,\n y: e.clientY - this.currentDrag.initialPosition.y\n }\n }\n }));\n },\n\n /**\n * Handle drag end event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDragEnd: function (e, element) {\n // Remove dragging class\n element.classList.remove('is-dragging');\n element.classList.add('is-dropped');\n setTimeout(() => element.classList.remove('is-dropped'), 300);\n\n // Accessibility: update aria-grabbed\n element.setAttribute('aria-grabbed', 'false');\n\n // Hide feedback\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n\n // Guard against null state\n const data = this.currentDrag?.data || this.getData(element);\n const initialPos = this.currentDrag?.initialPosition || { x: 0, y: 0 };\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:end', {\n bubbles: true,\n detail: {\n element: element,\n data: data,\n position: { x: e.clientX, y: e.clientY },\n delta: {\n x: e.clientX - initialPos.x,\n y: e.clientY - initialPos.y\n }\n }\n }));\n\n // Reset drag state\n this.currentDrag = null;\n },\n\n /**\n * Handle touch start event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchStart: function (e, element) {\n // Don't prevent default here \u2014 it blocks scrolling.\n // We only prevent default in touchmove once drag threshold is reached.\n const touch = e.touches[0];\n const rect = element.getBoundingClientRect();\n this.touchState = {\n element: element,\n startX: touch.clientX,\n startY: touch.clientY,\n lastX: touch.clientX,\n lastY: touch.clientY,\n // Keep preview anchored to the original grab point.\n offsetX: touch.clientX - rect.left,\n offsetY: touch.clientY - rect.top,\n startTime: Date.now(),\n isDragging: false\n };\n },\n\n /**\n * Handle touch move event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchMove: function (e, element) {\n if (!this.touchState) return;\n\n const touch = e.touches[0];\n this.touchState.lastX = touch.clientX;\n this.touchState.lastY = touch.clientY;\n const deltaX = touch.clientX - this.touchState.startX;\n const deltaY = touch.clientY - this.touchState.startY;\n\n // Only start dragging if moved a minimum distance\n if (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10) {\n // Now we know it's a drag, not a scroll \u2014 prevent default\n if (e.cancelable) e.preventDefault();\n\n if (!this.touchState.isDragging) {\n this.touchState.isDragging = true;\n element.classList.add('is-dragging');\n element.setAttribute('aria-grabbed', 'true');\n\n // Store drag state\n this.currentDrag = {\n element: element,\n initialPosition: { x: this.touchState.startX, y: this.touchState.startY },\n initialBounds: element.getBoundingClientRect(),\n data: this.getData(element),\n // Preserve where inside the element the drag started for accurate ghost positioning.\n offsetX: this.touchState.offsetX,\n offsetY: this.touchState.offsetY\n };\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:start', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: touch.clientX, y: touch.clientY }\n }\n }));\n }\n\n // Update feedback\n this.updateFeedback(touch.clientX, touch.clientY);\n\n // Dispatch event\n if (this.currentDrag) {\n element.dispatchEvent(new CustomEvent('draggable:drag', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: touch.clientX, y: touch.clientY },\n delta: { x: deltaX, y: deltaY }\n }\n }));\n\n this.updateTouchDropZone(touch.clientX, touch.clientY);\n\n // Reorder for touch\n const container = element.closest(this.containerSelector);\n if (container && container.contains(element)) {\n this.handleReorder(container, element, touch.clientX, touch.clientY);\n }\n }\n }\n },\n\n /**\n * Handle touch end event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchEnd: function (e, element) {\n if (this.touchState && this.touchState.isDragging) {\n if (e.cancelable) e.preventDefault();\n const endTouch = e.changedTouches?.[0];\n const endPosition = {\n x: endTouch?.clientX ?? this.touchState.lastX ?? this.touchState.startX,\n y: endTouch?.clientY ?? this.touchState.lastY ?? this.touchState.startY\n };\n\n const dropZone = this.resolveDropZoneAtPoint(endPosition.x, endPosition.y) || this.touchState.overZone;\n if (dropZone) {\n this.dispatchDrop(dropZone, endPosition);\n } else if (this.touchState.overZone) {\n this.touchState.overZone.classList.remove('is-drag-over');\n }\n\n element.classList.remove('is-dragging');\n element.classList.add('is-dropped');\n element.setAttribute('aria-grabbed', 'false');\n setTimeout(() => element.classList.remove('is-dropped'), 300);\n\n // Hide feedback\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n\n // Dispatch event\n const data = this.currentDrag?.data || this.getData(element);\n const startX = this.touchState?.startX || 0;\n const startY = this.touchState?.startY || 0;\n\n element.dispatchEvent(new CustomEvent('draggable:end', {\n bubbles: true,\n detail: {\n element: element,\n data: data,\n position: endPosition,\n delta: {\n x: endPosition.x - startX,\n y: endPosition.y - startY\n }\n }\n }));\n }\n\n // Reset states\n this.touchState = null;\n this.currentDrag = null;\n },\n\n /**\n * Handle drag over event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} _zone - Drop zone element\n */\n handleDragOver: function (e, _zone) {\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n },\n\n /**\n * Handle drag enter event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDragEnter: function (e, zone) {\n e.preventDefault();\n zone.classList.add('is-drag-over');\n },\n\n /**\n * Handle drag leave event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDragLeave: function (e, zone) {\n zone.classList.remove('is-drag-over');\n },\n\n /**\n * Handle drop event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDrop: function (e, zone) {\n e.preventDefault();\n this.dispatchDrop(zone, { x: e.clientX, y: e.clientY });\n },\n\n /**\n * Resolve a drop zone from viewport coordinates\n * @param {number} x\n * @param {number} y\n * @returns {HTMLElement|null}\n */\n resolveDropZoneAtPoint: function (x, y) {\n if (!Number.isFinite(x) || !Number.isFinite(y)) return null;\n\n // Prefer the full stacking list so overlays/top elements don't hide real drop targets.\n if (typeof document.elementsFromPoint === 'function') {\n const stacked = document.elementsFromPoint(x, y);\n for (const element of stacked) {\n const zone = element.closest('.vd-drop-zone');\n if (zone) return zone;\n }\n }\n\n const target = document.elementFromPoint(x, y);\n const targetZone = target ? target.closest('.vd-drop-zone') : null;\n if (targetZone) return targetZone;\n\n // Last-resort fallback for mobile emulation edge cases.\n const zones = document.querySelectorAll('.vd-drop-zone');\n for (const zone of zones) {\n const rect = zone.getBoundingClientRect();\n if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {\n return zone;\n }\n }\n\n return null;\n },\n\n /**\n * Track and update active drop-zone hover state on touch devices\n * @param {number} x\n * @param {number} y\n */\n updateTouchDropZone: function (x, y) {\n if (!this.touchState) return;\n\n const nextZone = this.resolveDropZoneAtPoint(x, y);\n const prevZone = this.touchState.overZone || null;\n\n if (prevZone && prevZone !== nextZone) {\n prevZone.classList.remove('is-drag-over');\n }\n\n if (nextZone && nextZone !== prevZone) {\n nextZone.classList.add('is-drag-over');\n }\n\n this.touchState.overZone = nextZone || null;\n },\n\n /**\n * Dispatch a normalized drop event for mouse and touch flows\n * @param {HTMLElement} zone\n * @param {{x:number, y:number}} position\n */\n dispatchDrop: function (zone, position) {\n zone.classList.remove('is-drag-over');\n zone.dispatchEvent(new CustomEvent('draggable:drop', {\n bubbles: true,\n detail: {\n zone: zone,\n element: this.currentDrag?.element,\n data: this.currentDrag?.data,\n position: position\n }\n }));\n },\n\n /**\n * Reorder elements in container based on cursor position\n * @param {HTMLElement} container \n * @param {HTMLElement} element \n * @param {number} clientX \n * @param {number} clientY \n */\n handleReorder: function (container, element, clientX, clientY) {\n const isVertical = container.classList.contains('vd-draggable-container-vertical');\n const siblings = [...container.querySelectorAll('.vd-draggable-item:not(.is-dragging), .vd-draggable:not(.is-dragging)')];\n\n const nextSibling = siblings.reduce((closest, child) => {\n const box = child.getBoundingClientRect();\n const offset = isVertical\n ? clientY - box.top - box.height / 2\n : clientX - box.left - box.width / 2;\n\n if (offset < 0 && offset > closest.offset) {\n return { offset: offset, element: child };\n } else {\n return closest;\n }\n }, { offset: Number.NEGATIVE_INFINITY }).element;\n\n if (nextSibling == null) {\n container.appendChild(element);\n } else {\n container.insertBefore(element, nextSibling);\n }\n },\n\n /**\n * Handle keyboard events\n * @param {KeyboardEvent} e - Keyboard event\n * @param {HTMLElement} element - Draggable element\n */\n handleKeydown: function (e, element) {\n switch (e.key) {\n case 'Enter':\n case ' ':\n e.preventDefault();\n // Trigger click or custom action\n element.click();\n break;\n case 'Escape':\n // Cancel drag if in progress\n if (element.classList.contains('is-dragging')) {\n element.classList.remove('is-dragging');\n element.setAttribute('aria-grabbed', 'false');\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n this.currentDrag = null;\n }\n break;\n case 'ArrowUp':\n case 'ArrowLeft': {\n e.preventDefault();\n const prev = element.previousElementSibling;\n if (prev && (prev.classList.contains('vd-draggable') || prev.classList.contains('vd-draggable-item'))) {\n element.parentNode.insertBefore(element, prev);\n element.focus();\n element.dispatchEvent(new CustomEvent('draggable:reorder', {\n bubbles: true,\n detail: { element: element, direction: 'up' }\n }));\n }\n break;\n }\n case 'ArrowDown':\n case 'ArrowRight': {\n e.preventDefault();\n const next = element.nextElementSibling;\n if (next && (next.classList.contains('vd-draggable') || next.classList.contains('vd-draggable-item'))) {\n element.parentNode.insertBefore(next, element);\n element.focus();\n element.dispatchEvent(new CustomEvent('draggable:reorder', {\n bubbles: true,\n detail: { element: element, direction: 'down' }\n }));\n }\n break;\n }\n }\n },\n\n /**\n * Get data from draggable element\n * @param {HTMLElement} element - Draggable element\n * @returns {string} Data associated with the element\n */\n getData: function (element) {\n return element.dataset.draggable || element.textContent.trim();\n },\n\n /**\n * Update drag feedback element\n * @param {number} x - Current X coordinate\n * @param {number} y - Current Y coordinate\n */\n updateFeedback: function (x, y) {\n if (!this.currentDrag) return;\n\n // Show feedback\n this.feedbackElement.classList.remove('hidden');\n\n // Update feedback content\n const rect = this.currentDrag.initialBounds;\n this.feedbackElement.innerHTML = '';\n const clone = this.currentDrag.element.cloneNode(true);\n this.feedbackElement.appendChild(clone);\n\n // Set styles\n const offsetX = this.currentDrag.offsetX ?? 20;\n const offsetY = this.currentDrag.offsetY ?? 20;\n Object.assign(this.feedbackElement.style, {\n left: (x - offsetX) + 'px',\n top: (y - offsetY) + 'px',\n width: rect.width + 'px',\n height: rect.height + 'px'\n });\n },\n\n /**\n * Make an element draggable programmatically\n * @param {HTMLElement|string} element - Element or selector\n * @param {Object} options - Configuration options\n */\n makeDraggable: function (element, options = {}) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n\n if (el && !this.instances.has(el)) {\n // Add classes and attributes\n el.classList.add('vd-draggable');\n el.setAttribute('draggable', 'true');\n\n // Set options\n if (options.data) {\n el.dataset.draggable = options.data;\n }\n\n // Initialize\n this.initDraggable(el);\n }\n },\n\n /**\n * Remove draggable functionality from an element\n * @param {HTMLElement|string} element - Element or selector\n */\n removeDraggable: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n\n if (el && this.instances.has(el)) {\n // Clean up\n const instance = this.instances.get(el);\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n\n // Remove classes and attributes\n el.classList.remove('vd-draggable');\n el.removeAttribute('draggable');\n el.removeAttribute('data-draggable');\n }\n },\n\n /**\n * Destroy a draggable instance and clean up event listeners\n * @param {HTMLElement} element - Draggable element\n */\n destroy: function (element) {\n this.removeDraggable(element);\n },\n\n /**\n * Destroy all draggable instances\n */\n destroyAll: function () {\n const instances = Array.from(this.instances.keys());\n instances.forEach(element => this.destroy(element));\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('draggable', Draggable);\n }\n\n // Expose globally\n window.VanduoDraggable = Draggable;\n})();\n", "/**\n * Vanduo Framework \u2013 LazyLoad Component\n * v1.2.1\n *\n * Provides two levels of API:\n *\n * LOW-LEVEL (generic IntersectionObserver wrapper)\n * VanduoLazyLoad.observe(element, callback, options?)\n * VanduoLazyLoad.unobserve(element)\n * VanduoLazyLoad.unobserveAll()\n *\n * HIGH-LEVEL (HTML-section fetcher, mirrors vanduo-docs SPA behaviour)\n * VanduoLazyLoad.loadSection(url, containerEl, options?)\n * options: { placeholder, threshold, rootMargin, onLoaded, onError }\n * placeholder: 'skeleton' | 'spinner' | HTMLString (default: 'skeleton')\n *\n * ATTRIBUTE-DRIVEN (zero-JS usage, wired by init())\n *
    \u2026
    \n *\n * EVENTS dispatched on the host element:\n * lazysection:loading \u2014 fetch started\n * lazysection:loaded \u2014 content injected\n * lazysection:error \u2014 fetch failed\n */\n\n(function () {\n 'use strict';\n\n /* \u2500\u2500 Private state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /** @type {Map} */\n const _observerMap = new Map();\n\n /* \u2500\u2500 Security helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Returns true only if `url` resolves to the same origin as the page\n * (relative paths and same-origin absolute URLs are allowed).\n * Cross-origin URLs are rejected to prevent SSRF-style fetch abuse.\n * @param {string} url\n * @returns {boolean}\n */\n function _isSafeUrl(url) {\n try {\n // Relative URLs (no origin) are always safe\n const resolved = new URL(url, window.location.href);\n return resolved.origin === window.location.origin;\n } catch (_) {\n return false;\n }\n }\n\n /**\n * Safely inject fetched HTML into a container by parsing it with\n * DOMParser (avoids script execution) and replacing children via\n * standard DOM APIs instead of raw innerHTML assignment.\n * @param {Element} containerEl\n * @param {string} html\n */\n function _safeInjectHtml(containerEl, html) {\n const parser = new DOMParser();\n const doc = parser.parseFromString(html.trim(), 'text/html'); // CodeQL [js/xss-through-dom] Intentional: parsed nodes are sanitized below before adoption into live DOM\n\n const DANGEROUS_TAGS = ['SCRIPT', 'IFRAME', 'OBJECT', 'EMBED', 'FORM', 'BASE', 'LINK', 'META', 'STYLE'];\n for (const tag of DANGEROUS_TAGS) {\n const els = doc.querySelectorAll(tag);\n for (let i = els.length - 1; i >= 0; i--) {\n els[i].parentNode.removeChild(els[i]);\n }\n }\n\n // Strip dangerous attributes (e.g. onerror, javascript: urls) from all elements\n function _sanitizeNode(node) {\n if (node.nodeType === Node.ELEMENT_NODE) {\n const attrs = node.attributes;\n for (let i = attrs.length - 1; i >= 0; i--) {\n const attrName = attrs[i].name.toLowerCase();\n const attrValue = attrs[i].value.toLowerCase();\n const trimmedValue = attrValue.trim();\n if (\n attrName.startsWith('on') ||\n trimmedValue.startsWith('javascript:') ||\n trimmedValue.startsWith('data:') ||\n trimmedValue.startsWith('vbscript:')\n ) {\n node.removeAttribute(attrs[i].name);\n }\n }\n const children = node.childNodes;\n for (let i = 0; i < children.length; i++) {\n _sanitizeNode(children[i]);\n }\n }\n }\n _sanitizeNode(doc.body);\n\n // Collect all top-level body children\n const nodes = Array.from(doc.body.childNodes);\n // Clear container and append parsed nodes\n while (containerEl.firstChild) {\n containerEl.removeChild(containerEl.firstChild);\n }\n nodes.forEach(function (node) {\n containerEl.appendChild(document.adoptNode(node));\n });\n }\n\n /* \u2500\u2500 Placeholder HTML \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n function _skeletonHtml() {\n return '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + 'Loading\u2026
    ';\n }\n\n function _spinnerHtml() {\n return '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + 'Loading\u2026
    ';\n }\n\n function _resolvePlaceholder(placeholder) {\n if (!placeholder || placeholder === 'skeleton') return _skeletonHtml();\n if (placeholder === 'spinner') return _spinnerHtml();\n // Caller-supplied HTML string\n return placeholder;\n }\n\n /* \u2500\u2500 Dispatch helper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n function _dispatch(el, eventName, detail) {\n el.dispatchEvent(new CustomEvent(eventName, { bubbles: true, detail: detail || {} }));\n }\n\n /* \u2500\u2500 VanduoLazyLoad \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const VanduoLazyLoad = {\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * LOW-LEVEL API\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Observe an element. `callback` is invoked once when the element\n * enters the viewport, then the element is automatically unobserved.\n *\n * @param {Element} element\n * @param {function(Element): void} callback\n * @param {{ threshold?: number, rootMargin?: string }} [options]\n */\n observe: function (element, callback, options) {\n if (!(element instanceof Element)) {\n console.warn('[VanduoLazyLoad] observe() requires a DOM Element.');\n return;\n }\n if (typeof callback !== 'function') {\n console.warn('[VanduoLazyLoad] observe() requires a callback function.');\n return;\n }\n // Already observed \u2014 ignore\n if (_observerMap.has(element)) return;\n\n const threshold = (options && options.threshold != null) ? options.threshold : 0;\n const rootMargin = (options && options.rootMargin) ? options.rootMargin : '0px';\n\n const observer = new IntersectionObserver(function (entries, obs) {\n entries.forEach(function (entry) {\n if (entry.isIntersecting) {\n obs.unobserve(entry.target);\n _observerMap.delete(entry.target);\n try {\n callback(entry.target);\n } catch (e) {\n console.error('[VanduoLazyLoad] Callback threw:', e);\n }\n }\n });\n }, { threshold: threshold, rootMargin: rootMargin });\n\n _observerMap.set(element, observer);\n observer.observe(element);\n },\n\n /**\n * Stop observing an element that was previously passed to observe().\n * @param {Element} element\n */\n unobserve: function (element) {\n const observer = _observerMap.get(element);\n if (observer) {\n observer.unobserve(element);\n _observerMap.delete(element);\n }\n },\n\n /**\n * Stop observing ALL currently observed elements.\n */\n unobserveAll: function () {\n _observerMap.forEach(function (observer, element) {\n observer.unobserve(element);\n });\n _observerMap.clear();\n },\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * HIGH-LEVEL API\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Fetch an HTML partial and inject it into `containerEl` when the\n * container enters the viewport. A placeholder is shown immediately.\n *\n * @param {string} url URL of the HTML partial to fetch\n * @param {Element} containerEl Target element whose content will be replaced\n * @param {{\n * placeholder?: 'skeleton'|'spinner'|string,\n * threshold?: number,\n * rootMargin?: string,\n * onLoaded?: function(Element): void,\n * onError?: function(Error): void\n * }} [options]\n */\n loadSection: function (url, containerEl, options) {\n if (typeof url !== 'string' || !url) {\n console.warn('[VanduoLazyLoad] loadSection() requires a non-empty URL string.');\n return;\n }\n if (!(containerEl instanceof Element)) {\n console.warn('[VanduoLazyLoad] loadSection() requires a DOM Element as containerEl.');\n return;\n }\n // Reject cross-origin URLs to prevent SSRF-style fetch abuse\n if (!_isSafeUrl(url)) {\n console.error('[VanduoLazyLoad] loadSection() blocked cross-origin URL:', url);\n return;\n }\n\n const opts = options || {};\n // Placeholders are known-safe static HTML strings built internally\n const placeholderHtml = _resolvePlaceholder(opts.placeholder);\n\n // Use _safeInjectHtml instead of innerHTML to prevent XSS from caller-supplied placeholders\n _safeInjectHtml(containerEl, placeholderHtml);\n _dispatch(containerEl, 'lazysection:loading', { url: url });\n\n // Fetch when visible\n this.observe(containerEl, function () {\n const controller = new window.AbortController();\n const timeoutId = setTimeout(function () { controller.abort(); }, 10000);\n\n window.fetch(url, { signal: controller.signal })\n .then(function (res) {\n clearTimeout(timeoutId);\n if (!res.ok) throw new Error('HTTP ' + res.status);\n return res.text();\n })\n .then(function (html) {\n // Use DOMParser to parse fetched content safely, avoiding\n // raw innerHTML assignment of externally-sourced strings\n _safeInjectHtml(containerEl, html);\n _dispatch(containerEl, 'lazysection:loaded', { url: url });\n // Re-init Vanduo components inside the new content\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.init();\n }\n if (typeof opts.onLoaded === 'function') {\n opts.onLoaded(containerEl);\n }\n })\n .catch(function (err) {\n // Build error node via DOM APIs \u2014 no dynamic HTML strings\n const alertEl = document.createElement('div');\n alertEl.className = 'vd-alert vd-alert-error';\n alertEl.setAttribute('role', 'alert');\n const msgEl = document.createElement('span');\n msgEl.textContent = 'Failed to load content. ';\n const detailEl = document.createElement('small');\n detailEl.style.opacity = '0.7';\n detailEl.textContent = err.message;\n alertEl.appendChild(msgEl);\n alertEl.appendChild(detailEl);\n while (containerEl.firstChild) {\n containerEl.removeChild(containerEl.firstChild);\n }\n containerEl.appendChild(alertEl);\n _dispatch(containerEl, 'lazysection:error', { url: url, error: err });\n console.error('[VanduoLazyLoad] loadSection failed:', err);\n if (typeof opts.onError === 'function') {\n opts.onError(err);\n }\n });\n }, { threshold: opts.threshold, rootMargin: opts.rootMargin });\n },\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * ATTRIBUTE-DRIVEN INIT\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Scan the DOM for [data-vd-lazy] elements and wire them up.\n * Safe to call multiple times \u2014 already-observed elements are skipped.\n */\n init: function () {\n const self = this;\n const elements = document.querySelectorAll('[data-vd-lazy]');\n elements.forEach(function (el) {\n // Skip already-observed or already-loaded elements\n if (_observerMap.has(el) || el.dataset.vdLazyState === 'loading' || el.dataset.vdLazyState === 'loaded') return;\n\n const url = el.getAttribute('data-vd-lazy');\n if (!url) return;\n\n el.dataset.vdLazyState = 'loading';\n const placeholder = el.getAttribute('data-vd-lazy-placeholder') || 'skeleton';\n\n self.loadSection(url, el, {\n placeholder: placeholder,\n onLoaded: function () {\n el.dataset.vdLazyState = 'loaded';\n },\n onError: function () {\n el.dataset.vdLazyState = 'error';\n }\n });\n });\n }\n };\n\n /* \u2500\u2500 Register with Vanduo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('LazyLoad', VanduoLazyLoad);\n }\n\n /* \u2500\u2500 Global convenience alias \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n window.VanduoLazyLoad = VanduoLazyLoad;\n\n})();\n", "/**\n * Vanduo Framework - Glass Scroll Activation\n * Generic scroll-aware glass activation via IntersectionObserver.\n *\n * Usage:\n * Add `data-glass-scroll` to any element carrying a `.vd-glass*` class.\n * By default the previous sibling is used as the sentinel.\n * Point to a custom sentinel via `data-glass-sentinel=\"\"`.\n *\n * Behaviour:\n * - Adds `.is-glass-active` when the sentinel leaves the viewport (scrolled past).\n * - Removes `.is-glass-active` when the sentinel re-enters the viewport.\n */\n(function () {\n 'use strict';\n\n const GlassScroll = {\n /** @type {Map} */\n observers: new Map(),\n\n init: function () {\n document.querySelectorAll('[data-glass-scroll]').forEach(el => {\n if (this.observers.has(el)) return;\n this.initElement(el);\n });\n },\n\n /**\n * Wire up a single scroll-activated glass element.\n * @param {HTMLElement} el\n */\n initElement: function (el) {\n const sentinelSelector = el.dataset.glassSentinel;\n let sentinel;\n\n if (sentinelSelector) {\n sentinel = document.querySelector(sentinelSelector);\n }\n\n if (!sentinel) {\n // Fall back to the previous sibling element\n sentinel = el.previousElementSibling;\n }\n\n if (!sentinel) {\n // No sentinel available \u2014 activate immediately so glass is always shown\n el.classList.add('is-glass-active');\n return;\n }\n\n const observer = new IntersectionObserver(\n (entries) => {\n entries.forEach(entry => {\n // Active when sentinel is NOT intersecting (scrolled past it)\n el.classList.toggle('is-glass-active', !entry.isIntersecting);\n });\n },\n { threshold: 0, rootMargin: '0px' }\n );\n\n observer.observe(sentinel);\n this.observers.set(el, observer);\n },\n\n /**\n * Disconnect and remove a single element's observer.\n * @param {HTMLElement} el\n */\n destroy: function (el) {\n const observer = this.observers.get(el);\n if (observer) {\n observer.disconnect();\n this.observers.delete(el);\n }\n },\n\n destroyAll: function () {\n this.observers.forEach((observer, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('glassScroll', GlassScroll);\n }\n\n window.VanduoGlassScroll = GlassScroll;\n})();\n", "/**\n * Vanduo Framework - Water Morph Effect Component\n * Liquid wave content-swap animation on click\n *\n * Usage:\n * Add .vd-morph or [data-vd-morph] to any element.\n * Provide .vd-morph-content.vd-morph-current and .vd-morph-content.vd-morph-next children.\n * Wave and shine layers are auto-created if absent.\n *\n * JS API:\n * VanduoMorph.morph(el) \u2014 trigger morph programmatically (from center)\n * VanduoMorph.destroy(el) \u2014 tear down listeners for one element\n * VanduoMorph.destroyAll() \u2014 tear down all instances\n */\n\n(function () {\n 'use strict';\n\n const MORPH_DURATION_MS = 750;\n\n const Morph = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-morph, [data-vd-morph]');\n elements.forEach(function (el) {\n if (Morph.instances.has(el)) return;\n if (el.getAttribute('data-vd-morph') === 'manual') return;\n Morph.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n Morph._ensureLayers(el);\n\n const cleanup = [];\n let morphing = false;\n\n const handleClick = function (e) {\n if (morphing) return;\n Morph._runMorph(el, e, function () { morphing = false; });\n morphing = true;\n };\n\n el.addEventListener('click', handleClick);\n cleanup.push(function () { el.removeEventListener('click', handleClick); });\n\n this.instances.set(el, { cleanup: cleanup });\n },\n\n morph: function (el) {\n if (!el) return;\n if (!this.instances.has(el)) this.initInstance(el);\n this._runMorph(el, null, null);\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(function (fn) { fn(); });\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) { Morph.destroy(el); });\n },\n\n /* \u2500\u2500 Internal helpers \u2500\u2500 */\n\n _ensureLayers: function (el) {\n if (!el.querySelector('.vd-morph-wave')) {\n const wave = document.createElement('span');\n wave.className = 'vd-morph-wave';\n wave.setAttribute('aria-hidden', 'true');\n el.insertBefore(wave, el.firstChild);\n }\n if (!el.querySelector('.vd-morph-shine')) {\n const shine = document.createElement('span');\n shine.className = 'vd-morph-shine';\n shine.setAttribute('aria-hidden', 'true');\n const waveEl = el.querySelector('.vd-morph-wave');\n if (waveEl && waveEl.nextSibling) {\n el.insertBefore(shine, waveEl.nextSibling);\n } else {\n el.insertBefore(shine, el.firstChild);\n }\n }\n },\n\n _runMorph: function (el, pointerEvent, onComplete) {\n const wave = el.querySelector('.vd-morph-wave');\n if (wave) {\n const rect = el.getBoundingClientRect();\n const cx = rect.left + rect.width / 2;\n const cy = rect.top + rect.height / 2;\n const px = pointerEvent ? (pointerEvent.clientX || cx) : cx;\n const py = pointerEvent ? (pointerEvent.clientY || cy) : cy;\n wave.style.left = (px - rect.left) + 'px';\n wave.style.top = (py - rect.top) + 'px';\n }\n\n el.classList.add('is-morphing');\n\n let duration = MORPH_DURATION_MS;\n const custom = getComputedStyle(el).getPropertyValue('--morph-duration');\n if (custom) {\n const parsed = parseFloat(custom);\n if (!isNaN(parsed)) duration = parsed * (custom.indexOf('ms') !== -1 ? 1 : 1000);\n }\n\n setTimeout(function () {\n el.classList.remove('is-morphing');\n\n const current = el.querySelector('.vd-morph-current');\n const next = el.querySelector('.vd-morph-next');\n if (current && next) {\n current.classList.remove('vd-morph-current');\n current.classList.add('vd-morph-next');\n next.classList.remove('vd-morph-next');\n next.classList.add('vd-morph-current');\n }\n\n if (typeof onComplete === 'function') onComplete();\n }, duration);\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('morph', Morph);\n }\n\n window.VanduoMorph = Morph;\n\n})();\n", "/**\n * Vanduo Framework \u2014 Expanding flex cards\n * Click / Enter / Space / Arrow keys to change the active card.\n *\n * Usage:\n *
    \n * Use data-vd-expanding-cards=\"manual\" to skip auto-init.\n */\n\n(function () {\n 'use strict';\n\n const ExpandingCards = {\n instances: new Map(),\n\n init: function () {\n document.querySelectorAll('.vd-expanding-cards').forEach(function (el) {\n if (el.getAttribute('data-vd-expanding-cards') === 'manual') return;\n if (ExpandingCards.instances.has(el)) return;\n ExpandingCards.initContainer(el);\n });\n },\n\n initContainer: function (container) {\n const cleanup = [];\n\n const getCards = function () {\n return Array.prototype.slice.call(container.querySelectorAll('.vd-expanding-card'));\n };\n\n const setActive = function (card) {\n const cards = getCards();\n if (!card || cards.indexOf(card) === -1) return;\n cards.forEach(function (c) {\n c.classList.toggle('is-active', c === card);\n });\n card.focus({ preventScroll: true });\n };\n\n const onClick = function (e) {\n const t = e.target;\n const card = t.closest ? t.closest('.vd-expanding-card') : null;\n if (!card || !container.contains(card)) return;\n setActive(card);\n };\n\n const onKeydown = function (e) {\n if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'Home' && e.key !== 'End') {\n return;\n }\n const cards = getCards().filter(function (c) {\n return c.offsetParent !== null || c.getClientRects().length > 0;\n });\n if (!cards.length) return;\n const activeEl = document.activeElement;\n let idx = cards.indexOf(activeEl);\n if (idx < 0) {\n idx = cards.findIndex(function (c) {\n return c.classList.contains('is-active');\n });\n }\n if (idx < 0) idx = 0;\n\n if (e.key === 'ArrowLeft') {\n e.preventDefault();\n setActive(cards[Math.max(0, idx - 1)]);\n } else if (e.key === 'ArrowRight') {\n e.preventDefault();\n setActive(cards[Math.min(cards.length - 1, idx + 1)]);\n } else if (e.key === 'Home') {\n e.preventDefault();\n setActive(cards[0]);\n } else if (e.key === 'End') {\n e.preventDefault();\n setActive(cards[cards.length - 1]);\n }\n };\n\n container.addEventListener('click', onClick);\n cleanup.push(function () {\n container.removeEventListener('click', onClick);\n });\n\n container.addEventListener('keydown', onKeydown);\n cleanup.push(function () {\n container.removeEventListener('keydown', onKeydown);\n });\n\n getCards().forEach(function (card) {\n if (!card.hasAttribute('tabindex')) {\n card.setAttribute('tabindex', '0');\n }\n card.setAttribute('role', 'button');\n if (!card.hasAttribute('aria-pressed')) {\n card.setAttribute('aria-pressed', card.classList.contains('is-active') ? 'true' : 'false');\n }\n });\n\n const syncAria = function () {\n getCards().forEach(function (card) {\n card.setAttribute('aria-pressed', card.classList.contains('is-active') ? 'true' : 'false');\n });\n };\n\n const mo = new MutationObserver(syncAria);\n mo.observe(container, { attributes: true, subtree: true, attributeFilter: ['class'] });\n cleanup.push(function () {\n mo.disconnect();\n });\n syncAria();\n\n ExpandingCards.instances.set(container, { cleanup: cleanup });\n },\n\n destroy: function (container) {\n const inst = this.instances.get(container);\n if (!inst) return;\n inst.cleanup.forEach(function (fn) {\n fn();\n });\n this.instances.delete(container);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) {\n ExpandingCards.destroy(el);\n });\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('expandingCards', ExpandingCards);\n }\n\n window.VanduoExpandingCards = ExpandingCards;\n})();\n", "/**\n * Vanduo Framework \u2014 Timeline animated reveal\n * Opt-in: add `.vd-timeline-animated` to a `.vd-timeline` container.\n * Uses IntersectionObserver to add `.is-revealed` per item with staggered delays.\n * Optional: `.vd-timeline-playback` with `[data-vd-timeline-prev|next|play|pause]` for stepped control.\n */\n\n(function () {\n 'use strict';\n\n const STAGGER_MS = 140;\n const MAX_STAGGER_INDEX = 7;\n const PLAY_INTERVAL_MS = 800;\n\n function countRevealedPrefix(items) {\n let count = 0;\n for (let i = 0; i < items.length; i++) {\n if (!items[i].classList.contains('is-revealed')) break;\n count++;\n }\n return count;\n }\n\n function findPlaybackControls(container) {\n return container.parentElement || document.body;\n }\n\n function initPlayback(container, items, cleanup) {\n items.forEach(function (item) {\n item.classList.remove('is-revealed');\n });\n\n const scope = findPlaybackControls(container);\n const prevBtn = scope.querySelector('[data-vd-timeline-prev]');\n const nextBtn = scope.querySelector('[data-vd-timeline-next]');\n const playBtn = scope.querySelector('[data-vd-timeline-play]');\n const pauseBtn = scope.querySelector('[data-vd-timeline-pause]');\n\n let playTimer = null;\n\n function updateNavButtons() {\n const k = countRevealedPrefix(items);\n const n = items.length;\n if (prevBtn) {\n const atStart = k === 0;\n prevBtn.disabled = atStart;\n prevBtn.setAttribute('aria-disabled', atStart ? 'true' : 'false');\n }\n if (nextBtn) {\n const atEnd = k >= n;\n nextBtn.disabled = atEnd;\n nextBtn.setAttribute('aria-disabled', atEnd ? 'true' : 'false');\n }\n if (playBtn) {\n playBtn.setAttribute('aria-pressed', playTimer ? 'true' : 'false');\n }\n if (pauseBtn) {\n pauseBtn.disabled = !playTimer;\n }\n }\n\n function stepNext() {\n const k = countRevealedPrefix(items);\n if (k < items.length) {\n items[k].classList.add('is-revealed');\n }\n updateNavButtons();\n }\n\n function stepPrev() {\n const k = countRevealedPrefix(items);\n if (k > 0) {\n items[k - 1].classList.remove('is-revealed');\n }\n updateNavButtons();\n }\n\n function play() {\n if (playTimer) return;\n playTimer = setInterval(function () {\n if (countRevealedPrefix(items) >= items.length) {\n pause();\n return;\n }\n stepNext();\n }, PLAY_INTERVAL_MS);\n updateNavButtons();\n }\n\n function pause() {\n if (playTimer) {\n clearInterval(playTimer);\n playTimer = null;\n }\n updateNavButtons();\n }\n\n function addClick(el, fn) {\n if (!el) return;\n const handler = function (e) {\n e.preventDefault();\n fn();\n };\n el.addEventListener('click', handler);\n cleanup.push(function () {\n el.removeEventListener('click', handler);\n });\n }\n\n addClick(prevBtn, stepPrev);\n addClick(nextBtn, stepNext);\n addClick(playBtn, play);\n addClick(pauseBtn, pause);\n\n cleanup.push(function () {\n pause();\n });\n\n updateNavButtons();\n\n return {\n stepNext: stepNext,\n stepPrev: stepPrev,\n play: play,\n pause: pause\n };\n }\n\n const Timeline = {\n instances: new Map(),\n\n init: function () {\n document.querySelectorAll('.vd-timeline.vd-timeline-animated').forEach(function (el) {\n if (Timeline.instances.has(el)) return;\n Timeline.initInstance(el);\n });\n },\n\n reinit: function () {\n Timeline.destroyAll();\n Timeline.init();\n },\n\n initInstance: function (container) {\n const cleanup = [];\n const items = Array.prototype.filter.call(container.children, function (child) {\n return child.classList && child.classList.contains('vd-timeline-item');\n });\n\n items.forEach(function (item, i) {\n const idx = Math.min(i, MAX_STAGGER_INDEX);\n item.style.setProperty('--vd-timeline-reveal-delay', (idx * STAGGER_MS) + 'ms');\n });\n\n const reducedMotion = typeof window.matchMedia === 'function'\n && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n if (reducedMotion) {\n items.forEach(function (item) {\n item.classList.add('is-revealed');\n });\n Timeline.instances.set(container, { cleanup: cleanup });\n return;\n }\n\n const playback = container.classList && container.classList.contains('vd-timeline-playback');\n\n if (playback) {\n const playbackApi = initPlayback(container, items, cleanup);\n Timeline.instances.set(container, { cleanup: cleanup, playback: playbackApi });\n return;\n }\n\n if (typeof IntersectionObserver === 'undefined') {\n items.forEach(function (item) {\n item.classList.add('is-revealed');\n });\n Timeline.instances.set(container, { cleanup: cleanup });\n return;\n }\n\n const observer = new IntersectionObserver(function (entries) {\n entries.forEach(function (entry) {\n if (!entry.isIntersecting) return;\n entry.target.classList.add('is-revealed');\n observer.unobserve(entry.target);\n });\n }, {\n root: null,\n rootMargin: '0px 0px -10% 0px',\n threshold: 0.15\n });\n\n items.forEach(function (item) {\n observer.observe(item);\n });\n\n cleanup.push(function () {\n observer.disconnect();\n });\n\n Timeline.instances.set(container, { cleanup: cleanup });\n },\n\n destroy: function (container) {\n const inst = this.instances.get(container);\n if (!inst) return;\n inst.cleanup.forEach(function (fn) {\n fn();\n });\n this.instances.delete(container);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) {\n Timeline.destroy(el);\n });\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('timeline', Timeline);\n }\n\n window.VanduoTimeline = Timeline;\n})();\n", "/**\n * Vanduo Framework - Flow (Carousel/Slider) Component\n * Touch-enabled carousel with slide/fade transitions, autoplay, indicators\n */\n\n(function () {\n 'use strict';\n\n const Flow = {\n instances: new Map(),\n\n init: function () {\n const carousels = document.querySelectorAll('.vd-flow, .vd-carousel');\n carousels.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const track = el.querySelector('.vd-flow-track');\n if (!track) return;\n\n const slides = Array.from(track.querySelectorAll('.vd-flow-slide'));\n if (slides.length === 0) return;\n\n const isFade = el.classList.contains('vd-flow-fade');\n const autoplay = el.hasAttribute('data-vd-autoplay');\n const interval = parseInt(el.getAttribute('data-vd-interval'), 10) || 5000;\n const loop = el.getAttribute('data-vd-loop') !== 'false';\n\n const state = {\n current: 0,\n total: slides.length,\n autoplayTimer: null,\n isFade: isFade,\n loop: loop,\n isDragging: false,\n startX: 0,\n currentX: 0,\n threshold: 50\n };\n\n const cleanup = [];\n\n // Set initial active slide\n slides.forEach((slide, i) => {\n slide.setAttribute('role', 'group');\n slide.setAttribute('aria-roledescription', 'slide');\n slide.setAttribute('aria-label', 'Slide ' + (i + 1) + ' of ' + slides.length);\n if (i === 0) slide.classList.add('is-active');\n });\n\n el.setAttribute('role', 'region');\n el.setAttribute('aria-roledescription', 'carousel');\n if (!el.getAttribute('aria-label')) {\n el.setAttribute('aria-label', 'Carousel');\n }\n\n // Live region for announcements\n const liveRegion = document.createElement('div');\n liveRegion.setAttribute('aria-live', 'polite');\n liveRegion.setAttribute('aria-atomic', 'true');\n liveRegion.className = 'sr-only';\n liveRegion.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);';\n el.appendChild(liveRegion);\n\n const goTo = (index, announce) => {\n if (announce === undefined) announce = true;\n let target = index;\n if (state.loop) {\n target = ((index % state.total) + state.total) % state.total;\n } else {\n target = Math.max(0, Math.min(index, state.total - 1));\n }\n\n const prev = state.current;\n state.current = target;\n\n if (state.isFade) {\n slides.forEach((s, i) => {\n s.classList.toggle('is-active', i === target);\n });\n } else {\n track.style.transform = 'translateX(-' + (target * 100) + '%)';\n }\n\n // Update indicators\n const indicators = el.querySelectorAll('.vd-flow-indicator');\n indicators.forEach((ind, i) => {\n ind.classList.toggle('is-active', i === target);\n ind.setAttribute('aria-selected', i === target ? 'true' : 'false');\n });\n\n // Update slide ARIA\n slides.forEach((s, i) => {\n s.setAttribute('aria-hidden', i !== target ? 'true' : 'false');\n });\n\n if (announce) {\n liveRegion.textContent = 'Slide ' + (target + 1) + ' of ' + state.total;\n }\n\n el.dispatchEvent(new CustomEvent('flow:change', {\n detail: { current: target, previous: prev, total: state.total }\n }));\n };\n\n const next = () => goTo(state.current + 1);\n const prev = () => goTo(state.current - 1);\n\n // Controls\n const prevBtn = el.querySelector('.vd-flow-prev');\n const nextBtn = el.querySelector('.vd-flow-next');\n\n if (prevBtn) {\n const h = () => prev();\n prevBtn.addEventListener('click', h);\n cleanup.push(() => prevBtn.removeEventListener('click', h));\n }\n if (nextBtn) {\n const h = () => next();\n nextBtn.addEventListener('click', h);\n cleanup.push(() => nextBtn.removeEventListener('click', h));\n }\n\n // Indicators\n const indicators = el.querySelectorAll('.vd-flow-indicator');\n indicators.forEach((ind, i) => {\n ind.setAttribute('role', 'tab');\n ind.setAttribute('aria-selected', i === 0 ? 'true' : 'false');\n ind.setAttribute('aria-label', 'Go to slide ' + (i + 1));\n const h = () => goTo(i);\n ind.addEventListener('click', h);\n cleanup.push(() => ind.removeEventListener('click', h));\n });\n\n // Keyboard navigation\n const keyHandler = (e) => {\n if (e.key === 'ArrowLeft') { prev(); e.preventDefault(); }\n if (e.key === 'ArrowRight') { next(); e.preventDefault(); }\n };\n el.setAttribute('tabindex', '0');\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n\n // Touch / pointer support\n const pointerDown = (e) => {\n state.isDragging = true;\n state.startX = e.clientX || (e.touches && e.touches[0].clientX) || 0;\n state.currentX = state.startX;\n el.classList.add('is-dragging');\n };\n\n const pointerMove = (e) => {\n if (!state.isDragging) return;\n state.currentX = e.clientX || (e.touches && e.touches[0].clientX) || 0;\n };\n\n const pointerUp = () => {\n if (!state.isDragging) return;\n state.isDragging = false;\n el.classList.remove('is-dragging');\n const diff = state.startX - state.currentX;\n if (Math.abs(diff) > state.threshold) {\n if (diff > 0) next();\n else prev();\n }\n };\n\n el.addEventListener('mousedown', pointerDown);\n el.addEventListener('mousemove', pointerMove);\n el.addEventListener('mouseup', pointerUp);\n el.addEventListener('mouseleave', pointerUp);\n el.addEventListener('touchstart', pointerDown, { passive: true });\n el.addEventListener('touchmove', pointerMove, { passive: true });\n el.addEventListener('touchend', pointerUp);\n\n cleanup.push(\n () => el.removeEventListener('mousedown', pointerDown),\n () => el.removeEventListener('mousemove', pointerMove),\n () => el.removeEventListener('mouseup', pointerUp),\n () => el.removeEventListener('mouseleave', pointerUp),\n () => el.removeEventListener('touchstart', pointerDown),\n () => el.removeEventListener('touchmove', pointerMove),\n () => el.removeEventListener('touchend', pointerUp)\n );\n\n // Autoplay\n const startAutoplay = () => {\n stopAutoplay();\n state.autoplayTimer = setInterval(next, interval);\n };\n\n const stopAutoplay = () => {\n if (state.autoplayTimer) {\n clearInterval(state.autoplayTimer);\n state.autoplayTimer = null;\n }\n };\n\n if (autoplay) {\n startAutoplay();\n const pauseHandler = () => stopAutoplay();\n const resumeHandler = () => startAutoplay();\n el.addEventListener('mouseenter', pauseHandler);\n el.addEventListener('mouseleave', resumeHandler);\n el.addEventListener('focusin', pauseHandler);\n el.addEventListener('focusout', resumeHandler);\n cleanup.push(\n () => el.removeEventListener('mouseenter', pauseHandler),\n () => el.removeEventListener('mouseleave', resumeHandler),\n () => el.removeEventListener('focusin', pauseHandler),\n () => el.removeEventListener('focusout', resumeHandler),\n () => stopAutoplay()\n );\n }\n\n // Initial ARIA state\n goTo(0, false);\n\n this.instances.set(el, {\n cleanup: cleanup,\n goTo: goTo,\n next: next,\n prev: prev,\n getState: () => ({ ...state })\n });\n },\n\n goTo: function (el, index) {\n const instance = this.instances.get(el);\n if (instance) instance.goTo(index);\n },\n\n next: function (el) {\n const instance = this.instances.get(el);\n if (instance) instance.next();\n },\n\n prev: function (el) {\n const instance = this.instances.get(el);\n if (instance) instance.prev();\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('flow', Flow);\n }\n\n window.VanduoFlow = Flow;\n\n})();\n", "/**\n * Vanduo Framework - Bubble (Popover) Component\n * Click-triggered rich HTML popover, reuses tooltip positioning concepts\n */\n\n(function () {\n 'use strict';\n\n const Bubble = {\n instances: new Map(),\n _globalCleanups: [],\n\n init: function () {\n const triggers = document.querySelectorAll('[data-vd-bubble], [data-vd-popover]');\n triggers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n\n if (this._globalCleanups.length === 0) {\n const outsideClick = (e) => {\n this.instances.forEach((inst, trigger) => {\n if (!inst.popover.contains(e.target) && !trigger.contains(e.target)) {\n this.hide(trigger);\n }\n });\n };\n const escHandler = (e) => {\n if (e.key === 'Escape') {\n this.instances.forEach((_, trigger) => this.hide(trigger));\n }\n };\n document.addEventListener('click', outsideClick, true);\n document.addEventListener('keydown', escHandler);\n this._globalCleanups.push(\n () => document.removeEventListener('click', outsideClick, true),\n () => document.removeEventListener('keydown', escHandler)\n );\n }\n },\n\n initInstance: function (trigger) {\n const cleanup = [];\n const placement = trigger.getAttribute('data-vd-bubble-placement') ||\n trigger.getAttribute('data-vd-popover-placement') || 'bottom';\n\n // Build popover element\n const popover = document.createElement('div');\n popover.className = 'vd-bubble-content';\n popover.setAttribute('role', 'dialog');\n popover.setAttribute('aria-modal', 'false');\n popover.setAttribute('data-placement', placement);\n\n const title = trigger.getAttribute('data-vd-bubble-title') ||\n trigger.getAttribute('data-vd-popover-title');\n const content = trigger.getAttribute('data-vd-bubble') ||\n trigger.getAttribute('data-vd-popover') || '';\n const htmlContent = trigger.getAttribute('data-vd-bubble-html') ||\n trigger.getAttribute('data-vd-popover-html');\n const allowSvg = trigger.hasAttribute('data-vd-bubble-allow-svg') ||\n trigger.hasAttribute('data-vd-popover-allow-svg');\n\n if (title) {\n const header = document.createElement('div');\n header.className = 'vd-bubble-header';\n const titleSpan = document.createElement('span');\n titleSpan.textContent = title;\n const closeBtn = document.createElement('button');\n closeBtn.className = 'vd-bubble-close';\n closeBtn.setAttribute('aria-label', 'Close');\n closeBtn.innerHTML = '×';\n header.appendChild(titleSpan);\n header.appendChild(closeBtn);\n popover.appendChild(header);\n\n const closeHandler = (e) => { e.stopPropagation(); this.hide(trigger); };\n closeBtn.addEventListener('click', closeHandler);\n cleanup.push(() => closeBtn.removeEventListener('click', closeHandler));\n }\n\n const body = document.createElement('div');\n body.className = 'vd-bubble-body';\n if (htmlContent) {\n if (typeof sanitizeHtml === 'function') {\n body.innerHTML = sanitizeHtml(htmlContent, { allowSvg });\n } else {\n body.textContent = htmlContent;\n }\n } else {\n body.textContent = content;\n }\n popover.appendChild(body);\n\n document.body.appendChild(popover);\n\n // ARIA on trigger\n const popId = 'vd-bubble-' + Math.random().toString(36).slice(2, 9);\n popover.id = popId;\n trigger.setAttribute('aria-haspopup', 'dialog');\n trigger.setAttribute('aria-expanded', 'false');\n trigger.setAttribute('aria-controls', popId);\n\n // Toggle on click\n const toggleHandler = (e) => {\n e.stopPropagation();\n if (popover.classList.contains('is-visible')) {\n this.hide(trigger);\n } else {\n this.hideAll();\n this.show(trigger);\n }\n };\n trigger.addEventListener('click', toggleHandler);\n cleanup.push(() => trigger.removeEventListener('click', toggleHandler));\n\n this.instances.set(trigger, { popover, cleanup, placement });\n },\n\n position: function (trigger, popover, placement) {\n const rect = trigger.getBoundingClientRect();\n const popRect = popover.getBoundingClientRect();\n const gap = 10;\n let top, left;\n\n switch (placement) {\n case 'top':\n top = rect.top - popRect.height - gap + window.scrollY;\n left = rect.left + (rect.width - popRect.width) / 2 + window.scrollX;\n break;\n case 'left':\n top = rect.top + (rect.height - popRect.height) / 2 + window.scrollY;\n left = rect.left - popRect.width - gap + window.scrollX;\n break;\n case 'right':\n top = rect.top + (rect.height - popRect.height) / 2 + window.scrollY;\n left = rect.right + gap + window.scrollX;\n break;\n default: // bottom\n top = rect.bottom + gap + window.scrollY;\n left = rect.left + (rect.width - popRect.width) / 2 + window.scrollX;\n }\n\n // Clamp to viewport\n left = Math.max(8, Math.min(left, window.innerWidth - popRect.width - 8));\n top = Math.max(8, top);\n\n popover.style.top = top + 'px';\n popover.style.left = left + 'px';\n },\n\n show: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n const { popover, placement } = instance;\n\n popover.style.display = 'block';\n popover.classList.add('is-visible');\n trigger.setAttribute('aria-expanded', 'true');\n\n requestAnimationFrame(() => {\n this.position(trigger, popover, placement);\n });\n\n trigger.dispatchEvent(new CustomEvent('bubble:show', {\n bubbles: true,\n detail: { trigger: trigger, placement: placement }\n }));\n },\n\n hide: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n instance.popover.classList.remove('is-visible');\n trigger.setAttribute('aria-expanded', 'false');\n trigger.dispatchEvent(new CustomEvent('bubble:hide', {\n bubbles: true,\n detail: { trigger: trigger }\n }));\n },\n\n hideAll: function () {\n this.instances.forEach((_, trigger) => this.hide(trigger));\n },\n\n destroy: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n if (instance.popover.parentNode) {\n instance.popover.parentNode.removeChild(instance.popover);\n }\n trigger.removeAttribute('aria-haspopup');\n trigger.removeAttribute('aria-expanded');\n trigger.removeAttribute('aria-controls');\n this.instances.delete(trigger);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, trigger) => this.destroy(trigger));\n this._globalCleanups.forEach(fn => fn());\n this._globalCleanups = [];\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('bubble', Bubble);\n }\n\n window.VanduoBubble = Bubble;\n\n})();\n", "/**\n * Vanduo Framework - Waypoint (Scrollspy) Component\n * Highlights navigation links based on scroll position using IntersectionObserver\n */\n\n(function () {\n 'use strict';\n\n const Waypoint = {\n instances: new Map(),\n\n init: function () {\n const navs = document.querySelectorAll('[data-vd-waypoint-nav], [data-vd-scrollspy-nav]');\n navs.forEach(nav => {\n if (this.instances.has(nav)) return;\n this.initInstance(nav);\n });\n },\n\n initInstance: function (nav) {\n const links = Array.from(nav.querySelectorAll('a[href^=\"#\"]'));\n if (links.length === 0) return;\n\n const cleanup = [];\n const offset = parseInt(nav.getAttribute('data-vd-waypoint-offset') || '80', 10);\n const sections = [];\n\n links.forEach(link => {\n const id = link.getAttribute('href').slice(1);\n const section = document.getElementById(id);\n if (section) {\n section.setAttribute('data-vd-waypoint-section', '');\n sections.push({ id, link, section });\n }\n });\n\n if (sections.length === 0) return;\n\n const activeSections = new Set();\n\n const setActive = (id) => {\n links.forEach(l => l.classList.remove('is-active'));\n const target = links.find(l => l.getAttribute('href') === '#' + id);\n if (target) {\n target.classList.add('is-active');\n nav.dispatchEvent(new CustomEvent('waypoint:change', {\n detail: { activeId: id, link: target }\n }));\n }\n };\n\n const rootMargin = '-' + offset + 'px 0px -40% 0px';\n\n const observer = new IntersectionObserver((entries) => {\n entries.forEach(entry => {\n if (entry.isIntersecting) {\n activeSections.add(entry.target.id);\n } else {\n activeSections.delete(entry.target.id);\n }\n });\n\n // Pick the topmost visible section\n for (let i = 0; i < sections.length; i++) {\n if (activeSections.has(sections[i].id)) {\n setActive(sections[i].id);\n return;\n }\n }\n }, {\n rootMargin: rootMargin,\n threshold: 0\n });\n\n sections.forEach(s => observer.observe(s.section));\n\n // Smooth scroll on click\n links.forEach(link => {\n const clickHandler = (e) => {\n e.preventDefault();\n const id = link.getAttribute('href').slice(1);\n const section = document.getElementById(id);\n if (section) {\n section.scrollIntoView({ behavior: 'smooth' });\n setActive(id);\n }\n };\n link.addEventListener('click', clickHandler);\n cleanup.push(() => link.removeEventListener('click', clickHandler));\n });\n\n cleanup.push(() => observer.disconnect());\n\n this.instances.set(nav, { observer, cleanup, sections, setActive });\n },\n\n refresh: function (nav) {\n this.destroy(nav);\n this.initInstance(nav);\n },\n\n destroy: function (nav) {\n const instance = this.instances.get(nav);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(nav);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, nav) => this.destroy(nav));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('waypoint', Waypoint);\n }\n\n window.VanduoWaypoint = Waypoint;\n\n})();\n", "/**\n * Vanduo Framework - Ripple (Waves Effect) Component\n * Adds expanding circle animation on click at pointer position\n */\n\n(function () {\n 'use strict';\n\n const Ripple = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-ripple, [data-vd-ripple]');\n elements.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n\n const createWave = (e) => {\n const rect = el.getBoundingClientRect();\n const size = Math.max(rect.width, rect.height);\n const x = (e.clientX || (e.touches && e.touches[0].clientX) || rect.left + rect.width / 2) - rect.left - size / 2;\n const y = (e.clientY || (e.touches && e.touches[0].clientY) || rect.top + rect.height / 2) - rect.top - size / 2;\n\n const wave = document.createElement('span');\n wave.className = 'vd-ripple-wave';\n wave.style.width = size + 'px';\n wave.style.height = size + 'px';\n wave.style.left = x + 'px';\n wave.style.top = y + 'px';\n\n el.appendChild(wave);\n\n wave.addEventListener('animationend', () => {\n if (wave.parentNode) wave.parentNode.removeChild(wave);\n });\n };\n\n el.addEventListener('mousedown', createWave);\n el.addEventListener('touchstart', createWave, { passive: true });\n\n cleanup.push(\n () => el.removeEventListener('mousedown', createWave),\n () => el.removeEventListener('touchstart', createWave)\n );\n\n this.instances.set(el, { cleanup });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n // Remove any lingering wave elements\n el.querySelectorAll('.vd-ripple-wave').forEach(w => w.remove());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('ripple', Ripple);\n }\n\n window.VanduoRipple = Ripple;\n\n})();\n", "/**\n * Vanduo Framework - Affix (Sticky) Component\n * Uses IntersectionObserver to toggle .is-stuck within the nearest scrollable parent\n */\n\n(function () {\n 'use strict';\n\n function isScrollable(element) {\n if (!element || element === document.body) return false;\n\n const style = window.getComputedStyle(element);\n const overflowY = style.overflowY;\n const overflowX = style.overflowX;\n const canScrollY = /(auto|scroll|overlay)/.test(overflowY) && element.scrollHeight > element.clientHeight;\n const canScrollX = /(auto|scroll|overlay)/.test(overflowX) && element.scrollWidth > element.clientWidth;\n\n return canScrollY || canScrollX;\n }\n\n function getScrollParent(element) {\n let parent = element.parentElement;\n\n while (parent && parent !== document.body && parent !== document.documentElement) {\n if (isScrollable(parent)) return parent;\n parent = parent.parentElement;\n }\n\n return null;\n }\n\n const Affix = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-affix, .vd-sticky, [data-vd-affix]');\n elements.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const parsedOffset = parseInt(el.getAttribute('data-vd-affix-offset') || '0', 10);\n const offset = Number.isNaN(parsedOffset) ? 0 : parsedOffset;\n const scrollParent = getScrollParent(el);\n let isStuck = false;\n\n const sentinel = document.createElement('div');\n sentinel.style.cssText = 'display:block;height:1px;margin-bottom:-1px;visibility:hidden;pointer-events:none;';\n el.parentNode.insertBefore(sentinel, el);\n\n el.style.setProperty('--affix-top-offset', offset + 'px');\n\n function stick() {\n if (isStuck) return;\n isStuck = true;\n el.classList.add('is-stuck');\n el.dispatchEvent(new CustomEvent('affix:stuck', {\n bubbles: true,\n detail: {\n offset: offset,\n root: scrollParent || window\n }\n }));\n }\n\n function unstick() {\n if (!isStuck) return;\n isStuck = false;\n el.classList.remove('is-stuck');\n el.dispatchEvent(new CustomEvent('affix:unstuck', {\n bubbles: true,\n detail: {\n offset: offset,\n root: scrollParent || window\n }\n }));\n }\n\n const observer = new IntersectionObserver(function (entries) {\n entries.forEach(entry => {\n if (!entry.isIntersecting) {\n stick();\n } else {\n unstick();\n }\n });\n }, {\n root: scrollParent,\n rootMargin: '-' + offset + 'px 0px 0px 0px',\n threshold: 0\n });\n\n observer.observe(sentinel);\n\n cleanup.push(\n () => observer.disconnect(),\n () => { if (sentinel.parentNode) sentinel.parentNode.removeChild(sentinel); },\n () => {\n el.classList.remove('is-stuck');\n el.style.removeProperty('--affix-top-offset');\n }\n );\n\n this.instances.set(el, { cleanup, observer, sentinel, scrollParent });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n el.classList.remove('is-stuck');\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('affix', Affix);\n }\n\n window.VanduoAffix = Affix;\n\n})();\n", "/**\n * Vanduo Framework - Suggest (Autocomplete) Component\n * Dropdown suggestion list with keyboard navigation, static/async data sources\n */\n\n(function () {\n 'use strict';\n\n /**\n * Escape HTML entities to prevent XSS when inserting into innerHTML\n * @param {string} text\n * @returns {string}\n */\n function _escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Allow same-origin URLs by default, or an explicit allowlist of origins.\n * @param {string} url\n * @param {string[]} allowlist\n * @returns {boolean}\n */\n function _isSafeUrl(url, allowlist) {\n try {\n const resolved = new URL(url, window.location.href);\n if (resolved.origin === window.location.origin) return true;\n return allowlist.includes(resolved.origin);\n } catch (_e) {\n return false;\n }\n }\n\n const Suggest = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-suggest], [data-vd-autocomplete]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const minChars = parseInt(input.getAttribute('data-vd-suggest-min-chars') || '1', 10);\n const url = input.getAttribute('data-vd-suggest-url') || '';\n const allowlistAttr = input.getAttribute('data-vd-suggest-allowlist') || '';\n const allowlist = allowlistAttr\n .split(',')\n .map(value => value.trim())\n .filter(Boolean);\n const staticData = input.getAttribute('data-vd-suggest') || input.getAttribute('data-vd-autocomplete') || '';\n let items = [];\n\n try { items = JSON.parse(staticData); } catch (_e) {\n items = staticData.split(',').map(s => s.trim()).filter(Boolean);\n }\n\n // Wrap input if not already wrapped\n let wrapper = input.closest('.vd-suggest-wrapper, .vd-autocomplete-wrapper');\n if (!wrapper) {\n wrapper = document.createElement('div');\n wrapper.className = 'vd-suggest-wrapper';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n }\n\n // Create dropdown list\n const list = document.createElement('ul');\n list.className = 'vd-suggest-list';\n list.setAttribute('role', 'listbox');\n const listId = 'vd-suggest-' + Math.random().toString(36).slice(2, 9);\n list.id = listId;\n wrapper.appendChild(list);\n\n // ARIA\n input.setAttribute('role', 'combobox');\n input.setAttribute('aria-autocomplete', 'list');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('aria-controls', listId);\n input.setAttribute('autocomplete', 'off');\n\n let highlighted = -1;\n let currentItems = [];\n let debounceTimer = null;\n\n const renderItems = (filtered, query) => {\n list.innerHTML = '';\n currentItems = filtered;\n highlighted = -1;\n\n if (filtered.length === 0) {\n const empty = document.createElement('li');\n empty.className = 'vd-suggest-empty';\n empty.textContent = 'No results';\n list.appendChild(empty);\n return;\n }\n\n filtered.forEach((item, i) => {\n const li = document.createElement('li');\n li.className = 'vd-suggest-item';\n li.setAttribute('role', 'option');\n li.id = listId + '-item-' + i;\n\n const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);\n if (query) {\n // Escape HTML first to prevent XSS, then highlight matches in the safe string\n const escaped = _escapeHtml(text);\n const re = new RegExp('(' + query.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi');\n li.innerHTML = escaped.replace(re, '$1');\n } else {\n li.textContent = text;\n }\n\n li.addEventListener('click', () => selectItem(i));\n list.appendChild(li);\n });\n };\n\n const open = () => {\n list.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n };\n\n const close = () => {\n list.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n highlighted = -1;\n input.removeAttribute('aria-activedescendant');\n };\n\n const selectItem = (index) => {\n const item = currentItems[index];\n const value = typeof item === 'object' ? (item.value || item.label || String(item)) : String(item);\n input.value = value;\n close();\n input.dispatchEvent(new CustomEvent('suggest:select', {\n detail: { value, item, index },\n bubbles: true\n }));\n };\n\n const highlight = (index) => {\n const listItems = list.querySelectorAll('.vd-suggest-item');\n listItems.forEach(li => li.classList.remove('is-highlighted'));\n if (index >= 0 && index < listItems.length) {\n highlighted = index;\n listItems[index].classList.add('is-highlighted');\n input.setAttribute('aria-activedescendant', listItems[index].id);\n listItems[index].scrollIntoView({ block: 'nearest' });\n }\n };\n\n const doSearch = async (query) => {\n if (query.length < minChars) { close(); return; }\n\n let filtered;\n if (url) {\n try {\n if (!_isSafeUrl(url, allowlist)) {\n console.warn('[VanduoSuggest] Blocked non-allowlisted URL:', url);\n filtered = [];\n } else {\n const separator = url.includes('?') ? '&' : '?';\n const res = await window.fetch(url + separator + 'q=' + encodeURIComponent(query));\n filtered = await res.json();\n }\n } catch (_e) {\n filtered = [];\n }\n } else {\n const lower = query.toLowerCase();\n filtered = items.filter(item => {\n const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);\n return text.toLowerCase().includes(lower);\n });\n }\n\n renderItems(filtered, query);\n if (filtered.length > 0) open();\n else open(); // show \"no results\"\n };\n\n const inputHandler = () => {\n clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => doSearch(input.value), 200);\n };\n\n const keyHandler = (e) => {\n if (!list.classList.contains('is-open')) {\n if (e.key === 'ArrowDown') { doSearch(input.value); e.preventDefault(); }\n return;\n }\n\n const total = currentItems.length;\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n highlight(highlighted < total - 1 ? highlighted + 1 : 0);\n break;\n case 'ArrowUp':\n e.preventDefault();\n highlight(highlighted > 0 ? highlighted - 1 : total - 1);\n break;\n case 'Enter':\n e.preventDefault();\n if (highlighted >= 0) selectItem(highlighted);\n break;\n case 'Escape':\n close();\n break;\n }\n };\n\n const blurHandler = () => {\n setTimeout(close, 200);\n };\n\n input.addEventListener('input', inputHandler);\n input.addEventListener('keydown', keyHandler);\n input.addEventListener('blur', blurHandler);\n input.addEventListener('focus', () => { if (input.value.length >= minChars) doSearch(input.value); });\n\n cleanup.push(\n () => input.removeEventListener('input', inputHandler),\n () => input.removeEventListener('keydown', keyHandler),\n () => input.removeEventListener('blur', blurHandler),\n () => clearTimeout(debounceTimer),\n () => { if (list.parentNode) list.parentNode.removeChild(list); }\n );\n\n this.instances.set(input, { cleanup, list, close });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('suggest', Suggest);\n }\n\n window.VanduoSuggest = Suggest;\n\n})();\n", "/**\n * Vanduo Framework - Validate (Form Validation) Component\n * Declarative validation via data attributes with real-time and on-submit modes\n */\n\n(function () {\n 'use strict';\n\n const Validate = {\n instances: new Map(),\n\n rules: {\n required: (value) => value.trim().length > 0,\n email: (value) => /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(value),\n url: (value) => { try { new URL(value); return true; } catch (_e) { return false; } },\n number: (value) => !isNaN(parseFloat(value)) && isFinite(value),\n min: (value, param) => value.length >= parseInt(param, 10),\n max: (value, param) => value.length <= parseInt(param, 10),\n minVal: (value, param) => parseFloat(value) >= parseFloat(param),\n maxVal: (value, param) => parseFloat(value) <= parseFloat(param),\n pattern: (value, param) => {\n try {\n // Cap regex length to prevent ReDoS from excessively complex patterns\n if (param.length > 100) return false;\n return new RegExp(param).test(value);\n } catch (_e) { return false; }\n },\n match: (value, param) => {\n try {\n const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(param) : param;\n const other = document.querySelector('[name=\"' + escaped + '\"]');\n return other ? value === other.value : false;\n } catch (_e) {\n return false;\n }\n }\n },\n\n messages: {\n required: 'This field is required',\n email: 'Please enter a valid email address',\n url: 'Please enter a valid URL',\n number: 'Please enter a valid number',\n min: 'Minimum {0} characters required',\n max: 'Maximum {0} characters allowed',\n minVal: 'Value must be at least {0}',\n maxVal: 'Value must be at most {0}',\n pattern: 'Invalid format',\n match: 'Fields do not match'\n },\n\n init: function () {\n const forms = document.querySelectorAll('[data-vd-validate], .vd-validate');\n forms.forEach(form => {\n if (this.instances.has(form)) return;\n this.initInstance(form);\n });\n },\n\n initInstance: function (form) {\n const cleanup = [];\n const mode = form.getAttribute('data-vd-validate-mode') || 'blur'; // blur | input | submit\n const fields = form.querySelectorAll('[data-vd-rules]');\n\n const validateField = (field) => {\n const rulesStr = field.getAttribute('data-vd-rules') || '';\n const rules = rulesStr.split('|').map(r => r.trim()).filter(Boolean);\n const value = field.value;\n const errors = [];\n\n for (const rule of rules) {\n const [name, ...params] = rule.split(':');\n const param = params.join(':');\n const validator = this.rules[name];\n\n if (validator && !validator(value, param)) {\n const customMsg = field.getAttribute('data-vd-msg-' + name);\n let msg = customMsg || this.messages[name] || 'Invalid';\n if (param) msg = msg.replace('{0}', param);\n errors.push(msg);\n break; // one error at a time\n }\n }\n\n this.setFieldState(field, errors);\n return errors.length === 0;\n };\n\n const validateAll = () => {\n let valid = true;\n fields.forEach(field => {\n if (!validateField(field)) valid = false;\n });\n return valid;\n };\n\n // Per-field listeners\n fields.forEach(field => {\n if (mode === 'input' || mode === 'blur') {\n const eventType = mode === 'input' ? 'input' : 'blur';\n const handler = () => validateField(field);\n field.addEventListener(eventType, handler);\n cleanup.push(() => field.removeEventListener(eventType, handler));\n\n if (mode === 'blur') {\n const inputClear = () => {\n if (field.classList.contains('is-invalid') || field.classList.contains('is-valid')) {\n validateField(field);\n }\n };\n field.addEventListener('input', inputClear);\n cleanup.push(() => field.removeEventListener('input', inputClear));\n }\n }\n });\n\n // Form submit\n const submitHandler = (e) => {\n const valid = validateAll();\n if (!valid) {\n e.preventDefault();\n e.stopPropagation();\n // Focus first invalid field\n const firstInvalid = form.querySelector('.is-invalid');\n if (firstInvalid) firstInvalid.focus();\n }\n form.dispatchEvent(new CustomEvent('validate:submit', {\n detail: { valid },\n bubbles: true\n }));\n };\n\n form.addEventListener('submit', submitHandler);\n cleanup.push(() => form.removeEventListener('submit', submitHandler));\n\n this.instances.set(form, { cleanup, validateAll, validateField });\n },\n\n setFieldState: function (field, errors) {\n const wrapper = field.closest('.vd-form-group') || field.parentElement;\n let errorEl = wrapper.querySelector('.vd-validate-error');\n\n field.classList.remove('is-valid', 'is-invalid');\n\n if (errors.length > 0) {\n field.classList.add('is-invalid');\n field.setAttribute('aria-invalid', 'true');\n if (!errorEl) {\n errorEl = document.createElement('div');\n errorEl.className = 'vd-validate-error';\n errorEl.id = 'vd-err-' + Math.random().toString(36).slice(2, 9);\n errorEl.setAttribute('role', 'alert');\n wrapper.appendChild(errorEl);\n }\n errorEl.textContent = errors[0];\n errorEl.style.display = '';\n field.setAttribute('aria-describedby', errorEl.id);\n } else if (field.value.trim()) {\n field.classList.add('is-valid');\n field.removeAttribute('aria-invalid');\n if (errorEl) errorEl.style.display = 'none';\n } else {\n field.removeAttribute('aria-invalid');\n if (errorEl) errorEl.style.display = 'none';\n }\n },\n\n validateForm: function (form) {\n const instance = this.instances.get(form);\n return instance ? instance.validateAll() : false;\n },\n\n addRule: function (name, validator, message) {\n this.rules[name] = validator;\n if (message) this.messages[name] = message;\n },\n\n destroy: function (form) {\n const instance = this.instances.get(form);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(form);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, form) => this.destroy(form));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('validate', Validate);\n }\n\n window.VanduoValidate = Validate;\n\n})();\n", "/**\n * Vanduo Framework - Datepicker Component\n * Calendar popup attached to input field with month/year navigation\n */\n\n(function () {\n 'use strict';\n\n const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];\n const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];\n\n function escapeRegexChar(c) {\n return c.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n }\n\n function buildParseFormat(format) {\n let regex = '^';\n const order = [];\n let i = 0;\n while (i < format.length) {\n const slice = format.slice(i);\n if (slice.toLowerCase().startsWith('yyyy')) {\n regex += '(\\\\d{4})';\n order.push('y');\n i += 4;\n } else if (slice.toLowerCase().startsWith('mm')) {\n regex += '(\\\\d{2})';\n order.push('m');\n i += 2;\n } else if (slice.toLowerCase().startsWith('dd')) {\n regex += '(\\\\d{2})';\n order.push('d');\n i += 2;\n } else {\n regex += escapeRegexChar(format[i]);\n i++;\n }\n }\n regex += '$';\n return { regex: new RegExp(regex), order };\n }\n\n function parseDateFromFormat(value, format) {\n if (!value || !format) return null;\n const { regex, order } = buildParseFormat(format);\n const m = value.trim().match(regex);\n if (!m) return null;\n let y;\n let mo;\n let d;\n let ci = 1;\n for (let k = 0; k < order.length; k++) {\n const part = order[k];\n const v = parseInt(m[ci++], 10);\n if (Number.isNaN(v)) return null;\n if (part === 'y') y = v;\n else if (part === 'm') mo = v - 1;\n else if (part === 'd') d = v;\n }\n if (y === undefined || mo === undefined || d === undefined) return null;\n const dt = new Date(y, mo, d);\n if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== d) return null;\n return dt;\n }\n\n function formatDate(d, format) {\n const yyyy = String(d.getFullYear());\n const mm = String(d.getMonth() + 1).padStart(2, '0');\n const dd = String(d.getDate()).padStart(2, '0');\n let out = '';\n let i = 0;\n while (i < format.length) {\n const slice = format.slice(i);\n if (slice.toLowerCase().startsWith('yyyy')) {\n out += yyyy;\n i += 4;\n } else if (slice.toLowerCase().startsWith('mm')) {\n out += mm;\n i += 2;\n } else if (slice.toLowerCase().startsWith('dd')) {\n out += dd;\n i += 2;\n } else {\n out += format[i];\n i++;\n }\n }\n return out;\n }\n\n function dateKey(d) {\n return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');\n }\n\n function addDays(d, n) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n x.setDate(x.getDate() + n);\n return x;\n }\n\n function addMonthsClamped(d, n) {\n return new Date(d.getFullYear(), d.getMonth() + n, d.getDate());\n }\n\n function parseYmdLocal(ymd) {\n if (!ymd || typeof ymd !== 'string') return null;\n const m = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(ymd.trim());\n if (!m) return null;\n const y = +m[1];\n const mo = +m[2] - 1;\n const day = +m[3];\n const dt = new Date(y, mo, day);\n if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== day) return null;\n return dt;\n }\n\n function startOfWeekSunday(d) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const day = x.getDay();\n x.setDate(x.getDate() - day);\n return x;\n }\n\n function endOfWeekSunday(d) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const day = x.getDay();\n x.setDate(x.getDate() + (6 - day));\n return x;\n }\n\n const Datepicker = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-datepicker]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const format = input.getAttribute('data-vd-datepicker-format') || 'YYYY-MM-DD';\n const minStr = input.getAttribute('data-vd-datepicker-min');\n const maxStr = input.getAttribute('data-vd-datepicker-max');\n const minDate = minStr ? parseYmdLocal(minStr) : null;\n const maxDate = maxStr ? parseYmdLocal(maxStr) : null;\n\n const today = new Date();\n let viewYear = today.getFullYear();\n let viewMonth = today.getMonth();\n let selectedDate = null;\n let viewMode = 'days'; // days | months | years\n let focusedDate = null;\n /** Prevents focus() after close from immediately re-opening the popup */\n let skipNextFocusOpen = false;\n\n const isDisabled = (d) => {\n if (minDate) {\n const t = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n if (t < minDate.getTime()) return true;\n }\n if (maxDate) {\n const t = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n if (t > maxDate.getTime()) return true;\n }\n return false;\n };\n\n const ensureMonthInRange = (y, m) => {\n if (!minDate && !maxDate) return { y: y, m: m };\n const first = new Date(y, m, 1);\n const last = new Date(y, m + 1, 0);\n if (minDate && last.getTime() < minDate.getTime()) {\n return { y: minDate.getFullYear(), m: minDate.getMonth() };\n }\n if (maxDate && first.getTime() > maxDate.getTime()) {\n return { y: maxDate.getFullYear(), m: maxDate.getMonth() };\n }\n return { y: y, m: m };\n };\n\n const firstSelectableInMonth = (y, m) => {\n const last = new Date(y, m + 1, 0).getDate();\n for (let day = 1; day <= last; day++) {\n const dt = new Date(y, m, day);\n if (!isDisabled(dt)) return dt;\n }\n return new Date(y, m, 1);\n };\n\n if (input.value) {\n const trimmed = input.value.trim();\n let parsed = parseDateFromFormat(trimmed, format);\n if (!parsed) {\n const fallback = new Date(trimmed);\n if (!isNaN(fallback.getTime())) parsed = fallback;\n }\n if (parsed) {\n selectedDate = parsed;\n viewYear = parsed.getFullYear();\n viewMonth = parsed.getMonth();\n }\n }\n\n const clampedInit = ensureMonthInRange(viewYear, viewMonth);\n viewYear = clampedInit.y;\n viewMonth = clampedInit.m;\n\n // Create popup\n const popup = document.createElement('div');\n popup.className = 'vd-datepicker-popup';\n popup.setAttribute('role', 'dialog');\n popup.setAttribute('aria-label', 'Choose date');\n popup.tabIndex = -1;\n\n const wrapper = document.createElement('div');\n wrapper.className = 'vd-suggest-wrapper';\n wrapper.style.position = 'relative';\n wrapper.style.display = 'inline-block';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n wrapper.appendChild(popup);\n\n const isSameDay = (a, b) => a && b &&\n a.getFullYear() === b.getFullYear() &&\n a.getMonth() === b.getMonth() &&\n a.getDate() === b.getDate();\n\n const selectDate = (date) => {\n selectedDate = date;\n viewYear = date.getFullYear();\n viewMonth = date.getMonth();\n input.value = formatDate(date, format);\n skipNextFocusOpen = true;\n close();\n input.dispatchEvent(new CustomEvent('datepicker:select', {\n detail: { date: date, formatted: input.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n input.focus();\n };\n\n const focusFocusedDay = () => {\n if (viewMode !== 'days' || !focusedDate) return;\n const key = dateKey(focusedDate);\n const btn = popup.querySelector('[data-vd-date=\"' + key + '\"]');\n if (btn && !btn.classList.contains('is-outside') && btn.getAttribute('aria-disabled') !== 'true') {\n btn.focus();\n }\n };\n\n const skipDisabled = (d, stepDir, maxSteps) => {\n let x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const step = stepDir > 0 ? 1 : -1;\n for (let i = 0; i < maxSteps; i++) {\n if (!isDisabled(x)) return x;\n x = addDays(x, step);\n }\n return d;\n };\n\n const createDayBtn = (day, outside, date) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-day';\n btn.textContent = day;\n btn.setAttribute('role', 'gridcell');\n\n if (outside) {\n btn.classList.add('is-outside');\n btn.tabIndex = -1;\n btn.setAttribute('aria-disabled', 'true');\n return btn;\n }\n\n btn.setAttribute('data-vd-date', dateKey(date));\n\n if (date && isSameDay(date, today)) btn.classList.add('is-today');\n if (date && isSameDay(date, selectedDate)) btn.classList.add('is-selected');\n if (date && isDisabled(date)) {\n btn.classList.add('is-disabled');\n btn.setAttribute('aria-disabled', 'true');\n btn.tabIndex = -1;\n return btn;\n }\n\n if (date) {\n const isFocused = focusedDate && isSameDay(date, focusedDate);\n btn.tabIndex = isFocused ? 0 : -1;\n\n btn.addEventListener('click', () => {\n selectedDate = date;\n viewYear = date.getFullYear();\n viewMonth = date.getMonth();\n focusedDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());\n input.value = formatDate(date, format);\n skipNextFocusOpen = true;\n close();\n input.dispatchEvent(new CustomEvent('datepicker:select', {\n detail: { date: date, formatted: input.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n input.focus();\n });\n }\n\n return btn;\n };\n\n const render = () => {\n popup.innerHTML = '';\n\n // Header\n const header = document.createElement('div');\n header.className = 'vd-datepicker-header';\n\n const prevBtn = document.createElement('button');\n prevBtn.type = 'button';\n prevBtn.className = 'vd-datepicker-prev';\n prevBtn.innerHTML = '‹';\n prevBtn.setAttribute('aria-label', 'Previous');\n\n const nextBtn = document.createElement('button');\n nextBtn.type = 'button';\n nextBtn.className = 'vd-datepicker-next';\n nextBtn.innerHTML = '›';\n nextBtn.setAttribute('aria-label', 'Next');\n\n const title = document.createElement('span');\n title.className = 'vd-datepicker-title';\n\n if (viewMode === 'days') {\n title.textContent = MONTHS[viewMonth] + ' ' + viewYear;\n title.addEventListener('click', () => { viewMode = 'months'; render(); });\n prevBtn.addEventListener('click', () => { viewMonth--; if (viewMonth < 0) { viewMonth = 11; viewYear--; } render(); });\n nextBtn.addEventListener('click', () => { viewMonth++; if (viewMonth > 11) { viewMonth = 0; viewYear++; } render(); });\n } else if (viewMode === 'months') {\n title.textContent = String(viewYear);\n title.addEventListener('click', () => { viewMode = 'years'; render(); });\n prevBtn.addEventListener('click', () => { viewYear--; render(); });\n nextBtn.addEventListener('click', () => { viewYear++; render(); });\n } else {\n const decadeStart = Math.floor(viewYear / 10) * 10;\n title.textContent = decadeStart + ' - ' + (decadeStart + 9);\n prevBtn.addEventListener('click', () => { viewYear -= 10; render(); });\n nextBtn.addEventListener('click', () => { viewYear += 10; render(); });\n }\n\n header.appendChild(prevBtn);\n header.appendChild(title);\n header.appendChild(nextBtn);\n popup.appendChild(header);\n\n if (viewMode === 'days') {\n const gridWrap = document.createElement('div');\n gridWrap.className = 'vd-datepicker-grid';\n gridWrap.setAttribute('role', 'grid');\n gridWrap.setAttribute('aria-label', 'Calendar');\n\n const weekdays = document.createElement('div');\n weekdays.className = 'vd-datepicker-weekdays';\n weekdays.setAttribute('role', 'row');\n DAYS.forEach(function (d) {\n const span = document.createElement('span');\n span.setAttribute('role', 'columnheader');\n span.setAttribute('aria-label', d);\n span.textContent = d;\n weekdays.appendChild(span);\n });\n gridWrap.appendChild(weekdays);\n\n const firstDay = new Date(viewYear, viewMonth, 1).getDay();\n const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();\n const daysInPrev = new Date(viewYear, viewMonth, 0).getDate();\n\n const cells = [];\n\n for (let i = firstDay - 1; i >= 0; i--) {\n const dayNum = daysInPrev - i;\n const prevMonth = viewMonth === 0 ? 11 : viewMonth - 1;\n const prevYear = viewMonth === 0 ? viewYear - 1 : viewYear;\n const date = new Date(prevYear, prevMonth, dayNum);\n cells.push({ day: dayNum, outside: true, date: date });\n }\n\n for (let d = 1; d <= daysInMonth; d++) {\n const date = new Date(viewYear, viewMonth, d);\n cells.push({ day: d, outside: false, date: date });\n }\n\n const totalCells = firstDay + daysInMonth;\n const remaining = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7);\n for (let i = 1; i <= remaining; i++) {\n const date = new Date(viewYear, viewMonth + 1, i);\n cells.push({ day: i, outside: true, date: date });\n }\n\n for (let r = 0; r < cells.length; r += 7) {\n const row = document.createElement('div');\n row.className = 'vd-datepicker-row';\n row.setAttribute('role', 'row');\n for (let c = 0; c < 7; c++) {\n const cell = cells[r + c];\n row.appendChild(createDayBtn(cell.day, cell.outside, cell.date));\n }\n gridWrap.appendChild(row);\n }\n\n popup.appendChild(gridWrap);\n } else if (viewMode === 'months') {\n const grid = document.createElement('div');\n grid.className = 'vd-datepicker-months';\n MONTHS.forEach((name, i) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-month-btn';\n btn.textContent = name.slice(0, 3);\n if (selectedDate && selectedDate.getFullYear() === viewYear && selectedDate.getMonth() === i) {\n btn.classList.add('is-selected');\n }\n btn.addEventListener('click', () => { viewMonth = i; viewMode = 'days'; render(); });\n grid.appendChild(btn);\n });\n popup.appendChild(grid);\n } else {\n const grid = document.createElement('div');\n grid.className = 'vd-datepicker-years';\n const decadeStart = Math.floor(viewYear / 10) * 10;\n for (let y = decadeStart - 1; y <= decadeStart + 10; y++) {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-year-btn';\n btn.textContent = y;\n if (selectedDate && selectedDate.getFullYear() === y) btn.classList.add('is-selected');\n if (y < decadeStart || y > decadeStart + 9) btn.style.opacity = '0.4';\n btn.addEventListener('click', () => { viewYear = y; viewMode = 'months'; render(); });\n grid.appendChild(btn);\n }\n popup.appendChild(grid);\n }\n };\n\n const handleGridKeydown = (e) => {\n if (!popup.classList.contains('is-open') || viewMode !== 'days') return;\n const grid = popup.querySelector('.vd-datepicker-grid');\n if (!grid || !grid.contains(e.target)) return;\n\n const key = e.key;\n if (key !== 'ArrowLeft' && key !== 'ArrowRight' && key !== 'ArrowUp' && key !== 'ArrowDown' &&\n key !== 'Home' && key !== 'End' && key !== 'PageUp' && key !== 'PageDown' &&\n key !== 'Enter' && key !== ' ' && key !== 'Escape') {\n return;\n }\n\n if (key === 'Escape') {\n e.preventDefault();\n e.stopPropagation();\n skipNextFocusOpen = true;\n close();\n input.focus();\n return;\n }\n\n if (!focusedDate) {\n focusedDate = firstSelectableInMonth(viewYear, viewMonth);\n }\n\n if (key === 'Enter' || key === ' ') {\n e.preventDefault();\n if (focusedDate && !isDisabled(focusedDate)) {\n selectDate(new Date(focusedDate.getFullYear(), focusedDate.getMonth(), focusedDate.getDate()));\n }\n return;\n }\n\n e.preventDefault();\n\n let next = new Date(focusedDate.getFullYear(), focusedDate.getMonth(), focusedDate.getDate());\n let skipDir = 1;\n\n if (key === 'ArrowLeft') {\n next = addDays(next, -1);\n skipDir = -1;\n } else if (key === 'ArrowRight') {\n next = addDays(next, 1);\n skipDir = 1;\n } else if (key === 'ArrowUp') {\n next = addDays(next, -7);\n skipDir = -1;\n } else if (key === 'ArrowDown') {\n next = addDays(next, 7);\n skipDir = 1;\n } else if (key === 'Home') {\n next = startOfWeekSunday(next);\n skipDir = 1;\n } else if (key === 'End') {\n next = endOfWeekSunday(next);\n skipDir = -1;\n } else if (key === 'PageUp') {\n next = addMonthsClamped(next, -1);\n skipDir = -1;\n } else if (key === 'PageDown') {\n next = addMonthsClamped(next, 1);\n skipDir = 1;\n }\n\n next = skipDisabled(next, skipDir, 400);\n\n if (next.getMonth() !== viewMonth || next.getFullYear() !== viewYear) {\n viewYear = next.getFullYear();\n viewMonth = next.getMonth();\n const cl = ensureMonthInRange(viewYear, viewMonth);\n viewYear = cl.y;\n viewMonth = cl.m;\n }\n\n focusedDate = next;\n render();\n requestAnimationFrame(focusFocusedDay);\n };\n\n const open = () => {\n viewMode = 'days';\n if (selectedDate) {\n viewYear = selectedDate.getFullYear();\n viewMonth = selectedDate.getMonth();\n }\n const cl = ensureMonthInRange(viewYear, viewMonth);\n viewYear = cl.y;\n viewMonth = cl.m;\n\n if (selectedDate) {\n focusedDate = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate());\n } else {\n focusedDate = firstSelectableInMonth(viewYear, viewMonth);\n }\n\n render();\n popup.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n requestAnimationFrame(focusFocusedDay);\n };\n\n const close = () => {\n popup.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n viewMode = 'days';\n };\n\n const focusHandler = () => {\n if (skipNextFocusOpen) {\n skipNextFocusOpen = false;\n return;\n }\n open();\n };\n const outsideHandler = (e) => {\n if (!wrapper.contains(e.target)) close();\n };\n const escHandler = (e) => {\n if (e.key === 'Escape' && popup.classList.contains('is-open')) {\n skipNextFocusOpen = true;\n close();\n input.focus();\n }\n };\n\n input.addEventListener('focus', focusHandler);\n document.addEventListener('click', outsideHandler, true);\n document.addEventListener('keydown', escHandler);\n popup.addEventListener('keydown', handleGridKeydown);\n\n input.setAttribute('aria-haspopup', 'dialog');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('autocomplete', 'off');\n\n cleanup.push(\n () => input.removeEventListener('focus', focusHandler),\n () => document.removeEventListener('click', outsideHandler, true),\n () => document.removeEventListener('keydown', escHandler),\n () => popup.removeEventListener('keydown', handleGridKeydown)\n );\n\n this.instances.set(input, { cleanup: cleanup, open: open, close: close, popup: popup });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('datepicker', Datepicker);\n }\n\n window.VanduoDatepicker = Datepicker;\n\n})();\n", "/**\n * Vanduo Framework - Timepicker Component\n * Dropdown time selection with 12h/24h format and configurable step intervals\n */\n\n(function () {\n 'use strict';\n\n const Timepicker = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-timepicker]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const is24h = input.getAttribute('data-vd-timepicker-format') === '24h';\n const step = parseInt(input.getAttribute('data-vd-timepicker-step') || '30', 10);\n\n // Create wrapper\n let wrapper = input.closest('.vd-suggest-wrapper');\n if (!wrapper) {\n wrapper = document.createElement('div');\n wrapper.style.position = 'relative';\n wrapper.style.display = 'inline-block';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n }\n\n // Create popup\n const popup = document.createElement('div');\n popup.className = 'vd-timepicker-popup';\n popup.setAttribute('role', 'listbox');\n wrapper.appendChild(popup);\n\n // Generate time slots\n const times = [];\n for (let h = 0; h < 24; h++) {\n for (let m = 0; m < 60; m += step) {\n const hh24 = String(h).padStart(2, '0');\n const mm = String(m).padStart(2, '0');\n\n if (is24h) {\n times.push({ display: hh24 + ':' + mm, value: hh24 + ':' + mm });\n } else {\n const period = h < 12 ? 'AM' : 'PM';\n const h12 = h === 0 ? 12 : (h > 12 ? h - 12 : h);\n const display = h12 + ':' + mm + ' ' + period;\n times.push({ display, value: hh24 + ':' + mm });\n }\n }\n }\n\n const render = () => {\n popup.innerHTML = '';\n times.forEach(t => {\n const item = document.createElement('div');\n item.className = 'vd-timepicker-item';\n item.setAttribute('role', 'option');\n item.textContent = t.display;\n\n if (input.value === t.value || input.value === t.display) {\n item.classList.add('is-selected');\n }\n\n item.addEventListener('click', () => {\n input.value = t.display;\n popup.querySelectorAll('.vd-timepicker-item').forEach(i => i.classList.remove('is-selected'));\n item.classList.add('is-selected');\n close();\n input.dispatchEvent(new CustomEvent('timepicker:select', {\n detail: { display: t.display, value: t.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n });\n\n popup.appendChild(item);\n });\n };\n\n const open = () => {\n render();\n popup.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n // Scroll to selected\n const selected = popup.querySelector('.is-selected');\n if (selected) selected.scrollIntoView({ block: 'center' });\n };\n\n const close = () => {\n popup.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n };\n\n const focusHandler = () => open();\n const outsideHandler = (e) => {\n if (!wrapper.contains(e.target)) close();\n };\n const escHandler = (e) => { if (e.key === 'Escape') close(); };\n\n input.addEventListener('focus', focusHandler);\n document.addEventListener('click', outsideHandler, true);\n document.addEventListener('keydown', escHandler);\n input.setAttribute('aria-haspopup', 'listbox');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('autocomplete', 'off');\n input.readOnly = true;\n\n cleanup.push(\n () => input.removeEventListener('focus', focusHandler),\n () => document.removeEventListener('click', outsideHandler, true),\n () => document.removeEventListener('keydown', escHandler)\n );\n\n this.instances.set(input, { cleanup, open, close });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('timepicker', Timepicker);\n }\n\n window.VanduoTimepicker = Timepicker;\n\n})();\n", "/**\n * Vanduo Framework - Stepper Component\n * Multi-step progress indicator with state management\n */\n\n(function () {\n 'use strict';\n\n const Stepper = {\n instances: new Map(),\n\n init: function () {\n const steppers = document.querySelectorAll('.vd-stepper');\n steppers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const items = Array.from(el.querySelectorAll('.vd-stepper-item'));\n const isClickable = el.classList.contains('vd-stepper-clickable');\n let currentIndex = items.findIndex(i => i.classList.contains('is-active'));\n if (currentIndex === -1) currentIndex = 0;\n\n const setStep = (index) => {\n if (index < 0 || index >= items.length) return;\n const prev = currentIndex;\n currentIndex = index;\n\n items.forEach((item, i) => {\n item.classList.remove('is-active', 'is-completed');\n if (i < index) item.classList.add('is-completed');\n else if (i === index) item.classList.add('is-active');\n });\n\n el.dispatchEvent(new CustomEvent('stepper:change', {\n detail: { current: index, previous: prev, total: items.length },\n bubbles: true\n }));\n };\n\n if (isClickable) {\n items.forEach((item, i) => {\n const handler = () => setStep(i);\n item.addEventListener('click', handler);\n cleanup.push(() => item.removeEventListener('click', handler));\n });\n }\n\n // Initialize current state\n setStep(currentIndex);\n\n this.instances.set(el, {\n cleanup,\n setStep,\n next: () => setStep(currentIndex + 1),\n prev: () => setStep(currentIndex - 1),\n getCurrent: () => currentIndex\n });\n },\n\n setStep: function (el, index) {\n const inst = this.instances.get(el);\n if (inst) inst.setStep(index);\n },\n\n next: function (el) {\n const inst = this.instances.get(el);\n if (inst) inst.next();\n },\n\n prev: function (el) {\n const inst = this.instances.get(el);\n if (inst) inst.prev();\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('stepper', Stepper);\n }\n\n window.VanduoStepper = Stepper;\n\n})();\n", "/**\n * Vanduo Framework - Rating Component\n * Star-based rating input with hover preview and read-only mode\n */\n\n(function () {\n 'use strict';\n\n const Rating = {\n instances: new Map(),\n\n init: function () {\n const ratings = document.querySelectorAll('[data-vd-rating]');\n ratings.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const max = parseInt(el.getAttribute('data-vd-rating-max') || '5', 10);\n const initialValue = parseFloat(el.getAttribute('data-vd-rating-value') || '0');\n const readonly = el.classList.contains('vd-rating-readonly') || el.hasAttribute('data-vd-rating-readonly');\n let currentValue = initialValue;\n\n el.classList.add('vd-rating');\n el.setAttribute('role', 'radiogroup');\n el.setAttribute('aria-label', el.getAttribute('aria-label') || 'Rating');\n\n // Clear existing stars\n el.innerHTML = '';\n\n // Create stars\n const stars = [];\n for (let i = 1; i <= max; i++) {\n const star = document.createElement('button');\n star.type = 'button';\n star.className = 'vd-rating-star';\n star.setAttribute('role', 'radio');\n star.setAttribute('aria-label', i + ' star' + (i > 1 ? 's' : ''));\n star.setAttribute('aria-checked', i <= currentValue ? 'true' : 'false');\n if (readonly) star.tabIndex = -1;\n stars.push(star);\n el.appendChild(star);\n }\n\n // Value display\n const valueDisplay = document.createElement('span');\n valueDisplay.className = 'vd-rating-value';\n valueDisplay.textContent = currentValue > 0 ? currentValue.toString() : '';\n el.appendChild(valueDisplay);\n\n const updateStars = (value) => {\n stars.forEach((star, i) => {\n star.classList.remove('is-active', 'is-half');\n const starNum = i + 1;\n if (starNum <= Math.floor(value)) {\n star.classList.add('is-active');\n } else if (starNum - 0.5 <= value) {\n star.classList.add('is-half');\n }\n star.setAttribute('aria-checked', starNum <= value ? 'true' : 'false');\n });\n valueDisplay.textContent = value > 0 ? value.toString() : '';\n };\n\n updateStars(currentValue);\n\n if (!readonly) {\n stars.forEach((star, i) => {\n const enterHandler = () => {\n stars.forEach((s, j) => {\n s.classList.toggle('is-hovered', j <= i);\n });\n };\n const leaveHandler = () => {\n stars.forEach(s => s.classList.remove('is-hovered'));\n };\n const clickHandler = () => {\n currentValue = i + 1;\n el.setAttribute('data-vd-rating-value', currentValue);\n updateStars(currentValue);\n el.dispatchEvent(new CustomEvent('rating:change', {\n detail: { value: currentValue, max },\n bubbles: true\n }));\n };\n\n star.addEventListener('mouseenter', enterHandler);\n star.addEventListener('mouseleave', leaveHandler);\n star.addEventListener('click', clickHandler);\n\n cleanup.push(\n () => star.removeEventListener('mouseenter', enterHandler),\n () => star.removeEventListener('mouseleave', leaveHandler),\n () => star.removeEventListener('click', clickHandler)\n );\n });\n\n // Keyboard\n const keyHandler = (e) => {\n if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {\n e.preventDefault();\n if (currentValue < max) {\n currentValue++;\n updateStars(currentValue);\n stars[currentValue - 1].focus();\n el.dispatchEvent(new CustomEvent('rating:change', { detail: { value: currentValue, max }, bubbles: true }));\n }\n } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {\n e.preventDefault();\n if (currentValue > 1) {\n currentValue--;\n updateStars(currentValue);\n stars[currentValue - 1].focus();\n el.dispatchEvent(new CustomEvent('rating:change', { detail: { value: currentValue, max }, bubbles: true }));\n }\n }\n };\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n }\n\n this.instances.set(el, {\n cleanup,\n getValue: () => currentValue,\n setValue: (v) => { currentValue = v; updateStars(v); }\n });\n },\n\n getValue: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getValue() : 0;\n },\n\n setValue: function (el, value) {\n const inst = this.instances.get(el);\n if (inst) inst.setValue(value);\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('rating', Rating);\n }\n\n window.VanduoRating = Rating;\n\n})();\n", "/**\n * Vanduo Framework - Transfer (Dual-list) Component\n * Source-to-target list transfer with search, select-all, and move actions\n */\n\n(function () {\n 'use strict';\n\n const Transfer = {\n instances: new Map(),\n\n init: function () {\n const transfers = document.querySelectorAll('[data-vd-transfer]');\n transfers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n el.classList.add('vd-transfer');\n\n let sourceData, targetData;\n try {\n const raw = JSON.parse(el.getAttribute('data-vd-transfer') || '[]');\n sourceData = raw.map((item, i) => ({\n id: item.id || 'item-' + i,\n label: item.label || item.text || String(item),\n selected: false\n }));\n } catch (_e) {\n sourceData = [];\n }\n targetData = [];\n\n const sourceSelected = new Set();\n const targetSelected = new Set();\n\n const render = () => {\n el.innerHTML = '';\n\n // Source panel\n const sourcePanel = createPanel('Source', sourceData, sourceSelected, 'source');\n // Actions\n const actions = document.createElement('div');\n actions.className = 'vd-transfer-actions';\n\n const moveRightBtn = document.createElement('button');\n moveRightBtn.type = 'button';\n moveRightBtn.className = 'vd-transfer-btn';\n moveRightBtn.innerHTML = '›';\n moveRightBtn.setAttribute('aria-label', 'Move to target');\n moveRightBtn.disabled = sourceSelected.size === 0;\n moveRightBtn.addEventListener('click', () => moveRight());\n\n const moveLeftBtn = document.createElement('button');\n moveLeftBtn.type = 'button';\n moveLeftBtn.className = 'vd-transfer-btn';\n moveLeftBtn.innerHTML = '‹';\n moveLeftBtn.setAttribute('aria-label', 'Move to source');\n moveLeftBtn.disabled = targetSelected.size === 0;\n moveLeftBtn.addEventListener('click', () => moveLeft());\n\n actions.appendChild(moveRightBtn);\n actions.appendChild(moveLeftBtn);\n\n // Target panel\n const targetPanel = createPanel('Target', targetData, targetSelected, 'target');\n\n el.appendChild(sourcePanel);\n el.appendChild(actions);\n el.appendChild(targetPanel);\n };\n\n const createPanel = (title, data, selected, _side) => {\n const panel = document.createElement('div');\n panel.className = 'vd-transfer-panel';\n\n const header = document.createElement('div');\n header.className = 'vd-transfer-header';\n const titleSpan = document.createElement('span');\n titleSpan.textContent = title;\n const count = document.createElement('span');\n count.className = 'vd-transfer-count';\n count.textContent = selected.size + '/' + data.length;\n header.appendChild(titleSpan);\n header.appendChild(count);\n panel.appendChild(header);\n\n // Search\n const searchDiv = document.createElement('div');\n searchDiv.className = 'vd-transfer-search';\n const searchInput = document.createElement('input');\n searchInput.type = 'text';\n searchInput.placeholder = 'Search...';\n searchInput.setAttribute('aria-label', 'Search ' + title.toLowerCase());\n searchDiv.appendChild(searchInput);\n panel.appendChild(searchDiv);\n\n // List\n const list = document.createElement('ul');\n list.className = 'vd-transfer-list';\n list.setAttribute('role', 'listbox');\n\n const renderList = (filter) => {\n list.innerHTML = '';\n const filtered = filter ? data.filter(d => {\n const label = (d.label || d.text || String(d)).toLowerCase();\n return label.includes(filter.toLowerCase());\n }) : data;\n filtered.forEach(item => {\n const li = document.createElement('li');\n li.className = 'vd-transfer-item';\n li.setAttribute('role', 'option');\n if (selected.has(item.id)) li.classList.add('is-selected');\n\n const checkbox = document.createElement('input');\n checkbox.type = 'checkbox';\n checkbox.checked = selected.has(item.id);\n checkbox.setAttribute('aria-label', item.label);\n\n const label = document.createElement('span');\n label.textContent = item.label;\n\n li.addEventListener('click', () => {\n if (selected.has(item.id)) selected.delete(item.id);\n else selected.add(item.id);\n render();\n });\n\n li.appendChild(checkbox);\n li.appendChild(label);\n list.appendChild(li);\n });\n };\n\n searchInput.addEventListener('input', () => renderList(searchInput.value));\n renderList('');\n\n panel.appendChild(list);\n return panel;\n };\n\n const moveRight = () => {\n const toMove = sourceData.filter(d => sourceSelected.has(d.id));\n sourceData = sourceData.filter(d => !sourceSelected.has(d.id));\n targetData = targetData.concat(toMove);\n sourceSelected.clear();\n render();\n fireChange();\n };\n\n const moveLeft = () => {\n const toMove = targetData.filter(d => targetSelected.has(d.id));\n targetData = targetData.filter(d => !targetSelected.has(d.id));\n sourceData = sourceData.concat(toMove);\n targetSelected.clear();\n render();\n fireChange();\n };\n\n const fireChange = () => {\n el.dispatchEvent(new CustomEvent('transfer:change', {\n detail: {\n source: sourceData.map(d => d.id),\n target: targetData.map(d => d.id)\n },\n bubbles: true\n }));\n };\n\n render();\n\n this.instances.set(el, {\n cleanup,\n getTarget: () => targetData.map(d => d.id),\n getSource: () => sourceData.map(d => d.id)\n });\n },\n\n getSelected: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getTarget() : [];\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n el.innerHTML = '';\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('transfer', Transfer);\n }\n\n window.VanduoTransfer = Transfer;\n\n})();\n", "/**\n * Vanduo Framework - Tree View Component\n * Hierarchical collapsible tree with checkbox selection and keyboard navigation\n */\n\n(function () {\n 'use strict';\n\n const Tree = {\n instances: new Map(),\n\n init: function () {\n const trees = document.querySelectorAll('[data-vd-tree]');\n trees.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const cascade = el.getAttribute('data-vd-tree-cascade') !== 'false';\n\n let data;\n try { data = JSON.parse(el.getAttribute('data-vd-tree') || '[]'); } catch (_e) { data = []; }\n\n el.classList.add('vd-tree');\n el.setAttribute('role', 'tree');\n\n const render = (items, parent) => {\n parent.innerHTML = '';\n items.forEach(item => {\n const node = document.createElement('li');\n node.className = 'vd-tree-node';\n node.setAttribute('role', 'treeitem');\n node.setAttribute('aria-expanded', item.open ? 'true' : 'false');\n if (item.open) node.classList.add('is-open');\n\n const content = document.createElement('div');\n content.className = 'vd-tree-node-content';\n\n // Toggle\n if (item.children && item.children.length > 0) {\n const toggle = document.createElement('button');\n toggle.type = 'button';\n toggle.className = 'vd-tree-toggle';\n toggle.setAttribute('aria-label', 'Toggle');\n toggle.addEventListener('click', (e) => {\n e.stopPropagation();\n item.open = !item.open;\n node.classList.toggle('is-open');\n node.setAttribute('aria-expanded', item.open ? 'true' : 'false');\n });\n content.appendChild(toggle);\n } else {\n const ph = document.createElement('span');\n ph.className = 'vd-tree-toggle-placeholder';\n content.appendChild(ph);\n }\n\n // Checkbox\n if (el.hasAttribute('data-vd-tree-checkbox')) {\n const cb = document.createElement('input');\n cb.type = 'checkbox';\n cb.className = 'vd-tree-checkbox';\n cb.checked = !!item.checked;\n cb.setAttribute('aria-label', item.label);\n cb.addEventListener('change', (e) => {\n e.stopPropagation();\n item.checked = cb.checked;\n if (cascade && item.children) {\n setChildChecked(item.children, cb.checked);\n render(data, el);\n }\n el.dispatchEvent(new CustomEvent('tree:check', {\n detail: { id: item.id, checked: cb.checked, label: item.label },\n bubbles: true\n }));\n });\n content.appendChild(cb);\n }\n\n // Icon\n if (item.icon) {\n const icon = document.createElement('span');\n icon.className = 'vd-tree-icon ' + item.icon;\n content.appendChild(icon);\n }\n\n // Label\n const label = document.createElement('span');\n label.className = 'vd-tree-label';\n label.textContent = item.label || '';\n content.appendChild(label);\n\n node.appendChild(content);\n\n // Children\n if (item.children && item.children.length > 0) {\n const childList = document.createElement('ul');\n childList.className = 'vd-tree-children';\n childList.setAttribute('role', 'group');\n render(item.children, childList);\n node.appendChild(childList);\n }\n\n parent.appendChild(node);\n });\n };\n\n const setChildChecked = (items, checked) => {\n items.forEach(item => {\n item.checked = checked;\n if (item.children) setChildChecked(item.children, checked);\n });\n };\n\n // Keyboard\n const keyHandler = (e) => {\n const focused = document.activeElement;\n if (!el.contains(focused)) return;\n\n const nodes = Array.from(el.querySelectorAll('.vd-tree-node-content'));\n const idx = nodes.indexOf(focused.closest('.vd-tree-node-content'));\n if (idx === -1) return;\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n if (idx < nodes.length - 1) {\n const next = nodes[idx + 1].querySelector('.vd-tree-toggle, .vd-tree-label');\n if (next) next.focus();\n }\n break;\n case 'ArrowUp':\n e.preventDefault();\n if (idx > 0) {\n const prev = nodes[idx - 1].querySelector('.vd-tree-toggle, .vd-tree-label');\n if (prev) prev.focus();\n }\n break;\n }\n };\n\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n\n render(data, el);\n\n this.instances.set(el, {\n cleanup,\n getData: () => data,\n getChecked: () => {\n const checked = [];\n const collect = (items) => {\n items.forEach(i => {\n if (i.checked) checked.push(i.id || i.label);\n if (i.children) collect(i.children);\n });\n };\n collect(data);\n return checked;\n }\n });\n },\n\n getChecked: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getChecked() : [];\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n el.innerHTML = '';\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('tree', Tree);\n }\n\n window.VanduoTree = Tree;\n\n})();\n", "/**\n * Vanduo Framework - Spotlight (Feature Discovery) Component\n * Guided tour with overlay highlight and step-through tooltip\n */\n\n(function () {\n 'use strict';\n\n const Spotlight = {\n _active: false,\n _steps: [],\n _currentStep: 0,\n _elements: {},\n _cleanup: [],\n _boundTriggers: new WeakMap(),\n _triggerElement: null,\n\n init: function () {\n const triggers = document.querySelectorAll('[data-vd-spotlight]');\n\n triggers.forEach(trigger => {\n if (this._boundTriggers.has(trigger)) return;\n\n const clickHandler = (event) => {\n event.preventDefault();\n\n const steps = this._parseSteps(trigger.getAttribute('data-vd-spotlight'));\n if (steps.length === 0) return;\n\n this.start(steps, { trigger });\n };\n\n trigger.addEventListener('click', clickHandler);\n this._boundTriggers.set(trigger, clickHandler);\n });\n },\n\n _parseSteps: function (raw) {\n if (typeof raw !== 'string' || raw.trim() === '') return [];\n\n try {\n const parsed = JSON.parse(raw);\n return this._normalizeSteps(parsed);\n } catch (error) {\n console.error('VanduoSpotlight: invalid data-vd-spotlight payload.', error);\n return [];\n }\n },\n\n _normalizeStep: function (step) {\n if (!step || typeof step !== 'object') return null;\n\n const target = step.target;\n const hasSelectorTarget = typeof target === 'string' && target.trim() !== '';\n const hasElementTarget = typeof Element !== 'undefined' && target instanceof Element;\n\n if (!hasSelectorTarget && !hasElementTarget) return null;\n\n const title = typeof step.title === 'string' ? step.title : '';\n const description = typeof step.description === 'string'\n ? step.description\n : (typeof step.content === 'string' ? step.content : '');\n\n return {\n target,\n title,\n description\n };\n },\n\n _normalizeSteps: function (steps) {\n if (!Array.isArray(steps)) return [];\n\n return steps\n .map(step => this._normalizeStep(step))\n .filter(Boolean);\n },\n\n start: function (steps, options) {\n if (this._active) this.stop();\n\n const normalizedSteps = this._normalizeSteps(steps);\n if (normalizedSteps.length === 0) return;\n\n const startOptions = options || {};\n\n this._steps = normalizedSteps;\n this._currentStep = 0;\n this._active = true;\n this._triggerElement = startOptions.trigger || (document.activeElement instanceof HTMLElement ? document.activeElement : null);\n\n // Create overlay\n const overlay = document.createElement('div');\n overlay.className = 'vd-spotlight-overlay';\n overlay.setAttribute('aria-hidden', 'true');\n document.body.appendChild(overlay);\n\n // Create tooltip\n const tooltip = document.createElement('div');\n tooltip.className = 'vd-spotlight-tooltip';\n tooltip.setAttribute('role', 'dialog');\n tooltip.setAttribute('aria-modal', 'true');\n tooltip.tabIndex = -1;\n document.body.appendChild(tooltip);\n\n this._elements = { overlay, tooltip };\n\n // ESC to close\n const escHandler = (e) => { if (e.key === 'Escape') this.stop(); };\n document.addEventListener('keydown', escHandler);\n this._cleanup.push(() => document.removeEventListener('keydown', escHandler));\n\n // Overlay click to close\n overlay.addEventListener('click', () => this.stop());\n\n this._showStep(this._currentStep);\n },\n\n _showStep: function (index) {\n const step = this._steps[index];\n if (!step) return;\n\n const target = typeof step.target === 'string' ? document.querySelector(step.target) : step.target;\n const { tooltip } = this._elements;\n\n // Remove previous highlight\n document.querySelectorAll('.vd-spotlight-target').forEach(el => {\n el.classList.remove('vd-spotlight-target');\n });\n\n // Highlight target\n if (target) {\n target.classList.add('vd-spotlight-target');\n target.scrollIntoView({ behavior: 'smooth', block: 'center' });\n }\n\n // Build tooltip content\n const total = this._steps.length;\n tooltip.innerHTML = '';\n tooltip.removeAttribute('aria-labelledby');\n tooltip.removeAttribute('aria-describedby');\n\n if (step.title) {\n const title = document.createElement('h4');\n title.className = 'vd-spotlight-title';\n title.id = 'vd-spotlight-title-' + index + '-' + Date.now();\n title.textContent = step.title;\n tooltip.appendChild(title);\n tooltip.setAttribute('aria-labelledby', title.id);\n }\n\n if (step.description) {\n const desc = document.createElement('p');\n desc.className = 'vd-spotlight-description';\n desc.id = 'vd-spotlight-description-' + index + '-' + Date.now();\n desc.textContent = step.description;\n tooltip.appendChild(desc);\n tooltip.setAttribute('aria-describedby', desc.id);\n }\n\n // Footer\n const footer = document.createElement('div');\n footer.className = 'vd-spotlight-footer';\n footer.setAttribute('aria-label', 'Step ' + (index + 1) + ' of ' + total);\n\n const counter = document.createElement('span');\n counter.className = 'vd-spotlight-counter';\n counter.textContent = (index + 1) + ' / ' + total;\n\n const actions = document.createElement('div');\n actions.className = 'vd-spotlight-actions';\n\n if (index > 0) {\n const prevBtn = document.createElement('button');\n prevBtn.type = 'button';\n prevBtn.className = 'vd-spotlight-btn';\n prevBtn.textContent = 'Back';\n prevBtn.addEventListener('click', () => this.prev());\n actions.appendChild(prevBtn);\n }\n\n const skipBtn = document.createElement('button');\n skipBtn.type = 'button';\n skipBtn.className = 'vd-spotlight-btn';\n skipBtn.textContent = 'Skip';\n skipBtn.addEventListener('click', () => this.stop());\n actions.appendChild(skipBtn);\n\n if (index < total - 1) {\n const nextBtn = document.createElement('button');\n nextBtn.type = 'button';\n nextBtn.className = 'vd-spotlight-btn vd-spotlight-btn-primary';\n nextBtn.textContent = 'Next';\n nextBtn.addEventListener('click', () => this.next());\n actions.appendChild(nextBtn);\n } else {\n const doneBtn = document.createElement('button');\n doneBtn.type = 'button';\n doneBtn.className = 'vd-spotlight-btn vd-spotlight-btn-primary';\n doneBtn.textContent = 'Done';\n doneBtn.addEventListener('click', () => this.stop());\n actions.appendChild(doneBtn);\n }\n\n footer.appendChild(counter);\n footer.appendChild(actions);\n tooltip.appendChild(footer);\n\n // Position tooltip near target\n if (target) {\n requestAnimationFrame(() => {\n const rect = target.getBoundingClientRect();\n const tRect = tooltip.getBoundingClientRect();\n let top = rect.bottom + 12 + window.scrollY;\n let left = rect.left + (rect.width - tRect.width) / 2 + window.scrollX;\n\n // Keep in viewport\n left = Math.max(8, Math.min(left, window.innerWidth - tRect.width - 8));\n if (top + tRect.height > window.innerHeight + window.scrollY) {\n top = rect.top - tRect.height - 12 + window.scrollY;\n }\n\n tooltip.style.top = top + 'px';\n tooltip.style.left = left + 'px';\n });\n }\n\n document.dispatchEvent(new CustomEvent('spotlight:step', {\n detail: { index, step: index, total, data: step }\n }));\n },\n\n next: function () {\n if (this._currentStep < this._steps.length - 1) {\n this._currentStep++;\n this._showStep(this._currentStep);\n }\n },\n\n prev: function () {\n if (this._currentStep > 0) {\n this._currentStep--;\n this._showStep(this._currentStep);\n }\n },\n\n stop: function () {\n if (!this._active) return;\n\n const total = this._steps.length;\n const detail = {\n completedSteps: total === 0 ? 0 : Math.min(this._currentStep + 1, total),\n total,\n completed: total > 0 && this._currentStep >= total - 1\n };\n\n this._active = false;\n\n document.querySelectorAll('.vd-spotlight-target').forEach(el => {\n el.classList.remove('vd-spotlight-target');\n });\n\n if (this._elements.overlay && this._elements.overlay.parentNode) {\n this._elements.overlay.parentNode.removeChild(this._elements.overlay);\n }\n if (this._elements.tooltip && this._elements.tooltip.parentNode) {\n this._elements.tooltip.parentNode.removeChild(this._elements.tooltip);\n }\n\n this._cleanup.forEach(fn => fn());\n this._cleanup = [];\n this._elements = {};\n this._steps = [];\n this._currentStep = 0;\n\n if (this._triggerElement && this._triggerElement.isConnected && typeof this._triggerElement.focus === 'function') {\n this._triggerElement.focus();\n }\n this._triggerElement = null;\n\n document.dispatchEvent(new CustomEvent('spotlight:end', { detail }));\n },\n\n destroyAll: function () {\n this.stop();\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('spotlight', Spotlight);\n }\n\n window.VanduoSpotlight = Spotlight;\n\n})();\n", "/**\n * Vanduo Framework - Music Player Component\n * HTML5 Audio-based music player with transport controls, volume,\n * and optional shuffle, seek bar, and playlist features.\n *\n * Options (passed to MusicPlayer.init or data-music-player-options):\n * tracks {Array} - [{name, url}] \u2014 required\n * volume {number} - Initial volume 0\u20131 (default 0.5)\n * shuffle {boolean} - Shuffle on init (default false)\n * showProgress {boolean} - Show seek/progress bar (default false)\n * showPlaylist {boolean} - Show expandable playlist panel (default false)\n * autoAdvance {boolean} - Auto-play next track on end (default true)\n * glass {boolean} - Frosted-glass surface styling (default false)\n * detachable {boolean} - Show detach/attach; float above page when detached (default false)\n * floatingPosition {string|null} - After detach: 'bottom-left' or 'bottom-right' (default null = bottom-right)\n * draggable {boolean} - When detached+detachable, drag by handle (default false)\n * minimizable {boolean} - Minimize/expand control (default false)\n * startMinimized {boolean} - On first detach, start minimized (default false)\n * persistPosition {boolean} - Save floating x/y in localStorage (default false)\n * persistKey {string} - Storage key for persist (default from element id)\n *\n * Custom events (all bubble, dispatched on container):\n * musicplayer:play \u2014 playback started\n * musicplayer:pause \u2014 playback paused\n * musicplayer:trackchange \u2014 detail: { index, name, url }\n * musicplayer:volumechange \u2014 detail: { volume }\n * musicplayer:ended \u2014 last track ended (autoAdvance=false only)\n * musicplayer:detach \u2014 after floating player is created\n * musicplayer:attach \u2014 after returned to document flow\n * musicplayer:minimize \u2014 collapsed to minimal controls\n * musicplayer:expand \u2014 full controls visible again\n *\n * Programmatic API:\n * MusicPlayer.init(container?, options?)\n * MusicPlayer.play(container)\n * MusicPlayer.pause(container)\n * MusicPlayer.toggle(container)\n * MusicPlayer.next(container)\n * MusicPlayer.previous(container)\n * MusicPlayer.setVolume(container, value)\n * MusicPlayer.setTrack(container, index)\n * MusicPlayer.shuffle(container)\n * MusicPlayer.detach(container, position?)\n * MusicPlayer.attach(container)\n * MusicPlayer.minimize(container)\n * MusicPlayer.expand(container)\n * MusicPlayer.toggleMinimize(container)\n * MusicPlayer.setPosition(container, 'bottom-left'|'bottom-right'|{x,y})\n * MusicPlayer.getState(container)\n * MusicPlayer.destroy(container)\n * MusicPlayer.destroyAll()\n */\n\n(function () {\n 'use strict';\n\n /* \u2500\u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Fisher-Yates shuffle (returns new array).\n * @param {Array} arr\n * @returns {Array}\n */\n function shuffleArray(arr) {\n const shuffled = arr.slice();\n for (let i = shuffled.length - 1; i > 0; i--) {\n const j = Math.floor(Math.random() * (i + 1));\n const tmp = shuffled[i];\n shuffled[i] = shuffled[j];\n shuffled[j] = tmp;\n }\n return shuffled;\n }\n\n /**\n * Format seconds as m:ss.\n * @param {number} seconds\n * @returns {string}\n */\n function formatTime(seconds) {\n if (!isFinite(seconds) || seconds < 0) return '0:00';\n const m = Math.floor(seconds / 60);\n const s = Math.floor(seconds % 60);\n return m + ':' + (s < 10 ? '0' : '') + s;\n }\n\n /**\n * Set CSS background-size on a range input to visually fill the track.\n * @param {HTMLInputElement} input\n */\n /**\n * @param {string|undefined} id\n * @returns {string}\n */\n function persistStorageKey(id) {\n return 'vanduo:music-player:' + (id && id.trim() ? id.trim() : 'default') + ':pos';\n }\n\n function updateRangeFill(input) {\n const min = parseFloat(input.min) || 0;\n const max = parseFloat(input.max) || 1;\n const val = parseFloat(input.value) || 0;\n const pct = ((val - min) / (max - min)) * 100;\n input.style.setProperty('--fill', pct + '%');\n // Fallback inline gradient for browsers without ::-moz-range-progress\n input.style.backgroundImage =\n 'linear-gradient(to right, var(--music-player-track-fill, currentColor) 0%, ' +\n 'var(--music-player-track-fill, currentColor) ' + pct + '%, ' +\n 'var(--music-player-track-bg, #ccc) ' + pct + '%, ' +\n 'var(--music-player-track-bg, #ccc) 100%)';\n }\n\n /* \u2500\u2500\u2500 Phosphor icon helper (matches framework icon style) \u2500 */\n\n /**\n * Return an element (Phosphor icon).\n * @param {string} name\n * @returns {HTMLElement}\n */\n function icon(name) {\n const el = document.createElement('i');\n el.className = 'ph ph-' + name;\n el.setAttribute('aria-hidden', 'true');\n return el;\n }\n\n /* \u2500\u2500\u2500 Component \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const MusicPlayer = {\n /** @type {Map} */\n instances: new Map(),\n\n /**\n * Default options.\n */\n defaults: {\n tracks: [],\n volume: 0.5,\n shuffle: false,\n showProgress: false,\n showPlaylist: false,\n autoAdvance: true,\n glass: false,\n detachable: false,\n /** @type {null|string} */\n floatingPosition: null,\n draggable: false,\n minimizable: false,\n startMinimized: false,\n persistPosition: false,\n persistKey: '',\n },\n\n /**\n * Auto-initialize all .vd-music-player / [data-music-player] elements.\n * Options can be provided via data-music-player-options (JSON string).\n */\n init: function () {\n document.querySelectorAll('.vd-music-player, [data-music-player]').forEach((el) => {\n if (this.instances.has(el)) return;\n\n let opts = {};\n const attr = el.getAttribute('data-music-player-options');\n if (attr) {\n try { opts = JSON.parse(attr); } catch (_) { /* ignore malformed JSON */ }\n }\n this.initPlayer(el, opts);\n });\n },\n\n /**\n * Initialize a single player element.\n * @param {HTMLElement} container\n * @param {Object} [options]\n */\n initPlayer: function (container, options) {\n const opts = Object.assign({}, this.defaults, options || {});\n\n // Validate and normalise tracks\n const rawTracks = Array.isArray(opts.tracks) ? opts.tracks : [];\n const tracks = rawTracks.filter((t) => t && typeof t.url === 'string' && t.url.trim());\n\n // Build shuffled working copy without mutating opts\n const trackList = opts.shuffle ? shuffleArray(tracks) : tracks.slice();\n\n /* \u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const state = {\n tracks: trackList,\n originalTracks: tracks.slice(),\n currentIndex: 0,\n isPlaying: false,\n volume: Math.max(0, Math.min(1, opts.volume)),\n shuffle: opts.shuffle,\n showProgress: opts.showProgress,\n showPlaylist: opts.showPlaylist,\n autoAdvance: opts.autoAdvance,\n audio: null,\n glass: Boolean(opts.glass),\n detachable: Boolean(opts.detachable),\n floatingPosition: opts.floatingPosition || 'bottom-right',\n draggable: Boolean(opts.draggable) && Boolean(opts.detachable),\n minimizable: Boolean(opts.minimizable),\n startMinimized: Boolean(opts.startMinimized),\n persistPosition: Boolean(opts.persistPosition),\n persistKey: typeof opts.persistKey === 'string' ? opts.persistKey : '',\n isDetached: false,\n isMinimized: false,\n _startMinimizeApplied: false,\n };\n\n /* \u2500\u2500 Audio element \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const audio = new Audio();\n audio.volume = state.volume;\n audio.preload = 'metadata';\n state.audio = audio;\n\n /* \u2500\u2500 Build DOM \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n this._buildDOM(container, state);\n\n // Grab references after DOM build\n const refs = {\n btnPlay: container.querySelector('.vd-music-player-btn-play'),\n btnPrev: container.querySelector('.vd-music-player-btn-prev'),\n btnNext: container.querySelector('.vd-music-player-btn-next'),\n btnShuffle: container.querySelector('.vd-music-player-btn-shuffle'),\n btnPlaylist: container.querySelector('.vd-music-player-btn-playlist'),\n btnDetach: container.querySelector('.vd-music-player-btn-detach'),\n btnAttach: container.querySelector('.vd-music-player-btn-attach'),\n btnMinimize: container.querySelector('.vd-music-player-btn-minimize'),\n dragHandle: container.querySelector('.vd-music-player-drag-handle'),\n trackName: container.querySelector('.vd-music-player-track-name'),\n volumeSlider: container.querySelector('.vd-music-player-volume-slider'),\n volumeIcon: container.querySelector('.vd-music-player-volume-icon'),\n progressBar: container.querySelector('.vd-music-player-progress-bar'),\n timeElapsed: container.querySelector('.vd-music-player-time-elapsed'),\n timeDuration: container.querySelector('.vd-music-player-time-duration'),\n playlistPanel: container.querySelector('.vd-music-player-playlist'),\n };\n\n /* \u2500\u2500 Internal render helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const renderPlayIcon = () => {\n const btn = refs.btnPlay;\n if (!btn) return;\n btn.innerHTML = '';\n btn.appendChild(icon(state.isPlaying ? 'pause' : 'play'));\n btn.setAttribute('aria-label', state.isPlaying ? 'Pause' : 'Play');\n btn.classList.toggle('is-active', state.isPlaying);\n };\n\n const renderTrackName = () => {\n const el = refs.trackName;\n if (!el) return;\n const track = state.tracks[state.currentIndex];\n if (track) {\n el.textContent = track.name || 'Unknown Track';\n el.classList.remove('is-idle');\n } else {\n el.textContent = 'No tracks loaded';\n el.classList.add('is-idle');\n }\n };\n\n const renderVolumeIcon = () => {\n const el = refs.volumeIcon;\n if (!el) return;\n el.innerHTML = '';\n const v = state.volume;\n const name = v === 0 ? 'speaker-none' : v < 0.5 ? 'speaker-low' : 'speaker-high';\n el.appendChild(icon(name));\n };\n\n const renderShuffleBtn = () => {\n const btn = refs.btnShuffle;\n if (!btn) return;\n btn.classList.toggle('is-active', state.shuffle);\n btn.setAttribute('aria-pressed', state.shuffle ? 'true' : 'false');\n };\n\n const renderPlaylistItems = () => {\n const panel = refs.playlistPanel;\n if (!panel) return;\n panel.innerHTML = '';\n state.tracks.forEach((track, i) => {\n const item = document.createElement('button');\n item.className =\n 'vd-music-player-playlist-item' + (i === state.currentIndex ? ' is-active' : '');\n item.type = 'button';\n item.setAttribute('data-index', String(i));\n item.setAttribute('aria-current', i === state.currentIndex ? 'true' : 'false');\n\n const num = document.createElement('span');\n num.className = 'vd-music-player-playlist-num';\n num.textContent = String(i + 1);\n\n const name = document.createElement('span');\n name.className = 'vd-music-player-playlist-name';\n name.textContent = track.name || 'Track ' + (i + 1);\n\n item.appendChild(num);\n item.appendChild(name);\n panel.appendChild(item);\n });\n };\n\n const renderProgress = () => {\n const bar = refs.progressBar;\n if (!bar || !audio.duration) return;\n const pct = (audio.currentTime / audio.duration) * 100;\n bar.value = String(pct);\n updateRangeFill(bar);\n if (refs.timeElapsed) refs.timeElapsed.textContent = formatTime(audio.currentTime);\n if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);\n };\n\n /* \u2500\u2500 Load track \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const loadTrack = (index, autoPlay) => {\n const track = state.tracks[index];\n if (!track) return;\n state.currentIndex = index;\n audio.src = track.url;\n renderTrackName();\n renderPlaylistItems();\n\n // Reset progress\n if (refs.progressBar) {\n refs.progressBar.value = '0';\n updateRangeFill(refs.progressBar);\n }\n if (refs.timeElapsed) refs.timeElapsed.textContent = '0:00';\n if (refs.timeDuration) refs.timeDuration.textContent = '0:00';\n\n container.dispatchEvent(\n new CustomEvent('musicplayer:trackchange', {\n bubbles: true,\n detail: { index, name: track.name, url: track.url },\n })\n );\n\n if (autoPlay) {\n audio.play().catch(() => { /* browser may block autoplay */ });\n }\n };\n\n /* \u2500\u2500 Audio event listeners \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const cleanupFunctions = [];\n\n const onPlay = () => {\n state.isPlaying = true;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:play', { bubbles: true }));\n };\n\n const onPause = () => {\n state.isPlaying = false;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:pause', { bubbles: true }));\n };\n\n const onEnded = () => {\n if (state.autoAdvance && state.tracks.length > 1) {\n const next = (state.currentIndex + 1) % state.tracks.length;\n loadTrack(next, true);\n } else {\n state.isPlaying = false;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:ended', { bubbles: true }));\n }\n };\n\n const onTimeUpdate = () => {\n if (state.showProgress) renderProgress();\n };\n\n const onLoadedMetadata = () => {\n if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);\n if (refs.progressBar) {\n refs.progressBar.max = '100';\n updateRangeFill(refs.progressBar);\n }\n };\n\n audio.addEventListener('play', onPlay);\n audio.addEventListener('pause', onPause);\n audio.addEventListener('ended', onEnded);\n audio.addEventListener('timeupdate', onTimeUpdate);\n audio.addEventListener('loadedmetadata', onLoadedMetadata);\n cleanupFunctions.push(() => {\n audio.removeEventListener('play', onPlay);\n audio.removeEventListener('pause', onPause);\n audio.removeEventListener('ended', onEnded);\n audio.removeEventListener('timeupdate', onTimeUpdate);\n audio.removeEventListener('loadedmetadata', onLoadedMetadata);\n audio.pause();\n audio.src = '';\n });\n\n /* \u2500\u2500 Control button listeners \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n if (refs.btnPlay) {\n const handler = () => {\n if (!audio.src && state.tracks.length) loadTrack(state.currentIndex, false);\n if (state.isPlaying) {\n audio.pause();\n } else {\n audio.play().catch(() => {});\n }\n };\n refs.btnPlay.addEventListener('click', handler);\n cleanupFunctions.push(() => refs.btnPlay.removeEventListener('click', handler));\n\n // Keyboard: Space / Enter (already native for `;let s="";for(let[a,r]of Object.entries(this.NEUTRAL_COLORS))s+=``;let o="";this.RADIUS_OPTIONS.forEach(a=>{o+=``});let i="";for(let[a,r]of Object.entries(this.FONT_OPTIONS))i+=``;return` +`).length,o=document.createElement("div");o.className="vd-code-snippet-line-numbers",o.setAttribute("aria-hidden","true");for(let a=1;a<=s;a++){let r=document.createElement("span");r.textContent=a,o.appendChild(r)}let i=document.createElement("div");i.className="vd-code-snippet-code",i.appendChild(t.cloneNode(!0)),t.parentNode.removeChild(t),e.appendChild(o),e.appendChild(i)},expand:function(e){if(typeof e=="string"&&(e=document.querySelector(e)),!e)return;e.dataset.expanded="true";let t=e.querySelector(".vd-code-snippet-toggle"),n=e.querySelector(".vd-code-snippet-content");t&&t.setAttribute("aria-expanded","true"),n&&(n.dataset.visible="true")},collapse:function(e){if(typeof e=="string"&&(e=document.querySelector(e)),!e)return;e.dataset.expanded="false";let t=e.querySelector(".vd-code-snippet-toggle"),n=e.querySelector(".vd-code-snippet-content");t&&t.setAttribute("aria-expanded","false"),n&&(n.dataset.visible="false")},showLang:function(e,t){if(typeof e=="string"&&(e=document.querySelector(e)),!e)return;let n=e.querySelector(`.vd-code-snippet-tab[data-lang="${t}"]`),s=e.querySelectorAll(".vd-code-snippet-tab"),o=e.querySelectorAll(".vd-code-snippet-pane");n&&this.switchTab(e,n,s,o)},destroy:function(e){typeof e=="string"&&(e=document.querySelector(e)),e&&(e._codeSnippetCleanup&&(e._codeSnippetCleanup.forEach(t=>t()),delete e._codeSnippetCleanup),delete e.dataset.initialized)},destroyAll:function(){document.querySelectorAll('.vd-code-snippet[data-initialized="true"]').forEach(t=>this.destroy(t))}};typeof window.Vanduo<"u"&&window.Vanduo.register("codeSnippet",m),window.CodeSnippet=m})();(function(){"use strict";let m={instances:new Map,init:function(){document.querySelectorAll(".vd-collapsible, .accordion").forEach(t=>{this.instances.has(t)||this.initCollapsible(t)})},initCollapsible:function(e){let t=e.classList.contains("accordion"),n=e.querySelectorAll(".vd-collapsible-item, .accordion-item"),s=[];n.forEach(o=>{let i=o.querySelector(".vd-collapsible-header, .accordion-header"),a=o.querySelector(".vd-collapsible-body, .accordion-body"),r=o.querySelector(".vd-collapsible-trigger, .accordion-trigger")||i;if(!i||!a)return;o.classList.contains("is-open")?this.openItem(o,a,!1):this.closeItem(o,a,!1);let c=l=>{l.preventDefault(),this.toggleItem(o,a,e,t)};r.addEventListener("click",c),s.push(()=>r.removeEventListener("click",c))}),this.instances.set(e,{cleanup:s})},toggleItem:function(e,t,n,s){e.classList.contains("is-open")?this.closeItem(e,t):(s&&n.querySelectorAll(".vd-collapsible-item.is-open, .accordion-item.is-open").forEach(a=>{if(a!==e){let r=a.querySelector(".vd-collapsible-body, .accordion-body");this.closeItem(a,r)}}),this.openItem(e,t))},openItem:function(e,t,n=!0){n||(t.style.transition="none"),e.classList.add("is-open"),e.setAttribute("aria-expanded","true");let s=t.scrollHeight;t.style.maxHeight=`${s}px`,n||setTimeout(()=>{t.style.transition=""},0),e.dispatchEvent(new CustomEvent("collapsible:open",{bubbles:!0}))},closeItem:function(e,t,n=!0){n||(t.style.transition="none"),e.classList.remove("is-open"),e.setAttribute("aria-expanded","false"),t.style.maxHeight="0",n||setTimeout(()=>{t.style.transition=""},0),e.dispatchEvent(new CustomEvent("collapsible:close",{bubbles:!0}))},open:function(e){let t=typeof e=="string"?document.querySelector(e):e;if(t){let n=t.querySelector(".vd-collapsible-body, .accordion-body");n&&this.openItem(t,n)}},close:function(e){let t=typeof e=="string"?document.querySelector(e):e;if(t){let n=t.querySelector(".vd-collapsible-body, .accordion-body");n&&this.closeItem(t,n)}},toggle:function(e){let t=typeof e=="string"?document.querySelector(e):e;if(t){let n=t.querySelector(".vd-collapsible-body, .accordion-body"),s=t.closest(".vd-collapsible, .accordion"),o=s&&s.classList.contains("accordion");n&&this.toggleItem(t,n,s,o)}},destroy:function(e){let t=this.instances.get(e);t&&(t.cleanup.forEach(n=>n()),this.instances.delete(e))},destroyAll:function(){this.instances.forEach((e,t)=>{this.destroy(t)})}};typeof window.Vanduo<"u"&&window.Vanduo.register("collapsible",m),window.VanduoCollapsible=m})();(function(){"use strict";let m={instances:new Map,init:function(){document.querySelectorAll(".vd-dropdown").forEach(t=>{this.instances.has(t)||this.initDropdown(t)})},initDropdown:function(e){let t=e.querySelector(".vd-dropdown-toggle"),n=e.querySelector(".vd-dropdown-menu");if(!t||!n)return;let s=[];t.setAttribute("aria-haspopup","true"),t.setAttribute("aria-expanded","false"),n.setAttribute("role","menu"),n.setAttribute("aria-hidden","true");let o=c=>{c.preventDefault(),c.stopPropagation(),this.toggleDropdown(e,t,n)};t.addEventListener("click",o),s.push(()=>t.removeEventListener("click",o));let i=c=>{!e.contains(c.target)&&n.classList.contains("is-open")&&this.closeDropdown(e,t,n)};document.addEventListener("click",i),s.push(()=>document.removeEventListener("click",i));let a=c=>{this.handleKeydown(c,e,t,n)};t.addEventListener("keydown",a),s.push(()=>t.removeEventListener("keydown",a)),n.querySelectorAll(".vd-dropdown-item:not(.disabled):not(.is-disabled)").forEach(c=>{let l=h=>{h.preventDefault(),this.selectItem(c,e,t,n)};c.addEventListener("click",l),s.push(()=>c.removeEventListener("click",l));let f=h=>{(h.key==="Enter"||h.key===" ")&&(h.preventDefault(),this.selectItem(c,e,t,n))};c.addEventListener("keydown",f),s.push(()=>c.removeEventListener("keydown",f))}),this.instances.set(e,{toggle:t,menu:n,cleanup:s,typeaheadBuffer:"",typeaheadTimer:null})},toggleDropdown:function(e,t,n){n.classList.contains("is-open")?this.closeDropdown(e,t,n):this.openDropdown(e,t,n)},openDropdown:function(e,t,n){document.querySelectorAll(".vd-dropdown-menu.is-open").forEach(i=>{if(i!==n){let a=i.closest(".vd-dropdown"),r=a.querySelector(".vd-dropdown-toggle");this.closeDropdown(a,r,i)}}),e.classList.add("is-open"),n.classList.add("is-open"),t.setAttribute("aria-expanded","true"),n.setAttribute("aria-hidden","false"),this.positionMenu(e,n);let o=n.querySelector(".vd-dropdown-item:not(.disabled):not(.is-disabled)");o&&setTimeout(()=>o.focus(),0)},closeDropdown:function(e,t,n){e.classList.remove("is-open"),n.classList.remove("is-open"),t.setAttribute("aria-expanded","false"),n.setAttribute("aria-hidden","true"),t.focus()},positionMenu:function(e,t){let n=e.getBoundingClientRect(),s=t.getBoundingClientRect(),o=window.innerWidth,i=window.innerHeight,a=8;if(t.classList.remove("vd-dropdown-menu-end","vd-dropdown-menu-start","vd-dropdown-menu-top"),e.classList.contains("vd-dropdown-dropup")){t.classList.add("vd-dropdown-menu-top");return}e.classList.contains("vd-dropdown-dropright")||e.classList.contains("vd-dropdown-dropleft")||(n.left+s.width>o-a?t.classList.add("vd-dropdown-menu-end"):t.classList.add("vd-dropdown-menu-start"),n.bottom+s.height>i-a&&n.top-s.height>a&&t.classList.add("vd-dropdown-menu-top"))},handleKeydown:function(e,t,n,s){let o=s.classList.contains("is-open"),i=Array.from(s.querySelectorAll(".vd-dropdown-item:not(.disabled):not(.is-disabled)")),a=i.findIndex(r=>r===document.activeElement);switch(e.key){case"Enter":case" ":case"ArrowDown":if(e.preventDefault(),!o)this.openDropdown(t,n,s);else if(e.key==="ArrowDown"){let r=a0?a-1:i.length-1;i[r].focus()}break;case"Escape":o&&(e.preventDefault(),this.closeDropdown(t,n,s));break;case"Home":o&&(e.preventDefault(),i[0].focus());break;case"End":o&&(e.preventDefault(),i[i.length-1].focus());break;default:if(o&&e.key.length===1&&!e.ctrlKey&&!e.metaKey&&!e.altKey){let r=this.instances.get(t);if(!r)break;clearTimeout(r.typeaheadTimer),r.typeaheadBuffer+=e.key.toLowerCase();let c=i.find(l=>l.textContent.trim().toLowerCase().startsWith(r.typeaheadBuffer));c&&c.focus(),r.typeaheadTimer=setTimeout(()=>{r.typeaheadBuffer=""},500)}break}},selectItem:function(e,t,n,s){s.querySelectorAll(".vd-dropdown-item").forEach(o=>{o.classList.remove("active","is-active")}),e.classList.add("active","is-active"),(n.tagName==="BUTTON"||n.classList.contains("btn"))&&(n.textContent=e.textContent.trim()),this.closeDropdown(t,n,s),e.dispatchEvent(new CustomEvent("dropdown:select",{bubbles:!0,detail:{item:e,value:e.dataset.value||e.textContent}}))},open:function(e){let t=typeof e=="string"?document.querySelector(e):e;if(t){let n=t.querySelector(".vd-dropdown-toggle"),s=t.querySelector(".vd-dropdown-menu");n&&s&&this.openDropdown(t,n,s)}},close:function(e){let t=typeof e=="string"?document.querySelector(e):e;if(t){let n=t.querySelector(".vd-dropdown-toggle"),s=t.querySelector(".vd-dropdown-menu");n&&s&&this.closeDropdown(t,n,s)}},destroy:function(e){let t=this.instances.get(e);t&&(t.cleanup.forEach(n=>n()),this.instances.delete(e))},destroyAll:function(){this.instances.forEach((e,t)=>{this.destroy(t)})}};typeof window.Vanduo<"u"&&window.Vanduo.register("dropdown",m),window.VanduoDropdown=m})();(function(){"use strict";let m={STORAGE_KEY:"vanduo-font-preference",isInitialized:!1,fonts:{system:{name:"System Default",family:null},"jetbrains-mono":{name:"JetBrains Mono",family:"'JetBrains Mono', monospace"},ubuntu:{name:"Ubuntu",family:"'Ubuntu', sans-serif",category:"sans-serif",description:"Friendly, humanist sans-serif"},"open-sans":{name:"Open Sans",family:"'Open Sans', sans-serif",category:"sans-serif",description:"Neutral, highly readable"},lato:{name:"Lato",family:"'Lato', sans-serif",category:"sans-serif",description:"Friendly, rounded sans-serif"}},init:function(){if(this.state={preference:this.getPreference()},this.fonts[this.state.preference]||(this.state.preference="ubuntu",this.setStorageValue(this.STORAGE_KEY,this.state.preference)),this.isInitialized){this.applyFont(),this.renderUI(),this.updateUI();return}this.isInitialized=!0,this.applyFont(),this.renderUI(),console.log("Vanduo Font Switcher initialized")},getPreference:function(){return this.getStorageValue(this.STORAGE_KEY,"ubuntu")},setPreference:function(e){if(!this.fonts[e]){console.warn("Unknown font:",e);return}this.state.preference=e,this.setStorageValue(this.STORAGE_KEY,e),this.applyFont(),this.updateUI();let t=new CustomEvent("font:change",{bubbles:!0,detail:{font:e,fontData:this.fonts[e]}});document.dispatchEvent(t)},applyFont:function(){let e=this.state.preference;e==="system"?document.documentElement.removeAttribute("data-font"):document.documentElement.setAttribute("data-font",e)},renderUI:function(){document.querySelectorAll('[data-toggle="font"]').forEach(t=>{if(t.getAttribute("data-font-initialized")==="true"){t.tagName==="SELECT"&&(t.value=this.state.preference);return}if(t.tagName==="SELECT"){t.value=this.state.preference;let n=s=>{this.setPreference(s.target.value)};t.addEventListener("change",n),t._fontToggleHandler=n}else{let n=()=>{let s=Object.keys(this.fonts),i=(s.indexOf(this.state.preference)+1)%s.length;this.setPreference(s[i])};t.addEventListener("click",n),t._fontToggleHandler=n}t.setAttribute("data-font-initialized","true")})},updateUI:function(){document.querySelectorAll('[data-toggle="font"]').forEach(t=>{if(t.tagName==="SELECT")t.value=this.state.preference;else{let n=t.querySelector(".font-current-label");n&&(n.textContent=this.fonts[this.state.preference].name)}})},getCurrentFont:function(){return this.state.preference},getFontData:function(e){return this.fonts[e]||null},destroyAll:function(){document.querySelectorAll('[data-toggle="font"][data-font-initialized="true"]').forEach(t=>{if(t._fontToggleHandler){let n=t.tagName==="SELECT"?"change":"click";t.removeEventListener(n,t._fontToggleHandler),delete t._fontToggleHandler}t.removeAttribute("data-font-initialized")}),this.isInitialized=!1},getStorageValue:function(e,t){if(typeof window.safeStorageGet=="function")return window.safeStorageGet(e,t);try{let n=localStorage.getItem(e);return n!==null?n:t}catch{return t}},setStorageValue:function(e,t){if(typeof window.safeStorageSet=="function")return window.safeStorageSet(e,t);try{return localStorage.setItem(e,t),!0}catch{return!1}}};window.Vanduo&&window.Vanduo.register("fontSwitcher",m),window.FontSwitcher=m})();(function(){"use strict";let m=(function(){try{return CSS.supports("selector(:has(*))")}catch{return!1}})(),e={instances:new Map,init:function(){document.querySelectorAll("[data-layout-mode]").forEach(function(n){this.instances.has(n)||this.initContainer(n)}.bind(this)),this.initToggleButtons()},initContainer:function(t){let n=t.getAttribute("data-layout-mode")||"standard",s=[];this.applyMode(t,n),t.setAttribute("role","region"),t.setAttribute("aria-label","Grid layout: "+n+" mode"),this.instances.set(t,{cleanup:s,mode:n})},initToggleButtons:function(){document.querySelectorAll("[data-grid-toggle]").forEach(function(n){if(n.getAttribute("data-grid-initialized")==="true")return;let s=function(o){o.preventDefault();let i=n.getAttribute("data-grid-toggle"),a;i?a=document.querySelector(i):a=n.closest("[data-layout-mode]"),a&&this.toggle(a)}.bind(this);n.addEventListener("click",s),n.setAttribute("data-grid-initialized","true"),n.setAttribute("aria-pressed","false"),n._gridCleanup=function(){n.removeEventListener("click",s),n.removeAttribute("data-grid-initialized"),n.removeAttribute("aria-pressed")}}.bind(this))},applyFibFallback:function(t){if(m)return;t.querySelectorAll(".vd-row, .row").forEach(function(s){let i=s.querySelectorAll(':scope > [class*="vd-col-"], :scope > [class*="col-"]').length;i===1?s.style.gridTemplateColumns="1fr":i===2?s.style.gridTemplateColumns="1fr 1.618fr":i===3?s.style.gridTemplateColumns="2fr 3fr 5fr":i===4?s.style.gridTemplateColumns="1fr 2fr 3fr 5fr":s.style.gridTemplateColumns="repeat("+i+", 1fr)"})},removeFibFallback:function(t){t.querySelectorAll(".vd-row, .row").forEach(function(s){s.style.gridTemplateColumns=""})},applyMode:function(t,n){t.classList.remove("vd-grid-standard","vd-grid-fibonacci"),n==="fibonacci"?(t.classList.add("vd-grid-fibonacci"),this.applyFibFallback(t)):(t.classList.add("vd-grid-standard"),this.removeFibFallback(t)),t.setAttribute("data-layout-mode",n),t.setAttribute("aria-label","Grid layout: "+n+" mode"),document.querySelectorAll("[data-grid-toggle]").forEach(function(a){let r=a.getAttribute("data-grid-toggle");if(r&&t.matches(r)){let c=n==="fibonacci";c?a.classList.add("is-active"):a.classList.remove("is-active"),a.setAttribute("aria-pressed",c?"true":"false")}});let o=this.instances.get(t);o&&(o.mode=n);let i;try{i=new CustomEvent("grid:modechange",{bubbles:!0,detail:{container:t,mode:n}})}catch{i=document.createEvent("CustomEvent"),i.initCustomEvent("grid:modechange",!0,!0,{container:t,mode:n})}t.dispatchEvent(i)},toggle:function(t){if(typeof t=="string"&&(t=document.querySelector(t)),!t)return;let s=(t.getAttribute("data-layout-mode")||"standard")==="fibonacci"?"standard":"fibonacci";this.applyMode(t,s)},setMode:function(t,n){typeof t=="string"&&(t=document.querySelector(t)),t&&(n!=="fibonacci"&&n!=="standard"||this.applyMode(t,n))},getMode:function(t){return typeof t=="string"&&(t=document.querySelector(t)),t?t.getAttribute("data-layout-mode")||"standard":null},destroy:function(t){let n=this.instances.get(t);n&&(n.cleanup.forEach(function(s){s()}),t.classList.remove("vd-grid-standard","vd-grid-fibonacci"),t.removeAttribute("aria-label"),this.removeFibFallback(t),this.instances.delete(t))},destroyAll:function(){this.instances.forEach(function(n,s){this.destroy(s)}.bind(this)),document.querySelectorAll('[data-grid-initialized="true"]').forEach(function(n){n._gridCleanup&&(n._gridCleanup(),delete n._gridCleanup)})}};typeof window.Vanduo<"u"&&window.Vanduo.register("gridLayout",e),window.VanduoGridLayout=e})();(function(){"use strict";let m={backdrop:null,container:null,img:null,closeBtn:null,caption:null,currentTrigger:null,scrollThreshold:50,initialScrollY:0,isOpen:!1,_cleanupFunctions:[],init:function(){this.createBackdrop(),this.bindTriggers()},createBackdrop:function(){if(this.backdrop||document.querySelector(".vd-image-box-backdrop")){this.backdrop||(this.backdrop=document.querySelector(".vd-image-box-backdrop"),this.container=this.backdrop.querySelector(".vd-image-box-container"),this.img=this.backdrop.querySelector(".vd-image-box-img"),this.closeBtn=this.backdrop.querySelector(".vd-image-box-close"),this.caption=this.backdrop.querySelector(".vd-image-box-caption"),this.bindBackdropEvents());return}this.backdrop=document.createElement("div"),this.backdrop.className="vd-image-box-backdrop",this.backdrop.setAttribute("role","dialog"),this.backdrop.setAttribute("aria-modal","true"),this.backdrop.setAttribute("aria-label","Image viewer"),this.backdrop.setAttribute("tabindex","-1"),this.container=document.createElement("div"),this.container.className="vd-image-box-container",this.img=document.createElement("img"),this.img.className="vd-image-box-img",this.img.alt="",this.closeBtn=document.createElement("button"),this.closeBtn.className="vd-image-box-close",this.closeBtn.setAttribute("aria-label","Close image viewer"),this.closeBtn.innerHTML="×",this.caption=document.createElement("div"),this.caption.className="vd-image-box-caption",this.container.appendChild(this.img),this.backdrop.appendChild(this.closeBtn),this.backdrop.appendChild(this.container),this.backdrop.appendChild(this.caption),document.body.appendChild(this.backdrop),this.bindBackdropEvents()},bindBackdropEvents:function(){let e=this,t=function(a){(a.target===e.backdrop||a.target===e.container)&&e.close()};this.backdrop.addEventListener("click",t),this._cleanupFunctions.push(()=>this.backdrop.removeEventListener("click",t));let n=function(){e.close()};this.img.addEventListener("click",n),this._cleanupFunctions.push(()=>this.img.removeEventListener("click",n));let s=function(){e.close()};this.closeBtn.addEventListener("click",s),this._cleanupFunctions.push(()=>this.closeBtn.removeEventListener("click",s));let o=function(a){a.key==="Escape"&&e.isOpen&&e.close()};document.addEventListener("keydown",o),this._cleanupFunctions.push(()=>document.removeEventListener("keydown",o));let i=function(){if(!e.isOpen)return;let a=window.scrollY;Math.abs(a-e.initialScrollY)>e.scrollThreshold&&e.close()};window.addEventListener("scroll",i,{passive:!0}),this._cleanupFunctions.push(()=>window.removeEventListener("scroll",i))},bindTriggers:function(){let e=this;document.querySelectorAll("[data-image-box]").forEach(function(n){if(n.dataset.imageBoxInitialized)return;if(n.dataset.imageBoxInitialized="true",n.classList.add("vd-image-box-trigger"),n.tagName==="IMG"){n.complete&&n.naturalWidth===0&&n.classList.add("is-broken");let o=function(){n.classList.add("is-broken")};n.addEventListener("error",o);let i=function(){n.classList.remove("is-broken")};n.addEventListener("load",i)}let s=function(o){o.preventDefault(),e.open(n)};if(n.addEventListener("click",s),n._imageBoxCleanup=()=>n.removeEventListener("click",s),n.tagName!=="BUTTON"&&n.tagName!=="A"){n.setAttribute("role","button"),n.setAttribute("tabindex","0"),n.setAttribute("aria-label","View enlarged image");let o=function(a){(a.key==="Enter"||a.key===" ")&&(a.preventDefault(),e.open(n))};n.addEventListener("keydown",o);let i=n._imageBoxCleanup;n._imageBoxCleanup=()=>{i(),n.removeEventListener("keydown",o)}}})},open:function(e){if(this.isOpen)return;this.currentTrigger=e,this.isOpen=!0,this.initialScrollY=window.scrollY;let t=e.dataset.imageBoxFullSrc||e.dataset.imageBoxSrc||e.src||e.href;if(!t){console.warn("[Vanduo ImageBox] No image source found for trigger:",e);return}let n=e.dataset.imageBoxCaption||e.alt||"";this.img.src=t,this.img.alt=e.alt||"",n?(this.caption.textContent=n,this.caption.style.display="block"):this.caption.style.display="none";let s=window.innerWidth-document.documentElement.clientWidth;document.body.style.setProperty("--scrollbar-width",`${s}px`),document.body.classList.add("body-image-box-open"),this.backdrop.classList.add("is-visible"),this.backdrop.focus(),e.dispatchEvent(new CustomEvent("imageBox:open",{bubbles:!0,detail:{src:t}})),this.img.complete||(this.img.style.opacity="0",this._imgLoadHandler=()=>{this.img.style.opacity=""},this.img.addEventListener("load",this._imgLoadHandler,{once:!0}))},close:function(){this.isOpen&&(this.isOpen=!1,this.backdrop.classList.remove("is-visible"),document.body.classList.remove("body-image-box-open"),document.body.style.removeProperty("--scrollbar-width"),this.currentTrigger&&(this.currentTrigger.focus(),this.currentTrigger.dispatchEvent(new CustomEvent("imageBox:close",{bubbles:!0})),this.currentTrigger=null),setTimeout(()=>{this.isOpen||(this._imgLoadHandler&&(this.img.removeEventListener("load",this._imgLoadHandler),this._imgLoadHandler=null),this.img.src="",this.img.alt="")},300))},reinit:function(){this.bindTriggers()},destroy:function(){this.isOpen&&this.close(),this.backdrop&&this.backdrop.parentNode&&this.backdrop.parentNode.removeChild(this.backdrop),this._cleanupFunctions.forEach(t=>t()),this._cleanupFunctions=[],document.querySelectorAll("[data-image-box-initialized]").forEach(t=>{t.classList.remove("vd-image-box-trigger"),t._imageBoxCleanup&&(t._imageBoxCleanup(),delete t._imageBoxCleanup),delete t.dataset.imageBoxInitialized}),this.backdrop=null,this.container=null,this.img=null,this.closeBtn=null,this.caption=null,this.currentTrigger=null,this.isOpen=!1},destroyAll:function(){this.destroy()}};typeof window.Vanduo<"u"&&window.Vanduo.register("imageBox",m),window.VanduoImageBox=m})();(function(){"use strict";let m={modals:new Map,openModals:[],zIndexCounter:1050,_triggerCleanups:[],_sharedEscHandler:null,getPortalState:function(e){return e._vdPortalState||(e._vdPortalState={originalParent:null,originalNextSibling:null,placeholder:null}),e._vdPortalState},portalToBody:function(e){if(!e||e.parentNode===document.body)return;let t=this.getPortalState(e);t.originalParent=e.parentNode,t.originalNextSibling=e.nextSibling,t.placeholder||(t.placeholder=document.createComment("vd-modal-placeholder")),t.originalParent.insertBefore(t.placeholder,e),document.body.appendChild(e),e.dataset.vdPortaled="true"},restoreFromPortal:function(e){if(!e)return;let t=this.getPortalState(e);if(!t.placeholder){delete e.dataset.vdPortaled;return}t.placeholder.parentNode?(t.placeholder.parentNode.insertBefore(e,t.placeholder),t.placeholder.parentNode.removeChild(t.placeholder)):t.originalParent&&t.originalParent.isConnected&&(t.originalNextSibling&&t.originalNextSibling.parentNode===t.originalParent?t.originalParent.insertBefore(e,t.originalNextSibling):t.originalParent.appendChild(e)),t.originalParent=null,t.originalNextSibling=null,t.placeholder=null,delete e.dataset.vdPortaled},init:function(){document.querySelectorAll(".vd-modal").forEach(n=>{this.modals.has(n)||this.initModal(n)}),document.querySelectorAll("[data-modal]").forEach(n=>{if(n.dataset.modalTriggerInitialized)return;n.dataset.modalTriggerInitialized="true";let s=o=>{o.preventDefault();let i=n.dataset.modal,a=document.querySelector(i);a&&this.open(a)};n.addEventListener("click",s),this._triggerCleanups.push(()=>n.removeEventListener("click",s))})},initModal:function(e){let t=this.createBackdrop(e),n=e.querySelectorAll('.vd-modal-close, [data-dismiss="modal"]'),s=e.querySelector(".vd-modal-dialog");if(!s)return;let o=[];e.setAttribute("role","dialog"),e.setAttribute("aria-modal","true"),e.setAttribute("aria-hidden","true"),e.id||(e.id="modal-"+Math.random().toString(36).substr(2,9));let i=e.querySelector(".vd-modal-title");i&&!i.id&&(i.id=e.id+"-title",e.setAttribute("aria-labelledby",i.id)),n.forEach(r=>{let c=()=>{this.close(e)};r.addEventListener("click",c),o.push(()=>r.removeEventListener("click",c))});let a=r=>{r.target===t&&e.dataset.backdrop!=="static"&&this.close(e)};t.addEventListener("click",a),o.push(()=>t.removeEventListener("click",a)),this._sharedEscHandler||(this._sharedEscHandler=r=>{if(r.key==="Escape"&&this.openModals.length>0){let c=this.openModals[this.openModals.length-1];c.dataset.keyboard!=="false"&&this.close(c)}},document.addEventListener("keydown",this._sharedEscHandler)),this.modals.set(e,{backdrop:t,dialog:s,trapHandler:null,cleanup:o})},createBackdrop:function(e){let t=e.querySelector(".vd-modal-backdrop");return t||(t=document.createElement("div"),t.className="vd-modal-backdrop",document.body.appendChild(t)),t},open:function(e){let t=typeof e=="string"?document.querySelector(e):e;if(!t){console.warn("[Vanduo Modals] Modal element not found:",e);return}if(!this.modals.has(t)){console.warn("[Vanduo Modals] Modal not initialized:",t);return}let n=this.modals.get(t),{backdrop:s,dialog:o}=n;if(this.portalToBody(t),this.zIndexCounter+=10,t.style.zIndex=this.zIndexCounter,s.style.zIndex=this.zIndexCounter-1,this.openModals.push(t),s.classList.add("is-visible"),t.classList.add("is-open"),t.setAttribute("aria-hidden","false"),this.openModals.length===1){document.body.classList.add("body-modal-open");let a=window.innerWidth-document.documentElement.clientWidth;a>0&&(document.body.style.paddingRight=`${a}px`)}let i=this.trapFocus(t);n.trapHandler=i,setTimeout(()=>{let a=this.getFocusableElements(t)[0];a&&a.focus()},100),t.dispatchEvent(new CustomEvent("modal:open",{bubbles:!0}))},close:function(e){let t=typeof e=="string"?document.querySelector(e):e;if(!t){console.warn("[Vanduo Modals] Modal element not found:",e);return}if(!this.modals.has(t)){console.warn("[Vanduo Modals] Modal not initialized:",t);return}let n=this.modals.get(t),{backdrop:s,trapHandler:o}=n;o&&(t.removeEventListener("keydown",o),n.trapHandler=null);let i=this.openModals.indexOf(t);if(i>-1&&this.openModals.splice(i,1),t.classList.remove("is-open"),t.setAttribute("aria-hidden","true"),this.openModals.length===0)s.classList.remove("is-visible"),document.body.classList.remove("body-modal-open"),document.body.style.paddingRight="",this.zIndexCounter=1050;else{let r=this.openModals[this.openModals.length-1];this.modals.get(r).backdrop.classList.add("is-visible")}let a=document.querySelector(`[data-modal="#${t.id}"]`);a&&a.focus(),t.dispatchEvent(new CustomEvent("modal:close",{bubbles:!0})),this.restoreFromPortal(t)},trapFocus:function(e){let t=this,n=function(s){if(s.key!=="Tab")return;let o=t.getFocusableElements(e),i=o[0],a=o[o.length-1];s.shiftKey?document.activeElement===i&&(s.preventDefault(),a.focus()):document.activeElement===a&&(s.preventDefault(),i.focus())};return e.addEventListener("keydown",n),n},getFocusableElements:function(e){return Array.from(e.querySelectorAll('a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter(n=>!n.hasAttribute("disabled")&&n.offsetWidth>0&&n.offsetHeight>0)},toggle:function(e){let t=typeof e=="string"?document.querySelector(e):e;t&&(t.classList.contains("is-open")?this.close(t):this.open(t))},destroy:function(e){let t=this.modals.get(e);if(t){if(e.classList.contains("is-open")){let n=this.openModals.indexOf(e);n>-1&&this.openModals.splice(n,1),t.backdrop.classList.remove("is-visible"),e.classList.remove("is-open"),e.setAttribute("aria-hidden","true"),this.openModals.length===0&&(document.body.classList.remove("body-modal-open"),document.body.style.paddingRight="",this.zIndexCounter=1050)}this.restoreFromPortal(e),t.cleanup&&t.cleanup.forEach(n=>n()),t.backdrop&&t.backdrop.parentNode&&t.backdrop.parentNode.removeChild(t.backdrop),this.modals.delete(e)}},destroyAll:function(){this.modals.forEach((e,t)=>{this.destroy(t)}),this._triggerCleanups.forEach(e=>e()),this._triggerCleanups=[],this._sharedEscHandler&&(document.removeEventListener("keydown",this._sharedEscHandler),this._sharedEscHandler=null)}};typeof window.Vanduo<"u"&&window.Vanduo.register("modals",m),window.VanduoModals=m})();(function(){"use strict";let m={instances:new Map,getBreakpoint:function(){let t=getComputedStyle(document.documentElement).getPropertyValue("--breakpoint-lg").trim(),n=parseInt(t,10);return isNaN(n)?992:n},init:function(){document.querySelectorAll(".vd-navbar").forEach(t=>{this.instances.has(t)||this.initNavbar(t)})},initScrollWatcher:function(e){let t=e.classList.contains("vd-navbar-glass"),n=e.classList.contains("vd-navbar-transparent");if(!t&&!n)return null;let s=()=>{let i=parseInt(e.dataset.scrollThreshold,10);return isNaN(i)?e.offsetHeight||60:i},o=()=>{let i=window.scrollY>s();e.classList.toggle("vd-navbar-scrolled",i)};return o(),window.addEventListener("scroll",o,{passive:!0}),()=>window.removeEventListener("scroll",o)},initNavbar:function(e){let t=e.querySelector(".vd-navbar-toggle, .vd-navbar-burger"),n=e.querySelector(".vd-navbar-menu"),s=e.querySelector(".vd-navbar-overlay")||this.createOverlay(e),o=[],i=this.initScrollWatcher(e);if(i&&o.push(i),!t||!n){o.length&&this.instances.set(e,{toggle:null,menu:null,overlay:null,cleanup:o});return}let a=u=>{u.preventDefault(),u.stopPropagation(),this.toggleMenu(e,t,n,s)};if(t.addEventListener("click",a),o.push(()=>t.removeEventListener("click",a)),s){let u=()=>{this.closeMenu(e,t,n,s)};s.addEventListener("click",u),o.push(()=>s.removeEventListener("click",u))}let r=u=>{u.key==="Escape"&&n.classList.contains("is-open")&&this.closeMenu(e,t,n,s)};document.addEventListener("keydown",r),o.push(()=>document.removeEventListener("keydown",r));let c,l=()=>{clearTimeout(c),c=setTimeout(()=>{let u=this.getBreakpoint();window.innerWidth>=u&&n.classList.contains("is-open")&&this.closeMenu(e,t,n,s)},250)};window.addEventListener("resize",l),o.push(()=>{clearTimeout(c),window.removeEventListener("resize",l)});let f=u=>{n.classList.contains("is-open")&&!e.contains(u.target)&&!n.contains(u.target)&&this.closeMenu(e,t,n,s)};document.addEventListener("click",f),o.push(()=>document.removeEventListener("click",f)),n.querySelectorAll(".vd-navbar-dropdown > .vd-nav-link, .vd-navbar-dropdown > .nav-link").forEach(u=>{let d=p=>{let b=this.getBreakpoint();if(window.innerWidthu.removeEventListener("click",d))}),this.instances.set(e,{toggle:t,menu:n,overlay:s,cleanup:o})},destroy:function(e){let t=this.instances.get(e);t&&(t.cleanup.forEach(n=>n()),t.overlay&&t.overlay.parentNode&&t.overlay.parentNode.removeChild(t.overlay),this.instances.delete(e))},destroyAll:function(){this.instances.forEach((e,t)=>{this.destroy(t)})},toggleMenu:function(e,t,n,s){n.classList.contains("is-open")?this.closeMenu(e,t,n,s):this.openMenu(e,t,n,s)},openMenu:function(e,t,n,s){n.classList.add("is-open"),t.classList.add("is-active"),s&&s.classList.add("is-active"),document.body.classList.add("body-navbar-open"),t.setAttribute("aria-expanded","true"),n.setAttribute("aria-hidden","false")},closeMenu:function(e,t,n,s){n.classList.remove("is-open"),t.classList.remove("is-active"),s&&s.classList.remove("is-active"),document.body.classList.remove("body-navbar-open"),n.querySelectorAll(".vd-navbar-dropdown-menu.is-open").forEach(i=>{i.classList.remove("is-open")}),t.setAttribute("aria-expanded","false"),n.setAttribute("aria-hidden","true")},createOverlay:function(e){let t=document.createElement("div");return t.className="vd-navbar-overlay",document.body.appendChild(t),t}};typeof window.Vanduo<"u"&&window.Vanduo.register("navbar",m),window.VanduoNavbar=m})();(function(){"use strict";let m={instances:new Map,init:function(){document.querySelectorAll(".vd-pagination[data-pagination]").forEach(t=>{this.instances.has(t)||this.initPagination(t)})},initPagination:function(e){let t=parseInt(e.dataset.totalPages)||1,n=parseInt(e.dataset.currentPage)||1,s=parseInt(e.dataset.maxVisible)||7;this.render(e,{totalPages:t,currentPage:n,maxVisible:s});let o=i=>{let a=i.target.closest(".vd-pagination-link");if(!a||a.closest(".vd-pagination-item.disabled")||a.closest(".vd-pagination-item.active"))return;i.preventDefault();let r=a.closest(".vd-pagination-item"),c=r.dataset.page;c?this.goToPage(e,parseInt(c)):r.classList.contains("pagination-prev")?this.prevPage(e):r.classList.contains("pagination-next")&&this.nextPage(e)};e.addEventListener("click",o),this.instances.set(e,{cleanup:[()=>e.removeEventListener("click",o)]})},render:function(e,t){let{totalPages:n,currentPage:s,maxVisible:o}=t;if(n<=1){e.innerHTML="";return}let i="";i+=`
  • `,i+='Previous',i+="
  • ";let a=this.calculatePages(s,n,o),r=0;a.forEach(c=>{if(c==="ellipsis")i+='
  • \u2026
  • ';else{c!==r+1&&r>0&&(i+='
  • \u2026
  • ');let l=Number(c);i+=`
  • `,i+=`${l}`,i+="
  • ",r=c}}),i+=`
  • `,i+='Next',i+="
  • ",e.innerHTML=i,e.dataset.currentPage=s},calculatePages:function(e,t,n){let s=[],o=Math.floor(n/2);if(t<=n)for(let i=1;i<=t;i++)s.push(i);else{s.push(1);let i=Math.max(2,e-o),a=Math.min(t-1,e+o);e<=o+1&&(a=Math.min(t-1,n-1)),e>=t-o&&(i=Math.max(2,t-n+2)),i>2&&s.push("ellipsis");for(let r=i;r<=a;r++)s.push(r);a1&&s.push(t)}return s},goToPage:function(e,t){let n=parseInt(e.dataset.totalPages)||1,s=parseInt(e.dataset.maxVisible)||7;t<1||t>n||(this.render(e,{totalPages:n,currentPage:t,maxVisible:s}),e.dispatchEvent(new CustomEvent("pagination:change",{bubbles:!0,detail:{page:t,totalPages:n}})))},prevPage:function(e){let t=parseInt(e.dataset.currentPage)||1;t>1&&this.goToPage(e,t-1)},nextPage:function(e){let t=parseInt(e.dataset.currentPage)||1,n=parseInt(e.dataset.totalPages)||1;tn()),this.instances.delete(e))},destroyAll:function(){this.instances.forEach((e,t)=>{this.destroy(t)})}};typeof window.Vanduo<"u"&&window.Vanduo.register("pagination",m),window.VanduoPagination=m})();(function(){"use strict";let m={parallaxElements:new Map,ticking:!1,isMobile:window.innerWidth<768,reducedMotion:window.matchMedia("(prefers-reduced-motion: reduce)").matches,isInitialized:!1,_onScroll:null,_onResize:null,init:function(){if(this.isInitialized){this.refresh();return}if(this.isInitialized=!0,this.reducedMotion)return;document.querySelectorAll(".vd-parallax").forEach(t=>{t.dataset.parallaxInitialized||this.initParallax(t)}),this.handleScroll(),this._onScroll=()=>{this.handleScroll()},window.addEventListener("scroll",this._onScroll,{passive:!0}),this._onResize=()=>{this.isMobile=window.innerWidth<768,this.updateAll()},window.addEventListener("resize",this._onResize)},initParallax:function(e){e.dataset.parallaxInitialized="true";let t=e.classList.contains("parallax-disable-mobile");if(t&&this.isMobile)return;let n=e.querySelectorAll(".vd-parallax-layer, .vd-parallax-bg"),s=this.getSpeed(e),o=e.classList.contains("parallax-horizontal")?"horizontal":"vertical";this.parallaxElements.set(e,{layers:Array.from(n),speed:s,direction:o,disableMobile:t}),this.updateParallax(e)},getSpeed:function(e){return e.classList.contains("parallax-slow")?.5:e.classList.contains("parallax-fast")?1.5:1},handleScroll:function(){this.ticking||(window.requestAnimationFrame(()=>{this.updateAll(),this.ticking=!1}),this.ticking=!0)},updateAll:function(){this.parallaxElements.forEach((e,t)=>{e.disableMobile&&this.isMobile||this.updateParallax(t)})},updateParallax:function(e){let t=this.parallaxElements.get(e);if(!t)return;let n=e.getBoundingClientRect(),s=window.innerHeight,o=n.top,i=n.height,r=(Math.max(0,Math.min(1,(s-o)/(s+i)))-.5)*t.speed*100;t.layers.forEach((c,l)=>{let f=c.dataset.parallaxSpeed?parseFloat(c.dataset.parallaxSpeed):1,h=r*f;t.direction==="horizontal"?c.style.transform=`translateX(${h}px)`:c.style.transform=`translateY(${h}px)`})},destroy:function(e){let t=typeof e=="string"?document.querySelector(e):e;t&&this.parallaxElements.has(t)&&(this.parallaxElements.get(t).layers.forEach(s=>{s.style.transform=""}),this.parallaxElements.delete(t))},refresh:function(){this.updateAll()},destroyAll:function(){this.parallaxElements.forEach((e,t)=>{this.destroy(t)}),this.parallaxElements.clear(),this._onScroll&&(window.removeEventListener("scroll",this._onScroll),this._onScroll=null),this._onResize&&(window.removeEventListener("resize",this._onResize),this._onResize=null),this.isInitialized=!1}};typeof window.Vanduo<"u"&&window.Vanduo.register("parallax",m),window.VanduoParallax=m})();(function(){"use strict";let m={init:function(){document.querySelectorAll(".vd-progress-bar[data-progress], .progress-bar[data-progress]").forEach(t=>{t.dataset.progressInitialized||this.initProgressBar(t)})},initProgressBar:function(e){e.dataset.progressInitialized="true";let t=parseInt(e.dataset.progress)||0;this.setProgress(e,t,!1)},setProgress:function(e,t,n=!0){let s=typeof e=="string"?document.querySelector(e):e;if(!s)return;t=Math.max(0,Math.min(100,t)),n?s.style.transition="width var(--transition-duration-slow) var(--transition-ease)":(s.style.transition="none",setTimeout(()=>{s.style.transition=""},0)),s.style.width=t+"%",s.setAttribute("aria-valuenow",t),s.setAttribute("aria-valuemin",0),s.setAttribute("aria-valuemax",100);let o=s.querySelector(".vd-progress-text, .progress-text");o&&(o.textContent=t+"%"),s.dispatchEvent(new CustomEvent("progress:update",{bubbles:!0,detail:{value:t,max:100}})),t>=100&&s.dispatchEvent(new CustomEvent("progress:complete",{bubbles:!0,detail:{value:t,max:100}}))},animateProgress:function(e,t,n=1e3){let s=typeof e=="string"?document.querySelector(e):e;if(!s)return;let o=parseInt(s.style.width)||0,i=t-o,a=performance.now(),r=c=>{let l=c-a,f=Math.min(l/n,1),h=1-Math.pow(1-f,3),u=o+i*h;this.setProgress(s,u,!1),f<1&&requestAnimationFrame(r)};requestAnimationFrame(r)},show:function(e){let t=typeof e=="string"?document.querySelector(e):e;t&&(t.style.display="inline-block",t.setAttribute("aria-hidden","false"))},hide:function(e){let t=typeof e=="string"?document.querySelector(e):e;t&&(t.style.display="none",t.setAttribute("aria-hidden","true"))},toggle:function(e){let t=typeof e=="string"?document.querySelector(e):e;t&&(t.style.display==="none"||t.getAttribute("aria-hidden")==="true"?this.show(t):this.hide(t))},destroyAll:function(){document.querySelectorAll('.vd-progress-bar[data-progress-initialized="true"], .progress-bar[data-progress-initialized="true"]').forEach(t=>{delete t.dataset.progressInitialized})}};typeof window.Vanduo<"u"&&window.Vanduo.register("preloader",m),window.VanduoPreloader=m})();(function(){"use strict";let m={instances:new Map,init:function(){document.querySelectorAll("select.vd-custom-select-input, select[data-custom-select]").forEach(t=>{this.instances.has(t)||this.initSelect(t)})},initSelect:function(e){if(e.closest(".vd-custom-select-wrapper"))return;let t=[],n=document.createElement("div");n.className="custom-select-wrapper",e.parentNode.insertBefore(n,e),n.appendChild(e);let s=document.createElement("button");s.type="button",s.className="custom-select-button",s.setAttribute("aria-haspopup","listbox"),s.setAttribute("aria-expanded","false"),s.setAttribute("aria-labelledby",e.id||this.generateId(e));let o=document.createElement("div");if(o.className="custom-select-dropdown",o.setAttribute("role","listbox"),e.dataset.searchable==="true"){let l=document.createElement("div");l.className="custom-select-search";let f=document.createElement("input");f.type="text",f.className="input input-sm",f.placeholder="Search...",f.setAttribute("aria-label","Search options"),l.appendChild(f),o.appendChild(l);let h=d=>{this.filterOptions(o,d.target.value)},u=typeof debounce=="function"?debounce(h,150):h;f.addEventListener("input",u),t.push(()=>f.removeEventListener("input",u))}this.buildOptions(e,o,s),n.appendChild(s),n.appendChild(o),this.updateButtonText(e,s);let i=l=>{l.preventDefault(),l.stopPropagation(),this.toggleDropdown(s,o)};s.addEventListener("click",i),t.push(()=>s.removeEventListener("click",i));let a=l=>{!n.contains(l.target)&&o.classList.contains("is-open")&&this.closeDropdown(s,o)};document.addEventListener("click",a),t.push(()=>document.removeEventListener("click",a));let r=l=>{this.handleKeydown(l,e,s,o)};s.addEventListener("keydown",r),t.push(()=>s.removeEventListener("keydown",r));let c=()=>{this.updateButtonText(e,s),this.updateSelectedOptions(e,o)};e.addEventListener("change",c),t.push(()=>e.removeEventListener("change",c)),this.instances.set(e,{wrapper:n,button:s,dropdown:o,cleanup:t,typeaheadBuffer:"",typeaheadTimer:null})},buildOptions:function(e,t,n){let s=e.querySelectorAll("option"),o=document.createDocumentFragment();s.forEach((i,a)=>{if(i.parentElement.tagName==="OPTGROUP"){let c=i.parentElement;if(!t.querySelector(`[data-group="${c.label}"]`)){let l=document.createElement("div");l.className="custom-select-option-group",l.textContent=c.label,l.dataset.group=c.label,o.appendChild(l)}}if(i.value===""&&!i.textContent.trim())return;let r=document.createElement("div");r.className="custom-select-option",r.textContent=i.textContent,r.setAttribute("role","option"),r.setAttribute("data-value",i.value),r.setAttribute("data-index",a),i.selected&&(r.classList.add("is-selected"),r.setAttribute("aria-selected","true")),i.disabled&&(r.classList.add("is-disabled"),r.setAttribute("aria-disabled","true")),r.addEventListener("click",c=>{i.disabled||this.selectOption(e,i,r,n,t)}),o.appendChild(r)}),t.appendChild(o)},selectOption:function(e,t,n,s,o){e.multiple?(t.selected=!t.selected,n.classList.toggle("is-selected"),n.setAttribute("aria-selected",t.selected)):(e.value=t.value,e.dispatchEvent(new Event("change",{bubbles:!0})),this.closeDropdown(s,o)),this.updateButtonText(e,s)},updateButtonText:function(e,t){if(e.multiple){let n=Array.from(e.selectedOptions);n.length===0?t.textContent=e.dataset.placeholder||"Select options...":n.length===1?t.textContent=n[0].textContent:t.textContent=`${n.length} selected`}else{let n=e.options[e.selectedIndex];t.textContent=n?n.textContent:e.dataset.placeholder||"Select..."}},updateSelectedOptions:function(e,t){let n=t.querySelectorAll(".custom-select-option"),s=Array.from(e.selectedOptions).map(o=>o.value);n.forEach(o=>{let i=o.dataset.value;s.includes(i)?(o.classList.add("is-selected"),o.setAttribute("aria-selected","true")):(o.classList.remove("is-selected"),o.setAttribute("aria-selected","false"))})},toggleDropdown:function(e,t){t.classList.contains("is-open")?this.closeDropdown(e,t):this.openDropdown(e,t)},openDropdown:function(e,t){t.classList.add("is-open"),e.setAttribute("aria-expanded","true");let n=t.querySelector(".custom-select-option:not(.is-disabled)");n&&n.focus()},closeDropdown:function(e,t){t.classList.remove("is-open"),e.setAttribute("aria-expanded","false")},handleKeydown:function(e,t,n,s){let o=s.classList.contains("is-open"),i=Array.from(s.querySelectorAll(".custom-select-option:not(.is-disabled)")),a=i.findIndex(r=>r===document.activeElement);switch(e.key){case"Enter":case" ":if(e.preventDefault(),o&&a>=0){let r=i[a],c=t.options[parseInt(r.dataset.index)];this.selectOption(t,c,r,n,s)}else this.openDropdown(n,s);break;case"Escape":o&&(e.preventDefault(),this.closeDropdown(n,s),n.focus());break;case"ArrowDown":if(e.preventDefault(),!o)this.openDropdown(n,s);else{let r=a0?a-1:i.length-1;i[r].focus()}break;case"Home":o&&(e.preventDefault(),i[0].focus());break;case"End":o&&(e.preventDefault(),i[i.length-1].focus());break;default:if(o&&e.key.length===1&&!e.ctrlKey&&!e.metaKey&&!e.altKey){let r=this.instances.get(t);if(!r)break;clearTimeout(r.typeaheadTimer),r.typeaheadBuffer+=e.key.toLowerCase();let c=i.find(l=>l.textContent.trim().toLowerCase().startsWith(r.typeaheadBuffer));c&&c.focus(),r.typeaheadTimer=setTimeout(()=>{r.typeaheadBuffer=""},500)}break}},filterOptions:function(e,t){let n=e.querySelectorAll(".vd-custom-select-option"),s=t.toLowerCase();n.forEach(o=>{o.textContent.toLowerCase().includes(s)?o.style.display="block":o.style.display="none"})},generateId:function(e){if(e.id)return e.id;let t="select-"+Math.random().toString(36).substr(2,9);return e.id=t,t},destroy:function(e){let t=this.instances.get(e);t&&(t.cleanup.forEach(n=>n()),t.wrapper&&t.wrapper.parentNode&&(t.wrapper.parentNode.insertBefore(e,t.wrapper),t.wrapper.parentNode.removeChild(t.wrapper)),this.instances.delete(e))},destroyAll:function(){this.instances.forEach((e,t)=>{this.destroy(t)})}};typeof window.Vanduo<"u"&&window.Vanduo.register("select",m)})();(function(){"use strict";let m={sidenavs:new Map,breakpoint:992,restoreDelayMs:450,_globalCleanups:[],isFixedVariant:function(e){return e.classList.contains("vd-sidenav-fixed")||e.classList.contains("sidenav-fixed")},isPushVariant:function(e){return e.classList.contains("vd-sidenav-push")||e.classList.contains("sidenav-push")},isRightVariant:function(e){return e.classList.contains("vd-sidenav-right")||e.classList.contains("sidenav-right")},getPortalState:function(e){return e._vdPortalState||(e._vdPortalState={originalParent:null,originalNextSibling:null,placeholder:null,restoreTimer:null,restoreHandler:null}),e._vdPortalState},cancelScheduledRestore:function(e){let t=this.getPortalState(e);t.restoreHandler&&(e.removeEventListener("transitionend",t.restoreHandler),t.restoreHandler=null),t.restoreTimer&&(window.clearTimeout(t.restoreTimer),t.restoreTimer=null)},portalToBody:function(e){if(!e)return;if(e.parentNode===document.body){this.cancelScheduledRestore(e);return}let t=this.getPortalState(e);this.cancelScheduledRestore(e),t.originalParent=e.parentNode,t.originalNextSibling=e.nextSibling,t.placeholder||(t.placeholder=document.createComment("vd-sidenav-placeholder")),t.originalParent.insertBefore(t.placeholder,e),document.body.appendChild(e),e.dataset.vdPortaled="true"},restoreFromPortal:function(e){if(!e)return;let t=this.getPortalState(e);if(this.cancelScheduledRestore(e),!t.placeholder){delete e.dataset.vdPortaled;return}t.placeholder.parentNode?(t.placeholder.parentNode.insertBefore(e,t.placeholder),t.placeholder.parentNode.removeChild(t.placeholder)):t.originalParent&&t.originalParent.isConnected&&(t.originalNextSibling&&t.originalNextSibling.parentNode===t.originalParent?t.originalParent.insertBefore(e,t.originalNextSibling):t.originalParent.appendChild(e)),t.originalParent=null,t.originalNextSibling=null,t.placeholder=null,delete e.dataset.vdPortaled},scheduleRestoreFromPortal:function(e){if(!e||e.parentNode!==document.body)return;let t=this.getPortalState(e);this.cancelScheduledRestore(e);let n=()=>{this.restoreFromPortal(e)},s=o=>{o.target!==e||o.propertyName!=="transform"||n()};t.restoreHandler=s,e.addEventListener("transitionend",s),t.restoreTimer=window.setTimeout(()=>{n()},this.restoreDelayMs)},init:function(){document.querySelectorAll(".vd-sidenav, .vd-offcanvas").forEach(s=>{this.sidenavs.has(s)||this.initSidenav(s)}),document.querySelectorAll("[data-sidenav-toggle]").forEach(s=>{if(s.dataset.sidenavToggleInitialized)return;s.dataset.sidenavToggleInitialized="true";let o=i=>{i.preventDefault();let a=s.dataset.sidenavToggle,r=document.querySelector(a);r&&this.toggle(r)};s.addEventListener("click",o),this._globalCleanups.push(()=>s.removeEventListener("click",o))}),this.handleResize();let n=()=>{this.handleResize()};window.addEventListener("resize",n),this._globalCleanups.push(()=>window.removeEventListener("resize",n))},initSidenav:function(e){let t=e.getAttribute("data-vd-position");if(t){let r=e.classList.contains("vd-offcanvas")?"vd-offcanvas":"vd-sidenav";e.classList.add(r+"-"+t)}let n=this.createOverlay(e),s=e.querySelector(".vd-sidenav-close, .vd-offcanvas-close"),o=[];if(e.setAttribute("role","navigation"),e.setAttribute("aria-hidden","true"),s){let r=()=>{this.close(e)};s.addEventListener("click",r),o.push(()=>s.removeEventListener("click",r))}let i=()=>{e.dataset.backdrop!=="static"&&this.close(e)};n.addEventListener("click",i),o.push(()=>n.removeEventListener("click",i));let a=r=>{r.key==="Escape"&&e.classList.contains("is-open")&&e.dataset.keyboard!=="false"&&this.close(e)};document.addEventListener("keydown",a),o.push(()=>document.removeEventListener("keydown",a)),this.sidenavs.set(e,{overlay:n,cleanup:o})},createOverlay:function(e){let t=e.querySelector(".vd-sidenav-overlay");return t||(t=document.createElement("div"),t.className="vd-sidenav-overlay",document.body.appendChild(t)),t},open:function(e){let t=typeof e=="string"?document.querySelector(e):e;if(!t||!this.sidenavs.has(t))return;let{overlay:n}=this.sidenavs.get(t);this.portalToBody(t),this.isFixedVariant(t)||n.classList.add("is-visible"),t.classList.add("is-open"),t.setAttribute("aria-hidden","false"),document.body.classList.add("body-sidenav-open"),this.isPushVariant(t)&&this.handlePushVariant(t,!0),t.dispatchEvent(new CustomEvent("sidenav:open",{bubbles:!0}))},close:function(e){let t=typeof e=="string"?document.querySelector(e):e;if(!t||!this.sidenavs.has(t))return;let{overlay:n}=this.sidenavs.get(t);n.classList.remove("is-visible"),t.classList.remove("is-open"),t.setAttribute("aria-hidden","true"),document.body.classList.remove("body-sidenav-open"),this.isPushVariant(t)&&this.handlePushVariant(t,!1),t.dispatchEvent(new CustomEvent("sidenav:close",{bubbles:!0})),this.scheduleRestoreFromPortal(t)},toggle:function(e){let t=typeof e=="string"?document.querySelector(e):e;t&&(t.classList.contains("is-open")?this.close(t):this.open(t))},handlePushVariant:function(e,t){let n=document.querySelector('main, .main-content, .content, [role="main"]')||document.body;t?window.innerWidth>=this.breakpoint&&(this.isRightVariant(e)?n.style.marginRight=e.offsetWidth+"px":n.style.marginLeft=e.offsetWidth+"px"):(n.style.marginLeft="",n.style.marginRight="")},handleResize:function(){this.sidenavs.forEach(({overlay:e},t)=>{window.innerWidth>=this.breakpoint?this.isFixedVariant(t)&&!t.classList.contains("is-open")&&(t.classList.add("is-open"),t.setAttribute("aria-hidden","false"),e.classList.remove("is-visible")):this.isFixedVariant(t)&&t.classList.contains("is-open")&&this.close(t)})},destroy:function(e){let t=this.sidenavs.get(e);t&&(e.classList.contains("is-open")&&(t.overlay.classList.remove("is-visible"),e.classList.remove("is-open"),e.setAttribute("aria-hidden","true"),document.body.classList.remove("body-sidenav-open")),this.restoreFromPortal(e),t.cleanup.forEach(n=>n()),t.overlay&&t.overlay.parentNode&&t.overlay.parentNode.removeChild(t.overlay),this.sidenavs.delete(e))},destroyAll:function(){this.sidenavs.forEach((e,t)=>{this.destroy(t)}),this._globalCleanups.forEach(e=>e()),this._globalCleanups=[]}};typeof window.Vanduo<"u"&&window.Vanduo.register("sidenav",m),window.VanduoSidenav=m})();(function(){"use strict";let m={instances:new Map,init:function(){document.querySelectorAll(".vd-tabs, [data-tabs]").forEach(t=>{this.instances.has(t)||this.initTabs(t)})},initTabs:function(e){let t=e.querySelector('.vd-tab-list, [role="tablist"]'),n=e.querySelectorAll(".vd-tab-link, [data-tab]"),s=e.querySelectorAll(".vd-tab-pane, [data-tab-pane]");if(!t||n.length===0)return;let o=[];t.setAttribute("role","tablist"),n.forEach((a,r)=>{let c=this.getTabId(a,r),l=this.findPane(e,c,s);a.setAttribute("role","tab"),a.setAttribute("aria-selected",a.classList.contains("is-active")?"true":"false"),a.setAttribute("tabindex",a.classList.contains("is-active")?"0":"-1"),a.id||(a.id=`tab-btn-${c}`),l&&(l.setAttribute("role","tabpanel"),l.setAttribute("aria-labelledby",a.id),l.id||(l.id=`tab-pane-${c}`),a.setAttribute("aria-controls",l.id));let f=u=>{u.preventDefault(),!a.classList.contains("disabled")&&!a.disabled&&this.activateTab(e,a,n,s)};a.addEventListener("click",f),o.push(()=>a.removeEventListener("click",f));let h=u=>{this.handleKeydown(u,e,a,n,s)};a.addEventListener("keydown",h),o.push(()=>a.removeEventListener("keydown",h))}),!e.querySelector(".vd-tab-link.is-active, [data-tab].is-active")&&n.length>0&&this.activateTab(e,n[0],n,s),this.instances.set(e,{cleanup:o})},getTabId:function(e,t){return e.dataset.tabTarget||e.dataset.tab||e.getAttribute("href")?.replace("#","")||(typeof t=="number"?`tab-${t}`:e.id)},findPane:function(e,t,n){let s=e.querySelector(`[data-tab-pane="${t}"]`);return s||(s=e.querySelector(`#${t}`)),s||e.querySelectorAll(".vd-tab-link, [data-tab]").forEach((i,a)=>{this.getTabId(i,a)===t&&n[a]&&(s=n[a])}),s},activateTab:function(e,t,n,s){let o=Array.from(n).indexOf(t),i=this.getTabId(t,o);n.forEach(c=>{c.classList.remove("is-active"),c.setAttribute("aria-selected","false"),c.setAttribute("tabindex","-1"),c.parentElement&&c.parentElement.classList.contains("tab-item")&&c.parentElement.classList.remove("is-active")}),s.forEach(c=>{c.classList.remove("is-active")}),t.classList.add("is-active"),t.setAttribute("aria-selected","true"),t.setAttribute("tabindex","0"),t.parentElement&&t.parentElement.classList.contains("tab-item")&&t.parentElement.classList.add("is-active");let a=this.findPane(e,i,s);a&&a.classList.add("is-active");let r=new CustomEvent("tab:change",{bubbles:!0,detail:{tab:t,pane:a,tabId:i}});e.dispatchEvent(r)},handleKeydown:function(e,t,n,s,o){let i=t.classList.contains("vd-tabs-vertical")||t.classList.contains("tabs-vertical"),a=Array.from(s).filter(l=>!l.classList.contains("disabled")&&!l.disabled),r=a.indexOf(n),c=r;switch(e.key){case"ArrowLeft":i||(e.preventDefault(),c=r>0?r-1:a.length-1);break;case"ArrowRight":i||(e.preventDefault(),c=r0?r-1:a.length-1);break;case"ArrowDown":i&&(e.preventDefault(),c=rn()),this.instances.delete(e))},destroyAll:function(){this.instances.forEach((e,t)=>{this.destroy(t)})}};typeof window.Vanduo<"u"&&window.Vanduo.register("tabs",m)})();(function(){"use strict";let m={STORAGE_KEYS:{PRIMARY:"vanduo-primary-color",NEUTRAL:"vanduo-neutral-color",RADIUS:"vanduo-radius",FONT:"vanduo-font-preference",THEME:"vanduo-theme-preference"},DEFAULTS:{PRIMARY_LIGHT:"black",PRIMARY_DARK:"amber",NEUTRAL:"charcoal",RADIUS:"0.5",FONT:"ubuntu",THEME:"system"},PRIMARY_COLORS:{black:{name:"Black",color:"#000000"},red:{name:"Red",color:"#fa5252"},orange:{name:"Orange",color:"#fd7e14"},amber:{name:"Amber",color:"#f59f00"},yellow:{name:"Yellow",color:"#fcc419"},lime:{name:"Lime",color:"#82c91e"},green:{name:"Green",color:"#40c057"},emerald:{name:"Emerald",color:"#20c997"},teal:{name:"Teal",color:"#12b886"},cyan:{name:"Cyan",color:"#22b8cf"},sky:{name:"Sky",color:"#3bc9db"},blue:{name:"Blue",color:"#228be6"},indigo:{name:"Indigo",color:"#4c6ef5"},violet:{name:"Violet",color:"#7950f2"},purple:{name:"Purple",color:"#be4bdb"},fuchsia:{name:"Fuchsia",color:"#f06595"},pink:{name:"Pink",color:"#e64980"},rose:{name:"Rose",color:"#ff8787"}},NEUTRAL_COLORS:{charcoal:{name:"Charcoal",color:"#2d333b"},slate:{name:"Slate",color:"#64748b"},gray:{name:"Gray",color:"#6b7280"},zinc:{name:"Zinc",color:"#71717a"},neutral:{name:"Neutral",color:"#737373"},stone:{name:"Stone",color:"#78716c"}},RADIUS_OPTIONS:["0","0.125","0.25","0.375","0.5"],FONT_OPTIONS:{"jetbrains-mono":{name:"JetBrains Mono",family:"'JetBrains Mono', monospace"},system:{name:"System Default",family:null},ubuntu:{name:"Ubuntu",family:"'Ubuntu', sans-serif"},lato:{name:"Lato",family:"'Lato', sans-serif"},"open-sans":{name:"Open Sans",family:"'Open Sans', sans-serif"}},THEME_MODES:["system","dark","light"],state:{primary:null,neutral:null,radius:null,font:null,theme:null,isOpen:!1},isInitialized:!1,_cleanup:[],elements:{customizer:null,trigger:null,triggers:[],panel:null,overlay:null},init:function(){if(this.isInitialized){this.bindExistingElements(),this.bindTriggerEvents(),this.bindPanelEvents(),this.updateUI();return}this.isInitialized=!0,this._cleanup=[],this.loadPreferences(),this.applyAllPreferences(),this.bindExistingElements(),this.bindEvents(),console.log("Vanduo Theme Customizer initialized")},addListener:function(e,t,n,s){e&&(e.addEventListener(t,n,s),this._cleanup.push(()=>e.removeEventListener(t,n,s)))},getDefaultPrimary:function(e){return e==="system"?window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?this.DEFAULTS.PRIMARY_DARK:this.DEFAULTS.PRIMARY_LIGHT:e==="dark"?this.DEFAULTS.PRIMARY_DARK:this.DEFAULTS.PRIMARY_LIGHT},loadPreferences:function(){this.state.theme=this.getStorageValue(this.STORAGE_KEYS.THEME,this.DEFAULTS.THEME),this.state.primary=this.getStorageValue(this.STORAGE_KEYS.PRIMARY,this.getDefaultPrimary(this.state.theme)),this._normalizeDefaultPrimaryIfStaleWithStoredTheme(),this.state.neutral=this.getStorageValue(this.STORAGE_KEYS.NEUTRAL,this.DEFAULTS.NEUTRAL),this.state.radius=this.getStorageValue(this.STORAGE_KEYS.RADIUS,this.DEFAULTS.RADIUS),this.state.font=this.getStorageValue(this.STORAGE_KEYS.FONT,this.DEFAULTS.FONT)},savePreference:function(e,t){this.setStorageValue(e,t)},applyAllPreferences:function(){this.applyPrimary(this.state.primary),this.applyNeutral(this.state.neutral),this.applyRadius(this.state.radius),this.applyFont(this.state.font),this.applyTheme(this.state.theme)},applyPrimary:function(e){this.PRIMARY_COLORS[e]||(e=this.getDefaultPrimary(this.state.theme)),this.state.primary=e,document.documentElement.setAttribute("data-primary",e),this.savePreference(this.STORAGE_KEYS.PRIMARY,e),this.dispatchEvent("primary-change",{color:e})},applyNeutral:function(e){this.NEUTRAL_COLORS[e]||(e=this.DEFAULTS.NEUTRAL),this.state.neutral=e,document.documentElement.setAttribute("data-neutral",e),this.savePreference(this.STORAGE_KEYS.NEUTRAL,e),this.dispatchEvent("neutral-change",{neutral:e})},applyRadius:function(e){this.RADIUS_OPTIONS.includes(e)||(e=this.DEFAULTS.RADIUS),this.state.radius=e,document.documentElement.setAttribute("data-radius",e),document.documentElement.style.setProperty("--radius-scale",e),this.savePreference(this.STORAGE_KEYS.RADIUS,e),this.dispatchEvent("radius-change",{radius:e})},applyFont:function(e){this.FONT_OPTIONS[e]||(e=this.DEFAULTS.FONT),this.state.font=e,e==="system"?document.documentElement.removeAttribute("data-font"):document.documentElement.setAttribute("data-font",e),this.savePreference(this.STORAGE_KEYS.FONT,e),window.FontSwitcher&&window.FontSwitcher.setPreference&&(window.FontSwitcher.state.preference=e,window.FontSwitcher.applyFont()),this.dispatchEvent("font-change",{font:e})},applyTheme:function(e){if(this.THEME_MODES.includes(e)||(e=this.DEFAULTS.THEME),this._isApplying=!0,this.isUsingDefaultPrimary()){let t=this.getDefaultPrimary(e);this.state.primary!==t&&this.applyPrimary(t)}if(this.state.theme=e,e==="system"?document.documentElement.removeAttribute("data-theme"):document.documentElement.setAttribute("data-theme",e),this.savePreference(this.STORAGE_KEYS.THEME,e),window.Vanduo&&window.Vanduo.components.themeSwitcher){let t=window.Vanduo.components.themeSwitcher;t.state&&t.state.preference!==e&&(t.state.preference=e,typeof t.setStorageValue=="function"&&t.setStorageValue(t.STORAGE_KEY,e),typeof t.updateUI=="function"&&t.updateUI())}this._isApplying=!1,this.dispatchEvent("mode-change",{mode:e})},dispatchEvent:function(e,t){let n=new CustomEvent("theme:"+e,{bubbles:!0,detail:t});document.dispatchEvent(n);let s=new CustomEvent("theme:change",{bubbles:!0,detail:{type:e,value:t[Object.keys(t)[0]],state:{...this.state}}});document.dispatchEvent(s)},bindExistingElements:function(){this.elements.customizer=document.querySelector(".vd-theme-customizer"),this.elements.triggers=Array.from(document.querySelectorAll("[data-theme-customizer-trigger]")),!this.elements.trigger&&this.elements.triggers.length&&(this.elements.trigger=this.elements.triggers[0]),this.elements.customizer?(this.elements.trigger=this.elements.customizer.querySelector(".vd-theme-customizer-trigger")||this.elements.trigger,this.elements.panel=this.elements.customizer.querySelector(".vd-theme-customizer-panel"),this.elements.overlay=this.elements.customizer.querySelector(".vd-theme-customizer-overlay")):this.elements.triggers.length&&this.createDynamicPanel(),this.updateUI()},createDynamicPanel:function(){if(!this.elements.triggers.length)return;this.elements.trigger=this.elements.triggers[0];let e=document.createElement("div");e.className="vd-theme-customizer-overlay";let t=document.createElement("div");t.className="vd-theme-customizer-panel",t.innerHTML=this.getPanelHTML(),document.body.appendChild(e),document.body.appendChild(t),this.elements.panel=t,this.elements.overlay=e,this.elements.customizer={contains:n=>t.contains(n)||this.elements.triggers.some(s=>s.contains(n))},this.positionPanel(),this.bindPanelEvents(),this.addListener(window,"resize",()=>this.positionPanel())},positionPanel:function(){if(!this.elements.panel||!this.elements.trigger)return;let e=this.elements.activeTrigger||this.elements.trigger;if(window.innerWidth<768)this.elements.panel.style.top="",this.elements.panel.style.right="",this.elements.panel.style.left="",this.elements.panel.style.height="",this.elements.panel.style.maxHeight="";else{let n=e.getBoundingClientRect(),s=320,o=n.bottom+8,i=window.innerWidth,a=i-n.right;i-a-s<8&&(a=i-s-8),this.elements.panel.style.top=o+"px",this.elements.panel.style.right=a+"px",this.elements.panel.style.left="",this.elements.panel.style.height="auto",this.elements.panel.style.maxHeight="calc(100vh - "+o+"px)"}},bindPanelEvents:function(){if(!this.elements.panel||this.elements.panel.getAttribute("data-customizer-initialized")==="true")return;this.elements.panel.setAttribute("data-customizer-initialized","true"),this.elements.panel.querySelectorAll("[data-color]").forEach(s=>{this.addListener(s,"click",()=>{this.applyPrimary(s.dataset.color),this.updateUI()})}),this.elements.panel.querySelectorAll("[data-neutral]").forEach(s=>{this.addListener(s,"click",()=>{this.applyNeutral(s.dataset.neutral),this.updateUI()})}),this.elements.panel.querySelectorAll("[data-radius]").forEach(s=>{this.addListener(s,"click",()=>{this.applyRadius(s.dataset.radius),this.updateUI()})});let e=this.elements.panel.querySelector("[data-customizer-font]");e&&this.addListener(e,"change",s=>{this.applyFont(s.target.value),this.updateUI()});let t=this.elements.panel.querySelector(".customizer-reset");t&&this.addListener(t,"click",()=>{this.reset()});let n=this.elements.panel.querySelector(".customizer-mobile-close");n&&this.addListener(n,"click",()=>{this.close()}),this.elements.overlay&&this.addListener(this.elements.overlay,"click",()=>{this.close()})},getPanelHTML:function(){let e=typeof escapeHtml=="function"?escapeHtml:function(a){let r=document.createElement("div");return r.textContent=String(a??""),r.innerHTML},t=function(a){let r=String(a??"").trim();return/^(#[0-9a-fA-F]{3,8}|rgb[a]?\([^)]{1,60}\)|hsl[a]?\([^)]{1,60}\)|var\(--[a-zA-Z0-9_-]{1,40}\))$/.test(r)?r:"#000000"},n="";for(let[a,r]of Object.entries(this.PRIMARY_COLORS))n+=``;let s="";for(let[a,r]of Object.entries(this.NEUTRAL_COLORS))s+=``;let o="";this.RADIUS_OPTIONS.forEach(a=>{o+=``});let i="";for(let[a,r]of Object.entries(this.FONT_OPTIONS))i+=``;return`

    Customize Theme

    `;\n }\n\n // Generate neutral color swatches\n let neutralSwatches = '';\n for (const [key, value] of Object.entries(this.NEUTRAL_COLORS)) {\n neutralSwatches += ``;\n }\n\n // Generate radius buttons\n let radiusButtons = '';\n this.RADIUS_OPTIONS.forEach(r => {\n radiusButtons += ``;\n });\n\n // Generate font options\n let fontOptions = '';\n for (const [key, value] of Object.entries(this.FONT_OPTIONS)) {\n fontOptions += ``;\n }\n\n return `\n
    \n

    Customize Theme

    \n \n
    \n
    \n\n
    \n \n
    \n ${primarySwatches}\n
    \n
    \n
    \n \n
    \n ${neutralSwatches}\n
    \n
    \n
    \n \n
    \n ${radiusButtons}\n
    \n
    \n
    \n \n \n
    \n
    \n
    \n \n
    \n `;\n },\n\n /**\n * Bind event listeners\n */\n /**\n * Check whether the current primary color is one of the auto-defaults\n * (i.e. the user hasn't explicitly picked a non-default color).\n */\n isUsingDefaultPrimary: function () {\n return this.state.primary === this.DEFAULTS.PRIMARY_LIGHT ||\n this.state.primary === this.DEFAULTS.PRIMARY_DARK;\n },\n\n /**\n * When primary is still one of the auto-default palette keys (black/amber) but\n * localStorage was written under a different theme (or OS changed in system mode),\n * align in-memory state before applyAllPreferences runs \u2014 avoids amber+light / black+dark drift.\n */\n _normalizeDefaultPrimaryIfStaleWithStoredTheme: function () {\n if (!this.isUsingDefaultPrimary()) {\n return;\n }\n const expected = this.getDefaultPrimary(this.state.theme);\n if (this.state.primary !== expected) {\n this.state.primary = expected;\n }\n },\n\n bindEvents: function () {\n this.bindTriggerEvents();\n\n this.bindPanelEvents();\n\n // Listen for OS dark/light changes so we can swap the default primary\n if (window.matchMedia) {\n const mq = window.matchMedia('(prefers-color-scheme: dark)');\n const handler = () => {\n if (this.state.theme === 'system' && this.isUsingDefaultPrimary()) {\n const newDefault = this.getDefaultPrimary('system');\n if (newDefault !== this.state.primary) {\n this.applyPrimary(newDefault);\n this.updateUI();\n }\n }\n };\n mq.addEventListener('change', handler);\n this._cleanup.push(() => mq.removeEventListener('change', handler));\n }\n\n // Close on outside click\n this.addListener(document, 'click', (e) => {\n if (this.state.isOpen && this.elements.customizer && !this.elements.customizer.contains(e.target)) {\n this.close();\n }\n });\n\n // Close on Escape key\n this.addListener(document, 'keydown', (e) => {\n if (e.key === 'Escape' && this.state.isOpen) {\n this.close();\n }\n });\n },\n\n bindTriggerEvents: function () {\n this.elements.triggers.forEach((trigger) => {\n if (trigger.getAttribute('data-customizer-trigger-initialized') === 'true') {\n return;\n }\n this.addListener(trigger, 'click', (e) => {\n e.preventDefault();\n e.stopPropagation();\n this.elements.activeTrigger = trigger;\n this.elements.trigger = trigger;\n this.toggle();\n });\n trigger.setAttribute('data-customizer-trigger-initialized', 'true');\n });\n },\n\n /**\n * Toggle panel open/close\n */\n toggle: function () {\n if (this.state.isOpen) {\n this.close();\n } else {\n this.open();\n }\n },\n\n /**\n * Open the panel\n */\n open: function () {\n this.state.isOpen = true;\n\n // Ensure panel is positioned correctly before opening\n this.positionPanel();\n\n if (this.elements.panel) {\n this.elements.panel.classList.add('is-open');\n }\n this.elements.triggers.forEach((trigger) => trigger.setAttribute('aria-expanded', 'false'));\n if (this.elements.trigger) this.elements.trigger.setAttribute('aria-expanded', 'true');\n if (this.elements.overlay) {\n this.elements.overlay.classList.add('is-active');\n }\n\n this.dispatchEvent('panel-open', { isOpen: true });\n },\n\n /**\n * Close the panel\n */\n close: function () {\n this.state.isOpen = false;\n\n if (this.elements.panel) {\n this.elements.panel.classList.remove('is-open');\n }\n this.elements.triggers.forEach((trigger) => trigger.setAttribute('aria-expanded', 'false'));\n if (this.elements.overlay) {\n this.elements.overlay.classList.remove('is-active');\n }\n\n this.dispatchEvent('panel-close', { isOpen: false });\n },\n\n /**\n * Update UI to reflect current state\n */\n updateUI: function () {\n if (!this.elements.panel) return;\n\n // Update primary color swatches\n this.elements.panel.querySelectorAll('[data-color]').forEach(swatch => {\n swatch.classList.toggle('is-active', swatch.dataset.color === this.state.primary);\n });\n\n // Update neutral color swatches\n this.elements.panel.querySelectorAll('[data-neutral]').forEach(swatch => {\n swatch.classList.toggle('is-active', swatch.dataset.neutral === this.state.neutral);\n });\n\n // Update radius buttons\n this.elements.panel.querySelectorAll('[data-radius]').forEach(btn => {\n btn.classList.toggle('is-active', btn.dataset.radius === this.state.radius);\n });\n\n // Update font selector\n const fontSelect = this.elements.panel.querySelector('[data-customizer-font]');\n if (fontSelect) {\n fontSelect.value = this.state.font;\n }\n\n },\n\n /**\n * Reset all preferences to defaults\n */\n reset: function () {\n this.applyTheme(this.DEFAULTS.THEME);\n this.applyPrimary(this.getDefaultPrimary(this.DEFAULTS.THEME));\n this.applyNeutral(this.DEFAULTS.NEUTRAL);\n this.applyRadius(this.DEFAULTS.RADIUS);\n this.applyFont(this.DEFAULTS.FONT);\n this.updateUI();\n\n this.dispatchEvent('reset', { state: { ...this.state } });\n },\n\n /**\n * Get current state\n */\n getState: function () {\n return { ...this.state };\n },\n\n /**\n * Programmatically set preferences\n */\n setPreferences: function (prefs) {\n if (prefs.primary) this.applyPrimary(prefs.primary);\n if (prefs.neutral) this.applyNeutral(prefs.neutral);\n if (prefs.radius) this.applyRadius(prefs.radius);\n if (prefs.font) this.applyFont(prefs.font);\n if (prefs.theme) this.applyTheme(prefs.theme);\n this.updateUI();\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 destroyAll: function () {\n this._cleanup.forEach(fn => fn());\n this._cleanup = [];\n\n if (this.elements.panel) {\n this.elements.panel.removeAttribute('data-customizer-initialized');\n }\n\n this.close();\n this.isInitialized = false;\n }\n };\n\n // Register component\n if (window.Vanduo) {\n window.Vanduo.register('themeCustomizer', ThemeCustomizer);\n }\n\n // Expose globally for convenience\n window.ThemeCustomizer = ThemeCustomizer;\n})();\n", "/**\n * Vanduo Framework - Theme Switcher\n * Handles light/dark/system theme toggling and persistence\n */\n\n(function () {\n 'use strict';\n\n const ThemeSwitcher = {\n isInitialized: false,\n _mediaQuery: null,\n _onMediaChange: null,\n\n init: function () {\n this.STORAGE_KEY = 'vanduo-theme-preference';\n this.state = {\n preference: this.getPreference() // 'light', 'dark', or 'system'\n };\n\n if (this.isInitialized) {\n this.applyTheme();\n this.renderUI();\n this.updateUI();\n return;\n }\n\n this.isInitialized = true;\n\n this.applyTheme();\n this.listenForSystemChanges();\n this.renderUI();\n\n console.log('Vanduo Theme Switcher initialized');\n },\n\n getPreference: function () {\n return this.getStorageValue(this.STORAGE_KEY, 'system');\n },\n\n setPreference: function (pref) {\n this.state.preference = pref;\n this.setStorageValue(this.STORAGE_KEY, pref);\n this.applyTheme();\n \n // Coordinate with ThemeCustomizer for primary color swap if available\n // Check _isApplying flag to prevent circular updates\n if (window.ThemeCustomizer && window.ThemeCustomizer.applyTheme && !window.ThemeCustomizer._isApplying) {\n window.ThemeCustomizer.applyTheme(pref);\n }\n \n this.updateUI();\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 applyTheme: function () {\n const pref = this.state.preference;\n\n if (pref === 'system') {\n // When in system mode, we remove the data attribute to let the media query take over\n // or we can explicitly set it. Explicitly setting it ensures consistency if we rely on [data-theme]\n // But if we rely on @media in CSS, we might want to remove attributes.\n // However, our CSS strategy uses :root:not([data-theme=\"light\"]) inside media query for system dark fallback\n // which is a bit complex.\n\n // Simpler approach: \n // If preference is system, REMOVE data-theme attribute. Let CSS media queries handle it.\n document.documentElement.removeAttribute('data-theme');\n } else {\n document.documentElement.setAttribute('data-theme', pref);\n }\n },\n\n listenForSystemChanges: function () {\n if (this._mediaQuery && this._onMediaChange) {\n return;\n }\n\n this._mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n this._onMediaChange = _e => {\n if (this.state.preference === 'system') {\n // Re-apply (effectively just to ensure consistency, though removing attribute usually suffices)\n this.applyTheme();\n // Keep default primary (black/amber) aligned when OS scheme changes while in system mode\n if (window.ThemeCustomizer && typeof window.ThemeCustomizer.applyTheme === 'function' && !window.ThemeCustomizer._isApplying) {\n window.ThemeCustomizer.applyTheme('system');\n }\n }\n };\n this._mediaQuery.addEventListener('change', this._onMediaChange);\n },\n\n // Helper to facilitate UI creation if needed, though often UI is in HTML\n renderUI: function () {\n // Look for any uninitialized theme toggles\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"]');\n toggles.forEach(toggle => {\n if (toggle.getAttribute('data-theme-initialized') === 'true') {\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n }\n return;\n }\n\n // Simplified UI Binding - assumes a select or a button cycle\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n const onChange = (e) => {\n this.setPreference(e.target.value);\n };\n toggle.addEventListener('change', onChange);\n toggle._themeToggleHandler = onChange;\n } else {\n // Button implementation (cycle)\n const onClick = () => {\n const modes = ['system', 'light', 'dark'];\n const nextIndex = (modes.indexOf(this.state.preference) + 1) % modes.length;\n this.setPreference(modes[nextIndex]);\n };\n toggle.addEventListener('click', onClick);\n toggle._themeToggleHandler = onClick;\n }\n // Mark as initialized\n toggle.setAttribute('data-theme-initialized', 'true');\n });\n },\n\n updateUI: function () {\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"]');\n toggles.forEach(toggle => {\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n } else {\n // Update button text/icon if needed\n // e.g. toggle.textContent = this.state.preference;\n // For now, assume the user handles visual state or generic text\n\n // If there is an icon or text span inside, update it\n const span = toggle.querySelector('.theme-current-label');\n if (span) {\n span.textContent = this.state.preference.charAt(0).toUpperCase() + this.state.preference.slice(1);\n }\n }\n });\n },\n\n destroyAll: function () {\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"][data-theme-initialized=\"true\"]');\n toggles.forEach(toggle => {\n if (toggle._themeToggleHandler) {\n const eventName = toggle.tagName === 'SELECT' ? 'change' : 'click';\n toggle.removeEventListener(eventName, toggle._themeToggleHandler);\n delete toggle._themeToggleHandler;\n }\n toggle.removeAttribute('data-theme-initialized');\n });\n\n if (this._mediaQuery && this._onMediaChange) {\n this._mediaQuery.removeEventListener('change', this._onMediaChange);\n }\n\n this._mediaQuery = null;\n this._onMediaChange = null;\n this.isInitialized = false;\n }\n };\n\n // Register component\n if (window.Vanduo) {\n window.Vanduo.register('themeSwitcher', ThemeSwitcher);\n }\n})();\n", "/**\n * Vanduo Framework - Toast Component\n * Popup notifications for user feedback\n */\n\n(function() {\n 'use strict';\n\n /**\n * Toast Component\n */\n const Toast = {\n // Default options\n defaults: {\n position: 'top-right',\n duration: 5000,\n dismissible: true,\n showProgress: true,\n pauseOnHover: true\n },\n\n // Container cache\n containers: {},\n\n /**\n * Get or create a toast container for a position\n * @param {string} position - Container position\n * @returns {HTMLElement} Toast container element\n */\n getContainer: function(position) {\n if (this.containers[position]) {\n return this.containers[position];\n }\n\n const container = document.createElement('div');\n container.className = `vd-toast-container vd-toast-container-${position}`;\n container.setAttribute('role', 'status');\n container.setAttribute('aria-live', 'polite');\n container.setAttribute('aria-atomic', 'false');\n document.body.appendChild(container);\n this.containers[position] = container;\n\n return container;\n },\n\n /**\n * Show a toast notification\n * @param {Object|string} options - Toast options or message string\n * @param {string} [type] - Toast type (success, error, warning, info)\n * @param {number} [duration] - Auto-dismiss duration in ms\n * @returns {HTMLElement} Toast element\n */\n show: function(options, type, duration) {\n // Support simple API:\n // - Toast.show('Message', 'success', 3000)\n // - Toast.show('Message', { type: 'success', duration: 3000 })\n if (typeof options === 'string') {\n if (type && typeof type === 'object') {\n options = Object.assign({}, type, {\n message: options\n });\n } else {\n options = {\n message: options,\n type: type,\n duration: duration\n };\n }\n }\n\n const config = Object.assign({}, this.defaults, options);\n const container = this.getContainer(config.position);\n\n // Create toast element\n const toast = document.createElement('div');\n toast.className = 'vd-toast';\n\n if (config.type) {\n toast.classList.add(`vd-toast-${config.type}`);\n }\n\n if (config.solid) {\n toast.classList.add('vd-toast-solid');\n }\n\n if (config.showProgress && config.duration > 0) {\n toast.classList.add('vd-toast-with-progress');\n }\n\n // Build toast content\n let html = '';\n\n // Icon (sanitize custom icons, default icons are trusted SVG)\n if (config.icon) {\n const allowSvg = config.iconAllowSvg === true;\n const safeIcon = typeof sanitizeHtml === 'function'\n ? sanitizeHtml(config.icon, { allowSvg })\n : escapeHtml(config.icon);\n html += `${safeIcon}`;\n } else if (config.type) {\n html += `${this.getDefaultIcon(config.type)}`;\n }\n\n // Local escape helper \u2014 guarantees HTML-safe output even if the\n // global escapeHtml utility is not loaded in the current bundle.\n const _esc = typeof escapeHtml === 'function'\n ? escapeHtml\n : function (s) {\n const d = document.createElement('div');\n d.appendChild(document.createTextNode(s));\n return d.innerHTML;\n };\n\n // Content (escape text to prevent injection)\n html += '
    ';\n if (config.title) {\n html += `
    ${_esc(String(config.title))}
    `;\n }\n if (config.message) {\n html += `
    ${_esc(String(config.message))}
    `;\n }\n html += '
    ';\n\n // Close button\n if (config.dismissible) {\n html += '';\n }\n\n // Progress bar\n if (config.showProgress && config.duration > 0) {\n const safeDuration = parseInt(config.duration, 10) || 0;\n html += `
    `;\n }\n\n toast.innerHTML = html;\n\n // Add to container\n container.appendChild(toast);\n\n toast._toastCleanup = [];\n\n // Set up close button handler\n if (config.dismissible) {\n const closeBtn = toast.querySelector('.vd-toast-close');\n const onClose = () => {\n this.dismiss(toast);\n };\n closeBtn.addEventListener('click', onClose);\n toast._toastCleanup.push(() => closeBtn.removeEventListener('click', onClose));\n }\n\n // Pause on hover\n let timeoutId = null;\n let remainingTime = config.duration;\n let startTime = null;\n\n const startTimer = () => {\n if (config.duration > 0) {\n startTime = Date.now();\n timeoutId = setTimeout(() => {\n this.dismiss(toast);\n }, remainingTime);\n toast._toastTimeoutId = timeoutId;\n\n // Resume progress animation\n const progress = toast.querySelector('.vd-toast-progress');\n if (progress) {\n progress.style.animationPlayState = 'running';\n }\n }\n };\n\n const pauseTimer = () => {\n if (timeoutId) {\n clearTimeout(timeoutId);\n timeoutId = null;\n toast._toastTimeoutId = null;\n remainingTime -= Date.now() - startTime;\n\n // Pause progress animation\n const progress = toast.querySelector('.vd-toast-progress');\n if (progress) {\n progress.style.animationPlayState = 'paused';\n }\n }\n };\n\n if (config.pauseOnHover) {\n toast.addEventListener('mouseenter', pauseTimer);\n toast.addEventListener('mouseleave', startTimer);\n toast._toastCleanup.push(\n () => toast.removeEventListener('mouseenter', pauseTimer),\n () => toast.removeEventListener('mouseleave', startTimer)\n );\n }\n\n // Trigger enter animation\n requestAnimationFrame(() => {\n toast.classList.add('is-visible');\n startTimer();\n });\n\n // Store config on element for later access\n toast._toastConfig = config;\n\n // Dispatch show event\n const showEvent = new CustomEvent('toast:show', {\n bubbles: true,\n detail: { toast, config }\n });\n toast.dispatchEvent(showEvent);\n\n return toast;\n },\n\n /**\n * Dismiss a toast\n * @param {HTMLElement} toast - Toast element to dismiss\n */\n dismiss: function(toast) {\n if (!toast || toast.classList.contains('is-exiting')) return;\n\n if (toast._toastTimeoutId) {\n clearTimeout(toast._toastTimeoutId);\n toast._toastTimeoutId = null;\n }\n\n toast.classList.remove('is-visible');\n toast.classList.add('is-exiting');\n\n // Dispatch dismiss event\n const dismissEvent = new CustomEvent('toast:dismiss', {\n bubbles: true,\n detail: { toast }\n });\n toast.dispatchEvent(dismissEvent);\n\n // Remove after animation\n const handleTransitionEnd = () => {\n toast.removeEventListener('transitionend', handleTransitionEnd);\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n };\n\n toast.addEventListener('transitionend', handleTransitionEnd);\n\n // Fallback removal if transition doesn't fire\n setTimeout(() => {\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n }, 400);\n },\n\n /**\n * Destroy all toasts and containers\n */\n destroyAll: function() {\n Object.keys(this.containers).forEach(position => {\n const container = this.containers[position];\n if (!container) return;\n\n const toasts = container.querySelectorAll('.vd-toast');\n toasts.forEach(toast => {\n if (toast._toastTimeoutId) {\n clearTimeout(toast._toastTimeoutId);\n }\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n });\n\n if (container.parentElement) {\n container.parentElement.removeChild(container);\n }\n });\n\n this.containers = {};\n },\n\n /**\n * Dismiss all toasts\n * @param {string} [position] - Optional position to clear (clears all if not specified)\n */\n dismissAll: function(position) {\n if (position && this.containers[position]) {\n const toasts = this.containers[position].querySelectorAll('.vd-toast');\n toasts.forEach(toast => this.dismiss(toast));\n } else {\n Object.values(this.containers).forEach(container => {\n const toasts = container.querySelectorAll('.vd-toast');\n toasts.forEach(toast => this.dismiss(toast));\n });\n }\n },\n\n /**\n * Get default icon SVG for a type\n * @param {string} type - Toast type\n * @returns {string} SVG icon markup\n */\n getDefaultIcon: function(type) {\n const icons = {\n success: '',\n error: '',\n warning: '',\n info: ''\n };\n\n return icons[type] || '';\n },\n\n /**\n * Convenience methods for common toast types\n */\n success: function(message, options) {\n return this.show(Object.assign({ message, type: 'success' }, options));\n },\n\n error: function(message, options) {\n return this.show(Object.assign({ message, type: 'error' }, options));\n },\n\n warning: function(message, options) {\n return this.show(Object.assign({ message, type: 'warning' }, options));\n },\n\n info: function(message, options) {\n return this.show(Object.assign({ message, type: 'info' }, options));\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('toast', Toast);\n }\n\n // Also expose globally for convenience\n window.Toast = Toast;\n\n})();\n", "/**\n * Vanduo Framework - Tooltips Component\n * JavaScript functionality for tooltips\n */\n\n(function () {\n 'use strict';\n\n /**\n * Tooltips Component\n */\n const Tooltips = {\n tooltips: new Map(),\n delayTimers: new Map(),\n\n /**\n * Sanitize HTML \u2014 delegates to shared sanitizeHtml from helpers.js\n * @param {string} input\n * @param {{ allowSvg?: boolean }} [options]\n * @returns {string} sanitized HTML\n */\n sanitizeHtml: function (input, options) {\n if (typeof sanitizeHtml === 'function') {\n return sanitizeHtml(input, options);\n }\n // Fallback: content originates from developer-authored data attributes,\n // so returning it as-is is no worse than any other inline HTML.\n return input || '';\n },\n\n /**\n * Initialize tooltips\n */\n init: function () {\n const elements = document.querySelectorAll('[data-tooltip], [data-tooltip-html]');\n\n elements.forEach(element => {\n if (this.tooltips.has(element)) {\n return;\n }\n this.initTooltip(element);\n });\n },\n\n /**\n * Initialize a single tooltip\n * @param {HTMLElement} element - Element with tooltip\n */\n initTooltip: function (element) {\n const tooltip = this.createTooltip(element);\n const cleanupFunctions = [];\n\n // Show on hover/focus\n const enterHandler = () => { this.showTooltip(element, tooltip); };\n const leaveHandler = () => { this.hideTooltip(element, tooltip); };\n const focusHandler = () => { this.showTooltip(element, tooltip); };\n const blurHandler = () => { this.hideTooltip(element, tooltip); };\n\n element.addEventListener('mouseenter', enterHandler);\n element.addEventListener('mouseleave', leaveHandler);\n element.addEventListener('focus', focusHandler);\n element.addEventListener('blur', blurHandler);\n\n cleanupFunctions.push(\n () => element.removeEventListener('mouseenter', enterHandler),\n () => element.removeEventListener('mouseleave', leaveHandler),\n () => element.removeEventListener('focus', focusHandler),\n () => element.removeEventListener('blur', blurHandler)\n );\n\n this.tooltips.set(element, { tooltip, cleanup: cleanupFunctions });\n },\n\n /**\n * Create tooltip element\n * @param {HTMLElement} element - Target element\n * @returns {HTMLElement} Tooltip element\n */\n createTooltip: function (element) {\n const tooltip = document.createElement('div');\n tooltip.className = 'vd-tooltip';\n tooltip.setAttribute('role', 'tooltip');\n tooltip.setAttribute('aria-hidden', 'true');\n\n // Generate unique ID and link via aria-describedby\n const tooltipId = 'tooltip-' + Math.random().toString(36).substr(2, 9);\n tooltip.id = tooltipId;\n element.setAttribute('aria-describedby', tooltipId);\n\n // Get content\n const htmlContent = element.dataset.tooltipHtml;\n const textContent = element.dataset.tooltip;\n\n if (htmlContent) {\n const allowSvg = element.hasAttribute('data-tooltip-allow-svg');\n tooltip.innerHTML = this.sanitizeHtml(htmlContent, { allowSvg });\n tooltip.classList.add('vd-tooltip-html');\n } else if (textContent) {\n tooltip.textContent = textContent;\n }\n\n // Get placement\n const placement = element.dataset.tooltipPlacement || element.dataset.placement || 'top';\n tooltip.setAttribute('data-placement', placement);\n tooltip.classList.add(`vd-tooltip-${placement}`);\n\n // Get variant\n if (element.dataset.tooltipVariant) {\n tooltip.classList.add(`vd-tooltip-${element.dataset.tooltipVariant}`);\n }\n\n // Get size\n if (element.dataset.tooltipSize) {\n tooltip.classList.add(`vd-tooltip-${element.dataset.tooltipSize}`);\n }\n\n // Get delay\n const delay = parseInt(element.dataset.tooltipDelay) || 0;\n tooltip.dataset.delay = delay;\n\n document.body.appendChild(tooltip);\n\n return tooltip;\n },\n\n /**\n * Show tooltip\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n showTooltip: function (element, tooltip) {\n const delay = parseInt(tooltip.dataset.delay) || 0;\n\n if (delay > 0) {\n const timer = setTimeout(() => {\n this.positionTooltip(element, tooltip);\n tooltip.classList.add('is-visible');\n tooltip.setAttribute('aria-hidden', 'false');\n }, delay);\n this.delayTimers.set(element, timer);\n } else {\n this.positionTooltip(element, tooltip);\n tooltip.classList.add('is-visible');\n tooltip.setAttribute('aria-hidden', 'false');\n }\n },\n\n /**\n * Hide tooltip\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n hideTooltip: function (element, tooltip) {\n // Clear delay timer if exists\n const timer = this.delayTimers.get(element);\n if (timer) {\n clearTimeout(timer);\n this.delayTimers.delete(element);\n }\n\n tooltip.classList.remove('is-visible');\n tooltip.setAttribute('aria-hidden', 'true');\n },\n\n /**\n * Position tooltip relative to element\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n positionTooltip: function (element, tooltip) {\n const placement = tooltip.dataset.placement || 'top';\n const rect = element.getBoundingClientRect();\n const tooltipRect = tooltip.getBoundingClientRect();\n const scrollTop = window.pageYOffset || document.documentElement.scrollTop;\n const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;\n\n let top = 0;\n let left = 0;\n\n switch (placement) {\n case 'top':\n top = rect.top + scrollTop - tooltipRect.height - 8;\n left = rect.left + scrollLeft + (rect.width / 2) - (tooltipRect.width / 2);\n break;\n case 'bottom':\n top = rect.bottom + scrollTop + 8;\n left = rect.left + scrollLeft + (rect.width / 2) - (tooltipRect.width / 2);\n break;\n case 'left':\n top = rect.top + scrollTop + (rect.height / 2) - (tooltipRect.height / 2);\n left = rect.left + scrollLeft - tooltipRect.width - 8;\n break;\n case 'right':\n top = rect.top + scrollTop + (rect.height / 2) - (tooltipRect.height / 2);\n left = rect.right + scrollLeft + 8;\n break;\n }\n\n // Prevent overflow\n const viewportWidth = window.innerWidth;\n const viewportHeight = window.innerHeight;\n const padding = 8;\n\n if (left < padding) {\n left = padding;\n } else if (left + tooltipRect.width > viewportWidth - padding) {\n left = viewportWidth - tooltipRect.width - padding;\n }\n\n if (top < scrollTop + padding) {\n top = scrollTop + padding;\n } else if (top + tooltipRect.height > scrollTop + viewportHeight - padding) {\n top = scrollTop + viewportHeight - tooltipRect.height - padding;\n }\n\n // Use single style assignment with transform for better performance\n tooltip.style.cssText = `position: absolute; top: 0; left: 0; transform: translate(${left}px, ${top}px);`;\n },\n\n /**\n * Show tooltip programmatically\n * @param {HTMLElement|string} element - Target element or selector\n */\n show: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n this.showTooltip(el, tooltip);\n }\n },\n\n /**\n * Hide tooltip programmatically\n * @param {HTMLElement|string} element - Target element or selector\n */\n hide: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n this.hideTooltip(el, tooltip);\n }\n },\n\n /**\n * Update tooltip content\n * @param {HTMLElement|string} element - Target element or selector\n * @param {string} content - New content\n * @param {boolean} isHtml - Whether content is HTML\n */\n update: function (element, content, isHtml = false) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n if (isHtml) {\n const allowSvg = el.hasAttribute('data-tooltip-allow-svg');\n tooltip.innerHTML = this.sanitizeHtml(content, { allowSvg });\n tooltip.classList.add('vd-tooltip-html');\n } else {\n tooltip.textContent = content;\n tooltip.classList.remove('vd-tooltip-html');\n }\n }\n },\n\n /**\n * Destroy a tooltip instance and clean up\n * @param {HTMLElement} element - Element with tooltip\n */\n destroy: function (element) {\n const data = this.tooltips.get(element);\n if (!data) return;\n\n // Clear any pending timer\n const timer = this.delayTimers.get(element);\n if (timer) {\n clearTimeout(timer);\n this.delayTimers.delete(element);\n }\n\n data.cleanup.forEach(fn => fn());\n\n // Remove tooltip element from DOM\n if (data.tooltip && data.tooltip.parentNode) {\n data.tooltip.parentNode.removeChild(data.tooltip);\n }\n\n this.tooltips.delete(element);\n },\n\n /**\n * Destroy all tooltip instances\n */\n destroyAll: function () {\n this.tooltips.forEach((data, element) => {\n this.destroy(element);\n });\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('tooltips', Tooltips);\n }\n\n // Expose globally\n window.VanduoTooltips = Tooltips;\n\n})();\n", "/**\n * Vanduo Framework - Search Component\n * Client-side search functionality for content pages\n * \n * @example Basic usage (initialize with defaults)\n * // HTML:\n * //
    \n * // \n * //
    \n * //
    \n * \n * @example Custom configuration\n * var search = Search.create({\n * containerSelector: '.my-search',\n * contentSelector: 'article[id]',\n * titleSelector: 'h2, h3',\n * maxResults: 5,\n * onSelect: function(result) {\n * console.log('Selected:', result.title);\n * }\n * });\n * \n * @example With custom data source\n * var search = Search.create({\n * containerSelector: '.my-search',\n * data: [\n * { id: 'item1', title: 'First Item', content: 'Description...', category: 'Category A' },\n * { id: 'item2', title: 'Second Item', content: 'Description...', category: 'Category B' }\n * ]\n * });\n */\n\n(function() {\n 'use strict';\n\n /**\n * Default configuration\n */\n const DEFAULTS = {\n // Behavior\n minQueryLength: 2,\n maxResults: 10,\n debounceMs: 150,\n highlightTag: 'mark',\n keyboardShortcut: true, // Enable Cmd/Ctrl+K shortcut\n \n // Selectors (for DOM-based indexing)\n containerSelector: '.vd-doc-search',\n inputSelector: '.vd-doc-search-input',\n resultsSelector: '.vd-doc-search-results',\n contentSelector: '.doc-content section[id]',\n titleSelector: '.demo-title, h2, h3',\n navSelector: '.doc-nav-link',\n sectionSelector: '.doc-nav-section',\n \n // Content extraction\n excludeFromContent: 'pre, code, script, style',\n maxContentLength: 500,\n \n // Custom data source (alternative to DOM indexing)\n data: null,\n \n // Category icons mapping\n categoryIcons: {\n 'getting-started': 'ph-rocket-launch',\n 'core': 'ph-cube',\n 'components': 'ph-puzzle-piece',\n 'interactive': 'ph-cursor-click',\n 'data-display': 'ph-table',\n 'feedback': 'ph-bell',\n 'meta': 'ph-info',\n 'default': 'ph-file-text'\n },\n \n // Callbacks\n onSelect: null, // function(result) - called when result is selected\n onSearch: null, // function(query, results) - called after search\n onOpen: null, // function() - called when results open\n onClose: null, // function() - called when results close\n \n // Text customization\n emptyTitle: 'No results found',\n emptyText: 'Try different keywords or check spelling',\n placeholder: 'Search...'\n };\n\n /**\n * Search Component Factory\n * Creates a new search instance with the given configuration\n * \n * @param {Object} options - Configuration options\n * @returns {Object} Search instance\n */\n function createSearch(options) {\n const config = Object.assign({}, DEFAULTS, options || {});\n \n // Instance state\n const state = {\n initialized: false,\n index: [],\n results: [],\n activeIndex: -1,\n isOpen: false,\n query: '',\n container: null,\n input: null,\n resultsContainer: null,\n debounceTimer: null,\n boundHandlers: {}\n };\n\n function safeInvokeCallback(name, fn, ...args) {\n try {\n fn(...args);\n } catch (error) {\n console.warn('[Vanduo Search] Callback error in \"' + name + '\":', error);\n }\n }\n\n function setResultsHtml(html) {\n if (!state.resultsContainer) return;\n try {\n state.resultsContainer.innerHTML = html;\n } catch (error) {\n console.warn('[Vanduo Search] Failed to render results:', error);\n }\n }\n\n /**\n * Initialize the search component\n * Idempotent \u2014 safe to call more than once on the same instance.\n * Returns the instance on success, null if required DOM elements are missing.\n */\n function init() {\n if (state.initialized) {\n return instance;\n }\n\n state.container = document.querySelector(config.containerSelector);\n if (!state.container) {\n state.initialized = false;\n return null;\n }\n\n state.input = state.container.querySelector(config.inputSelector);\n state.resultsContainer = state.container.querySelector(config.resultsSelector);\n\n if (!state.input || !state.resultsContainer) {\n state.initialized = false;\n return null;\n }\n\n // Set placeholder if configured\n if (config.placeholder) {\n state.input.setAttribute('placeholder', config.placeholder);\n }\n\n // Build search index\n buildIndex();\n\n // Bind events\n bindEvents();\n\n // Set up ARIA attributes\n setupAria();\n\n state.initialized = true;\n return instance;\n }\n\n /**\n * Build search index from DOM or custom data\n */\n function buildIndex() {\n state.index = [];\n \n // Use custom data if provided\n if (config.data && Array.isArray(config.data)) {\n config.data.forEach(function(item) {\n state.index.push({\n id: item.id || slugify(item.title),\n title: item.title || '',\n category: item.category || '',\n categorySlug: slugify(item.category || ''),\n content: item.content || '',\n keywords: item.keywords || extractKeywordsFromText(item.title + ' ' + item.content),\n url: item.url || '#' + (item.id || slugify(item.title)),\n icon: item.icon || ''\n });\n });\n return;\n }\n\n // Build from DOM\n const sections = document.querySelectorAll(config.contentSelector);\n const categoryMap = buildCategoryMap();\n\n sections.forEach(function(section) {\n const id = section.id;\n if (!id) return;\n\n const titleEl = section.querySelector(config.titleSelector);\n const title = titleEl ? titleEl.textContent.replace(/v[\\d.]+/g, '').trim() : id;\n const category = categoryMap[id] || 'Documentation';\n const content = extractContent(section);\n const keywords = extractKeywords(section, title);\n const iconEl = titleEl ? titleEl.querySelector('i.ph') : null;\n let icon = '';\n if (iconEl && iconEl.classList) {\n for (let ci = 0; ci < iconEl.classList.length; ci++) {\n if (iconEl.classList[ci].indexOf('ph-') === 0) {\n icon = iconEl.classList[ci];\n break;\n }\n }\n }\n\n state.index.push({\n id: id,\n title: title,\n category: category,\n categorySlug: slugify(category),\n content: content,\n keywords: keywords,\n url: '#' + id,\n icon: icon\n });\n });\n }\n\n /**\n * Build a map of section IDs to their categories\n */\n function buildCategoryMap() {\n const map = {};\n let currentCategory = 'Documentation';\n const navItems = document.querySelectorAll(config.navSelector + ', ' + config.sectionSelector);\n\n navItems.forEach(function(item) {\n if (item.classList.contains('doc-nav-section')) {\n currentCategory = item.textContent.trim();\n } else {\n const href = item.getAttribute('href');\n if (href && href.startsWith('#')) {\n const id = href.substring(1);\n map[id] = currentCategory;\n }\n }\n });\n\n return map;\n }\n\n /**\n * Extract searchable content from a section\n */\n function extractContent(section) {\n const clone = section.cloneNode(true);\n \n const toRemove = clone.querySelectorAll(config.excludeFromContent);\n toRemove.forEach(function(el) {\n el.remove();\n });\n\n let text = clone.textContent || '';\n text = text.replace(/\\s+/g, ' ').trim();\n \n return text.substring(0, config.maxContentLength);\n }\n\n /**\n * Extract keywords from a section\n */\n function extractKeywords(section, title) {\n const keywords = [];\n \n // Add title words\n title.toLowerCase().split(/\\s+/).forEach(function(word) {\n if (word.length > 2) {\n keywords.push(word);\n }\n });\n\n // Add class names from code examples\n const codeBlocks = section.querySelectorAll('code');\n codeBlocks.forEach(function(code) {\n const text = code.textContent || '';\n const classMatches = text.match(/\\.([\\w-]+)/g);\n if (classMatches) {\n classMatches.forEach(function(match) {\n keywords.push(match.substring(1).toLowerCase());\n });\n }\n });\n\n // Add data attributes\n const dataAttrs = section.querySelectorAll('[data-tooltip], [data-modal]');\n dataAttrs.forEach(function(el) {\n const attrs = el.getAttributeNames().filter(function(name) {\n return name.startsWith('data-');\n });\n attrs.forEach(function(attr) {\n keywords.push(attr.replace('data-', ''));\n });\n });\n\n return Array.from(new Set(keywords));\n }\n\n /**\n * Extract keywords from text string\n */\n function extractKeywordsFromText(text) {\n const words = text.toLowerCase().split(/\\s+/);\n return words.filter(function(word) {\n return word.length > 2;\n });\n }\n\n /**\n * Convert string to slug\n */\n function slugify(str) {\n return str.toLowerCase().replace(/\\s+/g, '-').replace(/[^a-z0-9-]/g, '');\n }\n\n /**\n * Bind event listeners\n */\n function bindEvents() {\n // Store bound handlers for cleanup\n state.boundHandlers.handleInput = function(e) {\n handleInput(e);\n };\n state.boundHandlers.handleFocus = function() {\n if (state.query.length >= config.minQueryLength) {\n open();\n }\n };\n state.boundHandlers.handleKeydown = function(e) {\n handleKeydown(e);\n };\n state.boundHandlers.handleOutsideClick = function(e) {\n if (!state.container.contains(e.target)) {\n close();\n }\n };\n state.boundHandlers.handleGlobalKeydown = function(e) {\n if (config.keyboardShortcut && (e.metaKey || e.ctrlKey) && e.key === 'k') {\n e.preventDefault();\n state.input.focus();\n state.input.select();\n }\n };\n state.boundHandlers.handleResultClick = function(e) {\n const result = e.target.closest('.vd-doc-search-result');\n if (result) {\n const index = parseInt(result.dataset.index, 10);\n select(index);\n }\n };\n\n // Input events\n state.input.addEventListener('input', state.boundHandlers.handleInput);\n state.input.addEventListener('focus', state.boundHandlers.handleFocus);\n state.input.addEventListener('keydown', state.boundHandlers.handleKeydown);\n\n // Close on outside click\n document.addEventListener('click', state.boundHandlers.handleOutsideClick);\n\n // Global keyboard shortcut\n document.addEventListener('keydown', state.boundHandlers.handleGlobalKeydown);\n\n // Result click handling\n state.resultsContainer.addEventListener('click', state.boundHandlers.handleResultClick);\n }\n\n /**\n * Unbind event listeners\n */\n function unbindEvents() {\n if (state.input) {\n state.input.removeEventListener('input', state.boundHandlers.handleInput);\n state.input.removeEventListener('focus', state.boundHandlers.handleFocus);\n state.input.removeEventListener('keydown', state.boundHandlers.handleKeydown);\n }\n document.removeEventListener('click', state.boundHandlers.handleOutsideClick);\n document.removeEventListener('keydown', state.boundHandlers.handleGlobalKeydown);\n if (state.resultsContainer) {\n state.resultsContainer.removeEventListener('click', state.boundHandlers.handleResultClick);\n }\n }\n\n /**\n * Set up ARIA attributes\n */\n function setupAria() {\n const resultsId = state.resultsContainer.id || 'search-results-' + Math.random().toString(36).substr(2, 9);\n state.resultsContainer.id = resultsId;\n \n state.input.setAttribute('role', 'combobox');\n state.input.setAttribute('aria-autocomplete', 'list');\n state.input.setAttribute('aria-controls', resultsId);\n state.input.setAttribute('aria-expanded', 'false');\n \n state.resultsContainer.setAttribute('role', 'listbox');\n state.resultsContainer.setAttribute('aria-label', 'Search results');\n }\n\n /**\n * Handle input changes\n */\n function handleInput(e) {\n const query = e.target.value.trim();\n\n if (state.debounceTimer) {\n clearTimeout(state.debounceTimer);\n }\n\n state.debounceTimer = setTimeout(function() {\n state.query = query;\n\n if (query.length < config.minQueryLength) {\n close();\n return;\n }\n\n state.results = search(query);\n state.activeIndex = -1;\n render();\n open();\n\n // Callback\n if (typeof config.onSearch === 'function') {\n safeInvokeCallback('onSearch', config.onSearch, query, state.results);\n }\n }, config.debounceMs);\n }\n\n /**\n * Handle keyboard navigation\n */\n function handleKeydown(e) {\n if (!state.isOpen) {\n if (e.key === 'ArrowDown' && state.query.length >= config.minQueryLength) {\n e.preventDefault();\n state.results = search(state.query);\n render();\n open();\n }\n return;\n }\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n navigate(1);\n break;\n\n case 'ArrowUp':\n e.preventDefault();\n navigate(-1);\n break;\n\n case 'Enter':\n e.preventDefault();\n if (state.activeIndex >= 0) {\n select(state.activeIndex);\n } else if (state.results.length > 0) {\n select(0);\n }\n break;\n\n case 'Escape':\n e.preventDefault();\n close();\n break;\n\n case 'Tab':\n close();\n break;\n }\n }\n\n /**\n * Perform search\n */\n function search(query) {\n const terms = query.toLowerCase().split(/\\s+/).filter(function(t) {\n return t.length > 0;\n });\n const scored = [];\n\n state.index.forEach(function(entry) {\n let score = 0;\n const titleLower = entry.title.toLowerCase();\n const categoryLower = entry.category.toLowerCase();\n const contentLower = entry.content.toLowerCase();\n\n terms.forEach(function(term) {\n // Title match - highest priority\n if (titleLower.includes(term)) {\n score += 100;\n if (titleLower === term) {\n score += 50;\n } else if (titleLower.startsWith(term)) {\n score += 25;\n }\n }\n\n // Category match\n if (categoryLower.includes(term)) {\n score += 50;\n }\n\n // Keyword match\n const keywordMatch = entry.keywords.some(function(k) {\n return k.includes(term);\n });\n if (keywordMatch) {\n score += 30;\n }\n\n // Content match\n if (contentLower.includes(term)) {\n score += 10;\n }\n });\n\n if (score > 0) {\n scored.push({\n id: entry.id,\n title: entry.title,\n category: entry.category,\n categorySlug: entry.categorySlug,\n content: entry.content,\n url: entry.url,\n icon: entry.icon,\n score: score\n });\n }\n });\n\n scored.sort(function(a, b) {\n return b.score - a.score;\n });\n\n return scored.slice(0, config.maxResults);\n }\n\n /**\n * Render search results\n */\n function render() {\n if (state.results.length === 0) {\n setResultsHtml(renderEmpty());\n return;\n }\n\n let html = '
      ';\n\n state.results.forEach(function(result, index) {\n const isActive = index === state.activeIndex;\n const icon = result.icon || getCategoryIcon(result.categorySlug);\n const excerpt = getExcerpt(result.content, state.query);\n\n html += '
    • ' +\n '
      ' +\n '' +\n '
      ' +\n '
      ' +\n '
      ' + highlight(result.title, state.query) + '
      ' +\n '
      ' + escapeHtml(result.category) + '
      ' +\n '
      ' + highlight(excerpt, state.query) + '
      ' +\n '
      ' +\n '
    • ';\n });\n\n html += '
    ';\n html += renderFooter();\n\n setResultsHtml(html);\n }\n\n /**\n * Render empty state\n */\n function renderEmpty() {\n return '
    ' +\n '
    ' +\n '
    ' + escapeHtml(config.emptyTitle) + '
    ' +\n '
    ' + escapeHtml(config.emptyText) + '
    ' +\n '
    ';\n }\n\n /**\n * Render footer with keyboard hints\n */\n function renderFooter() {\n return '
    ' +\n '\u2191\u2193 to navigate' +\n '\u21B5 to select' +\n 'esc to close' +\n '
    ';\n }\n\n /**\n * Get icon for category\n */\n function getCategoryIcon(categorySlug) {\n return config.categoryIcons[categorySlug] || config.categoryIcons['default'] || 'ph-file-text';\n }\n\n /**\n * Get excerpt from content\n */\n function getExcerpt(content, query) {\n const terms = query.toLowerCase().split(/\\s+/);\n const contentLower = content.toLowerCase();\n const excerptLength = 100;\n\n let matchPos = -1;\n for (let i = 0; i < terms.length; i++) {\n const pos = contentLower.indexOf(terms[i]);\n if (pos !== -1 && (matchPos === -1 || pos < matchPos)) {\n matchPos = pos;\n }\n }\n\n if (matchPos === -1) {\n return content.substring(0, excerptLength) + '...';\n }\n\n const start = Math.max(0, matchPos - 30);\n const end = Math.min(content.length, matchPos + excerptLength);\n let excerpt = content.substring(start, end);\n\n if (start > 0) {\n excerpt = '...' + excerpt;\n }\n if (end < content.length) {\n excerpt = excerpt + '...';\n }\n\n return excerpt;\n }\n\n /**\n * Highlight matched terms in text\n */\n function highlight(text, query) {\n if (!query) return escapeHtml(text);\n\n const terms = query.toLowerCase().split(/\\s+/).filter(function(t) {\n return t.length > 0;\n });\n let escaped = escapeHtml(text);\n\n terms.forEach(function(term) {\n // Skip overly long terms to prevent ReDoS\n if (term.length > 50) return;\n const regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi');\n escaped = escaped.replace(regex, '<' + config.highlightTag + '>$1');\n });\n\n return escaped;\n }\n\n /**\n * Escape HTML entities\n */\n function escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Navigate results with keyboard\n */\n function navigate(direction) {\n let newIndex = state.activeIndex + direction;\n\n if (newIndex < 0) {\n newIndex = state.results.length - 1;\n } else if (newIndex >= state.results.length) {\n newIndex = 0;\n }\n\n setActiveIndex(newIndex);\n }\n\n /**\n * Set active result index\n */\n function setActiveIndex(index) {\n const prevActive = state.resultsContainer.querySelector('.vd-doc-search-result.is-active');\n if (prevActive) {\n prevActive.classList.remove('is-active');\n prevActive.setAttribute('aria-selected', 'false');\n }\n\n state.activeIndex = index;\n\n const newActive = state.resultsContainer.querySelector('[data-index=\"' + index + '\"]');\n if (newActive) {\n newActive.classList.add('is-active');\n newActive.setAttribute('aria-selected', 'true');\n state.input.setAttribute('aria-activedescendant', 'vd-doc-search-result-' + index);\n newActive.scrollIntoView({ block: 'nearest' });\n }\n }\n\n /**\n * Select a result\n */\n function select(index) {\n const result = state.results[index];\n if (!result) return;\n\n // Close search\n close();\n state.input.value = '';\n state.query = '';\n\n // Custom callback\n if (typeof config.onSelect === 'function') {\n safeInvokeCallback('onSelect', config.onSelect, result);\n return;\n }\n\n // Default behavior: navigate to section\n const section = document.querySelector(result.url);\n if (section) {\n section.scrollIntoView({ behavior: 'smooth', block: 'start' });\n window.history.pushState(null, '', result.url);\n updateSidebarActive(result.id);\n }\n }\n\n /**\n * Update sidebar navigation active state\n */\n function updateSidebarActive(sectionId) {\n const navLinks = document.querySelectorAll(config.navSelector);\n navLinks.forEach(function(link) {\n link.classList.remove('active');\n if (link.getAttribute('href') === '#' + sectionId) {\n link.classList.add('active');\n }\n });\n }\n\n /**\n * Open results dropdown\n */\n function open() {\n if (state.isOpen) return;\n\n state.isOpen = true;\n state.resultsContainer.classList.add('is-open');\n state.input.setAttribute('aria-expanded', 'true');\n\n if (typeof config.onOpen === 'function') {\n safeInvokeCallback('onOpen', config.onOpen);\n }\n }\n\n /**\n * Close results dropdown\n */\n function close() {\n if (!state.isOpen) return;\n\n state.isOpen = false;\n state.activeIndex = -1;\n state.resultsContainer.classList.remove('is-open');\n state.input.setAttribute('aria-expanded', 'false');\n state.input.removeAttribute('aria-activedescendant');\n\n if (typeof config.onClose === 'function') {\n safeInvokeCallback('onClose', config.onClose);\n }\n }\n\n /**\n * Destroy the component\n */\n function destroy() {\n unbindEvents();\n \n state.initialized = false;\n state.index = [];\n state.results = [];\n state.isOpen = false;\n state.query = '';\n\n if (state.debounceTimer) {\n clearTimeout(state.debounceTimer);\n }\n\n if (state.resultsContainer) {\n setResultsHtml('');\n }\n }\n\n /**\n * Rebuild the search index\n */\n function rebuild() {\n buildIndex();\n }\n\n /**\n * Update configuration\n */\n function setConfig(newConfig) {\n Object.assign(config, newConfig);\n }\n\n /**\n * Get current configuration\n */\n function getConfig() {\n return Object.assign({}, config);\n }\n\n /**\n * Get search index\n */\n function getIndex() {\n return state.index.slice();\n }\n\n // Public instance API\n const instance = {\n init: init,\n destroy: destroy,\n rebuild: rebuild,\n search: search,\n open: open,\n close: close,\n setConfig: setConfig,\n getConfig: getConfig,\n getIndex: getIndex\n };\n\n return instance;\n }\n\n /**\n * Search Component (singleton for backward compatibility)\n */\n const Search = {\n // Factory method \u2014 creates and auto-initializes a new independent instance.\n // Always returns the instance so callers retain a reference even if the\n // DOM container is not yet available (they can retry init() later).\n create: function(options) {\n const instance = createSearch(options);\n if (instance) {\n instance.init();\n }\n return instance || null;\n },\n \n // Default instance\n _instance: null,\n \n // Configuration (for default instance)\n config: Object.assign({}, DEFAULTS),\n\n /**\n * Initialize the default search instance\n */\n init: function(options) {\n if (this._instance) {\n this._instance.destroy();\n }\n \n if (options) {\n Object.assign(this.config, options);\n }\n \n this._instance = createSearch(this.config);\n return this._instance ? this._instance.init() : null;\n },\n\n /**\n * Destroy the default instance\n */\n destroy: function() {\n if (this._instance) {\n this._instance.destroy();\n this._instance = null;\n }\n },\n\n destroyAll: function() {\n this.destroy();\n },\n\n /**\n * Rebuild the default instance index\n */\n rebuild: function() {\n if (this._instance) {\n this._instance.rebuild();\n }\n },\n\n /**\n * Search using the default instance\n */\n search: function(query) {\n return this._instance ? this._instance.search(query) : [];\n },\n\n /**\n * Open the default instance\n */\n open: function() {\n if (this._instance) {\n this._instance.open();\n }\n },\n\n /**\n * Close the default instance\n */\n close: function() {\n if (this._instance) {\n this._instance.close();\n }\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('docSearch', Search);\n }\n\n // Expose globally (both names for compatibility)\n window.Search = Search;\n window.DocSearch = Search; // Backward compatibility\n window.VanduoDocSearch = Search; // New name compatibility\n\n})();\n", "/**\n * Vanduo Framework - Draggable Component\n * JavaScript functionality for draggable elements and drop zones\n */\n\n(function () {\n 'use strict';\n\n /**\n * Draggable Component\n */\n const Draggable = {\n // Store initialized draggables and their cleanup functions\n instances: new Map(),\n // Store current drag state\n currentDrag: null,\n // Store touch state\n touchState: null,\n // Feedback element\n feedbackElement: null,\n // Shared selector used by init and touch reorder\n containerSelector: '.vd-draggable-container, .vd-draggable-container-vertical',\n\n /**\n * Initialize draggable components\n */\n init: function () {\n const draggables = document.querySelectorAll('.vd-draggable, [data-draggable]');\n\n draggables.forEach(element => {\n if (this.instances.has(element)) {\n return;\n }\n this.initDraggable(element);\n });\n\n const containers = document.querySelectorAll(this.containerSelector);\n containers.forEach(container => {\n if (!this.instances.has(container)) {\n this.initContainer(container);\n }\n });\n\n const dropZones = document.querySelectorAll('.vd-drop-zone');\n dropZones.forEach(zone => {\n if (!this.instances.has(zone)) {\n this.initDropZone(zone);\n }\n });\n\n this.createFeedbackElement();\n },\n\n /**\n * Initialize a single draggable element\n * @param {HTMLElement} element - Draggable element\n */\n initDraggable: function (element) {\n const cleanupFunctions = [];\n\n // Make element draggable if not already\n if (!element.hasAttribute('draggable')) {\n element.setAttribute('draggable', 'true');\n }\n\n // Accessibility: add ARIA attributes\n if (!element.hasAttribute('tabindex')) {\n element.setAttribute('tabindex', '0');\n }\n element.setAttribute('role', 'option');\n element.setAttribute('aria-roledescription', 'draggable item');\n element.setAttribute('aria-grabbed', 'false');\n\n // Handle drag start\n const dragStartHandler = (e) => {\n this.handleDragStart(e, element);\n };\n element.addEventListener('dragstart', dragStartHandler);\n cleanupFunctions.push(() => element.removeEventListener('dragstart', dragStartHandler));\n\n // Handle drag\n const dragHandler = (e) => {\n this.handleDrag(e, element);\n };\n element.addEventListener('drag', dragHandler);\n cleanupFunctions.push(() => element.removeEventListener('drag', dragHandler));\n\n // Handle drag end\n const dragEndHandler = (e) => {\n this.handleDragEnd(e, element);\n };\n element.addEventListener('dragend', dragEndHandler);\n cleanupFunctions.push(() => element.removeEventListener('dragend', dragEndHandler));\n\n // Handle touch start (for mobile)\n const touchStartHandler = (e) => {\n this.handleTouchStart(e, element);\n };\n element.addEventListener('touchstart', touchStartHandler);\n cleanupFunctions.push(() => element.removeEventListener('touchstart', touchStartHandler));\n\n // Handle touch move (for mobile)\n // { passive: false } is required so that e.preventDefault() works\n // once the drag threshold is reached \u2014 without it, modern browsers\n // treat the listener as passive and silently ignore preventDefault().\n const touchMoveHandler = (e) => {\n this.handleTouchMove(e, element);\n };\n element.addEventListener('touchmove', touchMoveHandler, { passive: false });\n cleanupFunctions.push(() => element.removeEventListener('touchmove', touchMoveHandler));\n\n // Handle touch end (for mobile)\n const touchEndHandler = (e) => {\n this.handleTouchEnd(e, element);\n };\n element.addEventListener('touchend', touchEndHandler, { passive: false });\n cleanupFunctions.push(() => element.removeEventListener('touchend', touchEndHandler));\n\n // Handle touch cancel (for mobile)\n const touchCancelHandler = (e) => {\n this.handleTouchEnd(e, element);\n };\n element.addEventListener('touchcancel', touchCancelHandler);\n cleanupFunctions.push(() => element.removeEventListener('touchcancel', touchCancelHandler));\n\n // Keyboard navigation\n const keydownHandler = (e) => {\n this.handleKeydown(e, element);\n };\n element.addEventListener('keydown', keydownHandler);\n cleanupFunctions.push(() => element.removeEventListener('keydown', keydownHandler));\n\n this.instances.set(element, { cleanup: cleanupFunctions });\n },\n\n /**\n * Initialize a draggable container\n * @param {HTMLElement} container - Draggable container\n */\n initContainer: function (container) {\n // Accessibility: add ARIA role to container\n container.setAttribute('role', 'listbox');\n container.setAttribute('aria-label', container.getAttribute('aria-label') || 'Draggable items');\n\n const items = container.querySelectorAll('.vd-draggable-item');\n items.forEach(item => {\n if (!this.instances.has(item)) {\n this.initDraggable(item);\n }\n });\n\n const cleanupFunctions = [];\n\n // Handle drag enter\n const dragEnterHandler = (e) => {\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n };\n\n // Handle drag over for auto-sorting\n const dragOverHandler = (e) => {\n e.preventDefault(); // Necessary to allow drop\n e.dataTransfer.dropEffect = 'move';\n\n if (!this.currentDrag) return;\n const draggingEl = this.currentDrag.element;\n\n // Only reorder if dragging an item that belongs to this container (or if we support cross-container drag, but keep simple)\n if (!container.contains(draggingEl)) return;\n\n // Prevent jumps when the dragging finalizes with 0,0 coordinates near the end\n if (e.clientX === 0 && e.clientY === 0) return;\n\n this.handleReorder(container, draggingEl, e.clientX, e.clientY);\n };\n\n // Handle drop\n const dropHandler = (e) => {\n e.preventDefault(); // crucial to prevent the browser's default handling and snapping back\n };\n\n container.addEventListener('dragenter', dragEnterHandler);\n container.addEventListener('dragover', dragOverHandler);\n container.addEventListener('drop', dropHandler);\n\n cleanupFunctions.push(() => {\n container.removeEventListener('dragenter', dragEnterHandler);\n container.removeEventListener('dragover', dragOverHandler);\n container.removeEventListener('drop', dropHandler);\n });\n\n this.instances.set(container, { cleanup: cleanupFunctions });\n },\n\n /**\n * Initialize a drop zone\n * @param {HTMLElement} zone - Drop zone element\n */\n initDropZone: function (zone) {\n const cleanupFunctions = [];\n\n // Accessibility: add ARIA role to drop zone\n zone.setAttribute('role', 'region');\n zone.setAttribute('aria-dropeffect', 'move');\n if (!zone.hasAttribute('aria-label')) {\n zone.setAttribute('aria-label', 'Drop zone');\n }\n\n // Handle drag over\n const dragOverHandler = (e) => {\n e.preventDefault();\n this.handleDragOver(e, zone);\n };\n zone.addEventListener('dragover', dragOverHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragover', dragOverHandler));\n\n // Handle drag enter\n const dragEnterHandler = (e) => {\n e.preventDefault();\n this.handleDragEnter(e, zone);\n };\n zone.addEventListener('dragenter', dragEnterHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragenter', dragEnterHandler));\n\n // Handle drag leave\n const dragLeaveHandler = (e) => {\n this.handleDragLeave(e, zone);\n };\n zone.addEventListener('dragleave', dragLeaveHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragleave', dragLeaveHandler));\n\n // Handle drop\n const dropHandler = (e) => {\n e.preventDefault();\n this.handleDrop(e, zone);\n };\n zone.addEventListener('drop', dropHandler);\n cleanupFunctions.push(() => zone.removeEventListener('drop', dropHandler));\n\n this.instances.set(zone, { cleanup: cleanupFunctions });\n },\n\n /**\n * Create feedback element for drag operations\n */\n createFeedbackElement: function () {\n if (!this.feedbackElement) {\n // Reuse existing element if present\n const existing = document.querySelector('.vd-drag-feedback');\n if (existing) {\n this.feedbackElement = existing;\n return;\n }\n\n this.feedbackElement = document.createElement('div');\n this.feedbackElement.className = 'vd-drag-feedback hidden';\n this.feedbackElement.setAttribute('role', 'presentation');\n document.body.appendChild(this.feedbackElement);\n }\n },\n\n /**\n * Handle drag start event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDragStart: function (e, element) {\n // Add dragging class\n element.classList.add('is-dragging');\n\n // Accessibility: update aria-grabbed\n element.setAttribute('aria-grabbed', 'true');\n\n // Store drag state\n this.currentDrag = {\n element: element,\n initialPosition: { x: e.clientX, y: e.clientY },\n initialBounds: element.getBoundingClientRect(),\n data: this.getData(element)\n };\n\n // Set drag data\n e.dataTransfer.effectAllowed = 'move';\n e.dataTransfer.setData('text/plain', this.currentDrag.data);\n\n // We no longer suppress the native ghost image or manually update feedback\n // for mouse drags, relying on the browser's native rendering instead.\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:start', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: e.clientX, y: e.clientY }\n }\n }));\n },\n\n /**\n * Handle drag event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDrag: function (e, element) {\n // Guard against null state (race condition on fast interactions)\n if (!this.currentDrag) return;\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:drag', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: e.clientX, y: e.clientY },\n delta: {\n x: e.clientX - this.currentDrag.initialPosition.x,\n y: e.clientY - this.currentDrag.initialPosition.y\n }\n }\n }));\n },\n\n /**\n * Handle drag end event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDragEnd: function (e, element) {\n // Remove dragging class\n element.classList.remove('is-dragging');\n element.classList.add('is-dropped');\n setTimeout(() => element.classList.remove('is-dropped'), 300);\n\n // Accessibility: update aria-grabbed\n element.setAttribute('aria-grabbed', 'false');\n\n // Hide feedback\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n\n // Guard against null state\n const data = this.currentDrag?.data || this.getData(element);\n const initialPos = this.currentDrag?.initialPosition || { x: 0, y: 0 };\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:end', {\n bubbles: true,\n detail: {\n element: element,\n data: data,\n position: { x: e.clientX, y: e.clientY },\n delta: {\n x: e.clientX - initialPos.x,\n y: e.clientY - initialPos.y\n }\n }\n }));\n\n // Reset drag state\n this.currentDrag = null;\n },\n\n /**\n * Handle touch start event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchStart: function (e, element) {\n // Don't prevent default here \u2014 it blocks scrolling.\n // We only prevent default in touchmove once drag threshold is reached.\n const touch = e.touches[0];\n const rect = element.getBoundingClientRect();\n this.touchState = {\n element: element,\n startX: touch.clientX,\n startY: touch.clientY,\n lastX: touch.clientX,\n lastY: touch.clientY,\n // Keep preview anchored to the original grab point.\n offsetX: touch.clientX - rect.left,\n offsetY: touch.clientY - rect.top,\n startTime: Date.now(),\n isDragging: false\n };\n },\n\n /**\n * Handle touch move event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchMove: function (e, element) {\n if (!this.touchState) return;\n\n const touch = e.touches[0];\n this.touchState.lastX = touch.clientX;\n this.touchState.lastY = touch.clientY;\n const deltaX = touch.clientX - this.touchState.startX;\n const deltaY = touch.clientY - this.touchState.startY;\n\n // Only start dragging if moved a minimum distance\n if (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10) {\n // Now we know it's a drag, not a scroll \u2014 prevent default\n if (e.cancelable) e.preventDefault();\n\n if (!this.touchState.isDragging) {\n this.touchState.isDragging = true;\n element.classList.add('is-dragging');\n element.setAttribute('aria-grabbed', 'true');\n\n // Store drag state\n this.currentDrag = {\n element: element,\n initialPosition: { x: this.touchState.startX, y: this.touchState.startY },\n initialBounds: element.getBoundingClientRect(),\n data: this.getData(element),\n // Preserve where inside the element the drag started for accurate ghost positioning.\n offsetX: this.touchState.offsetX,\n offsetY: this.touchState.offsetY\n };\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:start', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: touch.clientX, y: touch.clientY }\n }\n }));\n }\n\n // Update feedback\n this.updateFeedback(touch.clientX, touch.clientY);\n\n // Dispatch event\n if (this.currentDrag) {\n element.dispatchEvent(new CustomEvent('draggable:drag', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: touch.clientX, y: touch.clientY },\n delta: { x: deltaX, y: deltaY }\n }\n }));\n\n this.updateTouchDropZone(touch.clientX, touch.clientY);\n\n // Reorder for touch\n const container = element.closest(this.containerSelector);\n if (container && container.contains(element)) {\n this.handleReorder(container, element, touch.clientX, touch.clientY);\n }\n }\n }\n },\n\n /**\n * Handle touch end event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchEnd: function (e, element) {\n if (this.touchState && this.touchState.isDragging) {\n if (e.cancelable) e.preventDefault();\n const endTouch = e.changedTouches?.[0];\n const endPosition = {\n x: endTouch?.clientX ?? this.touchState.lastX ?? this.touchState.startX,\n y: endTouch?.clientY ?? this.touchState.lastY ?? this.touchState.startY\n };\n\n const dropZone = this.resolveDropZoneAtPoint(endPosition.x, endPosition.y) || this.touchState.overZone;\n if (dropZone) {\n this.dispatchDrop(dropZone, endPosition);\n } else if (this.touchState.overZone) {\n this.touchState.overZone.classList.remove('is-drag-over');\n }\n\n element.classList.remove('is-dragging');\n element.classList.add('is-dropped');\n element.setAttribute('aria-grabbed', 'false');\n setTimeout(() => element.classList.remove('is-dropped'), 300);\n\n // Hide feedback\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n\n // Dispatch event\n const data = this.currentDrag?.data || this.getData(element);\n const startX = this.touchState?.startX || 0;\n const startY = this.touchState?.startY || 0;\n\n element.dispatchEvent(new CustomEvent('draggable:end', {\n bubbles: true,\n detail: {\n element: element,\n data: data,\n position: endPosition,\n delta: {\n x: endPosition.x - startX,\n y: endPosition.y - startY\n }\n }\n }));\n }\n\n // Reset states\n this.touchState = null;\n this.currentDrag = null;\n },\n\n /**\n * Handle drag over event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} _zone - Drop zone element\n */\n handleDragOver: function (e, _zone) {\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n },\n\n /**\n * Handle drag enter event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDragEnter: function (e, zone) {\n e.preventDefault();\n zone.classList.add('is-drag-over');\n },\n\n /**\n * Handle drag leave event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDragLeave: function (e, zone) {\n zone.classList.remove('is-drag-over');\n },\n\n /**\n * Handle drop event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDrop: function (e, zone) {\n e.preventDefault();\n this.dispatchDrop(zone, { x: e.clientX, y: e.clientY });\n },\n\n /**\n * Resolve a drop zone from viewport coordinates\n * @param {number} x\n * @param {number} y\n * @returns {HTMLElement|null}\n */\n resolveDropZoneAtPoint: function (x, y) {\n if (!Number.isFinite(x) || !Number.isFinite(y)) return null;\n\n // Prefer the full stacking list so overlays/top elements don't hide real drop targets.\n if (typeof document.elementsFromPoint === 'function') {\n const stacked = document.elementsFromPoint(x, y);\n for (const element of stacked) {\n const zone = element.closest('.vd-drop-zone');\n if (zone) return zone;\n }\n }\n\n const target = document.elementFromPoint(x, y);\n const targetZone = target ? target.closest('.vd-drop-zone') : null;\n if (targetZone) return targetZone;\n\n // Last-resort fallback for mobile emulation edge cases.\n const zones = document.querySelectorAll('.vd-drop-zone');\n for (const zone of zones) {\n const rect = zone.getBoundingClientRect();\n if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {\n return zone;\n }\n }\n\n return null;\n },\n\n /**\n * Track and update active drop-zone hover state on touch devices\n * @param {number} x\n * @param {number} y\n */\n updateTouchDropZone: function (x, y) {\n if (!this.touchState) return;\n\n const nextZone = this.resolveDropZoneAtPoint(x, y);\n const prevZone = this.touchState.overZone || null;\n\n if (prevZone && prevZone !== nextZone) {\n prevZone.classList.remove('is-drag-over');\n }\n\n if (nextZone && nextZone !== prevZone) {\n nextZone.classList.add('is-drag-over');\n }\n\n this.touchState.overZone = nextZone || null;\n },\n\n /**\n * Dispatch a normalized drop event for mouse and touch flows\n * @param {HTMLElement} zone\n * @param {{x:number, y:number}} position\n */\n dispatchDrop: function (zone, position) {\n zone.classList.remove('is-drag-over');\n zone.dispatchEvent(new CustomEvent('draggable:drop', {\n bubbles: true,\n detail: {\n zone: zone,\n element: this.currentDrag?.element,\n data: this.currentDrag?.data,\n position: position\n }\n }));\n },\n\n /**\n * Reorder elements in container based on cursor position\n * @param {HTMLElement} container \n * @param {HTMLElement} element \n * @param {number} clientX \n * @param {number} clientY \n */\n handleReorder: function (container, element, clientX, clientY) {\n const isVertical = container.classList.contains('vd-draggable-container-vertical');\n const siblings = [...container.querySelectorAll('.vd-draggable-item:not(.is-dragging), .vd-draggable:not(.is-dragging)')];\n\n const nextSibling = siblings.reduce((closest, child) => {\n const box = child.getBoundingClientRect();\n const offset = isVertical\n ? clientY - box.top - box.height / 2\n : clientX - box.left - box.width / 2;\n\n if (offset < 0 && offset > closest.offset) {\n return { offset: offset, element: child };\n } else {\n return closest;\n }\n }, { offset: Number.NEGATIVE_INFINITY }).element;\n\n if (nextSibling == null) {\n container.appendChild(element);\n } else {\n container.insertBefore(element, nextSibling);\n }\n },\n\n /**\n * Handle keyboard events\n * @param {KeyboardEvent} e - Keyboard event\n * @param {HTMLElement} element - Draggable element\n */\n handleKeydown: function (e, element) {\n switch (e.key) {\n case 'Enter':\n case ' ':\n e.preventDefault();\n // Trigger click or custom action\n element.click();\n break;\n case 'Escape':\n // Cancel drag if in progress\n if (element.classList.contains('is-dragging')) {\n element.classList.remove('is-dragging');\n element.setAttribute('aria-grabbed', 'false');\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n this.currentDrag = null;\n }\n break;\n case 'ArrowUp':\n case 'ArrowLeft': {\n e.preventDefault();\n const prev = element.previousElementSibling;\n if (prev && (prev.classList.contains('vd-draggable') || prev.classList.contains('vd-draggable-item'))) {\n element.parentNode.insertBefore(element, prev);\n element.focus();\n element.dispatchEvent(new CustomEvent('draggable:reorder', {\n bubbles: true,\n detail: { element: element, direction: 'up' }\n }));\n }\n break;\n }\n case 'ArrowDown':\n case 'ArrowRight': {\n e.preventDefault();\n const next = element.nextElementSibling;\n if (next && (next.classList.contains('vd-draggable') || next.classList.contains('vd-draggable-item'))) {\n element.parentNode.insertBefore(next, element);\n element.focus();\n element.dispatchEvent(new CustomEvent('draggable:reorder', {\n bubbles: true,\n detail: { element: element, direction: 'down' }\n }));\n }\n break;\n }\n }\n },\n\n /**\n * Get data from draggable element\n * @param {HTMLElement} element - Draggable element\n * @returns {string} Data associated with the element\n */\n getData: function (element) {\n return element.dataset.draggable || element.textContent.trim();\n },\n\n /**\n * Update drag feedback element\n * @param {number} x - Current X coordinate\n * @param {number} y - Current Y coordinate\n */\n updateFeedback: function (x, y) {\n if (!this.currentDrag) return;\n\n // Show feedback\n this.feedbackElement.classList.remove('hidden');\n\n // Update feedback content\n const rect = this.currentDrag.initialBounds;\n this.feedbackElement.innerHTML = '';\n const clone = this.currentDrag.element.cloneNode(true);\n this.feedbackElement.appendChild(clone);\n\n // Set styles\n const offsetX = this.currentDrag.offsetX ?? 20;\n const offsetY = this.currentDrag.offsetY ?? 20;\n Object.assign(this.feedbackElement.style, {\n left: (x - offsetX) + 'px',\n top: (y - offsetY) + 'px',\n width: rect.width + 'px',\n height: rect.height + 'px'\n });\n },\n\n /**\n * Make an element draggable programmatically\n * @param {HTMLElement|string} element - Element or selector\n * @param {Object} options - Configuration options\n */\n makeDraggable: function (element, options = {}) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n\n if (el && !this.instances.has(el)) {\n // Add classes and attributes\n el.classList.add('vd-draggable');\n el.setAttribute('draggable', 'true');\n\n // Set options\n if (options.data) {\n el.dataset.draggable = options.data;\n }\n\n // Initialize\n this.initDraggable(el);\n }\n },\n\n /**\n * Remove draggable functionality from an element\n * @param {HTMLElement|string} element - Element or selector\n */\n removeDraggable: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n\n if (el && this.instances.has(el)) {\n // Clean up\n const instance = this.instances.get(el);\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n\n // Remove classes and attributes\n el.classList.remove('vd-draggable');\n el.removeAttribute('draggable');\n el.removeAttribute('data-draggable');\n }\n },\n\n /**\n * Destroy a draggable instance and clean up event listeners\n * @param {HTMLElement} element - Draggable element\n */\n destroy: function (element) {\n this.removeDraggable(element);\n },\n\n /**\n * Destroy all draggable instances\n */\n destroyAll: function () {\n const instances = Array.from(this.instances.keys());\n instances.forEach(element => this.destroy(element));\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('draggable', Draggable);\n }\n\n // Expose globally\n window.VanduoDraggable = Draggable;\n})();\n", "/**\n * Vanduo Framework \u2013 LazyLoad Component\n * v1.2.1\n *\n * Provides two levels of API:\n *\n * LOW-LEVEL (generic IntersectionObserver wrapper)\n * VanduoLazyLoad.observe(element, callback, options?)\n * VanduoLazyLoad.unobserve(element)\n * VanduoLazyLoad.unobserveAll()\n *\n * HIGH-LEVEL (HTML-section fetcher, mirrors vanduo-docs SPA behaviour)\n * VanduoLazyLoad.loadSection(url, containerEl, options?)\n * options: { placeholder, threshold, rootMargin, onLoaded, onError }\n * placeholder: 'skeleton' | 'spinner' | HTMLString (default: 'skeleton')\n *\n * ATTRIBUTE-DRIVEN (zero-JS usage, wired by init())\n *
    \u2026
    \n *\n * EVENTS dispatched on the host element:\n * lazysection:loading \u2014 fetch started\n * lazysection:loaded \u2014 content injected\n * lazysection:error \u2014 fetch failed\n */\n\n(function () {\n 'use strict';\n\n /* \u2500\u2500 Private state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /** @type {Map} */\n const _observerMap = new Map();\n\n /* \u2500\u2500 Security helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Returns true only if `url` resolves to the same origin as the page\n * (relative paths and same-origin absolute URLs are allowed).\n * Cross-origin URLs are rejected to prevent SSRF-style fetch abuse.\n * @param {string} url\n * @returns {boolean}\n */\n function _isSafeUrl(url) {\n try {\n // Relative URLs (no origin) are always safe\n const resolved = new URL(url, window.location.href);\n return resolved.origin === window.location.origin;\n } catch (_) {\n return false;\n }\n }\n\n /**\n * Safely inject fetched HTML into a container by parsing it with\n * DOMParser (avoids script execution) and replacing children via\n * standard DOM APIs instead of raw innerHTML assignment.\n * @param {Element} containerEl\n * @param {string} html\n */\n function _safeInjectHtml(containerEl, html) {\n const parser = new DOMParser();\n const doc = parser.parseFromString(html.trim(), 'text/html'); // CodeQL [js/xss-through-dom] Intentional: parsed nodes are sanitized below before adoption into live DOM\n\n const DANGEROUS_TAGS = ['SCRIPT', 'IFRAME', 'OBJECT', 'EMBED', 'FORM', 'BASE', 'LINK', 'META', 'STYLE'];\n for (const tag of DANGEROUS_TAGS) {\n const els = doc.querySelectorAll(tag);\n for (let i = els.length - 1; i >= 0; i--) {\n els[i].parentNode.removeChild(els[i]);\n }\n }\n\n // Strip dangerous attributes (e.g. onerror, javascript: urls) from all elements\n function _sanitizeNode(node) {\n if (node.nodeType === Node.ELEMENT_NODE) {\n const attrs = node.attributes;\n for (let i = attrs.length - 1; i >= 0; i--) {\n const attrName = attrs[i].name.toLowerCase();\n const attrValue = attrs[i].value.toLowerCase();\n const trimmedValue = attrValue.trim();\n if (\n attrName.startsWith('on') ||\n trimmedValue.startsWith('javascript:') ||\n trimmedValue.startsWith('data:') ||\n trimmedValue.startsWith('vbscript:')\n ) {\n node.removeAttribute(attrs[i].name);\n }\n }\n const children = node.childNodes;\n for (let i = 0; i < children.length; i++) {\n _sanitizeNode(children[i]);\n }\n }\n }\n _sanitizeNode(doc.body);\n\n // Collect all top-level body children\n const nodes = Array.from(doc.body.childNodes);\n // Clear container and append parsed nodes\n while (containerEl.firstChild) {\n containerEl.removeChild(containerEl.firstChild);\n }\n nodes.forEach(function (node) {\n containerEl.appendChild(document.adoptNode(node));\n });\n }\n\n /* \u2500\u2500 Placeholder HTML \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n function _skeletonHtml() {\n return '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + 'Loading\u2026
    ';\n }\n\n function _spinnerHtml() {\n return '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + 'Loading\u2026
    ';\n }\n\n function _resolvePlaceholder(placeholder) {\n if (!placeholder || placeholder === 'skeleton') return _skeletonHtml();\n if (placeholder === 'spinner') return _spinnerHtml();\n // Caller-supplied HTML string\n return placeholder;\n }\n\n /* \u2500\u2500 Dispatch helper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n function _dispatch(el, eventName, detail) {\n el.dispatchEvent(new CustomEvent(eventName, { bubbles: true, detail: detail || {} }));\n }\n\n /* \u2500\u2500 VanduoLazyLoad \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const VanduoLazyLoad = {\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * LOW-LEVEL API\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Observe an element. `callback` is invoked once when the element\n * enters the viewport, then the element is automatically unobserved.\n *\n * @param {Element} element\n * @param {function(Element): void} callback\n * @param {{ threshold?: number, rootMargin?: string }} [options]\n */\n observe: function (element, callback, options) {\n if (!(element instanceof Element)) {\n console.warn('[VanduoLazyLoad] observe() requires a DOM Element.');\n return;\n }\n if (typeof callback !== 'function') {\n console.warn('[VanduoLazyLoad] observe() requires a callback function.');\n return;\n }\n // Already observed \u2014 ignore\n if (_observerMap.has(element)) return;\n\n const threshold = (options && options.threshold != null) ? options.threshold : 0;\n const rootMargin = (options && options.rootMargin) ? options.rootMargin : '0px';\n\n const observer = new IntersectionObserver(function (entries, obs) {\n entries.forEach(function (entry) {\n if (entry.isIntersecting) {\n obs.unobserve(entry.target);\n _observerMap.delete(entry.target);\n try {\n callback(entry.target);\n } catch (e) {\n console.error('[VanduoLazyLoad] Callback threw:', e);\n }\n }\n });\n }, { threshold: threshold, rootMargin: rootMargin });\n\n _observerMap.set(element, observer);\n observer.observe(element);\n },\n\n /**\n * Stop observing an element that was previously passed to observe().\n * @param {Element} element\n */\n unobserve: function (element) {\n const observer = _observerMap.get(element);\n if (observer) {\n observer.unobserve(element);\n _observerMap.delete(element);\n }\n },\n\n /**\n * Stop observing ALL currently observed elements.\n */\n unobserveAll: function () {\n _observerMap.forEach(function (observer, element) {\n observer.unobserve(element);\n });\n _observerMap.clear();\n },\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * HIGH-LEVEL API\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Fetch an HTML partial and inject it into `containerEl` when the\n * container enters the viewport. A placeholder is shown immediately.\n *\n * @param {string} url URL of the HTML partial to fetch\n * @param {Element} containerEl Target element whose content will be replaced\n * @param {{\n * placeholder?: 'skeleton'|'spinner'|string,\n * threshold?: number,\n * rootMargin?: string,\n * onLoaded?: function(Element): void,\n * onError?: function(Error): void\n * }} [options]\n */\n loadSection: function (url, containerEl, options) {\n if (typeof url !== 'string' || !url) {\n console.warn('[VanduoLazyLoad] loadSection() requires a non-empty URL string.');\n return;\n }\n if (!(containerEl instanceof Element)) {\n console.warn('[VanduoLazyLoad] loadSection() requires a DOM Element as containerEl.');\n return;\n }\n // Reject cross-origin URLs to prevent SSRF-style fetch abuse\n if (!_isSafeUrl(url)) {\n console.error('[VanduoLazyLoad] loadSection() blocked cross-origin URL:', url);\n return;\n }\n\n const opts = options || {};\n // Placeholders are known-safe static HTML strings built internally\n const placeholderHtml = _resolvePlaceholder(opts.placeholder);\n\n // Use _safeInjectHtml instead of innerHTML to prevent XSS from caller-supplied placeholders\n _safeInjectHtml(containerEl, placeholderHtml);\n _dispatch(containerEl, 'lazysection:loading', { url: url });\n\n // Fetch when visible\n this.observe(containerEl, function () {\n const controller = new window.AbortController();\n const timeoutId = setTimeout(function () { controller.abort(); }, 10000);\n\n window.fetch(url, { signal: controller.signal })\n .then(function (res) {\n clearTimeout(timeoutId);\n if (!res.ok) throw new Error('HTTP ' + res.status);\n return res.text();\n })\n .then(function (html) {\n // Use DOMParser to parse fetched content safely, avoiding\n // raw innerHTML assignment of externally-sourced strings\n _safeInjectHtml(containerEl, html);\n _dispatch(containerEl, 'lazysection:loaded', { url: url });\n // Re-init Vanduo components inside the new content\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.init();\n }\n if (typeof opts.onLoaded === 'function') {\n opts.onLoaded(containerEl);\n }\n })\n .catch(function (err) {\n // Build error node via DOM APIs \u2014 no dynamic HTML strings\n const alertEl = document.createElement('div');\n alertEl.className = 'vd-alert vd-alert-error';\n alertEl.setAttribute('role', 'alert');\n const msgEl = document.createElement('span');\n msgEl.textContent = 'Failed to load content. ';\n const detailEl = document.createElement('small');\n detailEl.style.opacity = '0.7';\n detailEl.textContent = err.message;\n alertEl.appendChild(msgEl);\n alertEl.appendChild(detailEl);\n while (containerEl.firstChild) {\n containerEl.removeChild(containerEl.firstChild);\n }\n containerEl.appendChild(alertEl);\n _dispatch(containerEl, 'lazysection:error', { url: url, error: err });\n console.error('[VanduoLazyLoad] loadSection failed:', err);\n if (typeof opts.onError === 'function') {\n opts.onError(err);\n }\n });\n }, { threshold: opts.threshold, rootMargin: opts.rootMargin });\n },\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * ATTRIBUTE-DRIVEN INIT\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Scan the DOM for [data-vd-lazy] elements and wire them up.\n * Safe to call multiple times \u2014 already-observed elements are skipped.\n */\n init: function () {\n const self = this;\n const elements = document.querySelectorAll('[data-vd-lazy]');\n elements.forEach(function (el) {\n // Skip already-observed or already-loaded elements\n if (_observerMap.has(el) || el.dataset.vdLazyState === 'loading' || el.dataset.vdLazyState === 'loaded') return;\n\n const url = el.getAttribute('data-vd-lazy');\n if (!url) return;\n\n el.dataset.vdLazyState = 'loading';\n const placeholder = el.getAttribute('data-vd-lazy-placeholder') || 'skeleton';\n\n self.loadSection(url, el, {\n placeholder: placeholder,\n onLoaded: function () {\n el.dataset.vdLazyState = 'loaded';\n },\n onError: function () {\n el.dataset.vdLazyState = 'error';\n }\n });\n });\n }\n };\n\n /* \u2500\u2500 Register with Vanduo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('LazyLoad', VanduoLazyLoad);\n }\n\n /* \u2500\u2500 Global convenience alias \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n window.VanduoLazyLoad = VanduoLazyLoad;\n\n})();\n", "/**\n * Vanduo Framework - Glass Scroll Activation\n * Generic scroll-aware glass activation via IntersectionObserver.\n *\n * Usage:\n * Add `data-glass-scroll` to any element carrying a `.vd-glass*` class.\n * By default the previous sibling is used as the sentinel.\n * Point to a custom sentinel via `data-glass-sentinel=\"\"`.\n *\n * Behaviour:\n * - Adds `.is-glass-active` when the sentinel leaves the viewport (scrolled past).\n * - Removes `.is-glass-active` when the sentinel re-enters the viewport.\n */\n(function () {\n 'use strict';\n\n const GlassScroll = {\n /** @type {Map} */\n observers: new Map(),\n\n init: function () {\n document.querySelectorAll('[data-glass-scroll]').forEach(el => {\n if (this.observers.has(el)) return;\n this.initElement(el);\n });\n },\n\n /**\n * Wire up a single scroll-activated glass element.\n * @param {HTMLElement} el\n */\n initElement: function (el) {\n const sentinelSelector = el.dataset.glassSentinel;\n let sentinel;\n\n if (sentinelSelector) {\n sentinel = document.querySelector(sentinelSelector);\n }\n\n if (!sentinel) {\n // Fall back to the previous sibling element\n sentinel = el.previousElementSibling;\n }\n\n if (!sentinel) {\n // No sentinel available \u2014 activate immediately so glass is always shown\n el.classList.add('is-glass-active');\n return;\n }\n\n const observer = new IntersectionObserver(\n (entries) => {\n entries.forEach(entry => {\n // Active when sentinel is NOT intersecting (scrolled past it)\n el.classList.toggle('is-glass-active', !entry.isIntersecting);\n });\n },\n { threshold: 0, rootMargin: '0px' }\n );\n\n observer.observe(sentinel);\n this.observers.set(el, observer);\n },\n\n /**\n * Disconnect and remove a single element's observer.\n * @param {HTMLElement} el\n */\n destroy: function (el) {\n const observer = this.observers.get(el);\n if (observer) {\n observer.disconnect();\n this.observers.delete(el);\n }\n },\n\n destroyAll: function () {\n this.observers.forEach((observer, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('glassScroll', GlassScroll);\n }\n\n window.VanduoGlassScroll = GlassScroll;\n})();\n", "/**\n * Vanduo Framework - Water Morph Effect Component\n * Liquid wave content-swap animation on click\n *\n * Usage:\n * Add .vd-morph or [data-vd-morph] to any element.\n * Provide .vd-morph-content.vd-morph-current and .vd-morph-content.vd-morph-next children.\n * Wave and shine layers are auto-created if absent.\n *\n * JS API:\n * VanduoMorph.morph(el) \u2014 trigger morph programmatically (from center)\n * VanduoMorph.destroy(el) \u2014 tear down listeners for one element\n * VanduoMorph.destroyAll() \u2014 tear down all instances\n */\n\n(function () {\n 'use strict';\n\n const MORPH_DURATION_MS = 750;\n\n const Morph = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-morph, [data-vd-morph]');\n elements.forEach(function (el) {\n if (Morph.instances.has(el)) return;\n if (el.getAttribute('data-vd-morph') === 'manual') return;\n Morph.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n Morph._ensureLayers(el);\n\n const cleanup = [];\n let morphing = false;\n\n const handleClick = function (e) {\n if (morphing) return;\n Morph._runMorph(el, e, function () { morphing = false; });\n morphing = true;\n };\n\n el.addEventListener('click', handleClick);\n cleanup.push(function () { el.removeEventListener('click', handleClick); });\n\n this.instances.set(el, { cleanup: cleanup });\n },\n\n morph: function (el) {\n if (!el) return;\n if (!this.instances.has(el)) this.initInstance(el);\n this._runMorph(el, null, null);\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(function (fn) { fn(); });\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) { Morph.destroy(el); });\n },\n\n /* \u2500\u2500 Internal helpers \u2500\u2500 */\n\n _ensureLayers: function (el) {\n if (!el.querySelector('.vd-morph-wave')) {\n const wave = document.createElement('span');\n wave.className = 'vd-morph-wave';\n wave.setAttribute('aria-hidden', 'true');\n el.insertBefore(wave, el.firstChild);\n }\n if (!el.querySelector('.vd-morph-shine')) {\n const shine = document.createElement('span');\n shine.className = 'vd-morph-shine';\n shine.setAttribute('aria-hidden', 'true');\n const waveEl = el.querySelector('.vd-morph-wave');\n if (waveEl && waveEl.nextSibling) {\n el.insertBefore(shine, waveEl.nextSibling);\n } else {\n el.insertBefore(shine, el.firstChild);\n }\n }\n },\n\n _runMorph: function (el, pointerEvent, onComplete) {\n const wave = el.querySelector('.vd-morph-wave');\n if (wave) {\n const rect = el.getBoundingClientRect();\n const cx = rect.left + rect.width / 2;\n const cy = rect.top + rect.height / 2;\n const px = pointerEvent ? (pointerEvent.clientX || cx) : cx;\n const py = pointerEvent ? (pointerEvent.clientY || cy) : cy;\n wave.style.left = (px - rect.left) + 'px';\n wave.style.top = (py - rect.top) + 'px';\n }\n\n el.classList.add('is-morphing');\n\n let duration = MORPH_DURATION_MS;\n const custom = getComputedStyle(el).getPropertyValue('--morph-duration');\n if (custom) {\n const parsed = parseFloat(custom);\n if (!isNaN(parsed)) duration = parsed * (custom.indexOf('ms') !== -1 ? 1 : 1000);\n }\n\n setTimeout(function () {\n el.classList.remove('is-morphing');\n\n const current = el.querySelector('.vd-morph-current');\n const next = el.querySelector('.vd-morph-next');\n if (current && next) {\n current.classList.remove('vd-morph-current');\n current.classList.add('vd-morph-next');\n next.classList.remove('vd-morph-next');\n next.classList.add('vd-morph-current');\n }\n\n if (typeof onComplete === 'function') onComplete();\n }, duration);\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('morph', Morph);\n }\n\n window.VanduoMorph = Morph;\n\n})();\n", "/**\n * Vanduo Framework \u2014 Expanding flex cards\n * Click / Enter / Space / Arrow keys to change the active card.\n *\n * Usage:\n *
    \n * Use data-vd-expanding-cards=\"manual\" to skip auto-init.\n */\n\n(function () {\n 'use strict';\n\n const ExpandingCards = {\n instances: new Map(),\n\n init: function () {\n document.querySelectorAll('.vd-expanding-cards').forEach(function (el) {\n if (el.getAttribute('data-vd-expanding-cards') === 'manual') return;\n if (ExpandingCards.instances.has(el)) return;\n ExpandingCards.initContainer(el);\n });\n },\n\n initContainer: function (container) {\n const cleanup = [];\n\n const getCards = function () {\n return Array.prototype.slice.call(container.querySelectorAll('.vd-expanding-card'));\n };\n\n const setActive = function (card) {\n const cards = getCards();\n if (!card || cards.indexOf(card) === -1) return;\n cards.forEach(function (c) {\n c.classList.toggle('is-active', c === card);\n });\n card.focus({ preventScroll: true });\n };\n\n const onClick = function (e) {\n const t = e.target;\n const card = t.closest ? t.closest('.vd-expanding-card') : null;\n if (!card || !container.contains(card)) return;\n setActive(card);\n };\n\n const onKeydown = function (e) {\n if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'Home' && e.key !== 'End') {\n return;\n }\n const cards = getCards().filter(function (c) {\n return c.offsetParent !== null || c.getClientRects().length > 0;\n });\n if (!cards.length) return;\n const activeEl = document.activeElement;\n let idx = cards.indexOf(activeEl);\n if (idx < 0) {\n idx = cards.findIndex(function (c) {\n return c.classList.contains('is-active');\n });\n }\n if (idx < 0) idx = 0;\n\n if (e.key === 'ArrowLeft') {\n e.preventDefault();\n setActive(cards[Math.max(0, idx - 1)]);\n } else if (e.key === 'ArrowRight') {\n e.preventDefault();\n setActive(cards[Math.min(cards.length - 1, idx + 1)]);\n } else if (e.key === 'Home') {\n e.preventDefault();\n setActive(cards[0]);\n } else if (e.key === 'End') {\n e.preventDefault();\n setActive(cards[cards.length - 1]);\n }\n };\n\n container.addEventListener('click', onClick);\n cleanup.push(function () {\n container.removeEventListener('click', onClick);\n });\n\n container.addEventListener('keydown', onKeydown);\n cleanup.push(function () {\n container.removeEventListener('keydown', onKeydown);\n });\n\n getCards().forEach(function (card) {\n if (!card.hasAttribute('tabindex')) {\n card.setAttribute('tabindex', '0');\n }\n card.setAttribute('role', 'button');\n if (!card.hasAttribute('aria-pressed')) {\n card.setAttribute('aria-pressed', card.classList.contains('is-active') ? 'true' : 'false');\n }\n });\n\n const syncAria = function () {\n getCards().forEach(function (card) {\n card.setAttribute('aria-pressed', card.classList.contains('is-active') ? 'true' : 'false');\n });\n };\n\n const mo = new MutationObserver(syncAria);\n mo.observe(container, { attributes: true, subtree: true, attributeFilter: ['class'] });\n cleanup.push(function () {\n mo.disconnect();\n });\n syncAria();\n\n ExpandingCards.instances.set(container, { cleanup: cleanup });\n },\n\n destroy: function (container) {\n const inst = this.instances.get(container);\n if (!inst) return;\n inst.cleanup.forEach(function (fn) {\n fn();\n });\n this.instances.delete(container);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) {\n ExpandingCards.destroy(el);\n });\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('expandingCards', ExpandingCards);\n }\n\n window.VanduoExpandingCards = ExpandingCards;\n})();\n", "/**\n * Vanduo Framework \u2014 Timeline animated reveal\n * Opt-in: add `.vd-timeline-animated` to a `.vd-timeline` container.\n * Uses IntersectionObserver to add `.is-revealed` per item with staggered delays.\n * Optional: `.vd-timeline-playback` with `[data-vd-timeline-prev|next|play|pause]` for stepped control.\n */\n\n(function () {\n 'use strict';\n\n const STAGGER_MS = 140;\n const MAX_STAGGER_INDEX = 7;\n const PLAY_INTERVAL_MS = 800;\n\n function countRevealedPrefix(items) {\n let count = 0;\n for (let i = 0; i < items.length; i++) {\n if (!items[i].classList.contains('is-revealed')) break;\n count++;\n }\n return count;\n }\n\n function findPlaybackControls(container) {\n return container.parentElement || document.body;\n }\n\n function initPlayback(container, items, cleanup) {\n items.forEach(function (item) {\n item.classList.remove('is-revealed');\n });\n\n const scope = findPlaybackControls(container);\n const prevBtn = scope.querySelector('[data-vd-timeline-prev]');\n const nextBtn = scope.querySelector('[data-vd-timeline-next]');\n const playBtn = scope.querySelector('[data-vd-timeline-play]');\n const pauseBtn = scope.querySelector('[data-vd-timeline-pause]');\n\n let playTimer = null;\n\n function updateNavButtons() {\n const k = countRevealedPrefix(items);\n const n = items.length;\n if (prevBtn) {\n const atStart = k === 0;\n prevBtn.disabled = atStart;\n prevBtn.setAttribute('aria-disabled', atStart ? 'true' : 'false');\n }\n if (nextBtn) {\n const atEnd = k >= n;\n nextBtn.disabled = atEnd;\n nextBtn.setAttribute('aria-disabled', atEnd ? 'true' : 'false');\n }\n if (playBtn) {\n playBtn.setAttribute('aria-pressed', playTimer ? 'true' : 'false');\n }\n if (pauseBtn) {\n pauseBtn.disabled = !playTimer;\n }\n }\n\n function stepNext() {\n const k = countRevealedPrefix(items);\n if (k < items.length) {\n items[k].classList.add('is-revealed');\n }\n updateNavButtons();\n }\n\n function stepPrev() {\n const k = countRevealedPrefix(items);\n if (k > 0) {\n items[k - 1].classList.remove('is-revealed');\n }\n updateNavButtons();\n }\n\n function play() {\n if (playTimer) return;\n playTimer = setInterval(function () {\n if (countRevealedPrefix(items) >= items.length) {\n pause();\n return;\n }\n stepNext();\n }, PLAY_INTERVAL_MS);\n updateNavButtons();\n }\n\n function pause() {\n if (playTimer) {\n clearInterval(playTimer);\n playTimer = null;\n }\n updateNavButtons();\n }\n\n function addClick(el, fn) {\n if (!el) return;\n const handler = function (e) {\n e.preventDefault();\n fn();\n };\n el.addEventListener('click', handler);\n cleanup.push(function () {\n el.removeEventListener('click', handler);\n });\n }\n\n addClick(prevBtn, stepPrev);\n addClick(nextBtn, stepNext);\n addClick(playBtn, play);\n addClick(pauseBtn, pause);\n\n cleanup.push(function () {\n pause();\n });\n\n updateNavButtons();\n\n return {\n stepNext: stepNext,\n stepPrev: stepPrev,\n play: play,\n pause: pause\n };\n }\n\n const Timeline = {\n instances: new Map(),\n\n init: function () {\n document.querySelectorAll('.vd-timeline.vd-timeline-animated').forEach(function (el) {\n if (Timeline.instances.has(el)) return;\n Timeline.initInstance(el);\n });\n },\n\n reinit: function () {\n Timeline.destroyAll();\n Timeline.init();\n },\n\n initInstance: function (container) {\n const cleanup = [];\n const items = Array.prototype.filter.call(container.children, function (child) {\n return child.classList && child.classList.contains('vd-timeline-item');\n });\n\n items.forEach(function (item, i) {\n const idx = Math.min(i, MAX_STAGGER_INDEX);\n item.style.setProperty('--vd-timeline-reveal-delay', (idx * STAGGER_MS) + 'ms');\n });\n\n const reducedMotion = typeof window.matchMedia === 'function'\n && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n if (reducedMotion) {\n items.forEach(function (item) {\n item.classList.add('is-revealed');\n });\n Timeline.instances.set(container, { cleanup: cleanup });\n return;\n }\n\n const playback = container.classList && container.classList.contains('vd-timeline-playback');\n\n if (playback) {\n const playbackApi = initPlayback(container, items, cleanup);\n Timeline.instances.set(container, { cleanup: cleanup, playback: playbackApi });\n return;\n }\n\n if (typeof IntersectionObserver === 'undefined') {\n items.forEach(function (item) {\n item.classList.add('is-revealed');\n });\n Timeline.instances.set(container, { cleanup: cleanup });\n return;\n }\n\n const observer = new IntersectionObserver(function (entries) {\n entries.forEach(function (entry) {\n if (!entry.isIntersecting) return;\n entry.target.classList.add('is-revealed');\n observer.unobserve(entry.target);\n });\n }, {\n root: null,\n rootMargin: '0px 0px -10% 0px',\n threshold: 0.15\n });\n\n items.forEach(function (item) {\n observer.observe(item);\n });\n\n cleanup.push(function () {\n observer.disconnect();\n });\n\n Timeline.instances.set(container, { cleanup: cleanup });\n },\n\n destroy: function (container) {\n const inst = this.instances.get(container);\n if (!inst) return;\n inst.cleanup.forEach(function (fn) {\n fn();\n });\n this.instances.delete(container);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) {\n Timeline.destroy(el);\n });\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('timeline', Timeline);\n }\n\n window.VanduoTimeline = Timeline;\n})();\n", "/**\n * Vanduo Framework - Flow (Carousel/Slider) Component\n * Touch-enabled carousel with slide/fade transitions, autoplay, indicators\n */\n\n(function () {\n 'use strict';\n\n const Flow = {\n instances: new Map(),\n\n init: function () {\n const carousels = document.querySelectorAll('.vd-flow, .vd-carousel');\n carousels.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const track = el.querySelector('.vd-flow-track');\n if (!track) return;\n\n const slides = Array.from(track.querySelectorAll('.vd-flow-slide'));\n if (slides.length === 0) return;\n\n const isFade = el.classList.contains('vd-flow-fade');\n const autoplay = el.hasAttribute('data-vd-autoplay');\n const interval = parseInt(el.getAttribute('data-vd-interval'), 10) || 5000;\n const loop = el.getAttribute('data-vd-loop') !== 'false';\n\n const state = {\n current: 0,\n total: slides.length,\n autoplayTimer: null,\n isFade: isFade,\n loop: loop,\n isDragging: false,\n startX: 0,\n currentX: 0,\n threshold: 50\n };\n\n const cleanup = [];\n\n // Set initial active slide\n slides.forEach((slide, i) => {\n slide.setAttribute('role', 'group');\n slide.setAttribute('aria-roledescription', 'slide');\n slide.setAttribute('aria-label', 'Slide ' + (i + 1) + ' of ' + slides.length);\n if (i === 0) slide.classList.add('is-active');\n });\n\n el.setAttribute('role', 'region');\n el.setAttribute('aria-roledescription', 'carousel');\n if (!el.getAttribute('aria-label')) {\n el.setAttribute('aria-label', 'Carousel');\n }\n\n // Live region for announcements\n const liveRegion = document.createElement('div');\n liveRegion.setAttribute('aria-live', 'polite');\n liveRegion.setAttribute('aria-atomic', 'true');\n liveRegion.className = 'sr-only';\n liveRegion.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);';\n el.appendChild(liveRegion);\n\n const goTo = (index, announce) => {\n if (announce === undefined) announce = true;\n let target = index;\n if (state.loop) {\n target = ((index % state.total) + state.total) % state.total;\n } else {\n target = Math.max(0, Math.min(index, state.total - 1));\n }\n\n const prev = state.current;\n state.current = target;\n\n if (state.isFade) {\n slides.forEach((s, i) => {\n s.classList.toggle('is-active', i === target);\n });\n } else {\n track.style.transform = 'translateX(-' + (target * 100) + '%)';\n }\n\n // Update indicators\n const indicators = el.querySelectorAll('.vd-flow-indicator');\n indicators.forEach((ind, i) => {\n ind.classList.toggle('is-active', i === target);\n ind.setAttribute('aria-selected', i === target ? 'true' : 'false');\n });\n\n // Update slide ARIA\n slides.forEach((s, i) => {\n s.setAttribute('aria-hidden', i !== target ? 'true' : 'false');\n });\n\n if (announce) {\n liveRegion.textContent = 'Slide ' + (target + 1) + ' of ' + state.total;\n }\n\n el.dispatchEvent(new CustomEvent('flow:change', {\n detail: { current: target, previous: prev, total: state.total }\n }));\n };\n\n const next = () => goTo(state.current + 1);\n const prev = () => goTo(state.current - 1);\n\n // Controls\n const prevBtn = el.querySelector('.vd-flow-prev');\n const nextBtn = el.querySelector('.vd-flow-next');\n\n if (prevBtn) {\n const h = () => prev();\n prevBtn.addEventListener('click', h);\n cleanup.push(() => prevBtn.removeEventListener('click', h));\n }\n if (nextBtn) {\n const h = () => next();\n nextBtn.addEventListener('click', h);\n cleanup.push(() => nextBtn.removeEventListener('click', h));\n }\n\n // Indicators\n const indicators = el.querySelectorAll('.vd-flow-indicator');\n indicators.forEach((ind, i) => {\n ind.setAttribute('role', 'tab');\n ind.setAttribute('aria-selected', i === 0 ? 'true' : 'false');\n ind.setAttribute('aria-label', 'Go to slide ' + (i + 1));\n const h = () => goTo(i);\n ind.addEventListener('click', h);\n cleanup.push(() => ind.removeEventListener('click', h));\n });\n\n // Keyboard navigation\n const keyHandler = (e) => {\n if (e.key === 'ArrowLeft') { prev(); e.preventDefault(); }\n if (e.key === 'ArrowRight') { next(); e.preventDefault(); }\n };\n el.setAttribute('tabindex', '0');\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n\n // Touch / pointer support\n const pointerDown = (e) => {\n state.isDragging = true;\n state.startX = e.clientX || (e.touches && e.touches[0].clientX) || 0;\n state.currentX = state.startX;\n el.classList.add('is-dragging');\n };\n\n const pointerMove = (e) => {\n if (!state.isDragging) return;\n state.currentX = e.clientX || (e.touches && e.touches[0].clientX) || 0;\n };\n\n const pointerUp = () => {\n if (!state.isDragging) return;\n state.isDragging = false;\n el.classList.remove('is-dragging');\n const diff = state.startX - state.currentX;\n if (Math.abs(diff) > state.threshold) {\n if (diff > 0) next();\n else prev();\n }\n };\n\n el.addEventListener('mousedown', pointerDown);\n el.addEventListener('mousemove', pointerMove);\n el.addEventListener('mouseup', pointerUp);\n el.addEventListener('mouseleave', pointerUp);\n el.addEventListener('touchstart', pointerDown, { passive: true });\n el.addEventListener('touchmove', pointerMove, { passive: true });\n el.addEventListener('touchend', pointerUp);\n\n cleanup.push(\n () => el.removeEventListener('mousedown', pointerDown),\n () => el.removeEventListener('mousemove', pointerMove),\n () => el.removeEventListener('mouseup', pointerUp),\n () => el.removeEventListener('mouseleave', pointerUp),\n () => el.removeEventListener('touchstart', pointerDown),\n () => el.removeEventListener('touchmove', pointerMove),\n () => el.removeEventListener('touchend', pointerUp)\n );\n\n // Autoplay\n const startAutoplay = () => {\n stopAutoplay();\n state.autoplayTimer = setInterval(next, interval);\n };\n\n const stopAutoplay = () => {\n if (state.autoplayTimer) {\n clearInterval(state.autoplayTimer);\n state.autoplayTimer = null;\n }\n };\n\n if (autoplay) {\n startAutoplay();\n const pauseHandler = () => stopAutoplay();\n const resumeHandler = () => startAutoplay();\n el.addEventListener('mouseenter', pauseHandler);\n el.addEventListener('mouseleave', resumeHandler);\n el.addEventListener('focusin', pauseHandler);\n el.addEventListener('focusout', resumeHandler);\n cleanup.push(\n () => el.removeEventListener('mouseenter', pauseHandler),\n () => el.removeEventListener('mouseleave', resumeHandler),\n () => el.removeEventListener('focusin', pauseHandler),\n () => el.removeEventListener('focusout', resumeHandler),\n () => stopAutoplay()\n );\n }\n\n // Initial ARIA state\n goTo(0, false);\n\n this.instances.set(el, {\n cleanup: cleanup,\n goTo: goTo,\n next: next,\n prev: prev,\n getState: () => ({ ...state })\n });\n },\n\n goTo: function (el, index) {\n const instance = this.instances.get(el);\n if (instance) instance.goTo(index);\n },\n\n next: function (el) {\n const instance = this.instances.get(el);\n if (instance) instance.next();\n },\n\n prev: function (el) {\n const instance = this.instances.get(el);\n if (instance) instance.prev();\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('flow', Flow);\n }\n\n window.VanduoFlow = Flow;\n\n})();\n", "/**\n * Vanduo Framework - Bubble (Popover) Component\n * Click-triggered rich HTML popover, reuses tooltip positioning concepts\n */\n\n(function () {\n 'use strict';\n\n const Bubble = {\n instances: new Map(),\n _globalCleanups: [],\n\n init: function () {\n const triggers = document.querySelectorAll('[data-vd-bubble], [data-vd-popover]');\n triggers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n\n if (this._globalCleanups.length === 0) {\n const outsideClick = (e) => {\n this.instances.forEach((inst, trigger) => {\n if (!inst.popover.contains(e.target) && !trigger.contains(e.target)) {\n this.hide(trigger);\n }\n });\n };\n const escHandler = (e) => {\n if (e.key === 'Escape') {\n this.instances.forEach((_, trigger) => this.hide(trigger));\n }\n };\n document.addEventListener('click', outsideClick, true);\n document.addEventListener('keydown', escHandler);\n this._globalCleanups.push(\n () => document.removeEventListener('click', outsideClick, true),\n () => document.removeEventListener('keydown', escHandler)\n );\n }\n },\n\n initInstance: function (trigger) {\n const cleanup = [];\n const placement = trigger.getAttribute('data-vd-bubble-placement') ||\n trigger.getAttribute('data-vd-popover-placement') || 'bottom';\n\n // Build popover element\n const popover = document.createElement('div');\n popover.className = 'vd-bubble-content';\n popover.setAttribute('role', 'dialog');\n popover.setAttribute('aria-modal', 'false');\n popover.setAttribute('data-placement', placement);\n\n const title = trigger.getAttribute('data-vd-bubble-title') ||\n trigger.getAttribute('data-vd-popover-title');\n const content = trigger.getAttribute('data-vd-bubble') ||\n trigger.getAttribute('data-vd-popover') || '';\n const htmlContent = trigger.getAttribute('data-vd-bubble-html') ||\n trigger.getAttribute('data-vd-popover-html');\n const allowSvg = trigger.hasAttribute('data-vd-bubble-allow-svg') ||\n trigger.hasAttribute('data-vd-popover-allow-svg');\n\n if (title) {\n const header = document.createElement('div');\n header.className = 'vd-bubble-header';\n const titleSpan = document.createElement('span');\n titleSpan.textContent = title;\n const closeBtn = document.createElement('button');\n closeBtn.className = 'vd-bubble-close';\n closeBtn.setAttribute('aria-label', 'Close');\n closeBtn.innerHTML = '×';\n header.appendChild(titleSpan);\n header.appendChild(closeBtn);\n popover.appendChild(header);\n\n const closeHandler = (e) => { e.stopPropagation(); this.hide(trigger); };\n closeBtn.addEventListener('click', closeHandler);\n cleanup.push(() => closeBtn.removeEventListener('click', closeHandler));\n }\n\n const body = document.createElement('div');\n body.className = 'vd-bubble-body';\n if (htmlContent) {\n if (typeof sanitizeHtml === 'function') {\n body.innerHTML = sanitizeHtml(htmlContent, { allowSvg });\n } else {\n body.textContent = htmlContent;\n }\n } else {\n body.textContent = content;\n }\n popover.appendChild(body);\n\n document.body.appendChild(popover);\n\n // ARIA on trigger\n const popId = 'vd-bubble-' + Math.random().toString(36).slice(2, 9);\n popover.id = popId;\n trigger.setAttribute('aria-haspopup', 'dialog');\n trigger.setAttribute('aria-expanded', 'false');\n trigger.setAttribute('aria-controls', popId);\n\n // Toggle on click\n const toggleHandler = (e) => {\n e.stopPropagation();\n if (popover.classList.contains('is-visible')) {\n this.hide(trigger);\n } else {\n this.hideAll();\n this.show(trigger);\n }\n };\n trigger.addEventListener('click', toggleHandler);\n cleanup.push(() => trigger.removeEventListener('click', toggleHandler));\n\n this.instances.set(trigger, { popover, cleanup, placement });\n },\n\n position: function (trigger, popover, placement) {\n const rect = trigger.getBoundingClientRect();\n const popRect = popover.getBoundingClientRect();\n const gap = 10;\n let top, left;\n\n switch (placement) {\n case 'top':\n top = rect.top - popRect.height - gap + window.scrollY;\n left = rect.left + (rect.width - popRect.width) / 2 + window.scrollX;\n break;\n case 'left':\n top = rect.top + (rect.height - popRect.height) / 2 + window.scrollY;\n left = rect.left - popRect.width - gap + window.scrollX;\n break;\n case 'right':\n top = rect.top + (rect.height - popRect.height) / 2 + window.scrollY;\n left = rect.right + gap + window.scrollX;\n break;\n default: // bottom\n top = rect.bottom + gap + window.scrollY;\n left = rect.left + (rect.width - popRect.width) / 2 + window.scrollX;\n }\n\n // Clamp to viewport\n left = Math.max(8, Math.min(left, window.innerWidth - popRect.width - 8));\n top = Math.max(8, top);\n\n popover.style.top = top + 'px';\n popover.style.left = left + 'px';\n },\n\n show: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n const { popover, placement } = instance;\n\n popover.style.display = 'block';\n popover.classList.add('is-visible');\n trigger.setAttribute('aria-expanded', 'true');\n\n requestAnimationFrame(() => {\n this.position(trigger, popover, placement);\n });\n\n trigger.dispatchEvent(new CustomEvent('bubble:show', {\n bubbles: true,\n detail: { trigger: trigger, placement: placement }\n }));\n },\n\n hide: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n instance.popover.classList.remove('is-visible');\n trigger.setAttribute('aria-expanded', 'false');\n trigger.dispatchEvent(new CustomEvent('bubble:hide', {\n bubbles: true,\n detail: { trigger: trigger }\n }));\n },\n\n hideAll: function () {\n this.instances.forEach((_, trigger) => this.hide(trigger));\n },\n\n destroy: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n if (instance.popover.parentNode) {\n instance.popover.parentNode.removeChild(instance.popover);\n }\n trigger.removeAttribute('aria-haspopup');\n trigger.removeAttribute('aria-expanded');\n trigger.removeAttribute('aria-controls');\n this.instances.delete(trigger);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, trigger) => this.destroy(trigger));\n this._globalCleanups.forEach(fn => fn());\n this._globalCleanups = [];\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('bubble', Bubble);\n }\n\n window.VanduoBubble = Bubble;\n\n})();\n", "/**\n * Vanduo Framework - Waypoint (Scrollspy) Component\n * Highlights navigation links based on scroll position using IntersectionObserver\n */\n\n(function () {\n 'use strict';\n\n const Waypoint = {\n instances: new Map(),\n\n init: function () {\n const navs = document.querySelectorAll('[data-vd-waypoint-nav], [data-vd-scrollspy-nav]');\n navs.forEach(nav => {\n if (this.instances.has(nav)) return;\n this.initInstance(nav);\n });\n },\n\n initInstance: function (nav) {\n const links = Array.from(nav.querySelectorAll('a[href^=\"#\"]'));\n if (links.length === 0) return;\n\n const cleanup = [];\n const offset = parseInt(nav.getAttribute('data-vd-waypoint-offset') || '80', 10);\n const sections = [];\n\n links.forEach(link => {\n const id = link.getAttribute('href').slice(1);\n const section = document.getElementById(id);\n if (section) {\n section.setAttribute('data-vd-waypoint-section', '');\n sections.push({ id, link, section });\n }\n });\n\n if (sections.length === 0) return;\n\n const activeSections = new Set();\n\n const setActive = (id) => {\n links.forEach(l => l.classList.remove('is-active'));\n const target = links.find(l => l.getAttribute('href') === '#' + id);\n if (target) {\n target.classList.add('is-active');\n nav.dispatchEvent(new CustomEvent('waypoint:change', {\n detail: { activeId: id, link: target }\n }));\n }\n };\n\n const rootMargin = '-' + offset + 'px 0px -40% 0px';\n\n const observer = new IntersectionObserver((entries) => {\n entries.forEach(entry => {\n if (entry.isIntersecting) {\n activeSections.add(entry.target.id);\n } else {\n activeSections.delete(entry.target.id);\n }\n });\n\n // Pick the topmost visible section\n for (let i = 0; i < sections.length; i++) {\n if (activeSections.has(sections[i].id)) {\n setActive(sections[i].id);\n return;\n }\n }\n }, {\n rootMargin: rootMargin,\n threshold: 0\n });\n\n sections.forEach(s => observer.observe(s.section));\n\n // Smooth scroll on click\n links.forEach(link => {\n const clickHandler = (e) => {\n e.preventDefault();\n const id = link.getAttribute('href').slice(1);\n const section = document.getElementById(id);\n if (section) {\n section.scrollIntoView({ behavior: 'smooth' });\n setActive(id);\n }\n };\n link.addEventListener('click', clickHandler);\n cleanup.push(() => link.removeEventListener('click', clickHandler));\n });\n\n cleanup.push(() => observer.disconnect());\n\n this.instances.set(nav, { observer, cleanup, sections, setActive });\n },\n\n refresh: function (nav) {\n this.destroy(nav);\n this.initInstance(nav);\n },\n\n destroy: function (nav) {\n const instance = this.instances.get(nav);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(nav);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, nav) => this.destroy(nav));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('waypoint', Waypoint);\n }\n\n window.VanduoWaypoint = Waypoint;\n\n})();\n", "/**\n * Vanduo Framework - Ripple (Waves Effect) Component\n * Adds expanding circle animation on click at pointer position\n */\n\n(function () {\n 'use strict';\n\n const Ripple = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-ripple, [data-vd-ripple]');\n elements.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n\n const createWave = (e) => {\n const rect = el.getBoundingClientRect();\n const size = Math.max(rect.width, rect.height);\n const x = (e.clientX || (e.touches && e.touches[0].clientX) || rect.left + rect.width / 2) - rect.left - size / 2;\n const y = (e.clientY || (e.touches && e.touches[0].clientY) || rect.top + rect.height / 2) - rect.top - size / 2;\n\n const wave = document.createElement('span');\n wave.className = 'vd-ripple-wave';\n wave.style.width = size + 'px';\n wave.style.height = size + 'px';\n wave.style.left = x + 'px';\n wave.style.top = y + 'px';\n\n el.appendChild(wave);\n\n wave.addEventListener('animationend', () => {\n if (wave.parentNode) wave.parentNode.removeChild(wave);\n });\n };\n\n el.addEventListener('mousedown', createWave);\n el.addEventListener('touchstart', createWave, { passive: true });\n\n cleanup.push(\n () => el.removeEventListener('mousedown', createWave),\n () => el.removeEventListener('touchstart', createWave)\n );\n\n this.instances.set(el, { cleanup });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n // Remove any lingering wave elements\n el.querySelectorAll('.vd-ripple-wave').forEach(w => w.remove());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('ripple', Ripple);\n }\n\n window.VanduoRipple = Ripple;\n\n})();\n", "/**\n * Vanduo Framework - Affix (Sticky) Component\n * Uses IntersectionObserver to toggle .is-stuck within the nearest scrollable parent\n */\n\n(function () {\n 'use strict';\n\n function isScrollable(element) {\n if (!element || element === document.body) return false;\n\n const style = window.getComputedStyle(element);\n const overflowY = style.overflowY;\n const overflowX = style.overflowX;\n const canScrollY = /(auto|scroll|overlay)/.test(overflowY) && element.scrollHeight > element.clientHeight;\n const canScrollX = /(auto|scroll|overlay)/.test(overflowX) && element.scrollWidth > element.clientWidth;\n\n return canScrollY || canScrollX;\n }\n\n function getScrollParent(element) {\n let parent = element.parentElement;\n\n while (parent && parent !== document.body && parent !== document.documentElement) {\n if (isScrollable(parent)) return parent;\n parent = parent.parentElement;\n }\n\n return null;\n }\n\n const Affix = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-affix, .vd-sticky, [data-vd-affix]');\n elements.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const parsedOffset = parseInt(el.getAttribute('data-vd-affix-offset') || '0', 10);\n const offset = Number.isNaN(parsedOffset) ? 0 : parsedOffset;\n const scrollParent = getScrollParent(el);\n let isStuck = false;\n\n const sentinel = document.createElement('div');\n sentinel.style.cssText = 'display:block;height:1px;margin-bottom:-1px;visibility:hidden;pointer-events:none;';\n el.parentNode.insertBefore(sentinel, el);\n\n el.style.setProperty('--affix-top-offset', offset + 'px');\n\n function stick() {\n if (isStuck) return;\n isStuck = true;\n el.classList.add('is-stuck');\n el.dispatchEvent(new CustomEvent('affix:stuck', {\n bubbles: true,\n detail: {\n offset: offset,\n root: scrollParent || window\n }\n }));\n }\n\n function unstick() {\n if (!isStuck) return;\n isStuck = false;\n el.classList.remove('is-stuck');\n el.dispatchEvent(new CustomEvent('affix:unstuck', {\n bubbles: true,\n detail: {\n offset: offset,\n root: scrollParent || window\n }\n }));\n }\n\n const observer = new IntersectionObserver(function (entries) {\n entries.forEach(entry => {\n if (!entry.isIntersecting) {\n stick();\n } else {\n unstick();\n }\n });\n }, {\n root: scrollParent,\n rootMargin: '-' + offset + 'px 0px 0px 0px',\n threshold: 0\n });\n\n observer.observe(sentinel);\n\n cleanup.push(\n () => observer.disconnect(),\n () => { if (sentinel.parentNode) sentinel.parentNode.removeChild(sentinel); },\n () => {\n el.classList.remove('is-stuck');\n el.style.removeProperty('--affix-top-offset');\n }\n );\n\n this.instances.set(el, { cleanup, observer, sentinel, scrollParent });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n el.classList.remove('is-stuck');\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('affix', Affix);\n }\n\n window.VanduoAffix = Affix;\n\n})();\n", "/**\n * Vanduo Framework - Suggest (Autocomplete) Component\n * Dropdown suggestion list with keyboard navigation, static/async data sources\n */\n\n(function () {\n 'use strict';\n\n /**\n * Escape HTML entities to prevent XSS when inserting into innerHTML\n * @param {string} text\n * @returns {string}\n */\n function _escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Allow same-origin URLs by default, or an explicit allowlist of origins.\n * @param {string} url\n * @param {string[]} allowlist\n * @returns {boolean}\n */\n function _isSafeUrl(url, allowlist) {\n try {\n const resolved = new URL(url, window.location.href);\n if (resolved.origin === window.location.origin) return true;\n return allowlist.includes(resolved.origin);\n } catch (_e) {\n return false;\n }\n }\n\n const Suggest = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-suggest], [data-vd-autocomplete]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const minChars = parseInt(input.getAttribute('data-vd-suggest-min-chars') || '1', 10);\n const url = input.getAttribute('data-vd-suggest-url') || '';\n const allowlistAttr = input.getAttribute('data-vd-suggest-allowlist') || '';\n const allowlist = allowlistAttr\n .split(',')\n .map(value => value.trim())\n .filter(Boolean);\n const staticData = input.getAttribute('data-vd-suggest') || input.getAttribute('data-vd-autocomplete') || '';\n let items = [];\n\n try { items = JSON.parse(staticData); } catch (_e) {\n items = staticData.split(',').map(s => s.trim()).filter(Boolean);\n }\n\n // Wrap input if not already wrapped\n let wrapper = input.closest('.vd-suggest-wrapper, .vd-autocomplete-wrapper');\n if (!wrapper) {\n wrapper = document.createElement('div');\n wrapper.className = 'vd-suggest-wrapper';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n }\n\n // Create dropdown list\n const list = document.createElement('ul');\n list.className = 'vd-suggest-list';\n list.setAttribute('role', 'listbox');\n const listId = 'vd-suggest-' + Math.random().toString(36).slice(2, 9);\n list.id = listId;\n wrapper.appendChild(list);\n\n // ARIA\n input.setAttribute('role', 'combobox');\n input.setAttribute('aria-autocomplete', 'list');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('aria-controls', listId);\n input.setAttribute('autocomplete', 'off');\n\n let highlighted = -1;\n let currentItems = [];\n let debounceTimer = null;\n\n const renderItems = (filtered, query) => {\n list.innerHTML = '';\n currentItems = filtered;\n highlighted = -1;\n\n if (filtered.length === 0) {\n const empty = document.createElement('li');\n empty.className = 'vd-suggest-empty';\n empty.textContent = 'No results';\n list.appendChild(empty);\n return;\n }\n\n filtered.forEach((item, i) => {\n const li = document.createElement('li');\n li.className = 'vd-suggest-item';\n li.setAttribute('role', 'option');\n li.id = listId + '-item-' + i;\n\n const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);\n if (query) {\n // Escape HTML first to prevent XSS, then highlight matches in the safe string\n const escaped = _escapeHtml(text);\n const re = new RegExp('(' + query.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi');\n li.innerHTML = escaped.replace(re, '$1');\n } else {\n li.textContent = text;\n }\n\n li.addEventListener('click', () => selectItem(i));\n list.appendChild(li);\n });\n };\n\n const open = () => {\n list.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n };\n\n const close = () => {\n list.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n highlighted = -1;\n input.removeAttribute('aria-activedescendant');\n };\n\n const selectItem = (index) => {\n const item = currentItems[index];\n const value = typeof item === 'object' ? (item.value || item.label || String(item)) : String(item);\n input.value = value;\n close();\n input.dispatchEvent(new CustomEvent('suggest:select', {\n detail: { value, item, index },\n bubbles: true\n }));\n };\n\n const highlight = (index) => {\n const listItems = list.querySelectorAll('.vd-suggest-item');\n listItems.forEach(li => li.classList.remove('is-highlighted'));\n if (index >= 0 && index < listItems.length) {\n highlighted = index;\n listItems[index].classList.add('is-highlighted');\n input.setAttribute('aria-activedescendant', listItems[index].id);\n listItems[index].scrollIntoView({ block: 'nearest' });\n }\n };\n\n const doSearch = async (query) => {\n if (query.length < minChars) { close(); return; }\n\n let filtered;\n if (url) {\n try {\n if (!_isSafeUrl(url, allowlist)) {\n console.warn('[VanduoSuggest] Blocked non-allowlisted URL:', url);\n filtered = [];\n } else {\n const separator = url.includes('?') ? '&' : '?';\n const res = await window.fetch(url + separator + 'q=' + encodeURIComponent(query));\n filtered = await res.json();\n }\n } catch (_e) {\n filtered = [];\n }\n } else {\n const lower = query.toLowerCase();\n filtered = items.filter(item => {\n const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);\n return text.toLowerCase().includes(lower);\n });\n }\n\n renderItems(filtered, query);\n if (filtered.length > 0) open();\n else open(); // show \"no results\"\n };\n\n const inputHandler = () => {\n clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => doSearch(input.value), 200);\n };\n\n const keyHandler = (e) => {\n if (!list.classList.contains('is-open')) {\n if (e.key === 'ArrowDown') { doSearch(input.value); e.preventDefault(); }\n return;\n }\n\n const total = currentItems.length;\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n highlight(highlighted < total - 1 ? highlighted + 1 : 0);\n break;\n case 'ArrowUp':\n e.preventDefault();\n highlight(highlighted > 0 ? highlighted - 1 : total - 1);\n break;\n case 'Enter':\n e.preventDefault();\n if (highlighted >= 0) selectItem(highlighted);\n break;\n case 'Escape':\n close();\n break;\n }\n };\n\n const blurHandler = () => {\n setTimeout(close, 200);\n };\n\n input.addEventListener('input', inputHandler);\n input.addEventListener('keydown', keyHandler);\n input.addEventListener('blur', blurHandler);\n input.addEventListener('focus', () => { if (input.value.length >= minChars) doSearch(input.value); });\n\n cleanup.push(\n () => input.removeEventListener('input', inputHandler),\n () => input.removeEventListener('keydown', keyHandler),\n () => input.removeEventListener('blur', blurHandler),\n () => clearTimeout(debounceTimer),\n () => { if (list.parentNode) list.parentNode.removeChild(list); }\n );\n\n this.instances.set(input, { cleanup, list, close });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('suggest', Suggest);\n }\n\n window.VanduoSuggest = Suggest;\n\n})();\n", "/**\n * Vanduo Framework - Validate (Form Validation) Component\n * Declarative validation via data attributes with real-time and on-submit modes\n */\n\n(function () {\n 'use strict';\n\n const Validate = {\n instances: new Map(),\n\n rules: {\n required: (value) => value.trim().length > 0,\n email: (value) => /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(value),\n url: (value) => { try { new URL(value); return true; } catch (_e) { return false; } },\n number: (value) => !isNaN(parseFloat(value)) && isFinite(value),\n min: (value, param) => value.length >= parseInt(param, 10),\n max: (value, param) => value.length <= parseInt(param, 10),\n minVal: (value, param) => parseFloat(value) >= parseFloat(param),\n maxVal: (value, param) => parseFloat(value) <= parseFloat(param),\n pattern: (value, param) => {\n try {\n // Cap regex length to prevent ReDoS from excessively complex patterns\n if (param.length > 100) return false;\n return new RegExp(param).test(value);\n } catch (_e) { return false; }\n },\n match: (value, param) => {\n try {\n const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(param) : param;\n const other = document.querySelector('[name=\"' + escaped + '\"]');\n return other ? value === other.value : false;\n } catch (_e) {\n return false;\n }\n }\n },\n\n messages: {\n required: 'This field is required',\n email: 'Please enter a valid email address',\n url: 'Please enter a valid URL',\n number: 'Please enter a valid number',\n min: 'Minimum {0} characters required',\n max: 'Maximum {0} characters allowed',\n minVal: 'Value must be at least {0}',\n maxVal: 'Value must be at most {0}',\n pattern: 'Invalid format',\n match: 'Fields do not match'\n },\n\n init: function () {\n const forms = document.querySelectorAll('[data-vd-validate], .vd-validate');\n forms.forEach(form => {\n if (this.instances.has(form)) return;\n this.initInstance(form);\n });\n },\n\n initInstance: function (form) {\n const cleanup = [];\n const mode = form.getAttribute('data-vd-validate-mode') || 'blur'; // blur | input | submit\n const fields = form.querySelectorAll('[data-vd-rules]');\n\n const validateField = (field) => {\n const rulesStr = field.getAttribute('data-vd-rules') || '';\n const rules = rulesStr.split('|').map(r => r.trim()).filter(Boolean);\n const value = field.value;\n const errors = [];\n\n for (const rule of rules) {\n const [name, ...params] = rule.split(':');\n const param = params.join(':');\n const validator = this.rules[name];\n\n if (validator && !validator(value, param)) {\n const customMsg = field.getAttribute('data-vd-msg-' + name);\n let msg = customMsg || this.messages[name] || 'Invalid';\n if (param) msg = msg.replace('{0}', param);\n errors.push(msg);\n break; // one error at a time\n }\n }\n\n this.setFieldState(field, errors);\n return errors.length === 0;\n };\n\n const validateAll = () => {\n let valid = true;\n fields.forEach(field => {\n if (!validateField(field)) valid = false;\n });\n return valid;\n };\n\n // Per-field listeners\n fields.forEach(field => {\n if (mode === 'input' || mode === 'blur') {\n const eventType = mode === 'input' ? 'input' : 'blur';\n const handler = () => validateField(field);\n field.addEventListener(eventType, handler);\n cleanup.push(() => field.removeEventListener(eventType, handler));\n\n if (mode === 'blur') {\n const inputClear = () => {\n if (field.classList.contains('is-invalid') || field.classList.contains('is-valid')) {\n validateField(field);\n }\n };\n field.addEventListener('input', inputClear);\n cleanup.push(() => field.removeEventListener('input', inputClear));\n }\n }\n });\n\n // Form submit\n const submitHandler = (e) => {\n const valid = validateAll();\n if (!valid) {\n e.preventDefault();\n e.stopPropagation();\n // Focus first invalid field\n const firstInvalid = form.querySelector('.is-invalid');\n if (firstInvalid) firstInvalid.focus();\n }\n form.dispatchEvent(new CustomEvent('validate:submit', {\n detail: { valid },\n bubbles: true\n }));\n };\n\n form.addEventListener('submit', submitHandler);\n cleanup.push(() => form.removeEventListener('submit', submitHandler));\n\n this.instances.set(form, { cleanup, validateAll, validateField });\n },\n\n setFieldState: function (field, errors) {\n const wrapper = field.closest('.vd-form-group') || field.parentElement;\n let errorEl = wrapper.querySelector('.vd-validate-error');\n\n field.classList.remove('is-valid', 'is-invalid');\n\n if (errors.length > 0) {\n field.classList.add('is-invalid');\n field.setAttribute('aria-invalid', 'true');\n if (!errorEl) {\n errorEl = document.createElement('div');\n errorEl.className = 'vd-validate-error';\n errorEl.id = 'vd-err-' + Math.random().toString(36).slice(2, 9);\n errorEl.setAttribute('role', 'alert');\n wrapper.appendChild(errorEl);\n }\n errorEl.textContent = errors[0];\n errorEl.style.display = '';\n field.setAttribute('aria-describedby', errorEl.id);\n } else if (field.value.trim()) {\n field.classList.add('is-valid');\n field.removeAttribute('aria-invalid');\n if (errorEl) errorEl.style.display = 'none';\n } else {\n field.removeAttribute('aria-invalid');\n if (errorEl) errorEl.style.display = 'none';\n }\n },\n\n validateForm: function (form) {\n const instance = this.instances.get(form);\n return instance ? instance.validateAll() : false;\n },\n\n addRule: function (name, validator, message) {\n this.rules[name] = validator;\n if (message) this.messages[name] = message;\n },\n\n destroy: function (form) {\n const instance = this.instances.get(form);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(form);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, form) => this.destroy(form));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('validate', Validate);\n }\n\n window.VanduoValidate = Validate;\n\n})();\n", "/**\n * Vanduo Framework - Datepicker Component\n * Calendar popup attached to input field with month/year navigation\n */\n\n(function () {\n 'use strict';\n\n const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];\n const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];\n\n function escapeRegexChar(c) {\n return c.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n }\n\n function buildParseFormat(format) {\n let regex = '^';\n const order = [];\n let i = 0;\n while (i < format.length) {\n const slice = format.slice(i);\n if (slice.toLowerCase().startsWith('yyyy')) {\n regex += '(\\\\d{4})';\n order.push('y');\n i += 4;\n } else if (slice.toLowerCase().startsWith('mm')) {\n regex += '(\\\\d{2})';\n order.push('m');\n i += 2;\n } else if (slice.toLowerCase().startsWith('dd')) {\n regex += '(\\\\d{2})';\n order.push('d');\n i += 2;\n } else {\n regex += escapeRegexChar(format[i]);\n i++;\n }\n }\n regex += '$';\n return { regex: new RegExp(regex), order };\n }\n\n function parseDateFromFormat(value, format) {\n if (!value || !format) return null;\n const { regex, order } = buildParseFormat(format);\n const m = value.trim().match(regex);\n if (!m) return null;\n let y;\n let mo;\n let d;\n let ci = 1;\n for (let k = 0; k < order.length; k++) {\n const part = order[k];\n const v = parseInt(m[ci++], 10);\n if (Number.isNaN(v)) return null;\n if (part === 'y') y = v;\n else if (part === 'm') mo = v - 1;\n else if (part === 'd') d = v;\n }\n if (y === undefined || mo === undefined || d === undefined) return null;\n const dt = new Date(y, mo, d);\n if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== d) return null;\n return dt;\n }\n\n function formatDate(d, format) {\n const yyyy = String(d.getFullYear());\n const mm = String(d.getMonth() + 1).padStart(2, '0');\n const dd = String(d.getDate()).padStart(2, '0');\n let out = '';\n let i = 0;\n while (i < format.length) {\n const slice = format.slice(i);\n if (slice.toLowerCase().startsWith('yyyy')) {\n out += yyyy;\n i += 4;\n } else if (slice.toLowerCase().startsWith('mm')) {\n out += mm;\n i += 2;\n } else if (slice.toLowerCase().startsWith('dd')) {\n out += dd;\n i += 2;\n } else {\n out += format[i];\n i++;\n }\n }\n return out;\n }\n\n function dateKey(d) {\n return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');\n }\n\n function addDays(d, n) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n x.setDate(x.getDate() + n);\n return x;\n }\n\n function addMonthsClamped(d, n) {\n return new Date(d.getFullYear(), d.getMonth() + n, d.getDate());\n }\n\n function parseYmdLocal(ymd) {\n if (!ymd || typeof ymd !== 'string') return null;\n const m = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(ymd.trim());\n if (!m) return null;\n const y = +m[1];\n const mo = +m[2] - 1;\n const day = +m[3];\n const dt = new Date(y, mo, day);\n if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== day) return null;\n return dt;\n }\n\n function startOfWeekSunday(d) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const day = x.getDay();\n x.setDate(x.getDate() - day);\n return x;\n }\n\n function endOfWeekSunday(d) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const day = x.getDay();\n x.setDate(x.getDate() + (6 - day));\n return x;\n }\n\n const Datepicker = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-datepicker]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const format = input.getAttribute('data-vd-datepicker-format') || 'YYYY-MM-DD';\n const minStr = input.getAttribute('data-vd-datepicker-min');\n const maxStr = input.getAttribute('data-vd-datepicker-max');\n const minDate = minStr ? parseYmdLocal(minStr) : null;\n const maxDate = maxStr ? parseYmdLocal(maxStr) : null;\n\n const today = new Date();\n let viewYear = today.getFullYear();\n let viewMonth = today.getMonth();\n let selectedDate = null;\n let viewMode = 'days'; // days | months | years\n let focusedDate = null;\n /** Prevents focus() after close from immediately re-opening the popup */\n let skipNextFocusOpen = false;\n\n const isDisabled = (d) => {\n if (minDate) {\n const t = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n if (t < minDate.getTime()) return true;\n }\n if (maxDate) {\n const t = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n if (t > maxDate.getTime()) return true;\n }\n return false;\n };\n\n const ensureMonthInRange = (y, m) => {\n if (!minDate && !maxDate) return { y: y, m: m };\n const first = new Date(y, m, 1);\n const last = new Date(y, m + 1, 0);\n if (minDate && last.getTime() < minDate.getTime()) {\n return { y: minDate.getFullYear(), m: minDate.getMonth() };\n }\n if (maxDate && first.getTime() > maxDate.getTime()) {\n return { y: maxDate.getFullYear(), m: maxDate.getMonth() };\n }\n return { y: y, m: m };\n };\n\n const firstSelectableInMonth = (y, m) => {\n const last = new Date(y, m + 1, 0).getDate();\n for (let day = 1; day <= last; day++) {\n const dt = new Date(y, m, day);\n if (!isDisabled(dt)) return dt;\n }\n return new Date(y, m, 1);\n };\n\n if (input.value) {\n const trimmed = input.value.trim();\n let parsed = parseDateFromFormat(trimmed, format);\n if (!parsed) {\n const fallback = new Date(trimmed);\n if (!isNaN(fallback.getTime())) parsed = fallback;\n }\n if (parsed) {\n selectedDate = parsed;\n viewYear = parsed.getFullYear();\n viewMonth = parsed.getMonth();\n }\n }\n\n const clampedInit = ensureMonthInRange(viewYear, viewMonth);\n viewYear = clampedInit.y;\n viewMonth = clampedInit.m;\n\n // Create popup\n const popup = document.createElement('div');\n popup.className = 'vd-datepicker-popup';\n popup.setAttribute('role', 'dialog');\n popup.setAttribute('aria-label', 'Choose date');\n popup.tabIndex = -1;\n\n const wrapper = document.createElement('div');\n wrapper.className = 'vd-suggest-wrapper';\n wrapper.style.position = 'relative';\n wrapper.style.display = 'inline-block';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n wrapper.appendChild(popup);\n\n const isSameDay = (a, b) => a && b &&\n a.getFullYear() === b.getFullYear() &&\n a.getMonth() === b.getMonth() &&\n a.getDate() === b.getDate();\n\n const selectDate = (date) => {\n selectedDate = date;\n viewYear = date.getFullYear();\n viewMonth = date.getMonth();\n input.value = formatDate(date, format);\n skipNextFocusOpen = true;\n close();\n input.dispatchEvent(new CustomEvent('datepicker:select', {\n detail: { date: date, formatted: input.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n input.focus();\n };\n\n const focusFocusedDay = () => {\n if (viewMode !== 'days' || !focusedDate) return;\n const key = dateKey(focusedDate);\n const btn = popup.querySelector('[data-vd-date=\"' + key + '\"]');\n if (btn && !btn.classList.contains('is-outside') && btn.getAttribute('aria-disabled') !== 'true') {\n btn.focus();\n }\n };\n\n const skipDisabled = (d, stepDir, maxSteps) => {\n let x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const step = stepDir > 0 ? 1 : -1;\n for (let i = 0; i < maxSteps; i++) {\n if (!isDisabled(x)) return x;\n x = addDays(x, step);\n }\n return d;\n };\n\n const createDayBtn = (day, outside, date) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-day';\n btn.textContent = day;\n btn.setAttribute('role', 'gridcell');\n\n if (outside) {\n btn.classList.add('is-outside');\n btn.tabIndex = -1;\n btn.setAttribute('aria-disabled', 'true');\n return btn;\n }\n\n btn.setAttribute('data-vd-date', dateKey(date));\n\n if (date && isSameDay(date, today)) btn.classList.add('is-today');\n if (date && isSameDay(date, selectedDate)) btn.classList.add('is-selected');\n if (date && isDisabled(date)) {\n btn.classList.add('is-disabled');\n btn.setAttribute('aria-disabled', 'true');\n btn.tabIndex = -1;\n return btn;\n }\n\n if (date) {\n const isFocused = focusedDate && isSameDay(date, focusedDate);\n btn.tabIndex = isFocused ? 0 : -1;\n\n btn.addEventListener('click', () => {\n selectedDate = date;\n viewYear = date.getFullYear();\n viewMonth = date.getMonth();\n focusedDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());\n input.value = formatDate(date, format);\n skipNextFocusOpen = true;\n close();\n input.dispatchEvent(new CustomEvent('datepicker:select', {\n detail: { date: date, formatted: input.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n input.focus();\n });\n }\n\n return btn;\n };\n\n const render = () => {\n popup.innerHTML = '';\n\n // Header\n const header = document.createElement('div');\n header.className = 'vd-datepicker-header';\n\n const prevBtn = document.createElement('button');\n prevBtn.type = 'button';\n prevBtn.className = 'vd-datepicker-prev';\n prevBtn.innerHTML = '‹';\n prevBtn.setAttribute('aria-label', 'Previous');\n\n const nextBtn = document.createElement('button');\n nextBtn.type = 'button';\n nextBtn.className = 'vd-datepicker-next';\n nextBtn.innerHTML = '›';\n nextBtn.setAttribute('aria-label', 'Next');\n\n const title = document.createElement('span');\n title.className = 'vd-datepicker-title';\n\n if (viewMode === 'days') {\n title.textContent = MONTHS[viewMonth] + ' ' + viewYear;\n title.addEventListener('click', () => { viewMode = 'months'; render(); });\n prevBtn.addEventListener('click', () => { viewMonth--; if (viewMonth < 0) { viewMonth = 11; viewYear--; } render(); });\n nextBtn.addEventListener('click', () => { viewMonth++; if (viewMonth > 11) { viewMonth = 0; viewYear++; } render(); });\n } else if (viewMode === 'months') {\n title.textContent = String(viewYear);\n title.addEventListener('click', () => { viewMode = 'years'; render(); });\n prevBtn.addEventListener('click', () => { viewYear--; render(); });\n nextBtn.addEventListener('click', () => { viewYear++; render(); });\n } else {\n const decadeStart = Math.floor(viewYear / 10) * 10;\n title.textContent = decadeStart + ' - ' + (decadeStart + 9);\n prevBtn.addEventListener('click', () => { viewYear -= 10; render(); });\n nextBtn.addEventListener('click', () => { viewYear += 10; render(); });\n }\n\n header.appendChild(prevBtn);\n header.appendChild(title);\n header.appendChild(nextBtn);\n popup.appendChild(header);\n\n if (viewMode === 'days') {\n const gridWrap = document.createElement('div');\n gridWrap.className = 'vd-datepicker-grid';\n gridWrap.setAttribute('role', 'grid');\n gridWrap.setAttribute('aria-label', 'Calendar');\n\n const weekdays = document.createElement('div');\n weekdays.className = 'vd-datepicker-weekdays';\n weekdays.setAttribute('role', 'row');\n DAYS.forEach(function (d) {\n const span = document.createElement('span');\n span.setAttribute('role', 'columnheader');\n span.setAttribute('aria-label', d);\n span.textContent = d;\n weekdays.appendChild(span);\n });\n gridWrap.appendChild(weekdays);\n\n const firstDay = new Date(viewYear, viewMonth, 1).getDay();\n const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();\n const daysInPrev = new Date(viewYear, viewMonth, 0).getDate();\n\n const cells = [];\n\n for (let i = firstDay - 1; i >= 0; i--) {\n const dayNum = daysInPrev - i;\n const prevMonth = viewMonth === 0 ? 11 : viewMonth - 1;\n const prevYear = viewMonth === 0 ? viewYear - 1 : viewYear;\n const date = new Date(prevYear, prevMonth, dayNum);\n cells.push({ day: dayNum, outside: true, date: date });\n }\n\n for (let d = 1; d <= daysInMonth; d++) {\n const date = new Date(viewYear, viewMonth, d);\n cells.push({ day: d, outside: false, date: date });\n }\n\n const totalCells = firstDay + daysInMonth;\n const remaining = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7);\n for (let i = 1; i <= remaining; i++) {\n const date = new Date(viewYear, viewMonth + 1, i);\n cells.push({ day: i, outside: true, date: date });\n }\n\n for (let r = 0; r < cells.length; r += 7) {\n const row = document.createElement('div');\n row.className = 'vd-datepicker-row';\n row.setAttribute('role', 'row');\n for (let c = 0; c < 7; c++) {\n const cell = cells[r + c];\n row.appendChild(createDayBtn(cell.day, cell.outside, cell.date));\n }\n gridWrap.appendChild(row);\n }\n\n popup.appendChild(gridWrap);\n } else if (viewMode === 'months') {\n const grid = document.createElement('div');\n grid.className = 'vd-datepicker-months';\n MONTHS.forEach((name, i) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-month-btn';\n btn.textContent = name.slice(0, 3);\n if (selectedDate && selectedDate.getFullYear() === viewYear && selectedDate.getMonth() === i) {\n btn.classList.add('is-selected');\n }\n btn.addEventListener('click', () => { viewMonth = i; viewMode = 'days'; render(); });\n grid.appendChild(btn);\n });\n popup.appendChild(grid);\n } else {\n const grid = document.createElement('div');\n grid.className = 'vd-datepicker-years';\n const decadeStart = Math.floor(viewYear / 10) * 10;\n for (let y = decadeStart - 1; y <= decadeStart + 10; y++) {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-year-btn';\n btn.textContent = y;\n if (selectedDate && selectedDate.getFullYear() === y) btn.classList.add('is-selected');\n if (y < decadeStart || y > decadeStart + 9) btn.style.opacity = '0.4';\n btn.addEventListener('click', () => { viewYear = y; viewMode = 'months'; render(); });\n grid.appendChild(btn);\n }\n popup.appendChild(grid);\n }\n };\n\n const handleGridKeydown = (e) => {\n if (!popup.classList.contains('is-open') || viewMode !== 'days') return;\n const grid = popup.querySelector('.vd-datepicker-grid');\n if (!grid || !grid.contains(e.target)) return;\n\n const key = e.key;\n if (key !== 'ArrowLeft' && key !== 'ArrowRight' && key !== 'ArrowUp' && key !== 'ArrowDown' &&\n key !== 'Home' && key !== 'End' && key !== 'PageUp' && key !== 'PageDown' &&\n key !== 'Enter' && key !== ' ' && key !== 'Escape') {\n return;\n }\n\n if (key === 'Escape') {\n e.preventDefault();\n e.stopPropagation();\n skipNextFocusOpen = true;\n close();\n input.focus();\n return;\n }\n\n if (!focusedDate) {\n focusedDate = firstSelectableInMonth(viewYear, viewMonth);\n }\n\n if (key === 'Enter' || key === ' ') {\n e.preventDefault();\n if (focusedDate && !isDisabled(focusedDate)) {\n selectDate(new Date(focusedDate.getFullYear(), focusedDate.getMonth(), focusedDate.getDate()));\n }\n return;\n }\n\n e.preventDefault();\n\n let next = new Date(focusedDate.getFullYear(), focusedDate.getMonth(), focusedDate.getDate());\n let skipDir = 1;\n\n if (key === 'ArrowLeft') {\n next = addDays(next, -1);\n skipDir = -1;\n } else if (key === 'ArrowRight') {\n next = addDays(next, 1);\n skipDir = 1;\n } else if (key === 'ArrowUp') {\n next = addDays(next, -7);\n skipDir = -1;\n } else if (key === 'ArrowDown') {\n next = addDays(next, 7);\n skipDir = 1;\n } else if (key === 'Home') {\n next = startOfWeekSunday(next);\n skipDir = 1;\n } else if (key === 'End') {\n next = endOfWeekSunday(next);\n skipDir = -1;\n } else if (key === 'PageUp') {\n next = addMonthsClamped(next, -1);\n skipDir = -1;\n } else if (key === 'PageDown') {\n next = addMonthsClamped(next, 1);\n skipDir = 1;\n }\n\n next = skipDisabled(next, skipDir, 400);\n\n if (next.getMonth() !== viewMonth || next.getFullYear() !== viewYear) {\n viewYear = next.getFullYear();\n viewMonth = next.getMonth();\n const cl = ensureMonthInRange(viewYear, viewMonth);\n viewYear = cl.y;\n viewMonth = cl.m;\n }\n\n focusedDate = next;\n render();\n requestAnimationFrame(focusFocusedDay);\n };\n\n const open = () => {\n viewMode = 'days';\n if (selectedDate) {\n viewYear = selectedDate.getFullYear();\n viewMonth = selectedDate.getMonth();\n }\n const cl = ensureMonthInRange(viewYear, viewMonth);\n viewYear = cl.y;\n viewMonth = cl.m;\n\n if (selectedDate) {\n focusedDate = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate());\n } else {\n focusedDate = firstSelectableInMonth(viewYear, viewMonth);\n }\n\n render();\n popup.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n requestAnimationFrame(focusFocusedDay);\n };\n\n const close = () => {\n popup.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n viewMode = 'days';\n };\n\n const focusHandler = () => {\n if (skipNextFocusOpen) {\n skipNextFocusOpen = false;\n return;\n }\n open();\n };\n const outsideHandler = (e) => {\n if (!wrapper.contains(e.target)) close();\n };\n const escHandler = (e) => {\n if (e.key === 'Escape' && popup.classList.contains('is-open')) {\n skipNextFocusOpen = true;\n close();\n input.focus();\n }\n };\n\n input.addEventListener('focus', focusHandler);\n document.addEventListener('click', outsideHandler, true);\n document.addEventListener('keydown', escHandler);\n popup.addEventListener('keydown', handleGridKeydown);\n\n input.setAttribute('aria-haspopup', 'dialog');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('autocomplete', 'off');\n\n cleanup.push(\n () => input.removeEventListener('focus', focusHandler),\n () => document.removeEventListener('click', outsideHandler, true),\n () => document.removeEventListener('keydown', escHandler),\n () => popup.removeEventListener('keydown', handleGridKeydown)\n );\n\n this.instances.set(input, { cleanup: cleanup, open: open, close: close, popup: popup });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('datepicker', Datepicker);\n }\n\n window.VanduoDatepicker = Datepicker;\n\n})();\n", "/**\n * Vanduo Framework - Timepicker Component\n * Dropdown time selection with 12h/24h format and configurable step intervals\n */\n\n(function () {\n 'use strict';\n\n const Timepicker = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-timepicker]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const is24h = input.getAttribute('data-vd-timepicker-format') === '24h';\n const step = parseInt(input.getAttribute('data-vd-timepicker-step') || '30', 10);\n\n // Create wrapper\n let wrapper = input.closest('.vd-suggest-wrapper');\n if (!wrapper) {\n wrapper = document.createElement('div');\n wrapper.style.position = 'relative';\n wrapper.style.display = 'inline-block';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n }\n\n // Create popup\n const popup = document.createElement('div');\n popup.className = 'vd-timepicker-popup';\n popup.setAttribute('role', 'listbox');\n wrapper.appendChild(popup);\n\n // Generate time slots\n const times = [];\n for (let h = 0; h < 24; h++) {\n for (let m = 0; m < 60; m += step) {\n const hh24 = String(h).padStart(2, '0');\n const mm = String(m).padStart(2, '0');\n\n if (is24h) {\n times.push({ display: hh24 + ':' + mm, value: hh24 + ':' + mm });\n } else {\n const period = h < 12 ? 'AM' : 'PM';\n const h12 = h === 0 ? 12 : (h > 12 ? h - 12 : h);\n const display = h12 + ':' + mm + ' ' + period;\n times.push({ display, value: hh24 + ':' + mm });\n }\n }\n }\n\n const render = () => {\n popup.innerHTML = '';\n times.forEach(t => {\n const item = document.createElement('div');\n item.className = 'vd-timepicker-item';\n item.setAttribute('role', 'option');\n item.textContent = t.display;\n\n if (input.value === t.value || input.value === t.display) {\n item.classList.add('is-selected');\n }\n\n item.addEventListener('click', () => {\n input.value = t.display;\n popup.querySelectorAll('.vd-timepicker-item').forEach(i => i.classList.remove('is-selected'));\n item.classList.add('is-selected');\n close();\n input.dispatchEvent(new CustomEvent('timepicker:select', {\n detail: { display: t.display, value: t.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n });\n\n popup.appendChild(item);\n });\n };\n\n const open = () => {\n render();\n popup.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n // Scroll to selected\n const selected = popup.querySelector('.is-selected');\n if (selected) selected.scrollIntoView({ block: 'center' });\n };\n\n const close = () => {\n popup.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n };\n\n const focusHandler = () => open();\n const outsideHandler = (e) => {\n if (!wrapper.contains(e.target)) close();\n };\n const escHandler = (e) => { if (e.key === 'Escape') close(); };\n\n input.addEventListener('focus', focusHandler);\n document.addEventListener('click', outsideHandler, true);\n document.addEventListener('keydown', escHandler);\n input.setAttribute('aria-haspopup', 'listbox');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('autocomplete', 'off');\n input.readOnly = true;\n\n cleanup.push(\n () => input.removeEventListener('focus', focusHandler),\n () => document.removeEventListener('click', outsideHandler, true),\n () => document.removeEventListener('keydown', escHandler)\n );\n\n this.instances.set(input, { cleanup, open, close });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('timepicker', Timepicker);\n }\n\n window.VanduoTimepicker = Timepicker;\n\n})();\n", "/**\n * Vanduo Framework - Stepper Component\n * Multi-step progress indicator with state management\n */\n\n(function () {\n 'use strict';\n\n const Stepper = {\n instances: new Map(),\n\n init: function () {\n const steppers = document.querySelectorAll('.vd-stepper');\n steppers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const items = Array.from(el.querySelectorAll('.vd-stepper-item'));\n const isClickable = el.classList.contains('vd-stepper-clickable');\n let currentIndex = items.findIndex(i => i.classList.contains('is-active'));\n if (currentIndex === -1) currentIndex = 0;\n\n const setStep = (index) => {\n if (index < 0 || index >= items.length) return;\n const prev = currentIndex;\n currentIndex = index;\n\n items.forEach((item, i) => {\n item.classList.remove('is-active', 'is-completed');\n if (i < index) item.classList.add('is-completed');\n else if (i === index) item.classList.add('is-active');\n });\n\n el.dispatchEvent(new CustomEvent('stepper:change', {\n detail: { current: index, previous: prev, total: items.length },\n bubbles: true\n }));\n };\n\n if (isClickable) {\n items.forEach((item, i) => {\n const handler = () => setStep(i);\n item.addEventListener('click', handler);\n cleanup.push(() => item.removeEventListener('click', handler));\n });\n }\n\n // Initialize current state\n setStep(currentIndex);\n\n this.instances.set(el, {\n cleanup,\n setStep,\n next: () => setStep(currentIndex + 1),\n prev: () => setStep(currentIndex - 1),\n getCurrent: () => currentIndex\n });\n },\n\n setStep: function (el, index) {\n const inst = this.instances.get(el);\n if (inst) inst.setStep(index);\n },\n\n next: function (el) {\n const inst = this.instances.get(el);\n if (inst) inst.next();\n },\n\n prev: function (el) {\n const inst = this.instances.get(el);\n if (inst) inst.prev();\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('stepper', Stepper);\n }\n\n window.VanduoStepper = Stepper;\n\n})();\n", "/**\n * Vanduo Framework - Rating Component\n * Star-based rating input with hover preview and read-only mode\n */\n\n(function () {\n 'use strict';\n\n const Rating = {\n instances: new Map(),\n\n init: function () {\n const ratings = document.querySelectorAll('[data-vd-rating]');\n ratings.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const max = parseInt(el.getAttribute('data-vd-rating-max') || '5', 10);\n const initialValue = parseFloat(el.getAttribute('data-vd-rating-value') || '0');\n const readonly = el.classList.contains('vd-rating-readonly') || el.hasAttribute('data-vd-rating-readonly');\n let currentValue = initialValue;\n\n el.classList.add('vd-rating');\n el.setAttribute('role', 'radiogroup');\n el.setAttribute('aria-label', el.getAttribute('aria-label') || 'Rating');\n\n // Clear existing stars\n el.innerHTML = '';\n\n // Create stars\n const stars = [];\n for (let i = 1; i <= max; i++) {\n const star = document.createElement('button');\n star.type = 'button';\n star.className = 'vd-rating-star';\n star.setAttribute('role', 'radio');\n star.setAttribute('aria-label', i + ' star' + (i > 1 ? 's' : ''));\n star.setAttribute('aria-checked', i <= currentValue ? 'true' : 'false');\n if (readonly) star.tabIndex = -1;\n stars.push(star);\n el.appendChild(star);\n }\n\n // Value display\n const valueDisplay = document.createElement('span');\n valueDisplay.className = 'vd-rating-value';\n valueDisplay.textContent = currentValue > 0 ? currentValue.toString() : '';\n el.appendChild(valueDisplay);\n\n const updateStars = (value) => {\n stars.forEach((star, i) => {\n star.classList.remove('is-active', 'is-half');\n const starNum = i + 1;\n if (starNum <= Math.floor(value)) {\n star.classList.add('is-active');\n } else if (starNum - 0.5 <= value) {\n star.classList.add('is-half');\n }\n star.setAttribute('aria-checked', starNum <= value ? 'true' : 'false');\n });\n valueDisplay.textContent = value > 0 ? value.toString() : '';\n };\n\n updateStars(currentValue);\n\n if (!readonly) {\n stars.forEach((star, i) => {\n const enterHandler = () => {\n stars.forEach((s, j) => {\n s.classList.toggle('is-hovered', j <= i);\n });\n };\n const leaveHandler = () => {\n stars.forEach(s => s.classList.remove('is-hovered'));\n };\n const clickHandler = () => {\n currentValue = i + 1;\n el.setAttribute('data-vd-rating-value', currentValue);\n updateStars(currentValue);\n el.dispatchEvent(new CustomEvent('rating:change', {\n detail: { value: currentValue, max },\n bubbles: true\n }));\n };\n\n star.addEventListener('mouseenter', enterHandler);\n star.addEventListener('mouseleave', leaveHandler);\n star.addEventListener('click', clickHandler);\n\n cleanup.push(\n () => star.removeEventListener('mouseenter', enterHandler),\n () => star.removeEventListener('mouseleave', leaveHandler),\n () => star.removeEventListener('click', clickHandler)\n );\n });\n\n // Keyboard\n const keyHandler = (e) => {\n if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {\n e.preventDefault();\n if (currentValue < max) {\n currentValue++;\n updateStars(currentValue);\n stars[currentValue - 1].focus();\n el.dispatchEvent(new CustomEvent('rating:change', { detail: { value: currentValue, max }, bubbles: true }));\n }\n } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {\n e.preventDefault();\n if (currentValue > 1) {\n currentValue--;\n updateStars(currentValue);\n stars[currentValue - 1].focus();\n el.dispatchEvent(new CustomEvent('rating:change', { detail: { value: currentValue, max }, bubbles: true }));\n }\n }\n };\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n }\n\n this.instances.set(el, {\n cleanup,\n getValue: () => currentValue,\n setValue: (v) => { currentValue = v; updateStars(v); }\n });\n },\n\n getValue: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getValue() : 0;\n },\n\n setValue: function (el, value) {\n const inst = this.instances.get(el);\n if (inst) inst.setValue(value);\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('rating', Rating);\n }\n\n window.VanduoRating = Rating;\n\n})();\n", "/**\n * Vanduo Framework - Transfer (Dual-list) Component\n * Source-to-target list transfer with search, select-all, and move actions\n */\n\n(function () {\n 'use strict';\n\n const Transfer = {\n instances: new Map(),\n\n init: function () {\n const transfers = document.querySelectorAll('[data-vd-transfer]');\n transfers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n el.classList.add('vd-transfer');\n\n let sourceData, targetData;\n try {\n const raw = JSON.parse(el.getAttribute('data-vd-transfer') || '[]');\n sourceData = raw.map((item, i) => ({\n id: item.id || 'item-' + i,\n label: item.label || item.text || String(item),\n selected: false\n }));\n } catch (_e) {\n sourceData = [];\n }\n targetData = [];\n\n const sourceSelected = new Set();\n const targetSelected = new Set();\n\n const render = () => {\n el.innerHTML = '';\n\n // Source panel\n const sourcePanel = createPanel('Source', sourceData, sourceSelected, 'source');\n // Actions\n const actions = document.createElement('div');\n actions.className = 'vd-transfer-actions';\n\n const moveRightBtn = document.createElement('button');\n moveRightBtn.type = 'button';\n moveRightBtn.className = 'vd-transfer-btn';\n moveRightBtn.innerHTML = '›';\n moveRightBtn.setAttribute('aria-label', 'Move to target');\n moveRightBtn.disabled = sourceSelected.size === 0;\n moveRightBtn.addEventListener('click', () => moveRight());\n\n const moveLeftBtn = document.createElement('button');\n moveLeftBtn.type = 'button';\n moveLeftBtn.className = 'vd-transfer-btn';\n moveLeftBtn.innerHTML = '‹';\n moveLeftBtn.setAttribute('aria-label', 'Move to source');\n moveLeftBtn.disabled = targetSelected.size === 0;\n moveLeftBtn.addEventListener('click', () => moveLeft());\n\n actions.appendChild(moveRightBtn);\n actions.appendChild(moveLeftBtn);\n\n // Target panel\n const targetPanel = createPanel('Target', targetData, targetSelected, 'target');\n\n el.appendChild(sourcePanel);\n el.appendChild(actions);\n el.appendChild(targetPanel);\n };\n\n const createPanel = (title, data, selected, _side) => {\n const panel = document.createElement('div');\n panel.className = 'vd-transfer-panel';\n\n const header = document.createElement('div');\n header.className = 'vd-transfer-header';\n const titleSpan = document.createElement('span');\n titleSpan.textContent = title;\n const count = document.createElement('span');\n count.className = 'vd-transfer-count';\n count.textContent = selected.size + '/' + data.length;\n header.appendChild(titleSpan);\n header.appendChild(count);\n panel.appendChild(header);\n\n // Search\n const searchDiv = document.createElement('div');\n searchDiv.className = 'vd-transfer-search';\n const searchInput = document.createElement('input');\n searchInput.type = 'text';\n searchInput.placeholder = 'Search...';\n searchInput.setAttribute('aria-label', 'Search ' + title.toLowerCase());\n searchDiv.appendChild(searchInput);\n panel.appendChild(searchDiv);\n\n // List\n const list = document.createElement('ul');\n list.className = 'vd-transfer-list';\n list.setAttribute('role', 'listbox');\n\n const renderList = (filter) => {\n list.innerHTML = '';\n const filtered = filter ? data.filter(d => {\n const label = (d.label || d.text || String(d)).toLowerCase();\n return label.includes(filter.toLowerCase());\n }) : data;\n filtered.forEach(item => {\n const li = document.createElement('li');\n li.className = 'vd-transfer-item';\n li.setAttribute('role', 'option');\n if (selected.has(item.id)) li.classList.add('is-selected');\n\n const checkbox = document.createElement('input');\n checkbox.type = 'checkbox';\n checkbox.checked = selected.has(item.id);\n checkbox.setAttribute('aria-label', item.label);\n\n const label = document.createElement('span');\n label.textContent = item.label;\n\n li.addEventListener('click', () => {\n if (selected.has(item.id)) selected.delete(item.id);\n else selected.add(item.id);\n render();\n });\n\n li.appendChild(checkbox);\n li.appendChild(label);\n list.appendChild(li);\n });\n };\n\n searchInput.addEventListener('input', () => renderList(searchInput.value));\n renderList('');\n\n panel.appendChild(list);\n return panel;\n };\n\n const moveRight = () => {\n const toMove = sourceData.filter(d => sourceSelected.has(d.id));\n sourceData = sourceData.filter(d => !sourceSelected.has(d.id));\n targetData = targetData.concat(toMove);\n sourceSelected.clear();\n render();\n fireChange();\n };\n\n const moveLeft = () => {\n const toMove = targetData.filter(d => targetSelected.has(d.id));\n targetData = targetData.filter(d => !targetSelected.has(d.id));\n sourceData = sourceData.concat(toMove);\n targetSelected.clear();\n render();\n fireChange();\n };\n\n const fireChange = () => {\n el.dispatchEvent(new CustomEvent('transfer:change', {\n detail: {\n source: sourceData.map(d => d.id),\n target: targetData.map(d => d.id)\n },\n bubbles: true\n }));\n };\n\n render();\n\n this.instances.set(el, {\n cleanup,\n getTarget: () => targetData.map(d => d.id),\n getSource: () => sourceData.map(d => d.id)\n });\n },\n\n getSelected: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getTarget() : [];\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n el.innerHTML = '';\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('transfer', Transfer);\n }\n\n window.VanduoTransfer = Transfer;\n\n})();\n", "/**\n * Vanduo Framework - Tree View Component\n * Hierarchical collapsible tree with checkbox selection and keyboard navigation\n */\n\n(function () {\n 'use strict';\n\n const Tree = {\n instances: new Map(),\n\n init: function () {\n const trees = document.querySelectorAll('[data-vd-tree]');\n trees.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const cascade = el.getAttribute('data-vd-tree-cascade') !== 'false';\n\n let data;\n try { data = JSON.parse(el.getAttribute('data-vd-tree') || '[]'); } catch (_e) { data = []; }\n\n el.classList.add('vd-tree');\n el.setAttribute('role', 'tree');\n\n const render = (items, parent) => {\n parent.innerHTML = '';\n items.forEach(item => {\n const node = document.createElement('li');\n node.className = 'vd-tree-node';\n node.setAttribute('role', 'treeitem');\n node.setAttribute('aria-expanded', item.open ? 'true' : 'false');\n if (item.open) node.classList.add('is-open');\n\n const content = document.createElement('div');\n content.className = 'vd-tree-node-content';\n\n // Toggle\n if (item.children && item.children.length > 0) {\n const toggle = document.createElement('button');\n toggle.type = 'button';\n toggle.className = 'vd-tree-toggle';\n toggle.setAttribute('aria-label', 'Toggle');\n toggle.addEventListener('click', (e) => {\n e.stopPropagation();\n item.open = !item.open;\n node.classList.toggle('is-open');\n node.setAttribute('aria-expanded', item.open ? 'true' : 'false');\n });\n content.appendChild(toggle);\n } else {\n const ph = document.createElement('span');\n ph.className = 'vd-tree-toggle-placeholder';\n content.appendChild(ph);\n }\n\n // Checkbox\n if (el.hasAttribute('data-vd-tree-checkbox')) {\n const cb = document.createElement('input');\n cb.type = 'checkbox';\n cb.className = 'vd-tree-checkbox';\n cb.checked = !!item.checked;\n cb.setAttribute('aria-label', item.label);\n cb.addEventListener('change', (e) => {\n e.stopPropagation();\n item.checked = cb.checked;\n if (cascade && item.children) {\n setChildChecked(item.children, cb.checked);\n render(data, el);\n }\n el.dispatchEvent(new CustomEvent('tree:check', {\n detail: { id: item.id, checked: cb.checked, label: item.label },\n bubbles: true\n }));\n });\n content.appendChild(cb);\n }\n\n // Icon\n if (item.icon) {\n const icon = document.createElement('span');\n icon.className = 'vd-tree-icon ' + item.icon;\n content.appendChild(icon);\n }\n\n // Label\n const label = document.createElement('span');\n label.className = 'vd-tree-label';\n label.textContent = item.label || '';\n content.appendChild(label);\n\n node.appendChild(content);\n\n // Children\n if (item.children && item.children.length > 0) {\n const childList = document.createElement('ul');\n childList.className = 'vd-tree-children';\n childList.setAttribute('role', 'group');\n render(item.children, childList);\n node.appendChild(childList);\n }\n\n parent.appendChild(node);\n });\n };\n\n const setChildChecked = (items, checked) => {\n items.forEach(item => {\n item.checked = checked;\n if (item.children) setChildChecked(item.children, checked);\n });\n };\n\n // Keyboard\n const keyHandler = (e) => {\n const focused = document.activeElement;\n if (!el.contains(focused)) return;\n\n const nodes = Array.from(el.querySelectorAll('.vd-tree-node-content'));\n const idx = nodes.indexOf(focused.closest('.vd-tree-node-content'));\n if (idx === -1) return;\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n if (idx < nodes.length - 1) {\n const next = nodes[idx + 1].querySelector('.vd-tree-toggle, .vd-tree-label');\n if (next) next.focus();\n }\n break;\n case 'ArrowUp':\n e.preventDefault();\n if (idx > 0) {\n const prev = nodes[idx - 1].querySelector('.vd-tree-toggle, .vd-tree-label');\n if (prev) prev.focus();\n }\n break;\n }\n };\n\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n\n render(data, el);\n\n this.instances.set(el, {\n cleanup,\n getData: () => data,\n getChecked: () => {\n const checked = [];\n const collect = (items) => {\n items.forEach(i => {\n if (i.checked) checked.push(i.id || i.label);\n if (i.children) collect(i.children);\n });\n };\n collect(data);\n return checked;\n }\n });\n },\n\n getChecked: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getChecked() : [];\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n el.innerHTML = '';\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('tree', Tree);\n }\n\n window.VanduoTree = Tree;\n\n})();\n", "/**\n * Vanduo Framework - Spotlight (Feature Discovery) Component\n * Guided tour with overlay highlight and step-through tooltip\n */\n\n(function () {\n 'use strict';\n\n const Spotlight = {\n _active: false,\n _steps: [],\n _currentStep: 0,\n _elements: {},\n _cleanup: [],\n _boundTriggers: new WeakMap(),\n _triggerElement: null,\n\n init: function () {\n const triggers = document.querySelectorAll('[data-vd-spotlight]');\n\n triggers.forEach(trigger => {\n if (this._boundTriggers.has(trigger)) return;\n\n const clickHandler = (event) => {\n event.preventDefault();\n\n const steps = this._parseSteps(trigger.getAttribute('data-vd-spotlight'));\n if (steps.length === 0) return;\n\n this.start(steps, { trigger });\n };\n\n trigger.addEventListener('click', clickHandler);\n this._boundTriggers.set(trigger, clickHandler);\n });\n },\n\n _parseSteps: function (raw) {\n if (typeof raw !== 'string' || raw.trim() === '') return [];\n\n try {\n const parsed = JSON.parse(raw);\n return this._normalizeSteps(parsed);\n } catch (error) {\n console.error('VanduoSpotlight: invalid data-vd-spotlight payload.', error);\n return [];\n }\n },\n\n _normalizeStep: function (step) {\n if (!step || typeof step !== 'object') return null;\n\n const target = step.target;\n const hasSelectorTarget = typeof target === 'string' && target.trim() !== '';\n const hasElementTarget = typeof Element !== 'undefined' && target instanceof Element;\n\n if (!hasSelectorTarget && !hasElementTarget) return null;\n\n const title = typeof step.title === 'string' ? step.title : '';\n const description = typeof step.description === 'string'\n ? step.description\n : (typeof step.content === 'string' ? step.content : '');\n\n return {\n target,\n title,\n description\n };\n },\n\n _normalizeSteps: function (steps) {\n if (!Array.isArray(steps)) return [];\n\n return steps\n .map(step => this._normalizeStep(step))\n .filter(Boolean);\n },\n\n start: function (steps, options) {\n if (this._active) this.stop();\n\n const normalizedSteps = this._normalizeSteps(steps);\n if (normalizedSteps.length === 0) return;\n\n const startOptions = options || {};\n\n this._steps = normalizedSteps;\n this._currentStep = 0;\n this._active = true;\n this._triggerElement = startOptions.trigger || (document.activeElement instanceof HTMLElement ? document.activeElement : null);\n\n // Create overlay\n const overlay = document.createElement('div');\n overlay.className = 'vd-spotlight-overlay';\n overlay.setAttribute('aria-hidden', 'true');\n document.body.appendChild(overlay);\n\n // Create tooltip\n const tooltip = document.createElement('div');\n tooltip.className = 'vd-spotlight-tooltip';\n tooltip.setAttribute('role', 'dialog');\n tooltip.setAttribute('aria-modal', 'true');\n tooltip.tabIndex = -1;\n document.body.appendChild(tooltip);\n\n this._elements = { overlay, tooltip };\n\n // ESC to close\n const escHandler = (e) => { if (e.key === 'Escape') this.stop(); };\n document.addEventListener('keydown', escHandler);\n this._cleanup.push(() => document.removeEventListener('keydown', escHandler));\n\n // Overlay click to close\n overlay.addEventListener('click', () => this.stop());\n\n this._showStep(this._currentStep);\n },\n\n _showStep: function (index) {\n const step = this._steps[index];\n if (!step) return;\n\n const target = typeof step.target === 'string' ? document.querySelector(step.target) : step.target;\n const { tooltip } = this._elements;\n\n // Remove previous highlight\n document.querySelectorAll('.vd-spotlight-target').forEach(el => {\n el.classList.remove('vd-spotlight-target');\n });\n\n // Highlight target\n if (target) {\n target.classList.add('vd-spotlight-target');\n target.scrollIntoView({ behavior: 'smooth', block: 'center' });\n }\n\n // Build tooltip content\n const total = this._steps.length;\n tooltip.innerHTML = '';\n tooltip.removeAttribute('aria-labelledby');\n tooltip.removeAttribute('aria-describedby');\n\n if (step.title) {\n const title = document.createElement('h4');\n title.className = 'vd-spotlight-title';\n title.id = 'vd-spotlight-title-' + index + '-' + Date.now();\n title.textContent = step.title;\n tooltip.appendChild(title);\n tooltip.setAttribute('aria-labelledby', title.id);\n }\n\n if (step.description) {\n const desc = document.createElement('p');\n desc.className = 'vd-spotlight-description';\n desc.id = 'vd-spotlight-description-' + index + '-' + Date.now();\n desc.textContent = step.description;\n tooltip.appendChild(desc);\n tooltip.setAttribute('aria-describedby', desc.id);\n }\n\n // Footer\n const footer = document.createElement('div');\n footer.className = 'vd-spotlight-footer';\n footer.setAttribute('aria-label', 'Step ' + (index + 1) + ' of ' + total);\n\n const counter = document.createElement('span');\n counter.className = 'vd-spotlight-counter';\n counter.textContent = (index + 1) + ' / ' + total;\n\n const actions = document.createElement('div');\n actions.className = 'vd-spotlight-actions';\n\n if (index > 0) {\n const prevBtn = document.createElement('button');\n prevBtn.type = 'button';\n prevBtn.className = 'vd-spotlight-btn';\n prevBtn.textContent = 'Back';\n prevBtn.addEventListener('click', () => this.prev());\n actions.appendChild(prevBtn);\n }\n\n const skipBtn = document.createElement('button');\n skipBtn.type = 'button';\n skipBtn.className = 'vd-spotlight-btn';\n skipBtn.textContent = 'Skip';\n skipBtn.addEventListener('click', () => this.stop());\n actions.appendChild(skipBtn);\n\n if (index < total - 1) {\n const nextBtn = document.createElement('button');\n nextBtn.type = 'button';\n nextBtn.className = 'vd-spotlight-btn vd-spotlight-btn-primary';\n nextBtn.textContent = 'Next';\n nextBtn.addEventListener('click', () => this.next());\n actions.appendChild(nextBtn);\n } else {\n const doneBtn = document.createElement('button');\n doneBtn.type = 'button';\n doneBtn.className = 'vd-spotlight-btn vd-spotlight-btn-primary';\n doneBtn.textContent = 'Done';\n doneBtn.addEventListener('click', () => this.stop());\n actions.appendChild(doneBtn);\n }\n\n footer.appendChild(counter);\n footer.appendChild(actions);\n tooltip.appendChild(footer);\n\n // Position tooltip near target\n if (target) {\n requestAnimationFrame(() => {\n const rect = target.getBoundingClientRect();\n const tRect = tooltip.getBoundingClientRect();\n let top = rect.bottom + 12 + window.scrollY;\n let left = rect.left + (rect.width - tRect.width) / 2 + window.scrollX;\n\n // Keep in viewport\n left = Math.max(8, Math.min(left, window.innerWidth - tRect.width - 8));\n if (top + tRect.height > window.innerHeight + window.scrollY) {\n top = rect.top - tRect.height - 12 + window.scrollY;\n }\n\n tooltip.style.top = top + 'px';\n tooltip.style.left = left + 'px';\n });\n }\n\n document.dispatchEvent(new CustomEvent('spotlight:step', {\n detail: { index, step: index, total, data: step }\n }));\n },\n\n next: function () {\n if (this._currentStep < this._steps.length - 1) {\n this._currentStep++;\n this._showStep(this._currentStep);\n }\n },\n\n prev: function () {\n if (this._currentStep > 0) {\n this._currentStep--;\n this._showStep(this._currentStep);\n }\n },\n\n stop: function () {\n if (!this._active) return;\n\n const total = this._steps.length;\n const detail = {\n completedSteps: total === 0 ? 0 : Math.min(this._currentStep + 1, total),\n total,\n completed: total > 0 && this._currentStep >= total - 1\n };\n\n this._active = false;\n\n document.querySelectorAll('.vd-spotlight-target').forEach(el => {\n el.classList.remove('vd-spotlight-target');\n });\n\n if (this._elements.overlay && this._elements.overlay.parentNode) {\n this._elements.overlay.parentNode.removeChild(this._elements.overlay);\n }\n if (this._elements.tooltip && this._elements.tooltip.parentNode) {\n this._elements.tooltip.parentNode.removeChild(this._elements.tooltip);\n }\n\n this._cleanup.forEach(fn => fn());\n this._cleanup = [];\n this._elements = {};\n this._steps = [];\n this._currentStep = 0;\n\n if (this._triggerElement && this._triggerElement.isConnected && typeof this._triggerElement.focus === 'function') {\n this._triggerElement.focus();\n }\n this._triggerElement = null;\n\n document.dispatchEvent(new CustomEvent('spotlight:end', { detail }));\n },\n\n destroyAll: function () {\n this.stop();\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('spotlight', Spotlight);\n }\n\n window.VanduoSpotlight = Spotlight;\n\n})();\n", "/**\n * Vanduo Framework - Music Player Component\n * HTML5 Audio-based music player with transport controls, volume,\n * and optional shuffle, seek bar, and playlist features.\n *\n * Options (passed to MusicPlayer.init or data-music-player-options):\n * tracks {Array} - [{name, url}] \u2014 required\n * volume {number} - Initial volume 0\u20131 (default 0.5)\n * shuffle {boolean} - Shuffle on init (default false)\n * showProgress {boolean} - Show seek/progress bar (default false)\n * showPlaylist {boolean} - Show expandable playlist panel (default false)\n * autoAdvance {boolean} - Auto-play next track on end (default true)\n * glass {boolean} - Frosted-glass surface styling (default false)\n * detachable {boolean} - Show detach/attach; float above page when detached (default false)\n * floatingPosition {string|null} - After detach: 'bottom-left' or 'bottom-right' (default null = bottom-right)\n * draggable {boolean} - When detached+detachable, drag by handle (default false)\n * minimizable {boolean} - Minimize/expand control (default false)\n * startMinimized {boolean} - On first detach, start minimized (default false)\n * persistPosition {boolean} - Save floating x/y in localStorage (default false)\n * persistKey {string} - Storage key for persist (default from element id)\n *\n * Custom events (all bubble, dispatched on container):\n * musicplayer:play \u2014 playback started\n * musicplayer:pause \u2014 playback paused\n * musicplayer:trackchange \u2014 detail: { index, name, url }\n * musicplayer:volumechange \u2014 detail: { volume }\n * musicplayer:ended \u2014 last track ended (autoAdvance=false only)\n * musicplayer:detach \u2014 after floating player is created\n * musicplayer:attach \u2014 after returned to document flow\n * musicplayer:minimize \u2014 collapsed to minimal controls\n * musicplayer:expand \u2014 full controls visible again\n *\n * Programmatic API:\n * MusicPlayer.init(container?, options?)\n * MusicPlayer.play(container)\n * MusicPlayer.pause(container)\n * MusicPlayer.toggle(container)\n * MusicPlayer.next(container)\n * MusicPlayer.previous(container)\n * MusicPlayer.setVolume(container, value)\n * MusicPlayer.setTrack(container, index)\n * MusicPlayer.shuffle(container)\n * MusicPlayer.detach(container, position?)\n * MusicPlayer.attach(container)\n * MusicPlayer.minimize(container)\n * MusicPlayer.expand(container)\n * MusicPlayer.toggleMinimize(container)\n * MusicPlayer.setPosition(container, 'bottom-left'|'bottom-right'|{x,y})\n * MusicPlayer.getState(container)\n * MusicPlayer.destroy(container)\n * MusicPlayer.destroyAll()\n */\n\n(function () {\n 'use strict';\n\n /* \u2500\u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Fisher-Yates shuffle (returns new array).\n * @param {Array} arr\n * @returns {Array}\n */\n function shuffleArray(arr) {\n const shuffled = arr.slice();\n for (let i = shuffled.length - 1; i > 0; i--) {\n const j = Math.floor(Math.random() * (i + 1));\n const tmp = shuffled[i];\n shuffled[i] = shuffled[j];\n shuffled[j] = tmp;\n }\n return shuffled;\n }\n\n /**\n * Format seconds as m:ss.\n * @param {number} seconds\n * @returns {string}\n */\n function formatTime(seconds) {\n if (!isFinite(seconds) || seconds < 0) return '0:00';\n const m = Math.floor(seconds / 60);\n const s = Math.floor(seconds % 60);\n return m + ':' + (s < 10 ? '0' : '') + s;\n }\n\n /**\n * Set CSS background-size on a range input to visually fill the track.\n * @param {HTMLInputElement} input\n */\n /**\n * @param {string|undefined} id\n * @returns {string}\n */\n function persistStorageKey(id) {\n return 'vanduo:music-player:' + (id && id.trim() ? id.trim() : 'default') + ':pos';\n }\n\n function updateRangeFill(input) {\n const min = parseFloat(input.min) || 0;\n const max = parseFloat(input.max) || 1;\n const val = parseFloat(input.value) || 0;\n const pct = ((val - min) / (max - min)) * 100;\n input.style.setProperty('--fill', pct + '%');\n // Fallback inline gradient for browsers without ::-moz-range-progress\n input.style.backgroundImage =\n 'linear-gradient(to right, var(--music-player-track-fill, currentColor) 0%, ' +\n 'var(--music-player-track-fill, currentColor) ' + pct + '%, ' +\n 'var(--music-player-track-bg, #ccc) ' + pct + '%, ' +\n 'var(--music-player-track-bg, #ccc) 100%)';\n }\n\n /* \u2500\u2500\u2500 Phosphor icon helper (matches framework icon style) \u2500 */\n\n /**\n * Return an element (Phosphor icon).\n * @param {string} name\n * @returns {HTMLElement}\n */\n function icon(name) {\n const el = document.createElement('i');\n el.className = 'ph ph-' + name;\n el.setAttribute('aria-hidden', 'true');\n return el;\n }\n\n /* \u2500\u2500\u2500 Component \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const MusicPlayer = {\n /** @type {Map} */\n instances: new Map(),\n\n /**\n * Default options.\n */\n defaults: {\n tracks: [],\n volume: 0.5,\n shuffle: false,\n showProgress: false,\n showPlaylist: false,\n autoAdvance: true,\n glass: false,\n detachable: false,\n /** @type {null|string} */\n floatingPosition: null,\n draggable: false,\n minimizable: false,\n startMinimized: false,\n persistPosition: false,\n persistKey: '',\n },\n\n /**\n * Auto-initialize all .vd-music-player / [data-music-player] elements.\n * Options can be provided via data-music-player-options (JSON string).\n */\n init: function () {\n document.querySelectorAll('.vd-music-player, [data-music-player]').forEach((el) => {\n if (this.instances.has(el)) return;\n\n let opts = {};\n const attr = el.getAttribute('data-music-player-options');\n if (attr) {\n try { opts = JSON.parse(attr); } catch (_) { /* ignore malformed JSON */ }\n }\n this.initPlayer(el, opts);\n });\n },\n\n /**\n * Initialize a single player element.\n * @param {HTMLElement} container\n * @param {Object} [options]\n */\n initPlayer: function (container, options) {\n const opts = Object.assign({}, this.defaults, options || {});\n\n // Validate and normalise tracks\n const rawTracks = Array.isArray(opts.tracks) ? opts.tracks : [];\n const tracks = rawTracks.filter((t) => t && typeof t.url === 'string' && t.url.trim());\n\n // Build shuffled working copy without mutating opts\n const trackList = opts.shuffle ? shuffleArray(tracks) : tracks.slice();\n\n /* \u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const state = {\n tracks: trackList,\n originalTracks: tracks.slice(),\n currentIndex: 0,\n isPlaying: false,\n volume: Math.max(0, Math.min(1, opts.volume)),\n shuffle: opts.shuffle,\n showProgress: opts.showProgress,\n showPlaylist: opts.showPlaylist,\n autoAdvance: opts.autoAdvance,\n audio: null,\n glass: Boolean(opts.glass),\n detachable: Boolean(opts.detachable),\n floatingPosition: opts.floatingPosition || 'bottom-right',\n draggable: Boolean(opts.draggable) && Boolean(opts.detachable),\n minimizable: Boolean(opts.minimizable),\n startMinimized: Boolean(opts.startMinimized),\n persistPosition: Boolean(opts.persistPosition),\n persistKey: typeof opts.persistKey === 'string' ? opts.persistKey : '',\n isDetached: false,\n isMinimized: false,\n _startMinimizeApplied: false,\n };\n\n /* \u2500\u2500 Audio element \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const audio = new Audio();\n audio.volume = state.volume;\n audio.preload = 'metadata';\n state.audio = audio;\n\n /* \u2500\u2500 Build DOM \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n this._buildDOM(container, state);\n\n // Grab references after DOM build\n const refs = {\n btnPlay: container.querySelector('.vd-music-player-btn-play'),\n btnPrev: container.querySelector('.vd-music-player-btn-prev'),\n btnNext: container.querySelector('.vd-music-player-btn-next'),\n btnShuffle: container.querySelector('.vd-music-player-btn-shuffle'),\n btnPlaylist: container.querySelector('.vd-music-player-btn-playlist'),\n btnDetach: container.querySelector('.vd-music-player-btn-detach'),\n btnAttach: container.querySelector('.vd-music-player-btn-attach'),\n btnMinimize: container.querySelector('.vd-music-player-btn-minimize'),\n dragHandle: container.querySelector('.vd-music-player-drag-handle'),\n trackName: container.querySelector('.vd-music-player-track-name'),\n volumeSlider: container.querySelector('.vd-music-player-volume-slider'),\n volumeIcon: container.querySelector('.vd-music-player-volume-icon'),\n progressBar: container.querySelector('.vd-music-player-progress-bar'),\n timeElapsed: container.querySelector('.vd-music-player-time-elapsed'),\n timeDuration: container.querySelector('.vd-music-player-time-duration'),\n playlistPanel: container.querySelector('.vd-music-player-playlist'),\n };\n\n /* \u2500\u2500 Internal render helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const renderPlayIcon = () => {\n const btn = refs.btnPlay;\n if (!btn) return;\n btn.innerHTML = '';\n btn.appendChild(icon(state.isPlaying ? 'pause' : 'play'));\n btn.setAttribute('aria-label', state.isPlaying ? 'Pause' : 'Play');\n btn.classList.toggle('is-active', state.isPlaying);\n };\n\n const renderTrackName = () => {\n const el = refs.trackName;\n if (!el) return;\n const track = state.tracks[state.currentIndex];\n if (track) {\n el.textContent = track.name || 'Unknown Track';\n el.classList.remove('is-idle');\n } else {\n el.textContent = 'No tracks loaded';\n el.classList.add('is-idle');\n }\n };\n\n const renderVolumeIcon = () => {\n const el = refs.volumeIcon;\n if (!el) return;\n el.innerHTML = '';\n const v = state.volume;\n const name = v === 0 ? 'speaker-none' : v < 0.5 ? 'speaker-low' : 'speaker-high';\n el.appendChild(icon(name));\n };\n\n const renderShuffleBtn = () => {\n const btn = refs.btnShuffle;\n if (!btn) return;\n btn.classList.toggle('is-active', state.shuffle);\n btn.setAttribute('aria-pressed', state.shuffle ? 'true' : 'false');\n };\n\n const renderPlaylistItems = () => {\n const panel = refs.playlistPanel;\n if (!panel) return;\n panel.innerHTML = '';\n state.tracks.forEach((track, i) => {\n const item = document.createElement('button');\n item.className =\n 'vd-music-player-playlist-item' + (i === state.currentIndex ? ' is-active' : '');\n item.type = 'button';\n item.setAttribute('data-index', String(i));\n item.setAttribute('aria-current', i === state.currentIndex ? 'true' : 'false');\n\n const num = document.createElement('span');\n num.className = 'vd-music-player-playlist-num';\n num.textContent = String(i + 1);\n\n const name = document.createElement('span');\n name.className = 'vd-music-player-playlist-name';\n name.textContent = track.name || 'Track ' + (i + 1);\n\n item.appendChild(num);\n item.appendChild(name);\n panel.appendChild(item);\n });\n };\n\n const renderProgress = () => {\n const bar = refs.progressBar;\n if (!bar || !audio.duration) return;\n const pct = (audio.currentTime / audio.duration) * 100;\n bar.value = String(pct);\n updateRangeFill(bar);\n if (refs.timeElapsed) refs.timeElapsed.textContent = formatTime(audio.currentTime);\n if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);\n };\n\n /* \u2500\u2500 Load track \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const loadTrack = (index, autoPlay) => {\n const track = state.tracks[index];\n if (!track) return;\n state.currentIndex = index;\n audio.src = track.url;\n renderTrackName();\n renderPlaylistItems();\n\n // Reset progress\n if (refs.progressBar) {\n refs.progressBar.value = '0';\n updateRangeFill(refs.progressBar);\n }\n if (refs.timeElapsed) refs.timeElapsed.textContent = '0:00';\n if (refs.timeDuration) refs.timeDuration.textContent = '0:00';\n\n container.dispatchEvent(\n new CustomEvent('musicplayer:trackchange', {\n bubbles: true,\n detail: { index, name: track.name, url: track.url },\n })\n );\n\n if (autoPlay) {\n audio.play().catch(() => { /* browser may block autoplay */ });\n }\n };\n\n /* \u2500\u2500 Audio event listeners \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const cleanupFunctions = [];\n\n const onPlay = () => {\n state.isPlaying = true;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:play', { bubbles: true }));\n };\n\n const onPause = () => {\n state.isPlaying = false;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:pause', { bubbles: true }));\n };\n\n const onEnded = () => {\n if (state.autoAdvance && state.tracks.length > 1) {\n const next = (state.currentIndex + 1) % state.tracks.length;\n loadTrack(next, true);\n } else {\n state.isPlaying = false;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:ended', { bubbles: true }));\n }\n };\n\n const onTimeUpdate = () => {\n if (state.showProgress) renderProgress();\n };\n\n const onLoadedMetadata = () => {\n if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);\n if (refs.progressBar) {\n refs.progressBar.max = '100';\n updateRangeFill(refs.progressBar);\n }\n };\n\n audio.addEventListener('play', onPlay);\n audio.addEventListener('pause', onPause);\n audio.addEventListener('ended', onEnded);\n audio.addEventListener('timeupdate', onTimeUpdate);\n audio.addEventListener('loadedmetadata', onLoadedMetadata);\n cleanupFunctions.push(() => {\n audio.removeEventListener('play', onPlay);\n audio.removeEventListener('pause', onPause);\n audio.removeEventListener('ended', onEnded);\n audio.removeEventListener('timeupdate', onTimeUpdate);\n audio.removeEventListener('loadedmetadata', onLoadedMetadata);\n audio.pause();\n audio.src = '';\n });\n\n /* \u2500\u2500 Control button listeners \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n if (refs.btnPlay) {\n const handler = () => {\n if (!audio.src && state.tracks.length) loadTrack(state.currentIndex, false);\n if (state.isPlaying) {\n audio.pause();\n } else {\n audio.play().catch(() => {});\n }\n };\n refs.btnPlay.addEventListener('click', handler);\n cleanupFunctions.push(() => refs.btnPlay.removeEventListener('click', handler));\n\n // Keyboard: Space / Enter (already native for `;\n }\n\n // Generate neutral color swatches\n let neutralSwatches = '';\n for (const [key, value] of Object.entries(this.NEUTRAL_COLORS)) {\n neutralSwatches += ``;\n }\n\n // Generate radius buttons\n let radiusButtons = '';\n this.RADIUS_OPTIONS.forEach(r => {\n radiusButtons += ``;\n });\n\n // Generate font options\n let fontOptions = '';\n for (const [key, value] of Object.entries(this.FONT_OPTIONS)) {\n fontOptions += ``;\n }\n\n return `\n
    \n

    Customize Theme

    \n \n
    \n
    \n\n
    \n \n
    \n ${primarySwatches}\n
    \n
    \n
    \n \n
    \n ${neutralSwatches}\n
    \n
    \n
    \n \n
    \n ${radiusButtons}\n
    \n
    \n
    \n \n \n
    \n
    \n
    \n \n
    \n `;\n },\n\n /**\n * Bind event listeners\n */\n /**\n * Check whether the current primary color is one of the auto-defaults\n * (i.e. the user hasn't explicitly picked a non-default color).\n */\n isUsingDefaultPrimary: function () {\n return this.state.primary === this.DEFAULTS.PRIMARY_LIGHT ||\n this.state.primary === this.DEFAULTS.PRIMARY_DARK;\n },\n\n /**\n * When primary is still one of the auto-default palette keys (black/amber) but\n * localStorage was written under a different theme (or OS changed in system mode),\n * align in-memory state before applyAllPreferences runs \u2014 avoids amber+light / black+dark drift.\n */\n _normalizeDefaultPrimaryIfStaleWithStoredTheme: function () {\n if (!this.isUsingDefaultPrimary()) {\n return;\n }\n const expected = this.getDefaultPrimary(this.state.theme);\n if (this.state.primary !== expected) {\n this.state.primary = expected;\n }\n },\n\n bindEvents: function () {\n this.bindTriggerEvents();\n\n this.bindPanelEvents();\n\n // Listen for OS dark/light changes so we can swap the default primary\n if (window.matchMedia) {\n const mq = window.matchMedia('(prefers-color-scheme: dark)');\n const handler = () => {\n if (this.state.theme === 'system' && this.isUsingDefaultPrimary()) {\n const newDefault = this.getDefaultPrimary('system');\n if (newDefault !== this.state.primary) {\n this.applyPrimary(newDefault);\n this.updateUI();\n }\n }\n };\n mq.addEventListener('change', handler);\n this._cleanup.push(() => mq.removeEventListener('change', handler));\n }\n\n // Close on outside click\n this.addListener(document, 'click', (e) => {\n if (this.state.isOpen && this.elements.customizer && !this.elements.customizer.contains(e.target)) {\n this.close();\n }\n });\n\n // Close on Escape key\n this.addListener(document, 'keydown', (e) => {\n if (e.key === 'Escape' && this.state.isOpen) {\n this.close();\n }\n });\n },\n\n bindTriggerEvents: function () {\n this.elements.triggers.forEach((trigger) => {\n if (trigger.getAttribute('data-customizer-trigger-initialized') === 'true') {\n return;\n }\n this.addListener(trigger, 'click', (e) => {\n e.preventDefault();\n e.stopPropagation();\n this.elements.activeTrigger = trigger;\n this.elements.trigger = trigger;\n this.toggle();\n });\n trigger.setAttribute('data-customizer-trigger-initialized', 'true');\n });\n },\n\n /**\n * Toggle panel open/close\n */\n toggle: function () {\n if (this.state.isOpen) {\n this.close();\n } else {\n this.open();\n }\n },\n\n /**\n * Open the panel\n */\n open: function () {\n this.state.isOpen = true;\n\n // Ensure panel is positioned correctly before opening\n this.positionPanel();\n\n if (this.elements.panel) {\n this.elements.panel.classList.add('is-open');\n }\n this.elements.triggers.forEach((trigger) => trigger.setAttribute('aria-expanded', 'false'));\n if (this.elements.trigger) this.elements.trigger.setAttribute('aria-expanded', 'true');\n if (this.elements.overlay) {\n this.elements.overlay.classList.add('is-active');\n }\n\n this.dispatchEvent('panel-open', { isOpen: true });\n },\n\n /**\n * Close the panel\n */\n close: function () {\n this.state.isOpen = false;\n\n if (this.elements.panel) {\n this.elements.panel.classList.remove('is-open');\n }\n this.elements.triggers.forEach((trigger) => trigger.setAttribute('aria-expanded', 'false'));\n if (this.elements.overlay) {\n this.elements.overlay.classList.remove('is-active');\n }\n\n this.dispatchEvent('panel-close', { isOpen: false });\n },\n\n /**\n * Update UI to reflect current state\n */\n updateUI: function () {\n if (!this.elements.panel) return;\n\n // Update primary color swatches\n this.elements.panel.querySelectorAll('[data-color]').forEach(swatch => {\n swatch.classList.toggle('is-active', swatch.dataset.color === this.state.primary);\n });\n\n // Update neutral color swatches\n this.elements.panel.querySelectorAll('[data-neutral]').forEach(swatch => {\n swatch.classList.toggle('is-active', swatch.dataset.neutral === this.state.neutral);\n });\n\n // Update radius buttons\n this.elements.panel.querySelectorAll('[data-radius]').forEach(btn => {\n btn.classList.toggle('is-active', btn.dataset.radius === this.state.radius);\n });\n\n // Update font selector\n const fontSelect = this.elements.panel.querySelector('[data-customizer-font]');\n if (fontSelect) {\n fontSelect.value = this.state.font;\n }\n\n },\n\n /**\n * Reset all preferences to defaults\n */\n reset: function () {\n this.applyTheme(this.DEFAULTS.THEME);\n this.applyPrimary(this.getDefaultPrimary(this.DEFAULTS.THEME));\n this.applyNeutral(this.DEFAULTS.NEUTRAL);\n this.applyRadius(this.DEFAULTS.RADIUS);\n this.applyFont(this.DEFAULTS.FONT);\n this.updateUI();\n\n this.dispatchEvent('reset', { state: { ...this.state } });\n },\n\n /**\n * Get current state\n */\n getState: function () {\n return { ...this.state };\n },\n\n /**\n * Programmatically set preferences\n */\n setPreferences: function (prefs) {\n if (prefs.primary) this.applyPrimary(prefs.primary);\n if (prefs.neutral) this.applyNeutral(prefs.neutral);\n if (prefs.radius) this.applyRadius(prefs.radius);\n if (prefs.font) this.applyFont(prefs.font);\n if (prefs.theme) this.applyTheme(prefs.theme);\n this.updateUI();\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 destroyAll: function () {\n this._cleanup.forEach(fn => fn());\n this._cleanup = [];\n\n if (this.elements.panel) {\n this.elements.panel.removeAttribute('data-customizer-initialized');\n }\n\n this.close();\n this.isInitialized = false;\n }\n };\n\n // Register component\n if (window.Vanduo) {\n window.Vanduo.register('themeCustomizer', ThemeCustomizer);\n }\n\n // Expose globally for convenience\n window.ThemeCustomizer = ThemeCustomizer;\n})();\n", "/**\n * Vanduo Framework - Theme Switcher\n * Handles light/dark/system theme toggling and persistence\n */\n\n(function () {\n 'use strict';\n\n const ThemeSwitcher = {\n isInitialized: false,\n _mediaQuery: null,\n _onMediaChange: null,\n\n init: function () {\n this.STORAGE_KEY = 'vanduo-theme-preference';\n this.state = {\n preference: this.getPreference() // 'light', 'dark', or 'system'\n };\n\n if (this.isInitialized) {\n this.applyTheme();\n this.renderUI();\n this.updateUI();\n return;\n }\n\n this.isInitialized = true;\n\n this.applyTheme();\n this.listenForSystemChanges();\n this.renderUI();\n\n console.log('Vanduo Theme Switcher initialized');\n },\n\n getPreference: function () {\n return this.getStorageValue(this.STORAGE_KEY, 'system');\n },\n\n setPreference: function (pref) {\n this.state.preference = pref;\n this.setStorageValue(this.STORAGE_KEY, pref);\n this.applyTheme();\n \n // Coordinate with ThemeCustomizer for primary color swap if available\n // Check _isApplying flag to prevent circular updates\n if (window.ThemeCustomizer && window.ThemeCustomizer.applyTheme && !window.ThemeCustomizer._isApplying) {\n window.ThemeCustomizer.applyTheme(pref);\n }\n \n this.updateUI();\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 applyTheme: function () {\n const pref = this.state.preference;\n\n if (pref === 'system') {\n // When in system mode, we remove the data attribute to let the media query take over\n // or we can explicitly set it. Explicitly setting it ensures consistency if we rely on [data-theme]\n // But if we rely on @media in CSS, we might want to remove attributes.\n // However, our CSS strategy uses :root:not([data-theme=\"light\"]) inside media query for system dark fallback\n // which is a bit complex.\n\n // Simpler approach: \n // If preference is system, REMOVE data-theme attribute. Let CSS media queries handle it.\n document.documentElement.removeAttribute('data-theme');\n } else {\n document.documentElement.setAttribute('data-theme', pref);\n }\n },\n\n listenForSystemChanges: function () {\n if (this._mediaQuery && this._onMediaChange) {\n return;\n }\n\n this._mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n this._onMediaChange = _e => {\n if (this.state.preference === 'system') {\n // Re-apply (effectively just to ensure consistency, though removing attribute usually suffices)\n this.applyTheme();\n // Keep default primary (black/amber) aligned when OS scheme changes while in system mode\n if (window.ThemeCustomizer && typeof window.ThemeCustomizer.applyTheme === 'function' && !window.ThemeCustomizer._isApplying) {\n window.ThemeCustomizer.applyTheme('system');\n }\n }\n };\n this._mediaQuery.addEventListener('change', this._onMediaChange);\n },\n\n // Helper to facilitate UI creation if needed, though often UI is in HTML\n renderUI: function () {\n // Look for any uninitialized theme toggles\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"]');\n toggles.forEach(toggle => {\n if (toggle.getAttribute('data-theme-initialized') === 'true') {\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n }\n return;\n }\n\n // Simplified UI Binding - assumes a select or a button cycle\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n const onChange = (e) => {\n this.setPreference(e.target.value);\n };\n toggle.addEventListener('change', onChange);\n toggle._themeToggleHandler = onChange;\n } else {\n // Button implementation (cycle)\n const onClick = () => {\n const modes = ['system', 'light', 'dark'];\n const nextIndex = (modes.indexOf(this.state.preference) + 1) % modes.length;\n this.setPreference(modes[nextIndex]);\n };\n toggle.addEventListener('click', onClick);\n toggle._themeToggleHandler = onClick;\n }\n // Mark as initialized\n toggle.setAttribute('data-theme-initialized', 'true');\n });\n },\n\n updateUI: function () {\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"]');\n toggles.forEach(toggle => {\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n } else {\n // Update button text/icon if needed\n // e.g. toggle.textContent = this.state.preference;\n // For now, assume the user handles visual state or generic text\n\n // If there is an icon or text span inside, update it\n const span = toggle.querySelector('.theme-current-label');\n if (span) {\n span.textContent = this.state.preference.charAt(0).toUpperCase() + this.state.preference.slice(1);\n }\n }\n });\n },\n\n destroyAll: function () {\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"][data-theme-initialized=\"true\"]');\n toggles.forEach(toggle => {\n if (toggle._themeToggleHandler) {\n const eventName = toggle.tagName === 'SELECT' ? 'change' : 'click';\n toggle.removeEventListener(eventName, toggle._themeToggleHandler);\n delete toggle._themeToggleHandler;\n }\n toggle.removeAttribute('data-theme-initialized');\n });\n\n if (this._mediaQuery && this._onMediaChange) {\n this._mediaQuery.removeEventListener('change', this._onMediaChange);\n }\n\n this._mediaQuery = null;\n this._onMediaChange = null;\n this.isInitialized = false;\n }\n };\n\n // Register component\n if (window.Vanduo) {\n window.Vanduo.register('themeSwitcher', ThemeSwitcher);\n }\n})();\n", "/**\n * Vanduo Framework - Toast Component\n * Popup notifications for user feedback\n */\n\n(function() {\n 'use strict';\n\n /**\n * Toast Component\n */\n const Toast = {\n // Default options\n defaults: {\n position: 'top-right',\n duration: 5000,\n dismissible: true,\n showProgress: true,\n pauseOnHover: true\n },\n\n // Container cache\n containers: {},\n\n /**\n * Get or create a toast container for a position\n * @param {string} position - Container position\n * @returns {HTMLElement} Toast container element\n */\n getContainer: function(position) {\n if (this.containers[position]) {\n return this.containers[position];\n }\n\n const container = document.createElement('div');\n container.className = `vd-toast-container vd-toast-container-${position}`;\n container.setAttribute('role', 'status');\n container.setAttribute('aria-live', 'polite');\n container.setAttribute('aria-atomic', 'false');\n document.body.appendChild(container);\n this.containers[position] = container;\n\n return container;\n },\n\n /**\n * Show a toast notification\n * @param {Object|string} options - Toast options or message string\n * @param {string} [type] - Toast type (success, error, warning, info)\n * @param {number} [duration] - Auto-dismiss duration in ms\n * @returns {HTMLElement} Toast element\n */\n show: function(options, type, duration) {\n // Support simple API:\n // - Toast.show('Message', 'success', 3000)\n // - Toast.show('Message', { type: 'success', duration: 3000 })\n if (typeof options === 'string') {\n if (type && typeof type === 'object') {\n options = Object.assign({}, type, {\n message: options\n });\n } else {\n options = {\n message: options,\n type: type,\n duration: duration\n };\n }\n }\n\n const config = Object.assign({}, this.defaults, options);\n const container = this.getContainer(config.position);\n\n // Create toast element\n const toast = document.createElement('div');\n toast.className = 'vd-toast';\n\n if (config.type) {\n toast.classList.add(`vd-toast-${config.type}`);\n }\n\n if (config.solid) {\n toast.classList.add('vd-toast-solid');\n }\n\n if (config.showProgress && config.duration > 0) {\n toast.classList.add('vd-toast-with-progress');\n }\n\n // Build toast content\n let html = '';\n\n // Icon (sanitize custom icons, default icons are trusted SVG)\n if (config.icon) {\n const allowSvg = config.iconAllowSvg === true;\n const safeIcon = typeof sanitizeHtml === 'function'\n ? sanitizeHtml(config.icon, { allowSvg })\n : escapeHtml(config.icon);\n html += `${safeIcon}`;\n } else if (config.type) {\n html += `${this.getDefaultIcon(config.type)}`;\n }\n\n // Local escape helper \u2014 guarantees HTML-safe output even if the\n // global escapeHtml utility is not loaded in the current bundle.\n const _esc = typeof escapeHtml === 'function'\n ? escapeHtml\n : function (s) {\n const d = document.createElement('div');\n d.appendChild(document.createTextNode(s));\n return d.innerHTML;\n };\n\n // Content (escape text to prevent injection)\n html += '
    ';\n if (config.title) {\n html += `
    ${_esc(String(config.title))}
    `;\n }\n if (config.message) {\n html += `
    ${_esc(String(config.message))}
    `;\n }\n html += '
    ';\n\n // Close button\n if (config.dismissible) {\n html += '';\n }\n\n // Progress bar\n if (config.showProgress && config.duration > 0) {\n const safeDuration = parseInt(config.duration, 10) || 0;\n html += `
    `;\n }\n\n toast.innerHTML = html;\n\n // Add to container\n container.appendChild(toast);\n\n toast._toastCleanup = [];\n\n // Set up close button handler\n if (config.dismissible) {\n const closeBtn = toast.querySelector('.vd-toast-close');\n const onClose = () => {\n this.dismiss(toast);\n };\n closeBtn.addEventListener('click', onClose);\n toast._toastCleanup.push(() => closeBtn.removeEventListener('click', onClose));\n }\n\n // Pause on hover\n let timeoutId = null;\n let remainingTime = config.duration;\n let startTime = null;\n\n const startTimer = () => {\n if (config.duration > 0) {\n startTime = Date.now();\n timeoutId = setTimeout(() => {\n this.dismiss(toast);\n }, remainingTime);\n toast._toastTimeoutId = timeoutId;\n\n // Resume progress animation\n const progress = toast.querySelector('.vd-toast-progress');\n if (progress) {\n progress.style.animationPlayState = 'running';\n }\n }\n };\n\n const pauseTimer = () => {\n if (timeoutId) {\n clearTimeout(timeoutId);\n timeoutId = null;\n toast._toastTimeoutId = null;\n remainingTime -= Date.now() - startTime;\n\n // Pause progress animation\n const progress = toast.querySelector('.vd-toast-progress');\n if (progress) {\n progress.style.animationPlayState = 'paused';\n }\n }\n };\n\n if (config.pauseOnHover) {\n toast.addEventListener('mouseenter', pauseTimer);\n toast.addEventListener('mouseleave', startTimer);\n toast._toastCleanup.push(\n () => toast.removeEventListener('mouseenter', pauseTimer),\n () => toast.removeEventListener('mouseleave', startTimer)\n );\n }\n\n // Trigger enter animation\n requestAnimationFrame(() => {\n toast.classList.add('is-visible');\n startTimer();\n });\n\n // Store config on element for later access\n toast._toastConfig = config;\n\n // Dispatch show event\n const showEvent = new CustomEvent('toast:show', {\n bubbles: true,\n detail: { toast, config }\n });\n toast.dispatchEvent(showEvent);\n\n return toast;\n },\n\n /**\n * Dismiss a toast\n * @param {HTMLElement} toast - Toast element to dismiss\n */\n dismiss: function(toast) {\n if (!toast || toast.classList.contains('is-exiting')) return;\n\n if (toast._toastTimeoutId) {\n clearTimeout(toast._toastTimeoutId);\n toast._toastTimeoutId = null;\n }\n\n toast.classList.remove('is-visible');\n toast.classList.add('is-exiting');\n\n // Dispatch dismiss event\n const dismissEvent = new CustomEvent('toast:dismiss', {\n bubbles: true,\n detail: { toast }\n });\n toast.dispatchEvent(dismissEvent);\n\n // Remove after animation\n const handleTransitionEnd = () => {\n toast.removeEventListener('transitionend', handleTransitionEnd);\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n };\n\n toast.addEventListener('transitionend', handleTransitionEnd);\n\n // Fallback removal if transition doesn't fire\n setTimeout(() => {\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n }, 400);\n },\n\n /**\n * Destroy all toasts and containers\n */\n destroyAll: function() {\n Object.keys(this.containers).forEach(position => {\n const container = this.containers[position];\n if (!container) return;\n\n const toasts = container.querySelectorAll('.vd-toast');\n toasts.forEach(toast => {\n if (toast._toastTimeoutId) {\n clearTimeout(toast._toastTimeoutId);\n }\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n });\n\n if (container.parentElement) {\n container.parentElement.removeChild(container);\n }\n });\n\n this.containers = {};\n },\n\n /**\n * Dismiss all toasts\n * @param {string} [position] - Optional position to clear (clears all if not specified)\n */\n dismissAll: function(position) {\n if (position && this.containers[position]) {\n const toasts = this.containers[position].querySelectorAll('.vd-toast');\n toasts.forEach(toast => this.dismiss(toast));\n } else {\n Object.values(this.containers).forEach(container => {\n const toasts = container.querySelectorAll('.vd-toast');\n toasts.forEach(toast => this.dismiss(toast));\n });\n }\n },\n\n /**\n * Get default icon SVG for a type\n * @param {string} type - Toast type\n * @returns {string} SVG icon markup\n */\n getDefaultIcon: function(type) {\n const icons = {\n success: '',\n error: '',\n warning: '',\n info: ''\n };\n\n return icons[type] || '';\n },\n\n /**\n * Convenience methods for common toast types\n */\n success: function(message, options) {\n return this.show(Object.assign({ message, type: 'success' }, options));\n },\n\n error: function(message, options) {\n return this.show(Object.assign({ message, type: 'error' }, options));\n },\n\n warning: function(message, options) {\n return this.show(Object.assign({ message, type: 'warning' }, options));\n },\n\n info: function(message, options) {\n return this.show(Object.assign({ message, type: 'info' }, options));\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('toast', Toast);\n }\n\n // Also expose globally for convenience\n window.Toast = Toast;\n\n})();\n", "/**\n * Vanduo Framework - Tooltips Component\n * JavaScript functionality for tooltips\n */\n\n(function () {\n 'use strict';\n\n /**\n * Tooltips Component\n */\n const Tooltips = {\n tooltips: new Map(),\n delayTimers: new Map(),\n\n /**\n * Sanitize HTML \u2014 delegates to shared sanitizeHtml from helpers.js\n * @param {string} input\n * @param {{ allowSvg?: boolean }} [options]\n * @returns {string} sanitized HTML\n */\n sanitizeHtml: function (input, options) {\n if (typeof sanitizeHtml === 'function') {\n return sanitizeHtml(input, options);\n }\n // Fallback: content originates from developer-authored data attributes,\n // so returning it as-is is no worse than any other inline HTML.\n return input || '';\n },\n\n /**\n * Initialize tooltips\n */\n init: function () {\n const elements = document.querySelectorAll('[data-tooltip], [data-tooltip-html]');\n\n elements.forEach(element => {\n if (this.tooltips.has(element)) {\n return;\n }\n this.initTooltip(element);\n });\n },\n\n /**\n * Initialize a single tooltip\n * @param {HTMLElement} element - Element with tooltip\n */\n initTooltip: function (element) {\n const tooltip = this.createTooltip(element);\n const cleanupFunctions = [];\n\n // Show on hover/focus\n const enterHandler = () => { this.showTooltip(element, tooltip); };\n const leaveHandler = () => { this.hideTooltip(element, tooltip); };\n const focusHandler = () => { this.showTooltip(element, tooltip); };\n const blurHandler = () => { this.hideTooltip(element, tooltip); };\n\n element.addEventListener('mouseenter', enterHandler);\n element.addEventListener('mouseleave', leaveHandler);\n element.addEventListener('focus', focusHandler);\n element.addEventListener('blur', blurHandler);\n\n cleanupFunctions.push(\n () => element.removeEventListener('mouseenter', enterHandler),\n () => element.removeEventListener('mouseleave', leaveHandler),\n () => element.removeEventListener('focus', focusHandler),\n () => element.removeEventListener('blur', blurHandler)\n );\n\n this.tooltips.set(element, { tooltip, cleanup: cleanupFunctions });\n },\n\n /**\n * Create tooltip element\n * @param {HTMLElement} element - Target element\n * @returns {HTMLElement} Tooltip element\n */\n createTooltip: function (element) {\n const tooltip = document.createElement('div');\n tooltip.className = 'vd-tooltip';\n tooltip.setAttribute('role', 'tooltip');\n tooltip.setAttribute('aria-hidden', 'true');\n\n // Generate unique ID and link via aria-describedby\n const tooltipId = 'tooltip-' + Math.random().toString(36).substr(2, 9);\n tooltip.id = tooltipId;\n element.setAttribute('aria-describedby', tooltipId);\n\n // Get content\n const htmlContent = element.dataset.tooltipHtml;\n const textContent = element.dataset.tooltip;\n\n if (htmlContent) {\n const allowSvg = element.hasAttribute('data-tooltip-allow-svg');\n tooltip.innerHTML = this.sanitizeHtml(htmlContent, { allowSvg });\n tooltip.classList.add('vd-tooltip-html');\n } else if (textContent) {\n tooltip.textContent = textContent;\n }\n\n // Get placement\n const placement = element.dataset.tooltipPlacement || element.dataset.placement || 'top';\n tooltip.setAttribute('data-placement', placement);\n tooltip.classList.add(`vd-tooltip-${placement}`);\n\n // Get variant\n if (element.dataset.tooltipVariant) {\n tooltip.classList.add(`vd-tooltip-${element.dataset.tooltipVariant}`);\n }\n\n // Get size\n if (element.dataset.tooltipSize) {\n tooltip.classList.add(`vd-tooltip-${element.dataset.tooltipSize}`);\n }\n\n // Get delay\n const delay = parseInt(element.dataset.tooltipDelay) || 0;\n tooltip.dataset.delay = delay;\n\n document.body.appendChild(tooltip);\n\n return tooltip;\n },\n\n /**\n * Show tooltip\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n showTooltip: function (element, tooltip) {\n const delay = parseInt(tooltip.dataset.delay) || 0;\n\n if (delay > 0) {\n const timer = setTimeout(() => {\n this.positionTooltip(element, tooltip);\n tooltip.classList.add('is-visible');\n tooltip.setAttribute('aria-hidden', 'false');\n }, delay);\n this.delayTimers.set(element, timer);\n } else {\n this.positionTooltip(element, tooltip);\n tooltip.classList.add('is-visible');\n tooltip.setAttribute('aria-hidden', 'false');\n }\n },\n\n /**\n * Hide tooltip\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n hideTooltip: function (element, tooltip) {\n // Clear delay timer if exists\n const timer = this.delayTimers.get(element);\n if (timer) {\n clearTimeout(timer);\n this.delayTimers.delete(element);\n }\n\n tooltip.classList.remove('is-visible');\n tooltip.setAttribute('aria-hidden', 'true');\n },\n\n /**\n * Position tooltip relative to element\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n positionTooltip: function (element, tooltip) {\n const placement = tooltip.dataset.placement || 'top';\n const rect = element.getBoundingClientRect();\n const tooltipRect = tooltip.getBoundingClientRect();\n const scrollTop = window.pageYOffset || document.documentElement.scrollTop;\n const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;\n\n let top = 0;\n let left = 0;\n\n switch (placement) {\n case 'top':\n top = rect.top + scrollTop - tooltipRect.height - 8;\n left = rect.left + scrollLeft + (rect.width / 2) - (tooltipRect.width / 2);\n break;\n case 'bottom':\n top = rect.bottom + scrollTop + 8;\n left = rect.left + scrollLeft + (rect.width / 2) - (tooltipRect.width / 2);\n break;\n case 'left':\n top = rect.top + scrollTop + (rect.height / 2) - (tooltipRect.height / 2);\n left = rect.left + scrollLeft - tooltipRect.width - 8;\n break;\n case 'right':\n top = rect.top + scrollTop + (rect.height / 2) - (tooltipRect.height / 2);\n left = rect.right + scrollLeft + 8;\n break;\n }\n\n // Prevent overflow\n const viewportWidth = window.innerWidth;\n const viewportHeight = window.innerHeight;\n const padding = 8;\n\n if (left < padding) {\n left = padding;\n } else if (left + tooltipRect.width > viewportWidth - padding) {\n left = viewportWidth - tooltipRect.width - padding;\n }\n\n if (top < scrollTop + padding) {\n top = scrollTop + padding;\n } else if (top + tooltipRect.height > scrollTop + viewportHeight - padding) {\n top = scrollTop + viewportHeight - tooltipRect.height - padding;\n }\n\n // Use single style assignment with transform for better performance\n tooltip.style.cssText = `position: absolute; top: 0; left: 0; transform: translate(${left}px, ${top}px);`;\n },\n\n /**\n * Show tooltip programmatically\n * @param {HTMLElement|string} element - Target element or selector\n */\n show: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n this.showTooltip(el, tooltip);\n }\n },\n\n /**\n * Hide tooltip programmatically\n * @param {HTMLElement|string} element - Target element or selector\n */\n hide: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n this.hideTooltip(el, tooltip);\n }\n },\n\n /**\n * Update tooltip content\n * @param {HTMLElement|string} element - Target element or selector\n * @param {string} content - New content\n * @param {boolean} isHtml - Whether content is HTML\n */\n update: function (element, content, isHtml = false) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n if (isHtml) {\n const allowSvg = el.hasAttribute('data-tooltip-allow-svg');\n tooltip.innerHTML = this.sanitizeHtml(content, { allowSvg });\n tooltip.classList.add('vd-tooltip-html');\n } else {\n tooltip.textContent = content;\n tooltip.classList.remove('vd-tooltip-html');\n }\n }\n },\n\n /**\n * Destroy a tooltip instance and clean up\n * @param {HTMLElement} element - Element with tooltip\n */\n destroy: function (element) {\n const data = this.tooltips.get(element);\n if (!data) return;\n\n // Clear any pending timer\n const timer = this.delayTimers.get(element);\n if (timer) {\n clearTimeout(timer);\n this.delayTimers.delete(element);\n }\n\n data.cleanup.forEach(fn => fn());\n\n // Remove tooltip element from DOM\n if (data.tooltip && data.tooltip.parentNode) {\n data.tooltip.parentNode.removeChild(data.tooltip);\n }\n\n this.tooltips.delete(element);\n },\n\n /**\n * Destroy all tooltip instances\n */\n destroyAll: function () {\n this.tooltips.forEach((data, element) => {\n this.destroy(element);\n });\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('tooltips', Tooltips);\n }\n\n // Expose globally\n window.VanduoTooltips = Tooltips;\n\n})();\n", "/**\n * Vanduo Framework - Search Component\n * Client-side search functionality for content pages\n * \n * @example Basic usage (initialize with defaults)\n * // HTML:\n * //
    \n * // \n * //
    \n * //
    \n * \n * @example Custom configuration\n * var search = Search.create({\n * containerSelector: '.my-search',\n * contentSelector: 'article[id]',\n * titleSelector: 'h2, h3',\n * maxResults: 5,\n * onSelect: function(result) {\n * console.log('Selected:', result.title);\n * }\n * });\n * \n * @example With custom data source\n * var search = Search.create({\n * containerSelector: '.my-search',\n * data: [\n * { id: 'item1', title: 'First Item', content: 'Description...', category: 'Category A' },\n * { id: 'item2', title: 'Second Item', content: 'Description...', category: 'Category B' }\n * ]\n * });\n */\n\n(function() {\n 'use strict';\n\n /**\n * Default configuration\n */\n const DEFAULTS = {\n // Behavior\n minQueryLength: 2,\n maxResults: 10,\n debounceMs: 150,\n highlightTag: 'mark',\n keyboardShortcut: true, // Enable Cmd/Ctrl+K shortcut\n \n // Selectors (for DOM-based indexing)\n containerSelector: '.vd-doc-search',\n inputSelector: '.vd-doc-search-input',\n resultsSelector: '.vd-doc-search-results',\n contentSelector: '.doc-content section[id]',\n titleSelector: '.demo-title, h2, h3',\n navSelector: '.doc-nav-link',\n sectionSelector: '.doc-nav-section',\n \n // Content extraction\n excludeFromContent: 'pre, code, script, style',\n maxContentLength: 500,\n \n // Custom data source (alternative to DOM indexing)\n data: null,\n \n // Category icons mapping\n categoryIcons: {\n 'getting-started': 'ph-rocket-launch',\n 'core': 'ph-cube',\n 'components': 'ph-puzzle-piece',\n 'interactive': 'ph-cursor-click',\n 'data-display': 'ph-table',\n 'feedback': 'ph-bell',\n 'meta': 'ph-info',\n 'default': 'ph-file-text'\n },\n \n // Callbacks\n onSelect: null, // function(result) - called when result is selected\n onSearch: null, // function(query, results) - called after search\n onOpen: null, // function() - called when results open\n onClose: null, // function() - called when results close\n \n // Text customization\n emptyTitle: 'No results found',\n emptyText: 'Try different keywords or check spelling',\n placeholder: 'Search...'\n };\n\n /**\n * Search Component Factory\n * Creates a new search instance with the given configuration\n * \n * @param {Object} options - Configuration options\n * @returns {Object} Search instance\n */\n function createSearch(options) {\n const config = Object.assign({}, DEFAULTS, options || {});\n \n // Instance state\n const state = {\n initialized: false,\n index: [],\n results: [],\n activeIndex: -1,\n isOpen: false,\n query: '',\n container: null,\n input: null,\n resultsContainer: null,\n debounceTimer: null,\n boundHandlers: {}\n };\n\n function safeInvokeCallback(name, fn, ...args) {\n try {\n fn(...args);\n } catch (error) {\n console.warn('[Vanduo Search] Callback error in \"' + name + '\":', error);\n }\n }\n\n function setResultsHtml(html) {\n if (!state.resultsContainer) return;\n try {\n state.resultsContainer.innerHTML = html;\n } catch (error) {\n console.warn('[Vanduo Search] Failed to render results:', error);\n }\n }\n\n /**\n * Initialize the search component\n * Idempotent \u2014 safe to call more than once on the same instance.\n * Returns the instance on success, null if required DOM elements are missing.\n */\n function init() {\n if (state.initialized) {\n return instance;\n }\n\n state.container = document.querySelector(config.containerSelector);\n if (!state.container) {\n state.initialized = false;\n return null;\n }\n\n state.input = state.container.querySelector(config.inputSelector);\n state.resultsContainer = state.container.querySelector(config.resultsSelector);\n\n if (!state.input || !state.resultsContainer) {\n state.initialized = false;\n return null;\n }\n\n // Set placeholder if configured\n if (config.placeholder) {\n state.input.setAttribute('placeholder', config.placeholder);\n }\n\n // Build search index\n buildIndex();\n\n // Bind events\n bindEvents();\n\n // Set up ARIA attributes\n setupAria();\n\n state.initialized = true;\n return instance;\n }\n\n /**\n * Build search index from DOM or custom data\n */\n function buildIndex() {\n state.index = [];\n \n // Use custom data if provided\n if (config.data && Array.isArray(config.data)) {\n config.data.forEach(function(item) {\n state.index.push({\n id: item.id || slugify(item.title),\n title: item.title || '',\n category: item.category || '',\n categorySlug: slugify(item.category || ''),\n content: item.content || '',\n keywords: item.keywords || extractKeywordsFromText(item.title + ' ' + item.content),\n url: item.url || '#' + (item.id || slugify(item.title)),\n icon: item.icon || ''\n });\n });\n return;\n }\n\n // Build from DOM\n const sections = document.querySelectorAll(config.contentSelector);\n const categoryMap = buildCategoryMap();\n\n sections.forEach(function(section) {\n const id = section.id;\n if (!id) return;\n\n const titleEl = section.querySelector(config.titleSelector);\n const title = titleEl ? titleEl.textContent.replace(/v[\\d.]+/g, '').trim() : id;\n const category = categoryMap[id] || 'Documentation';\n const content = extractContent(section);\n const keywords = extractKeywords(section, title);\n const iconEl = titleEl ? titleEl.querySelector('i.ph') : null;\n let icon = '';\n if (iconEl && iconEl.classList) {\n for (let ci = 0; ci < iconEl.classList.length; ci++) {\n if (iconEl.classList[ci].indexOf('ph-') === 0) {\n icon = iconEl.classList[ci];\n break;\n }\n }\n }\n\n state.index.push({\n id: id,\n title: title,\n category: category,\n categorySlug: slugify(category),\n content: content,\n keywords: keywords,\n url: '#' + id,\n icon: icon\n });\n });\n }\n\n /**\n * Build a map of section IDs to their categories\n */\n function buildCategoryMap() {\n const map = {};\n let currentCategory = 'Documentation';\n const navItems = document.querySelectorAll(config.navSelector + ', ' + config.sectionSelector);\n\n navItems.forEach(function(item) {\n if (item.classList.contains('doc-nav-section')) {\n currentCategory = item.textContent.trim();\n } else {\n const href = item.getAttribute('href');\n if (href && href.startsWith('#')) {\n const id = href.substring(1);\n map[id] = currentCategory;\n }\n }\n });\n\n return map;\n }\n\n /**\n * Extract searchable content from a section\n */\n function extractContent(section) {\n const clone = section.cloneNode(true);\n \n const toRemove = clone.querySelectorAll(config.excludeFromContent);\n toRemove.forEach(function(el) {\n el.remove();\n });\n\n let text = clone.textContent || '';\n text = text.replace(/\\s+/g, ' ').trim();\n \n return text.substring(0, config.maxContentLength);\n }\n\n /**\n * Extract keywords from a section\n */\n function extractKeywords(section, title) {\n const keywords = [];\n \n // Add title words\n title.toLowerCase().split(/\\s+/).forEach(function(word) {\n if (word.length > 2) {\n keywords.push(word);\n }\n });\n\n // Add class names from code examples\n const codeBlocks = section.querySelectorAll('code');\n codeBlocks.forEach(function(code) {\n const text = code.textContent || '';\n const classMatches = text.match(/\\.([\\w-]+)/g);\n if (classMatches) {\n classMatches.forEach(function(match) {\n keywords.push(match.substring(1).toLowerCase());\n });\n }\n });\n\n // Add data attributes\n const dataAttrs = section.querySelectorAll('[data-tooltip], [data-modal]');\n dataAttrs.forEach(function(el) {\n const attrs = el.getAttributeNames().filter(function(name) {\n return name.startsWith('data-');\n });\n attrs.forEach(function(attr) {\n keywords.push(attr.replace('data-', ''));\n });\n });\n\n return Array.from(new Set(keywords));\n }\n\n /**\n * Extract keywords from text string\n */\n function extractKeywordsFromText(text) {\n const words = text.toLowerCase().split(/\\s+/);\n return words.filter(function(word) {\n return word.length > 2;\n });\n }\n\n /**\n * Convert string to slug\n */\n function slugify(str) {\n return str.toLowerCase().replace(/\\s+/g, '-').replace(/[^a-z0-9-]/g, '');\n }\n\n /**\n * Bind event listeners\n */\n function bindEvents() {\n // Store bound handlers for cleanup\n state.boundHandlers.handleInput = function(e) {\n handleInput(e);\n };\n state.boundHandlers.handleFocus = function() {\n if (state.query.length >= config.minQueryLength) {\n open();\n }\n };\n state.boundHandlers.handleKeydown = function(e) {\n handleKeydown(e);\n };\n state.boundHandlers.handleOutsideClick = function(e) {\n if (!state.container.contains(e.target)) {\n close();\n }\n };\n state.boundHandlers.handleGlobalKeydown = function(e) {\n if (config.keyboardShortcut && (e.metaKey || e.ctrlKey) && e.key === 'k') {\n e.preventDefault();\n state.input.focus();\n state.input.select();\n }\n };\n state.boundHandlers.handleResultClick = function(e) {\n const result = e.target.closest('.vd-doc-search-result');\n if (result) {\n const index = parseInt(result.dataset.index, 10);\n select(index);\n }\n };\n\n // Input events\n state.input.addEventListener('input', state.boundHandlers.handleInput);\n state.input.addEventListener('focus', state.boundHandlers.handleFocus);\n state.input.addEventListener('keydown', state.boundHandlers.handleKeydown);\n\n // Close on outside click\n document.addEventListener('click', state.boundHandlers.handleOutsideClick);\n\n // Global keyboard shortcut\n document.addEventListener('keydown', state.boundHandlers.handleGlobalKeydown);\n\n // Result click handling\n state.resultsContainer.addEventListener('click', state.boundHandlers.handleResultClick);\n }\n\n /**\n * Unbind event listeners\n */\n function unbindEvents() {\n if (state.input) {\n state.input.removeEventListener('input', state.boundHandlers.handleInput);\n state.input.removeEventListener('focus', state.boundHandlers.handleFocus);\n state.input.removeEventListener('keydown', state.boundHandlers.handleKeydown);\n }\n document.removeEventListener('click', state.boundHandlers.handleOutsideClick);\n document.removeEventListener('keydown', state.boundHandlers.handleGlobalKeydown);\n if (state.resultsContainer) {\n state.resultsContainer.removeEventListener('click', state.boundHandlers.handleResultClick);\n }\n }\n\n /**\n * Set up ARIA attributes\n */\n function setupAria() {\n const resultsId = state.resultsContainer.id || 'search-results-' + Math.random().toString(36).substr(2, 9);\n state.resultsContainer.id = resultsId;\n \n state.input.setAttribute('role', 'combobox');\n state.input.setAttribute('aria-autocomplete', 'list');\n state.input.setAttribute('aria-controls', resultsId);\n state.input.setAttribute('aria-expanded', 'false');\n \n state.resultsContainer.setAttribute('role', 'listbox');\n state.resultsContainer.setAttribute('aria-label', 'Search results');\n }\n\n /**\n * Handle input changes\n */\n function handleInput(e) {\n const query = e.target.value.trim();\n\n if (state.debounceTimer) {\n clearTimeout(state.debounceTimer);\n }\n\n state.debounceTimer = setTimeout(function() {\n state.query = query;\n\n if (query.length < config.minQueryLength) {\n close();\n return;\n }\n\n state.results = search(query);\n state.activeIndex = -1;\n render();\n open();\n\n // Callback\n if (typeof config.onSearch === 'function') {\n safeInvokeCallback('onSearch', config.onSearch, query, state.results);\n }\n }, config.debounceMs);\n }\n\n /**\n * Handle keyboard navigation\n */\n function handleKeydown(e) {\n if (!state.isOpen) {\n if (e.key === 'ArrowDown' && state.query.length >= config.minQueryLength) {\n e.preventDefault();\n state.results = search(state.query);\n render();\n open();\n }\n return;\n }\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n navigate(1);\n break;\n\n case 'ArrowUp':\n e.preventDefault();\n navigate(-1);\n break;\n\n case 'Enter':\n e.preventDefault();\n if (state.activeIndex >= 0) {\n select(state.activeIndex);\n } else if (state.results.length > 0) {\n select(0);\n }\n break;\n\n case 'Escape':\n e.preventDefault();\n close();\n break;\n\n case 'Tab':\n close();\n break;\n }\n }\n\n /**\n * Perform search\n */\n function search(query) {\n const terms = query.toLowerCase().split(/\\s+/).filter(function(t) {\n return t.length > 0;\n });\n const scored = [];\n\n state.index.forEach(function(entry) {\n let score = 0;\n const titleLower = entry.title.toLowerCase();\n const categoryLower = entry.category.toLowerCase();\n const contentLower = entry.content.toLowerCase();\n\n terms.forEach(function(term) {\n // Title match - highest priority\n if (titleLower.includes(term)) {\n score += 100;\n if (titleLower === term) {\n score += 50;\n } else if (titleLower.startsWith(term)) {\n score += 25;\n }\n }\n\n // Category match\n if (categoryLower.includes(term)) {\n score += 50;\n }\n\n // Keyword match\n const keywordMatch = entry.keywords.some(function(k) {\n return k.includes(term);\n });\n if (keywordMatch) {\n score += 30;\n }\n\n // Content match\n if (contentLower.includes(term)) {\n score += 10;\n }\n });\n\n if (score > 0) {\n scored.push({\n id: entry.id,\n title: entry.title,\n category: entry.category,\n categorySlug: entry.categorySlug,\n content: entry.content,\n url: entry.url,\n icon: entry.icon,\n score: score\n });\n }\n });\n\n scored.sort(function(a, b) {\n return b.score - a.score;\n });\n\n return scored.slice(0, config.maxResults);\n }\n\n /**\n * Render search results\n */\n function render() {\n if (state.results.length === 0) {\n setResultsHtml(renderEmpty());\n return;\n }\n\n let html = '
      ';\n\n state.results.forEach(function(result, index) {\n const isActive = index === state.activeIndex;\n const icon = result.icon || getCategoryIcon(result.categorySlug);\n const excerpt = getExcerpt(result.content, state.query);\n\n html += '
    • ' +\n '
      ' +\n '' +\n '
      ' +\n '
      ' +\n '
      ' + highlight(result.title, state.query) + '
      ' +\n '
      ' + escapeHtml(result.category) + '
      ' +\n '
      ' + highlight(excerpt, state.query) + '
      ' +\n '
      ' +\n '
    • ';\n });\n\n html += '
    ';\n html += renderFooter();\n\n setResultsHtml(html);\n }\n\n /**\n * Render empty state\n */\n function renderEmpty() {\n return '
    ' +\n '
    ' +\n '
    ' + escapeHtml(config.emptyTitle) + '
    ' +\n '
    ' + escapeHtml(config.emptyText) + '
    ' +\n '
    ';\n }\n\n /**\n * Render footer with keyboard hints\n */\n function renderFooter() {\n return '
    ' +\n '\u2191\u2193 to navigate' +\n '\u21B5 to select' +\n 'esc to close' +\n '
    ';\n }\n\n /**\n * Get icon for category\n */\n function getCategoryIcon(categorySlug) {\n return config.categoryIcons[categorySlug] || config.categoryIcons['default'] || 'ph-file-text';\n }\n\n /**\n * Get excerpt from content\n */\n function getExcerpt(content, query) {\n const terms = query.toLowerCase().split(/\\s+/);\n const contentLower = content.toLowerCase();\n const excerptLength = 100;\n\n let matchPos = -1;\n for (let i = 0; i < terms.length; i++) {\n const pos = contentLower.indexOf(terms[i]);\n if (pos !== -1 && (matchPos === -1 || pos < matchPos)) {\n matchPos = pos;\n }\n }\n\n if (matchPos === -1) {\n return content.substring(0, excerptLength) + '...';\n }\n\n const start = Math.max(0, matchPos - 30);\n const end = Math.min(content.length, matchPos + excerptLength);\n let excerpt = content.substring(start, end);\n\n if (start > 0) {\n excerpt = '...' + excerpt;\n }\n if (end < content.length) {\n excerpt = excerpt + '...';\n }\n\n return excerpt;\n }\n\n /**\n * Highlight matched terms in text\n */\n function highlight(text, query) {\n if (!query) return escapeHtml(text);\n\n const terms = query.toLowerCase().split(/\\s+/).filter(function(t) {\n return t.length > 0;\n });\n let escaped = escapeHtml(text);\n\n terms.forEach(function(term) {\n // Skip overly long terms to prevent ReDoS\n if (term.length > 50) return;\n const regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi');\n escaped = escaped.replace(regex, '<' + config.highlightTag + '>$1');\n });\n\n return escaped;\n }\n\n /**\n * Escape HTML entities\n */\n function escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Navigate results with keyboard\n */\n function navigate(direction) {\n let newIndex = state.activeIndex + direction;\n\n if (newIndex < 0) {\n newIndex = state.results.length - 1;\n } else if (newIndex >= state.results.length) {\n newIndex = 0;\n }\n\n setActiveIndex(newIndex);\n }\n\n /**\n * Set active result index\n */\n function setActiveIndex(index) {\n const prevActive = state.resultsContainer.querySelector('.vd-doc-search-result.is-active');\n if (prevActive) {\n prevActive.classList.remove('is-active');\n prevActive.setAttribute('aria-selected', 'false');\n }\n\n state.activeIndex = index;\n\n const newActive = state.resultsContainer.querySelector('[data-index=\"' + index + '\"]');\n if (newActive) {\n newActive.classList.add('is-active');\n newActive.setAttribute('aria-selected', 'true');\n state.input.setAttribute('aria-activedescendant', 'vd-doc-search-result-' + index);\n newActive.scrollIntoView({ block: 'nearest' });\n }\n }\n\n /**\n * Select a result\n */\n function select(index) {\n const result = state.results[index];\n if (!result) return;\n\n // Close search\n close();\n state.input.value = '';\n state.query = '';\n\n // Custom callback\n if (typeof config.onSelect === 'function') {\n safeInvokeCallback('onSelect', config.onSelect, result);\n return;\n }\n\n // Default behavior: navigate to section\n const section = document.querySelector(result.url);\n if (section) {\n section.scrollIntoView({ behavior: 'smooth', block: 'start' });\n window.history.pushState(null, '', result.url);\n updateSidebarActive(result.id);\n }\n }\n\n /**\n * Update sidebar navigation active state\n */\n function updateSidebarActive(sectionId) {\n const navLinks = document.querySelectorAll(config.navSelector);\n navLinks.forEach(function(link) {\n link.classList.remove('active');\n if (link.getAttribute('href') === '#' + sectionId) {\n link.classList.add('active');\n }\n });\n }\n\n /**\n * Open results dropdown\n */\n function open() {\n if (state.isOpen) return;\n\n state.isOpen = true;\n state.resultsContainer.classList.add('is-open');\n state.input.setAttribute('aria-expanded', 'true');\n\n if (typeof config.onOpen === 'function') {\n safeInvokeCallback('onOpen', config.onOpen);\n }\n }\n\n /**\n * Close results dropdown\n */\n function close() {\n if (!state.isOpen) return;\n\n state.isOpen = false;\n state.activeIndex = -1;\n state.resultsContainer.classList.remove('is-open');\n state.input.setAttribute('aria-expanded', 'false');\n state.input.removeAttribute('aria-activedescendant');\n\n if (typeof config.onClose === 'function') {\n safeInvokeCallback('onClose', config.onClose);\n }\n }\n\n /**\n * Destroy the component\n */\n function destroy() {\n unbindEvents();\n \n state.initialized = false;\n state.index = [];\n state.results = [];\n state.isOpen = false;\n state.query = '';\n\n if (state.debounceTimer) {\n clearTimeout(state.debounceTimer);\n }\n\n if (state.resultsContainer) {\n setResultsHtml('');\n }\n }\n\n /**\n * Rebuild the search index\n */\n function rebuild() {\n buildIndex();\n }\n\n /**\n * Update configuration\n */\n function setConfig(newConfig) {\n Object.assign(config, newConfig);\n }\n\n /**\n * Get current configuration\n */\n function getConfig() {\n return Object.assign({}, config);\n }\n\n /**\n * Get search index\n */\n function getIndex() {\n return state.index.slice();\n }\n\n // Public instance API\n const instance = {\n init: init,\n destroy: destroy,\n rebuild: rebuild,\n search: search,\n open: open,\n close: close,\n setConfig: setConfig,\n getConfig: getConfig,\n getIndex: getIndex\n };\n\n return instance;\n }\n\n /**\n * Search Component (singleton for backward compatibility)\n */\n const Search = {\n // Factory method \u2014 creates and auto-initializes a new independent instance.\n // Always returns the instance so callers retain a reference even if the\n // DOM container is not yet available (they can retry init() later).\n create: function(options) {\n const instance = createSearch(options);\n if (instance) {\n instance.init();\n }\n return instance || null;\n },\n \n // Default instance\n _instance: null,\n \n // Configuration (for default instance)\n config: Object.assign({}, DEFAULTS),\n\n /**\n * Initialize the default search instance\n */\n init: function(options) {\n if (this._instance) {\n this._instance.destroy();\n }\n \n if (options) {\n Object.assign(this.config, options);\n }\n \n this._instance = createSearch(this.config);\n return this._instance ? this._instance.init() : null;\n },\n\n /**\n * Destroy the default instance\n */\n destroy: function() {\n if (this._instance) {\n this._instance.destroy();\n this._instance = null;\n }\n },\n\n destroyAll: function() {\n this.destroy();\n },\n\n /**\n * Rebuild the default instance index\n */\n rebuild: function() {\n if (this._instance) {\n this._instance.rebuild();\n }\n },\n\n /**\n * Search using the default instance\n */\n search: function(query) {\n return this._instance ? this._instance.search(query) : [];\n },\n\n /**\n * Open the default instance\n */\n open: function() {\n if (this._instance) {\n this._instance.open();\n }\n },\n\n /**\n * Close the default instance\n */\n close: function() {\n if (this._instance) {\n this._instance.close();\n }\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('docSearch', Search);\n }\n\n // Expose globally (both names for compatibility)\n window.Search = Search;\n window.DocSearch = Search; // Backward compatibility\n window.VanduoDocSearch = Search; // New name compatibility\n\n})();\n", "/**\n * Vanduo Framework - Draggable Component\n * JavaScript functionality for draggable elements and drop zones\n */\n\n(function () {\n 'use strict';\n\n /**\n * Draggable Component\n */\n const Draggable = {\n // Store initialized draggables and their cleanup functions\n instances: new Map(),\n // Store current drag state\n currentDrag: null,\n // Store touch state\n touchState: null,\n // Feedback element\n feedbackElement: null,\n // Shared selector used by init and touch reorder\n containerSelector: '.vd-draggable-container, .vd-draggable-container-vertical',\n\n /**\n * Initialize draggable components\n */\n init: function () {\n const draggables = document.querySelectorAll('.vd-draggable, [data-draggable]');\n\n draggables.forEach(element => {\n if (this.instances.has(element)) {\n return;\n }\n this.initDraggable(element);\n });\n\n const containers = document.querySelectorAll(this.containerSelector);\n containers.forEach(container => {\n if (!this.instances.has(container)) {\n this.initContainer(container);\n }\n });\n\n const dropZones = document.querySelectorAll('.vd-drop-zone');\n dropZones.forEach(zone => {\n if (!this.instances.has(zone)) {\n this.initDropZone(zone);\n }\n });\n\n this.createFeedbackElement();\n },\n\n /**\n * Initialize a single draggable element\n * @param {HTMLElement} element - Draggable element\n */\n initDraggable: function (element) {\n const cleanupFunctions = [];\n\n // Make element draggable if not already\n if (!element.hasAttribute('draggable')) {\n element.setAttribute('draggable', 'true');\n }\n\n // Accessibility: add ARIA attributes\n if (!element.hasAttribute('tabindex')) {\n element.setAttribute('tabindex', '0');\n }\n element.setAttribute('role', 'option');\n element.setAttribute('aria-roledescription', 'draggable item');\n element.setAttribute('aria-grabbed', 'false');\n\n // Handle drag start\n const dragStartHandler = (e) => {\n this.handleDragStart(e, element);\n };\n element.addEventListener('dragstart', dragStartHandler);\n cleanupFunctions.push(() => element.removeEventListener('dragstart', dragStartHandler));\n\n // Handle drag\n const dragHandler = (e) => {\n this.handleDrag(e, element);\n };\n element.addEventListener('drag', dragHandler);\n cleanupFunctions.push(() => element.removeEventListener('drag', dragHandler));\n\n // Handle drag end\n const dragEndHandler = (e) => {\n this.handleDragEnd(e, element);\n };\n element.addEventListener('dragend', dragEndHandler);\n cleanupFunctions.push(() => element.removeEventListener('dragend', dragEndHandler));\n\n // Handle touch start (for mobile)\n const touchStartHandler = (e) => {\n this.handleTouchStart(e, element);\n };\n element.addEventListener('touchstart', touchStartHandler);\n cleanupFunctions.push(() => element.removeEventListener('touchstart', touchStartHandler));\n\n // Handle touch move (for mobile)\n // { passive: false } is required so that e.preventDefault() works\n // once the drag threshold is reached \u2014 without it, modern browsers\n // treat the listener as passive and silently ignore preventDefault().\n const touchMoveHandler = (e) => {\n this.handleTouchMove(e, element);\n };\n element.addEventListener('touchmove', touchMoveHandler, { passive: false });\n cleanupFunctions.push(() => element.removeEventListener('touchmove', touchMoveHandler));\n\n // Handle touch end (for mobile)\n const touchEndHandler = (e) => {\n this.handleTouchEnd(e, element);\n };\n element.addEventListener('touchend', touchEndHandler, { passive: false });\n cleanupFunctions.push(() => element.removeEventListener('touchend', touchEndHandler));\n\n // Handle touch cancel (for mobile)\n const touchCancelHandler = (e) => {\n this.handleTouchEnd(e, element);\n };\n element.addEventListener('touchcancel', touchCancelHandler);\n cleanupFunctions.push(() => element.removeEventListener('touchcancel', touchCancelHandler));\n\n // Keyboard navigation\n const keydownHandler = (e) => {\n this.handleKeydown(e, element);\n };\n element.addEventListener('keydown', keydownHandler);\n cleanupFunctions.push(() => element.removeEventListener('keydown', keydownHandler));\n\n this.instances.set(element, { cleanup: cleanupFunctions });\n },\n\n /**\n * Initialize a draggable container\n * @param {HTMLElement} container - Draggable container\n */\n initContainer: function (container) {\n // Accessibility: add ARIA role to container\n container.setAttribute('role', 'listbox');\n container.setAttribute('aria-label', container.getAttribute('aria-label') || 'Draggable items');\n\n const items = container.querySelectorAll('.vd-draggable-item');\n items.forEach(item => {\n if (!this.instances.has(item)) {\n this.initDraggable(item);\n }\n });\n\n const cleanupFunctions = [];\n\n // Handle drag enter\n const dragEnterHandler = (e) => {\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n };\n\n // Handle drag over for auto-sorting\n const dragOverHandler = (e) => {\n e.preventDefault(); // Necessary to allow drop\n e.dataTransfer.dropEffect = 'move';\n\n if (!this.currentDrag) return;\n const draggingEl = this.currentDrag.element;\n\n // Only reorder if dragging an item that belongs to this container (or if we support cross-container drag, but keep simple)\n if (!container.contains(draggingEl)) return;\n\n // Prevent jumps when the dragging finalizes with 0,0 coordinates near the end\n if (e.clientX === 0 && e.clientY === 0) return;\n\n this.handleReorder(container, draggingEl, e.clientX, e.clientY);\n };\n\n // Handle drop\n const dropHandler = (e) => {\n e.preventDefault(); // crucial to prevent the browser's default handling and snapping back\n };\n\n container.addEventListener('dragenter', dragEnterHandler);\n container.addEventListener('dragover', dragOverHandler);\n container.addEventListener('drop', dropHandler);\n\n cleanupFunctions.push(() => {\n container.removeEventListener('dragenter', dragEnterHandler);\n container.removeEventListener('dragover', dragOverHandler);\n container.removeEventListener('drop', dropHandler);\n });\n\n this.instances.set(container, { cleanup: cleanupFunctions });\n },\n\n /**\n * Initialize a drop zone\n * @param {HTMLElement} zone - Drop zone element\n */\n initDropZone: function (zone) {\n const cleanupFunctions = [];\n\n // Accessibility: add ARIA role to drop zone\n zone.setAttribute('role', 'region');\n zone.setAttribute('aria-dropeffect', 'move');\n if (!zone.hasAttribute('aria-label')) {\n zone.setAttribute('aria-label', 'Drop zone');\n }\n\n // Handle drag over\n const dragOverHandler = (e) => {\n e.preventDefault();\n this.handleDragOver(e, zone);\n };\n zone.addEventListener('dragover', dragOverHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragover', dragOverHandler));\n\n // Handle drag enter\n const dragEnterHandler = (e) => {\n e.preventDefault();\n this.handleDragEnter(e, zone);\n };\n zone.addEventListener('dragenter', dragEnterHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragenter', dragEnterHandler));\n\n // Handle drag leave\n const dragLeaveHandler = (e) => {\n this.handleDragLeave(e, zone);\n };\n zone.addEventListener('dragleave', dragLeaveHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragleave', dragLeaveHandler));\n\n // Handle drop\n const dropHandler = (e) => {\n e.preventDefault();\n this.handleDrop(e, zone);\n };\n zone.addEventListener('drop', dropHandler);\n cleanupFunctions.push(() => zone.removeEventListener('drop', dropHandler));\n\n this.instances.set(zone, { cleanup: cleanupFunctions });\n },\n\n /**\n * Create feedback element for drag operations\n */\n createFeedbackElement: function () {\n if (!this.feedbackElement) {\n // Reuse existing element if present\n const existing = document.querySelector('.vd-drag-feedback');\n if (existing) {\n this.feedbackElement = existing;\n return;\n }\n\n this.feedbackElement = document.createElement('div');\n this.feedbackElement.className = 'vd-drag-feedback hidden';\n this.feedbackElement.setAttribute('role', 'presentation');\n document.body.appendChild(this.feedbackElement);\n }\n },\n\n /**\n * Handle drag start event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDragStart: function (e, element) {\n // Add dragging class\n element.classList.add('is-dragging');\n\n // Accessibility: update aria-grabbed\n element.setAttribute('aria-grabbed', 'true');\n\n // Store drag state\n this.currentDrag = {\n element: element,\n initialPosition: { x: e.clientX, y: e.clientY },\n initialBounds: element.getBoundingClientRect(),\n data: this.getData(element)\n };\n\n // Set drag data\n e.dataTransfer.effectAllowed = 'move';\n e.dataTransfer.setData('text/plain', this.currentDrag.data);\n\n // We no longer suppress the native ghost image or manually update feedback\n // for mouse drags, relying on the browser's native rendering instead.\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:start', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: e.clientX, y: e.clientY }\n }\n }));\n },\n\n /**\n * Handle drag event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDrag: function (e, element) {\n // Guard against null state (race condition on fast interactions)\n if (!this.currentDrag) return;\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:drag', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: e.clientX, y: e.clientY },\n delta: {\n x: e.clientX - this.currentDrag.initialPosition.x,\n y: e.clientY - this.currentDrag.initialPosition.y\n }\n }\n }));\n },\n\n /**\n * Handle drag end event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDragEnd: function (e, element) {\n // Remove dragging class\n element.classList.remove('is-dragging');\n element.classList.add('is-dropped');\n setTimeout(() => element.classList.remove('is-dropped'), 300);\n\n // Accessibility: update aria-grabbed\n element.setAttribute('aria-grabbed', 'false');\n\n // Hide feedback\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n\n // Guard against null state\n const data = this.currentDrag?.data || this.getData(element);\n const initialPos = this.currentDrag?.initialPosition || { x: 0, y: 0 };\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:end', {\n bubbles: true,\n detail: {\n element: element,\n data: data,\n position: { x: e.clientX, y: e.clientY },\n delta: {\n x: e.clientX - initialPos.x,\n y: e.clientY - initialPos.y\n }\n }\n }));\n\n // Reset drag state\n this.currentDrag = null;\n },\n\n /**\n * Handle touch start event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchStart: function (e, element) {\n // Don't prevent default here \u2014 it blocks scrolling.\n // We only prevent default in touchmove once drag threshold is reached.\n const touch = e.touches[0];\n const rect = element.getBoundingClientRect();\n this.touchState = {\n element: element,\n startX: touch.clientX,\n startY: touch.clientY,\n lastX: touch.clientX,\n lastY: touch.clientY,\n // Keep preview anchored to the original grab point.\n offsetX: touch.clientX - rect.left,\n offsetY: touch.clientY - rect.top,\n startTime: Date.now(),\n isDragging: false\n };\n },\n\n /**\n * Handle touch move event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchMove: function (e, element) {\n if (!this.touchState) return;\n\n const touch = e.touches[0];\n this.touchState.lastX = touch.clientX;\n this.touchState.lastY = touch.clientY;\n const deltaX = touch.clientX - this.touchState.startX;\n const deltaY = touch.clientY - this.touchState.startY;\n\n // Only start dragging if moved a minimum distance\n if (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10) {\n // Now we know it's a drag, not a scroll \u2014 prevent default\n if (e.cancelable) e.preventDefault();\n\n if (!this.touchState.isDragging) {\n this.touchState.isDragging = true;\n element.classList.add('is-dragging');\n element.setAttribute('aria-grabbed', 'true');\n\n // Store drag state\n this.currentDrag = {\n element: element,\n initialPosition: { x: this.touchState.startX, y: this.touchState.startY },\n initialBounds: element.getBoundingClientRect(),\n data: this.getData(element),\n // Preserve where inside the element the drag started for accurate ghost positioning.\n offsetX: this.touchState.offsetX,\n offsetY: this.touchState.offsetY\n };\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:start', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: touch.clientX, y: touch.clientY }\n }\n }));\n }\n\n // Update feedback\n this.updateFeedback(touch.clientX, touch.clientY);\n\n // Dispatch event\n if (this.currentDrag) {\n element.dispatchEvent(new CustomEvent('draggable:drag', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: touch.clientX, y: touch.clientY },\n delta: { x: deltaX, y: deltaY }\n }\n }));\n\n this.updateTouchDropZone(touch.clientX, touch.clientY);\n\n // Reorder for touch\n const container = element.closest(this.containerSelector);\n if (container && container.contains(element)) {\n this.handleReorder(container, element, touch.clientX, touch.clientY);\n }\n }\n }\n },\n\n /**\n * Handle touch end event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchEnd: function (e, element) {\n if (this.touchState && this.touchState.isDragging) {\n if (e.cancelable) e.preventDefault();\n const endTouch = e.changedTouches?.[0];\n const endPosition = {\n x: endTouch?.clientX ?? this.touchState.lastX ?? this.touchState.startX,\n y: endTouch?.clientY ?? this.touchState.lastY ?? this.touchState.startY\n };\n\n const dropZone = this.resolveDropZoneAtPoint(endPosition.x, endPosition.y) || this.touchState.overZone;\n if (dropZone) {\n this.dispatchDrop(dropZone, endPosition);\n } else if (this.touchState.overZone) {\n this.touchState.overZone.classList.remove('is-drag-over');\n }\n\n element.classList.remove('is-dragging');\n element.classList.add('is-dropped');\n element.setAttribute('aria-grabbed', 'false');\n setTimeout(() => element.classList.remove('is-dropped'), 300);\n\n // Hide feedback\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n\n // Dispatch event\n const data = this.currentDrag?.data || this.getData(element);\n const startX = this.touchState?.startX || 0;\n const startY = this.touchState?.startY || 0;\n\n element.dispatchEvent(new CustomEvent('draggable:end', {\n bubbles: true,\n detail: {\n element: element,\n data: data,\n position: endPosition,\n delta: {\n x: endPosition.x - startX,\n y: endPosition.y - startY\n }\n }\n }));\n }\n\n // Reset states\n this.touchState = null;\n this.currentDrag = null;\n },\n\n /**\n * Handle drag over event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} _zone - Drop zone element\n */\n handleDragOver: function (e, _zone) {\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n },\n\n /**\n * Handle drag enter event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDragEnter: function (e, zone) {\n e.preventDefault();\n zone.classList.add('is-drag-over');\n },\n\n /**\n * Handle drag leave event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDragLeave: function (e, zone) {\n zone.classList.remove('is-drag-over');\n },\n\n /**\n * Handle drop event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDrop: function (e, zone) {\n e.preventDefault();\n this.dispatchDrop(zone, { x: e.clientX, y: e.clientY });\n },\n\n /**\n * Resolve a drop zone from viewport coordinates\n * @param {number} x\n * @param {number} y\n * @returns {HTMLElement|null}\n */\n resolveDropZoneAtPoint: function (x, y) {\n if (!Number.isFinite(x) || !Number.isFinite(y)) return null;\n\n // Prefer the full stacking list so overlays/top elements don't hide real drop targets.\n if (typeof document.elementsFromPoint === 'function') {\n const stacked = document.elementsFromPoint(x, y);\n for (const element of stacked) {\n const zone = element.closest('.vd-drop-zone');\n if (zone) return zone;\n }\n }\n\n const target = document.elementFromPoint(x, y);\n const targetZone = target ? target.closest('.vd-drop-zone') : null;\n if (targetZone) return targetZone;\n\n // Last-resort fallback for mobile emulation edge cases.\n const zones = document.querySelectorAll('.vd-drop-zone');\n for (const zone of zones) {\n const rect = zone.getBoundingClientRect();\n if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {\n return zone;\n }\n }\n\n return null;\n },\n\n /**\n * Track and update active drop-zone hover state on touch devices\n * @param {number} x\n * @param {number} y\n */\n updateTouchDropZone: function (x, y) {\n if (!this.touchState) return;\n\n const nextZone = this.resolveDropZoneAtPoint(x, y);\n const prevZone = this.touchState.overZone || null;\n\n if (prevZone && prevZone !== nextZone) {\n prevZone.classList.remove('is-drag-over');\n }\n\n if (nextZone && nextZone !== prevZone) {\n nextZone.classList.add('is-drag-over');\n }\n\n this.touchState.overZone = nextZone || null;\n },\n\n /**\n * Dispatch a normalized drop event for mouse and touch flows\n * @param {HTMLElement} zone\n * @param {{x:number, y:number}} position\n */\n dispatchDrop: function (zone, position) {\n zone.classList.remove('is-drag-over');\n zone.dispatchEvent(new CustomEvent('draggable:drop', {\n bubbles: true,\n detail: {\n zone: zone,\n element: this.currentDrag?.element,\n data: this.currentDrag?.data,\n position: position\n }\n }));\n },\n\n /**\n * Reorder elements in container based on cursor position\n * @param {HTMLElement} container \n * @param {HTMLElement} element \n * @param {number} clientX \n * @param {number} clientY \n */\n handleReorder: function (container, element, clientX, clientY) {\n const isVertical = container.classList.contains('vd-draggable-container-vertical');\n const siblings = [...container.querySelectorAll('.vd-draggable-item:not(.is-dragging), .vd-draggable:not(.is-dragging)')];\n\n const nextSibling = siblings.reduce((closest, child) => {\n const box = child.getBoundingClientRect();\n const offset = isVertical\n ? clientY - box.top - box.height / 2\n : clientX - box.left - box.width / 2;\n\n if (offset < 0 && offset > closest.offset) {\n return { offset: offset, element: child };\n } else {\n return closest;\n }\n }, { offset: Number.NEGATIVE_INFINITY }).element;\n\n if (nextSibling == null) {\n container.appendChild(element);\n } else {\n container.insertBefore(element, nextSibling);\n }\n },\n\n /**\n * Handle keyboard events\n * @param {KeyboardEvent} e - Keyboard event\n * @param {HTMLElement} element - Draggable element\n */\n handleKeydown: function (e, element) {\n switch (e.key) {\n case 'Enter':\n case ' ':\n e.preventDefault();\n // Trigger click or custom action\n element.click();\n break;\n case 'Escape':\n // Cancel drag if in progress\n if (element.classList.contains('is-dragging')) {\n element.classList.remove('is-dragging');\n element.setAttribute('aria-grabbed', 'false');\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n this.currentDrag = null;\n }\n break;\n case 'ArrowUp':\n case 'ArrowLeft': {\n e.preventDefault();\n const prev = element.previousElementSibling;\n if (prev && (prev.classList.contains('vd-draggable') || prev.classList.contains('vd-draggable-item'))) {\n element.parentNode.insertBefore(element, prev);\n element.focus();\n element.dispatchEvent(new CustomEvent('draggable:reorder', {\n bubbles: true,\n detail: { element: element, direction: 'up' }\n }));\n }\n break;\n }\n case 'ArrowDown':\n case 'ArrowRight': {\n e.preventDefault();\n const next = element.nextElementSibling;\n if (next && (next.classList.contains('vd-draggable') || next.classList.contains('vd-draggable-item'))) {\n element.parentNode.insertBefore(next, element);\n element.focus();\n element.dispatchEvent(new CustomEvent('draggable:reorder', {\n bubbles: true,\n detail: { element: element, direction: 'down' }\n }));\n }\n break;\n }\n }\n },\n\n /**\n * Get data from draggable element\n * @param {HTMLElement} element - Draggable element\n * @returns {string} Data associated with the element\n */\n getData: function (element) {\n return element.dataset.draggable || element.textContent.trim();\n },\n\n /**\n * Update drag feedback element\n * @param {number} x - Current X coordinate\n * @param {number} y - Current Y coordinate\n */\n updateFeedback: function (x, y) {\n if (!this.currentDrag) return;\n\n // Show feedback\n this.feedbackElement.classList.remove('hidden');\n\n // Update feedback content\n const rect = this.currentDrag.initialBounds;\n this.feedbackElement.innerHTML = '';\n const clone = this.currentDrag.element.cloneNode(true);\n this.feedbackElement.appendChild(clone);\n\n // Set styles\n const offsetX = this.currentDrag.offsetX ?? 20;\n const offsetY = this.currentDrag.offsetY ?? 20;\n Object.assign(this.feedbackElement.style, {\n left: (x - offsetX) + 'px',\n top: (y - offsetY) + 'px',\n width: rect.width + 'px',\n height: rect.height + 'px'\n });\n },\n\n /**\n * Make an element draggable programmatically\n * @param {HTMLElement|string} element - Element or selector\n * @param {Object} options - Configuration options\n */\n makeDraggable: function (element, options = {}) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n\n if (el && !this.instances.has(el)) {\n // Add classes and attributes\n el.classList.add('vd-draggable');\n el.setAttribute('draggable', 'true');\n\n // Set options\n if (options.data) {\n el.dataset.draggable = options.data;\n }\n\n // Initialize\n this.initDraggable(el);\n }\n },\n\n /**\n * Remove draggable functionality from an element\n * @param {HTMLElement|string} element - Element or selector\n */\n removeDraggable: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n\n if (el && this.instances.has(el)) {\n // Clean up\n const instance = this.instances.get(el);\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n\n // Remove classes and attributes\n el.classList.remove('vd-draggable');\n el.removeAttribute('draggable');\n el.removeAttribute('data-draggable');\n }\n },\n\n /**\n * Destroy a draggable instance and clean up event listeners\n * @param {HTMLElement} element - Draggable element\n */\n destroy: function (element) {\n this.removeDraggable(element);\n },\n\n /**\n * Destroy all draggable instances\n */\n destroyAll: function () {\n const instances = Array.from(this.instances.keys());\n instances.forEach(element => this.destroy(element));\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('draggable', Draggable);\n }\n\n // Expose globally\n window.VanduoDraggable = Draggable;\n})();\n", "/**\n * Vanduo Framework \u2013 LazyLoad Component\n * v1.2.1\n *\n * Provides two levels of API:\n *\n * LOW-LEVEL (generic IntersectionObserver wrapper)\n * VanduoLazyLoad.observe(element, callback, options?)\n * VanduoLazyLoad.unobserve(element)\n * VanduoLazyLoad.unobserveAll()\n *\n * HIGH-LEVEL (HTML-section fetcher, mirrors vanduo-docs SPA behaviour)\n * VanduoLazyLoad.loadSection(url, containerEl, options?)\n * options: { placeholder, threshold, rootMargin, onLoaded, onError }\n * placeholder: 'skeleton' | 'spinner' | HTMLString (default: 'skeleton')\n *\n * ATTRIBUTE-DRIVEN (zero-JS usage, wired by init())\n *
    \u2026
    \n *\n * EVENTS dispatched on the host element:\n * lazysection:loading \u2014 fetch started\n * lazysection:loaded \u2014 content injected\n * lazysection:error \u2014 fetch failed\n */\n\n(function () {\n 'use strict';\n\n /* \u2500\u2500 Private state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /** @type {Map} */\n const _observerMap = new Map();\n\n /* \u2500\u2500 Security helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Returns true only if `url` resolves to the same origin as the page\n * (relative paths and same-origin absolute URLs are allowed).\n * Cross-origin URLs are rejected to prevent SSRF-style fetch abuse.\n * @param {string} url\n * @returns {boolean}\n */\n function _isSafeUrl(url) {\n try {\n // Relative URLs (no origin) are always safe\n const resolved = new URL(url, window.location.href);\n return resolved.origin === window.location.origin;\n } catch (_) {\n return false;\n }\n }\n\n /**\n * Safely inject fetched HTML into a container by parsing it with\n * DOMParser (avoids script execution) and replacing children via\n * standard DOM APIs instead of raw innerHTML assignment.\n * @param {Element} containerEl\n * @param {string} html\n */\n function _safeInjectHtml(containerEl, html) {\n const parser = new DOMParser();\n const doc = parser.parseFromString(html.trim(), 'text/html'); // CodeQL [js/xss-through-dom] Intentional: parsed nodes are sanitized below before adoption into live DOM\n\n const DANGEROUS_TAGS = ['SCRIPT', 'IFRAME', 'OBJECT', 'EMBED', 'FORM', 'BASE', 'LINK', 'META', 'STYLE'];\n for (const tag of DANGEROUS_TAGS) {\n const els = doc.querySelectorAll(tag);\n for (let i = els.length - 1; i >= 0; i--) {\n els[i].parentNode.removeChild(els[i]);\n }\n }\n\n // Strip dangerous attributes (e.g. onerror, javascript: urls) from all elements\n function _sanitizeNode(node) {\n if (node.nodeType === Node.ELEMENT_NODE) {\n const attrs = node.attributes;\n for (let i = attrs.length - 1; i >= 0; i--) {\n const attrName = attrs[i].name.toLowerCase();\n const attrValue = attrs[i].value.toLowerCase();\n const trimmedValue = attrValue.trim();\n if (\n attrName.startsWith('on') ||\n trimmedValue.startsWith('javascript:') ||\n trimmedValue.startsWith('data:') ||\n trimmedValue.startsWith('vbscript:')\n ) {\n node.removeAttribute(attrs[i].name);\n }\n }\n const children = node.childNodes;\n for (let i = 0; i < children.length; i++) {\n _sanitizeNode(children[i]);\n }\n }\n }\n _sanitizeNode(doc.body);\n\n // Collect all top-level body children\n const nodes = Array.from(doc.body.childNodes);\n // Clear container and append parsed nodes\n while (containerEl.firstChild) {\n containerEl.removeChild(containerEl.firstChild);\n }\n nodes.forEach(function (node) {\n containerEl.appendChild(document.adoptNode(node));\n });\n }\n\n /* \u2500\u2500 Placeholder HTML \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n function _skeletonHtml() {\n return '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + 'Loading\u2026
    ';\n }\n\n function _spinnerHtml() {\n return '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + 'Loading\u2026
    ';\n }\n\n function _resolvePlaceholder(placeholder) {\n if (!placeholder || placeholder === 'skeleton') return _skeletonHtml();\n if (placeholder === 'spinner') return _spinnerHtml();\n // Caller-supplied HTML string\n return placeholder;\n }\n\n /* \u2500\u2500 Dispatch helper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n function _dispatch(el, eventName, detail) {\n el.dispatchEvent(new CustomEvent(eventName, { bubbles: true, detail: detail || {} }));\n }\n\n /* \u2500\u2500 VanduoLazyLoad \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const VanduoLazyLoad = {\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * LOW-LEVEL API\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Observe an element. `callback` is invoked once when the element\n * enters the viewport, then the element is automatically unobserved.\n *\n * @param {Element} element\n * @param {function(Element): void} callback\n * @param {{ threshold?: number, rootMargin?: string }} [options]\n */\n observe: function (element, callback, options) {\n if (!(element instanceof Element)) {\n console.warn('[VanduoLazyLoad] observe() requires a DOM Element.');\n return;\n }\n if (typeof callback !== 'function') {\n console.warn('[VanduoLazyLoad] observe() requires a callback function.');\n return;\n }\n // Already observed \u2014 ignore\n if (_observerMap.has(element)) return;\n\n const threshold = (options && options.threshold != null) ? options.threshold : 0;\n const rootMargin = (options && options.rootMargin) ? options.rootMargin : '0px';\n\n const observer = new IntersectionObserver(function (entries, obs) {\n entries.forEach(function (entry) {\n if (entry.isIntersecting) {\n obs.unobserve(entry.target);\n _observerMap.delete(entry.target);\n try {\n callback(entry.target);\n } catch (e) {\n console.error('[VanduoLazyLoad] Callback threw:', e);\n }\n }\n });\n }, { threshold: threshold, rootMargin: rootMargin });\n\n _observerMap.set(element, observer);\n observer.observe(element);\n },\n\n /**\n * Stop observing an element that was previously passed to observe().\n * @param {Element} element\n */\n unobserve: function (element) {\n const observer = _observerMap.get(element);\n if (observer) {\n observer.unobserve(element);\n _observerMap.delete(element);\n }\n },\n\n /**\n * Stop observing ALL currently observed elements.\n */\n unobserveAll: function () {\n _observerMap.forEach(function (observer, element) {\n observer.unobserve(element);\n });\n _observerMap.clear();\n },\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * HIGH-LEVEL API\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Fetch an HTML partial and inject it into `containerEl` when the\n * container enters the viewport. A placeholder is shown immediately.\n *\n * @param {string} url URL of the HTML partial to fetch\n * @param {Element} containerEl Target element whose content will be replaced\n * @param {{\n * placeholder?: 'skeleton'|'spinner'|string,\n * threshold?: number,\n * rootMargin?: string,\n * onLoaded?: function(Element): void,\n * onError?: function(Error): void\n * }} [options]\n */\n loadSection: function (url, containerEl, options) {\n if (typeof url !== 'string' || !url) {\n console.warn('[VanduoLazyLoad] loadSection() requires a non-empty URL string.');\n return;\n }\n if (!(containerEl instanceof Element)) {\n console.warn('[VanduoLazyLoad] loadSection() requires a DOM Element as containerEl.');\n return;\n }\n // Reject cross-origin URLs to prevent SSRF-style fetch abuse\n if (!_isSafeUrl(url)) {\n console.error('[VanduoLazyLoad] loadSection() blocked cross-origin URL:', url);\n return;\n }\n\n const opts = options || {};\n // Placeholders are known-safe static HTML strings built internally\n const placeholderHtml = _resolvePlaceholder(opts.placeholder);\n\n // Use _safeInjectHtml instead of innerHTML to prevent XSS from caller-supplied placeholders\n _safeInjectHtml(containerEl, placeholderHtml);\n _dispatch(containerEl, 'lazysection:loading', { url: url });\n\n // Fetch when visible\n this.observe(containerEl, function () {\n const controller = new window.AbortController();\n const timeoutId = setTimeout(function () { controller.abort(); }, 10000);\n\n window.fetch(url, { signal: controller.signal })\n .then(function (res) {\n clearTimeout(timeoutId);\n if (!res.ok) throw new Error('HTTP ' + res.status);\n return res.text();\n })\n .then(function (html) {\n // Use DOMParser to parse fetched content safely, avoiding\n // raw innerHTML assignment of externally-sourced strings\n _safeInjectHtml(containerEl, html);\n _dispatch(containerEl, 'lazysection:loaded', { url: url });\n // Re-init Vanduo components inside the new content\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.init();\n }\n if (typeof opts.onLoaded === 'function') {\n opts.onLoaded(containerEl);\n }\n })\n .catch(function (err) {\n // Build error node via DOM APIs \u2014 no dynamic HTML strings\n const alertEl = document.createElement('div');\n alertEl.className = 'vd-alert vd-alert-error';\n alertEl.setAttribute('role', 'alert');\n const msgEl = document.createElement('span');\n msgEl.textContent = 'Failed to load content. ';\n const detailEl = document.createElement('small');\n detailEl.style.opacity = '0.7';\n detailEl.textContent = err.message;\n alertEl.appendChild(msgEl);\n alertEl.appendChild(detailEl);\n while (containerEl.firstChild) {\n containerEl.removeChild(containerEl.firstChild);\n }\n containerEl.appendChild(alertEl);\n _dispatch(containerEl, 'lazysection:error', { url: url, error: err });\n console.error('[VanduoLazyLoad] loadSection failed:', err);\n if (typeof opts.onError === 'function') {\n opts.onError(err);\n }\n });\n }, { threshold: opts.threshold, rootMargin: opts.rootMargin });\n },\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * ATTRIBUTE-DRIVEN INIT\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Scan the DOM for [data-vd-lazy] elements and wire them up.\n * Safe to call multiple times \u2014 already-observed elements are skipped.\n */\n init: function () {\n const self = this;\n const elements = document.querySelectorAll('[data-vd-lazy]');\n elements.forEach(function (el) {\n // Skip already-observed or already-loaded elements\n if (_observerMap.has(el) || el.dataset.vdLazyState === 'loading' || el.dataset.vdLazyState === 'loaded') return;\n\n const url = el.getAttribute('data-vd-lazy');\n if (!url) return;\n\n el.dataset.vdLazyState = 'loading';\n const placeholder = el.getAttribute('data-vd-lazy-placeholder') || 'skeleton';\n\n self.loadSection(url, el, {\n placeholder: placeholder,\n onLoaded: function () {\n el.dataset.vdLazyState = 'loaded';\n },\n onError: function () {\n el.dataset.vdLazyState = 'error';\n }\n });\n });\n }\n };\n\n /* \u2500\u2500 Register with Vanduo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('LazyLoad', VanduoLazyLoad);\n }\n\n /* \u2500\u2500 Global convenience alias \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n window.VanduoLazyLoad = VanduoLazyLoad;\n\n})();\n", "/**\n * Vanduo Framework - Glass Scroll Activation\n * Generic scroll-aware glass activation via IntersectionObserver.\n *\n * Usage:\n * Add `data-glass-scroll` to any element carrying a `.vd-glass*` class.\n * By default the previous sibling is used as the sentinel.\n * Point to a custom sentinel via `data-glass-sentinel=\"\"`.\n *\n * Behaviour:\n * - Adds `.is-glass-active` when the sentinel leaves the viewport (scrolled past).\n * - Removes `.is-glass-active` when the sentinel re-enters the viewport.\n */\n(function () {\n 'use strict';\n\n const GlassScroll = {\n /** @type {Map} */\n observers: new Map(),\n\n init: function () {\n document.querySelectorAll('[data-glass-scroll]').forEach(el => {\n if (this.observers.has(el)) return;\n this.initElement(el);\n });\n },\n\n /**\n * Wire up a single scroll-activated glass element.\n * @param {HTMLElement} el\n */\n initElement: function (el) {\n const sentinelSelector = el.dataset.glassSentinel;\n let sentinel;\n\n if (sentinelSelector) {\n sentinel = document.querySelector(sentinelSelector);\n }\n\n if (!sentinel) {\n // Fall back to the previous sibling element\n sentinel = el.previousElementSibling;\n }\n\n if (!sentinel) {\n // No sentinel available \u2014 activate immediately so glass is always shown\n el.classList.add('is-glass-active');\n return;\n }\n\n const observer = new IntersectionObserver(\n (entries) => {\n entries.forEach(entry => {\n // Active when sentinel is NOT intersecting (scrolled past it)\n el.classList.toggle('is-glass-active', !entry.isIntersecting);\n });\n },\n { threshold: 0, rootMargin: '0px' }\n );\n\n observer.observe(sentinel);\n this.observers.set(el, observer);\n },\n\n /**\n * Disconnect and remove a single element's observer.\n * @param {HTMLElement} el\n */\n destroy: function (el) {\n const observer = this.observers.get(el);\n if (observer) {\n observer.disconnect();\n this.observers.delete(el);\n }\n },\n\n destroyAll: function () {\n this.observers.forEach((observer, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('glassScroll', GlassScroll);\n }\n\n window.VanduoGlassScroll = GlassScroll;\n})();\n", "/**\n * Vanduo Framework - Water Morph Effect Component\n * Liquid wave content-swap animation on click\n *\n * Usage:\n * Add .vd-morph or [data-vd-morph] to any element.\n * Provide .vd-morph-content.vd-morph-current and .vd-morph-content.vd-morph-next children.\n * Wave and shine layers are auto-created if absent.\n *\n * JS API:\n * VanduoMorph.morph(el) \u2014 trigger morph programmatically (from center)\n * VanduoMorph.destroy(el) \u2014 tear down listeners for one element\n * VanduoMorph.destroyAll() \u2014 tear down all instances\n */\n\n(function () {\n 'use strict';\n\n const MORPH_DURATION_MS = 750;\n\n const Morph = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-morph, [data-vd-morph]');\n elements.forEach(function (el) {\n if (Morph.instances.has(el)) return;\n if (el.getAttribute('data-vd-morph') === 'manual') return;\n Morph.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n Morph._ensureLayers(el);\n\n const cleanup = [];\n let morphing = false;\n\n const handleClick = function (e) {\n if (morphing) return;\n Morph._runMorph(el, e, function () { morphing = false; });\n morphing = true;\n };\n\n el.addEventListener('click', handleClick);\n cleanup.push(function () { el.removeEventListener('click', handleClick); });\n\n this.instances.set(el, { cleanup: cleanup });\n },\n\n morph: function (el) {\n if (!el) return;\n if (!this.instances.has(el)) this.initInstance(el);\n this._runMorph(el, null, null);\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(function (fn) { fn(); });\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) { Morph.destroy(el); });\n },\n\n /* \u2500\u2500 Internal helpers \u2500\u2500 */\n\n _ensureLayers: function (el) {\n if (!el.querySelector('.vd-morph-wave')) {\n const wave = document.createElement('span');\n wave.className = 'vd-morph-wave';\n wave.setAttribute('aria-hidden', 'true');\n el.insertBefore(wave, el.firstChild);\n }\n if (!el.querySelector('.vd-morph-shine')) {\n const shine = document.createElement('span');\n shine.className = 'vd-morph-shine';\n shine.setAttribute('aria-hidden', 'true');\n const waveEl = el.querySelector('.vd-morph-wave');\n if (waveEl && waveEl.nextSibling) {\n el.insertBefore(shine, waveEl.nextSibling);\n } else {\n el.insertBefore(shine, el.firstChild);\n }\n }\n },\n\n _runMorph: function (el, pointerEvent, onComplete) {\n const wave = el.querySelector('.vd-morph-wave');\n if (wave) {\n const rect = el.getBoundingClientRect();\n const cx = rect.left + rect.width / 2;\n const cy = rect.top + rect.height / 2;\n const px = pointerEvent ? (pointerEvent.clientX || cx) : cx;\n const py = pointerEvent ? (pointerEvent.clientY || cy) : cy;\n wave.style.left = (px - rect.left) + 'px';\n wave.style.top = (py - rect.top) + 'px';\n }\n\n el.classList.add('is-morphing');\n\n let duration = MORPH_DURATION_MS;\n const custom = getComputedStyle(el).getPropertyValue('--morph-duration');\n if (custom) {\n const parsed = parseFloat(custom);\n if (!isNaN(parsed)) duration = parsed * (custom.indexOf('ms') !== -1 ? 1 : 1000);\n }\n\n setTimeout(function () {\n el.classList.remove('is-morphing');\n\n const current = el.querySelector('.vd-morph-current');\n const next = el.querySelector('.vd-morph-next');\n if (current && next) {\n current.classList.remove('vd-morph-current');\n current.classList.add('vd-morph-next');\n next.classList.remove('vd-morph-next');\n next.classList.add('vd-morph-current');\n }\n\n if (typeof onComplete === 'function') onComplete();\n }, duration);\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('morph', Morph);\n }\n\n window.VanduoMorph = Morph;\n\n})();\n", "/**\n * Vanduo Framework \u2014 Expanding flex cards\n * Click / Enter / Space / Arrow keys to change the active card.\n *\n * Usage:\n *
    \n * Use data-vd-expanding-cards=\"manual\" to skip auto-init.\n */\n\n(function () {\n 'use strict';\n\n const ExpandingCards = {\n instances: new Map(),\n\n init: function () {\n document.querySelectorAll('.vd-expanding-cards').forEach(function (el) {\n if (el.getAttribute('data-vd-expanding-cards') === 'manual') return;\n if (ExpandingCards.instances.has(el)) return;\n ExpandingCards.initContainer(el);\n });\n },\n\n initContainer: function (container) {\n const cleanup = [];\n\n const getCards = function () {\n return Array.prototype.slice.call(container.querySelectorAll('.vd-expanding-card'));\n };\n\n const setActive = function (card) {\n const cards = getCards();\n if (!card || cards.indexOf(card) === -1) return;\n cards.forEach(function (c) {\n c.classList.toggle('is-active', c === card);\n });\n card.focus({ preventScroll: true });\n };\n\n const onClick = function (e) {\n const t = e.target;\n const card = t.closest ? t.closest('.vd-expanding-card') : null;\n if (!card || !container.contains(card)) return;\n setActive(card);\n };\n\n const onKeydown = function (e) {\n if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'Home' && e.key !== 'End') {\n return;\n }\n const cards = getCards().filter(function (c) {\n return c.offsetParent !== null || c.getClientRects().length > 0;\n });\n if (!cards.length) return;\n const activeEl = document.activeElement;\n let idx = cards.indexOf(activeEl);\n if (idx < 0) {\n idx = cards.findIndex(function (c) {\n return c.classList.contains('is-active');\n });\n }\n if (idx < 0) idx = 0;\n\n if (e.key === 'ArrowLeft') {\n e.preventDefault();\n setActive(cards[Math.max(0, idx - 1)]);\n } else if (e.key === 'ArrowRight') {\n e.preventDefault();\n setActive(cards[Math.min(cards.length - 1, idx + 1)]);\n } else if (e.key === 'Home') {\n e.preventDefault();\n setActive(cards[0]);\n } else if (e.key === 'End') {\n e.preventDefault();\n setActive(cards[cards.length - 1]);\n }\n };\n\n container.addEventListener('click', onClick);\n cleanup.push(function () {\n container.removeEventListener('click', onClick);\n });\n\n container.addEventListener('keydown', onKeydown);\n cleanup.push(function () {\n container.removeEventListener('keydown', onKeydown);\n });\n\n getCards().forEach(function (card) {\n if (!card.hasAttribute('tabindex')) {\n card.setAttribute('tabindex', '0');\n }\n card.setAttribute('role', 'button');\n if (!card.hasAttribute('aria-pressed')) {\n card.setAttribute('aria-pressed', card.classList.contains('is-active') ? 'true' : 'false');\n }\n });\n\n const syncAria = function () {\n getCards().forEach(function (card) {\n card.setAttribute('aria-pressed', card.classList.contains('is-active') ? 'true' : 'false');\n });\n };\n\n const mo = new MutationObserver(syncAria);\n mo.observe(container, { attributes: true, subtree: true, attributeFilter: ['class'] });\n cleanup.push(function () {\n mo.disconnect();\n });\n syncAria();\n\n ExpandingCards.instances.set(container, { cleanup: cleanup });\n },\n\n destroy: function (container) {\n const inst = this.instances.get(container);\n if (!inst) return;\n inst.cleanup.forEach(function (fn) {\n fn();\n });\n this.instances.delete(container);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) {\n ExpandingCards.destroy(el);\n });\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('expandingCards', ExpandingCards);\n }\n\n window.VanduoExpandingCards = ExpandingCards;\n})();\n", "/**\n * Vanduo Framework \u2014 Timeline animated reveal\n * Opt-in: add `.vd-timeline-animated` to a `.vd-timeline` container.\n * Uses IntersectionObserver to add `.is-revealed` per item with staggered delays.\n * Optional: `.vd-timeline-playback` with `[data-vd-timeline-prev|next|play|pause]` for stepped control.\n */\n\n(function () {\n 'use strict';\n\n const STAGGER_MS = 140;\n const MAX_STAGGER_INDEX = 7;\n const PLAY_INTERVAL_MS = 800;\n\n function countRevealedPrefix(items) {\n let count = 0;\n for (let i = 0; i < items.length; i++) {\n if (!items[i].classList.contains('is-revealed')) break;\n count++;\n }\n return count;\n }\n\n function findPlaybackControls(container) {\n return container.parentElement || document.body;\n }\n\n function initPlayback(container, items, cleanup) {\n items.forEach(function (item) {\n item.classList.remove('is-revealed');\n });\n\n const scope = findPlaybackControls(container);\n const prevBtn = scope.querySelector('[data-vd-timeline-prev]');\n const nextBtn = scope.querySelector('[data-vd-timeline-next]');\n const playBtn = scope.querySelector('[data-vd-timeline-play]');\n const pauseBtn = scope.querySelector('[data-vd-timeline-pause]');\n\n let playTimer = null;\n\n function updateNavButtons() {\n const k = countRevealedPrefix(items);\n const n = items.length;\n if (prevBtn) {\n const atStart = k === 0;\n prevBtn.disabled = atStart;\n prevBtn.setAttribute('aria-disabled', atStart ? 'true' : 'false');\n }\n if (nextBtn) {\n const atEnd = k >= n;\n nextBtn.disabled = atEnd;\n nextBtn.setAttribute('aria-disabled', atEnd ? 'true' : 'false');\n }\n if (playBtn) {\n playBtn.setAttribute('aria-pressed', playTimer ? 'true' : 'false');\n }\n if (pauseBtn) {\n pauseBtn.disabled = !playTimer;\n }\n }\n\n function stepNext() {\n const k = countRevealedPrefix(items);\n if (k < items.length) {\n items[k].classList.add('is-revealed');\n }\n updateNavButtons();\n }\n\n function stepPrev() {\n const k = countRevealedPrefix(items);\n if (k > 0) {\n items[k - 1].classList.remove('is-revealed');\n }\n updateNavButtons();\n }\n\n function play() {\n if (playTimer) return;\n playTimer = setInterval(function () {\n if (countRevealedPrefix(items) >= items.length) {\n pause();\n return;\n }\n stepNext();\n }, PLAY_INTERVAL_MS);\n updateNavButtons();\n }\n\n function pause() {\n if (playTimer) {\n clearInterval(playTimer);\n playTimer = null;\n }\n updateNavButtons();\n }\n\n function addClick(el, fn) {\n if (!el) return;\n const handler = function (e) {\n e.preventDefault();\n fn();\n };\n el.addEventListener('click', handler);\n cleanup.push(function () {\n el.removeEventListener('click', handler);\n });\n }\n\n addClick(prevBtn, stepPrev);\n addClick(nextBtn, stepNext);\n addClick(playBtn, play);\n addClick(pauseBtn, pause);\n\n cleanup.push(function () {\n pause();\n });\n\n updateNavButtons();\n\n return {\n stepNext: stepNext,\n stepPrev: stepPrev,\n play: play,\n pause: pause\n };\n }\n\n const Timeline = {\n instances: new Map(),\n\n init: function () {\n document.querySelectorAll('.vd-timeline.vd-timeline-animated').forEach(function (el) {\n if (Timeline.instances.has(el)) return;\n Timeline.initInstance(el);\n });\n },\n\n reinit: function () {\n Timeline.destroyAll();\n Timeline.init();\n },\n\n initInstance: function (container) {\n const cleanup = [];\n const items = Array.prototype.filter.call(container.children, function (child) {\n return child.classList && child.classList.contains('vd-timeline-item');\n });\n\n items.forEach(function (item, i) {\n const idx = Math.min(i, MAX_STAGGER_INDEX);\n item.style.setProperty('--vd-timeline-reveal-delay', (idx * STAGGER_MS) + 'ms');\n });\n\n const reducedMotion = typeof window.matchMedia === 'function'\n && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n if (reducedMotion) {\n items.forEach(function (item) {\n item.classList.add('is-revealed');\n });\n Timeline.instances.set(container, { cleanup: cleanup });\n return;\n }\n\n const playback = container.classList && container.classList.contains('vd-timeline-playback');\n\n if (playback) {\n const playbackApi = initPlayback(container, items, cleanup);\n Timeline.instances.set(container, { cleanup: cleanup, playback: playbackApi });\n return;\n }\n\n if (typeof IntersectionObserver === 'undefined') {\n items.forEach(function (item) {\n item.classList.add('is-revealed');\n });\n Timeline.instances.set(container, { cleanup: cleanup });\n return;\n }\n\n const observer = new IntersectionObserver(function (entries) {\n entries.forEach(function (entry) {\n if (!entry.isIntersecting) return;\n entry.target.classList.add('is-revealed');\n observer.unobserve(entry.target);\n });\n }, {\n root: null,\n rootMargin: '0px 0px -10% 0px',\n threshold: 0.15\n });\n\n items.forEach(function (item) {\n observer.observe(item);\n });\n\n cleanup.push(function () {\n observer.disconnect();\n });\n\n Timeline.instances.set(container, { cleanup: cleanup });\n },\n\n destroy: function (container) {\n const inst = this.instances.get(container);\n if (!inst) return;\n inst.cleanup.forEach(function (fn) {\n fn();\n });\n this.instances.delete(container);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) {\n Timeline.destroy(el);\n });\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('timeline', Timeline);\n }\n\n window.VanduoTimeline = Timeline;\n})();\n", "/**\n * Vanduo Framework - Flow (Carousel/Slider) Component\n * Touch-enabled carousel with slide/fade transitions, autoplay, indicators\n */\n\n(function () {\n 'use strict';\n\n const Flow = {\n instances: new Map(),\n\n init: function () {\n const carousels = document.querySelectorAll('.vd-flow, .vd-carousel');\n carousels.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const track = el.querySelector('.vd-flow-track');\n if (!track) return;\n\n const slides = Array.from(track.querySelectorAll('.vd-flow-slide'));\n if (slides.length === 0) return;\n\n const isFade = el.classList.contains('vd-flow-fade');\n const autoplay = el.hasAttribute('data-vd-autoplay');\n const interval = parseInt(el.getAttribute('data-vd-interval'), 10) || 5000;\n const loop = el.getAttribute('data-vd-loop') !== 'false';\n\n const state = {\n current: 0,\n total: slides.length,\n autoplayTimer: null,\n isFade: isFade,\n loop: loop,\n isDragging: false,\n startX: 0,\n currentX: 0,\n threshold: 50\n };\n\n const cleanup = [];\n\n // Set initial active slide\n slides.forEach((slide, i) => {\n slide.setAttribute('role', 'group');\n slide.setAttribute('aria-roledescription', 'slide');\n slide.setAttribute('aria-label', 'Slide ' + (i + 1) + ' of ' + slides.length);\n if (i === 0) slide.classList.add('is-active');\n });\n\n el.setAttribute('role', 'region');\n el.setAttribute('aria-roledescription', 'carousel');\n if (!el.getAttribute('aria-label')) {\n el.setAttribute('aria-label', 'Carousel');\n }\n\n // Live region for announcements\n const liveRegion = document.createElement('div');\n liveRegion.setAttribute('aria-live', 'polite');\n liveRegion.setAttribute('aria-atomic', 'true');\n liveRegion.className = 'sr-only';\n liveRegion.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);';\n el.appendChild(liveRegion);\n\n const goTo = (index, announce) => {\n if (announce === undefined) announce = true;\n let target = index;\n if (state.loop) {\n target = ((index % state.total) + state.total) % state.total;\n } else {\n target = Math.max(0, Math.min(index, state.total - 1));\n }\n\n const prev = state.current;\n state.current = target;\n\n if (state.isFade) {\n slides.forEach((s, i) => {\n s.classList.toggle('is-active', i === target);\n });\n } else {\n track.style.transform = 'translateX(-' + (target * 100) + '%)';\n }\n\n // Update indicators\n const indicators = el.querySelectorAll('.vd-flow-indicator');\n indicators.forEach((ind, i) => {\n ind.classList.toggle('is-active', i === target);\n ind.setAttribute('aria-selected', i === target ? 'true' : 'false');\n });\n\n // Update slide ARIA\n slides.forEach((s, i) => {\n s.setAttribute('aria-hidden', i !== target ? 'true' : 'false');\n });\n\n if (announce) {\n liveRegion.textContent = 'Slide ' + (target + 1) + ' of ' + state.total;\n }\n\n el.dispatchEvent(new CustomEvent('flow:change', {\n detail: { current: target, previous: prev, total: state.total }\n }));\n };\n\n const next = () => goTo(state.current + 1);\n const prev = () => goTo(state.current - 1);\n\n // Controls\n const prevBtn = el.querySelector('.vd-flow-prev');\n const nextBtn = el.querySelector('.vd-flow-next');\n\n if (prevBtn) {\n const h = () => prev();\n prevBtn.addEventListener('click', h);\n cleanup.push(() => prevBtn.removeEventListener('click', h));\n }\n if (nextBtn) {\n const h = () => next();\n nextBtn.addEventListener('click', h);\n cleanup.push(() => nextBtn.removeEventListener('click', h));\n }\n\n // Indicators\n const indicators = el.querySelectorAll('.vd-flow-indicator');\n indicators.forEach((ind, i) => {\n ind.setAttribute('role', 'tab');\n ind.setAttribute('aria-selected', i === 0 ? 'true' : 'false');\n ind.setAttribute('aria-label', 'Go to slide ' + (i + 1));\n const h = () => goTo(i);\n ind.addEventListener('click', h);\n cleanup.push(() => ind.removeEventListener('click', h));\n });\n\n // Keyboard navigation\n const keyHandler = (e) => {\n if (e.key === 'ArrowLeft') { prev(); e.preventDefault(); }\n if (e.key === 'ArrowRight') { next(); e.preventDefault(); }\n };\n el.setAttribute('tabindex', '0');\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n\n // Touch / pointer support\n const pointerDown = (e) => {\n state.isDragging = true;\n state.startX = e.clientX || (e.touches && e.touches[0].clientX) || 0;\n state.currentX = state.startX;\n el.classList.add('is-dragging');\n };\n\n const pointerMove = (e) => {\n if (!state.isDragging) return;\n state.currentX = e.clientX || (e.touches && e.touches[0].clientX) || 0;\n };\n\n const pointerUp = () => {\n if (!state.isDragging) return;\n state.isDragging = false;\n el.classList.remove('is-dragging');\n const diff = state.startX - state.currentX;\n if (Math.abs(diff) > state.threshold) {\n if (diff > 0) next();\n else prev();\n }\n };\n\n el.addEventListener('mousedown', pointerDown);\n el.addEventListener('mousemove', pointerMove);\n el.addEventListener('mouseup', pointerUp);\n el.addEventListener('mouseleave', pointerUp);\n el.addEventListener('touchstart', pointerDown, { passive: true });\n el.addEventListener('touchmove', pointerMove, { passive: true });\n el.addEventListener('touchend', pointerUp);\n\n cleanup.push(\n () => el.removeEventListener('mousedown', pointerDown),\n () => el.removeEventListener('mousemove', pointerMove),\n () => el.removeEventListener('mouseup', pointerUp),\n () => el.removeEventListener('mouseleave', pointerUp),\n () => el.removeEventListener('touchstart', pointerDown),\n () => el.removeEventListener('touchmove', pointerMove),\n () => el.removeEventListener('touchend', pointerUp)\n );\n\n // Autoplay\n const startAutoplay = () => {\n stopAutoplay();\n state.autoplayTimer = setInterval(next, interval);\n };\n\n const stopAutoplay = () => {\n if (state.autoplayTimer) {\n clearInterval(state.autoplayTimer);\n state.autoplayTimer = null;\n }\n };\n\n if (autoplay) {\n startAutoplay();\n const pauseHandler = () => stopAutoplay();\n const resumeHandler = () => startAutoplay();\n el.addEventListener('mouseenter', pauseHandler);\n el.addEventListener('mouseleave', resumeHandler);\n el.addEventListener('focusin', pauseHandler);\n el.addEventListener('focusout', resumeHandler);\n cleanup.push(\n () => el.removeEventListener('mouseenter', pauseHandler),\n () => el.removeEventListener('mouseleave', resumeHandler),\n () => el.removeEventListener('focusin', pauseHandler),\n () => el.removeEventListener('focusout', resumeHandler),\n () => stopAutoplay()\n );\n }\n\n // Initial ARIA state\n goTo(0, false);\n\n this.instances.set(el, {\n cleanup: cleanup,\n goTo: goTo,\n next: next,\n prev: prev,\n getState: () => ({ ...state })\n });\n },\n\n goTo: function (el, index) {\n const instance = this.instances.get(el);\n if (instance) instance.goTo(index);\n },\n\n next: function (el) {\n const instance = this.instances.get(el);\n if (instance) instance.next();\n },\n\n prev: function (el) {\n const instance = this.instances.get(el);\n if (instance) instance.prev();\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('flow', Flow);\n }\n\n window.VanduoFlow = Flow;\n\n})();\n", "/**\n * Vanduo Framework - Bubble (Popover) Component\n * Click-triggered rich HTML popover, reuses tooltip positioning concepts\n */\n\n(function () {\n 'use strict';\n\n const Bubble = {\n instances: new Map(),\n _globalCleanups: [],\n\n init: function () {\n const triggers = document.querySelectorAll('[data-vd-bubble], [data-vd-popover]');\n triggers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n\n if (this._globalCleanups.length === 0) {\n const outsideClick = (e) => {\n this.instances.forEach((inst, trigger) => {\n if (!inst.popover.contains(e.target) && !trigger.contains(e.target)) {\n this.hide(trigger);\n }\n });\n };\n const escHandler = (e) => {\n if (e.key === 'Escape') {\n this.instances.forEach((_, trigger) => this.hide(trigger));\n }\n };\n document.addEventListener('click', outsideClick, true);\n document.addEventListener('keydown', escHandler);\n this._globalCleanups.push(\n () => document.removeEventListener('click', outsideClick, true),\n () => document.removeEventListener('keydown', escHandler)\n );\n }\n },\n\n initInstance: function (trigger) {\n const cleanup = [];\n const placement = trigger.getAttribute('data-vd-bubble-placement') ||\n trigger.getAttribute('data-vd-popover-placement') || 'bottom';\n\n // Build popover element\n const popover = document.createElement('div');\n popover.className = 'vd-bubble-content';\n popover.setAttribute('role', 'dialog');\n popover.setAttribute('aria-modal', 'false');\n popover.setAttribute('data-placement', placement);\n\n const title = trigger.getAttribute('data-vd-bubble-title') ||\n trigger.getAttribute('data-vd-popover-title');\n const content = trigger.getAttribute('data-vd-bubble') ||\n trigger.getAttribute('data-vd-popover') || '';\n const htmlContent = trigger.getAttribute('data-vd-bubble-html') ||\n trigger.getAttribute('data-vd-popover-html');\n const allowSvg = trigger.hasAttribute('data-vd-bubble-allow-svg') ||\n trigger.hasAttribute('data-vd-popover-allow-svg');\n\n if (title) {\n const header = document.createElement('div');\n header.className = 'vd-bubble-header';\n const titleSpan = document.createElement('span');\n titleSpan.textContent = title;\n const closeBtn = document.createElement('button');\n closeBtn.className = 'vd-bubble-close';\n closeBtn.setAttribute('aria-label', 'Close');\n closeBtn.innerHTML = '×';\n header.appendChild(titleSpan);\n header.appendChild(closeBtn);\n popover.appendChild(header);\n\n const closeHandler = (e) => { e.stopPropagation(); this.hide(trigger); };\n closeBtn.addEventListener('click', closeHandler);\n cleanup.push(() => closeBtn.removeEventListener('click', closeHandler));\n }\n\n const body = document.createElement('div');\n body.className = 'vd-bubble-body';\n if (htmlContent) {\n if (typeof sanitizeHtml === 'function') {\n body.innerHTML = sanitizeHtml(htmlContent, { allowSvg });\n } else {\n body.textContent = htmlContent;\n }\n } else {\n body.textContent = content;\n }\n popover.appendChild(body);\n\n document.body.appendChild(popover);\n\n // ARIA on trigger\n const popId = 'vd-bubble-' + Math.random().toString(36).slice(2, 9);\n popover.id = popId;\n trigger.setAttribute('aria-haspopup', 'dialog');\n trigger.setAttribute('aria-expanded', 'false');\n trigger.setAttribute('aria-controls', popId);\n\n // Toggle on click\n const toggleHandler = (e) => {\n e.stopPropagation();\n if (popover.classList.contains('is-visible')) {\n this.hide(trigger);\n } else {\n this.hideAll();\n this.show(trigger);\n }\n };\n trigger.addEventListener('click', toggleHandler);\n cleanup.push(() => trigger.removeEventListener('click', toggleHandler));\n\n this.instances.set(trigger, { popover, cleanup, placement });\n },\n\n position: function (trigger, popover, placement) {\n const rect = trigger.getBoundingClientRect();\n const popRect = popover.getBoundingClientRect();\n const gap = 10;\n let top, left;\n\n switch (placement) {\n case 'top':\n top = rect.top - popRect.height - gap + window.scrollY;\n left = rect.left + (rect.width - popRect.width) / 2 + window.scrollX;\n break;\n case 'left':\n top = rect.top + (rect.height - popRect.height) / 2 + window.scrollY;\n left = rect.left - popRect.width - gap + window.scrollX;\n break;\n case 'right':\n top = rect.top + (rect.height - popRect.height) / 2 + window.scrollY;\n left = rect.right + gap + window.scrollX;\n break;\n default: // bottom\n top = rect.bottom + gap + window.scrollY;\n left = rect.left + (rect.width - popRect.width) / 2 + window.scrollX;\n }\n\n // Clamp to viewport\n left = Math.max(8, Math.min(left, window.innerWidth - popRect.width - 8));\n top = Math.max(8, top);\n\n popover.style.top = top + 'px';\n popover.style.left = left + 'px';\n },\n\n show: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n const { popover, placement } = instance;\n\n popover.style.display = 'block';\n popover.classList.add('is-visible');\n trigger.setAttribute('aria-expanded', 'true');\n\n requestAnimationFrame(() => {\n this.position(trigger, popover, placement);\n });\n\n trigger.dispatchEvent(new CustomEvent('bubble:show', {\n bubbles: true,\n detail: { trigger: trigger, placement: placement }\n }));\n },\n\n hide: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n instance.popover.classList.remove('is-visible');\n trigger.setAttribute('aria-expanded', 'false');\n trigger.dispatchEvent(new CustomEvent('bubble:hide', {\n bubbles: true,\n detail: { trigger: trigger }\n }));\n },\n\n hideAll: function () {\n this.instances.forEach((_, trigger) => this.hide(trigger));\n },\n\n destroy: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n if (instance.popover.parentNode) {\n instance.popover.parentNode.removeChild(instance.popover);\n }\n trigger.removeAttribute('aria-haspopup');\n trigger.removeAttribute('aria-expanded');\n trigger.removeAttribute('aria-controls');\n this.instances.delete(trigger);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, trigger) => this.destroy(trigger));\n this._globalCleanups.forEach(fn => fn());\n this._globalCleanups = [];\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('bubble', Bubble);\n }\n\n window.VanduoBubble = Bubble;\n\n})();\n", "/**\n * Vanduo Framework - Waypoint (Scrollspy) Component\n * Highlights navigation links based on scroll position using IntersectionObserver\n */\n\n(function () {\n 'use strict';\n\n const Waypoint = {\n instances: new Map(),\n\n init: function () {\n const navs = document.querySelectorAll('[data-vd-waypoint-nav], [data-vd-scrollspy-nav]');\n navs.forEach(nav => {\n if (this.instances.has(nav)) return;\n this.initInstance(nav);\n });\n },\n\n initInstance: function (nav) {\n const links = Array.from(nav.querySelectorAll('a[href^=\"#\"]'));\n if (links.length === 0) return;\n\n const cleanup = [];\n const offset = parseInt(nav.getAttribute('data-vd-waypoint-offset') || '80', 10);\n const sections = [];\n\n links.forEach(link => {\n const id = link.getAttribute('href').slice(1);\n const section = document.getElementById(id);\n if (section) {\n section.setAttribute('data-vd-waypoint-section', '');\n sections.push({ id, link, section });\n }\n });\n\n if (sections.length === 0) return;\n\n const activeSections = new Set();\n\n const setActive = (id) => {\n links.forEach(l => l.classList.remove('is-active'));\n const target = links.find(l => l.getAttribute('href') === '#' + id);\n if (target) {\n target.classList.add('is-active');\n nav.dispatchEvent(new CustomEvent('waypoint:change', {\n detail: { activeId: id, link: target }\n }));\n }\n };\n\n const rootMargin = '-' + offset + 'px 0px -40% 0px';\n\n const observer = new IntersectionObserver((entries) => {\n entries.forEach(entry => {\n if (entry.isIntersecting) {\n activeSections.add(entry.target.id);\n } else {\n activeSections.delete(entry.target.id);\n }\n });\n\n // Pick the topmost visible section\n for (let i = 0; i < sections.length; i++) {\n if (activeSections.has(sections[i].id)) {\n setActive(sections[i].id);\n return;\n }\n }\n }, {\n rootMargin: rootMargin,\n threshold: 0\n });\n\n sections.forEach(s => observer.observe(s.section));\n\n // Smooth scroll on click\n links.forEach(link => {\n const clickHandler = (e) => {\n e.preventDefault();\n const id = link.getAttribute('href').slice(1);\n const section = document.getElementById(id);\n if (section) {\n section.scrollIntoView({ behavior: 'smooth' });\n setActive(id);\n }\n };\n link.addEventListener('click', clickHandler);\n cleanup.push(() => link.removeEventListener('click', clickHandler));\n });\n\n cleanup.push(() => observer.disconnect());\n\n this.instances.set(nav, { observer, cleanup, sections, setActive });\n },\n\n refresh: function (nav) {\n this.destroy(nav);\n this.initInstance(nav);\n },\n\n destroy: function (nav) {\n const instance = this.instances.get(nav);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(nav);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, nav) => this.destroy(nav));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('waypoint', Waypoint);\n }\n\n window.VanduoWaypoint = Waypoint;\n\n})();\n", "/**\n * Vanduo Framework - Ripple (Waves Effect) Component\n * Adds expanding circle animation on click at pointer position\n */\n\n(function () {\n 'use strict';\n\n const Ripple = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-ripple, [data-vd-ripple]');\n elements.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n\n const createWave = (e) => {\n const rect = el.getBoundingClientRect();\n const size = Math.max(rect.width, rect.height);\n const x = (e.clientX || (e.touches && e.touches[0].clientX) || rect.left + rect.width / 2) - rect.left - size / 2;\n const y = (e.clientY || (e.touches && e.touches[0].clientY) || rect.top + rect.height / 2) - rect.top - size / 2;\n\n const wave = document.createElement('span');\n wave.className = 'vd-ripple-wave';\n wave.style.width = size + 'px';\n wave.style.height = size + 'px';\n wave.style.left = x + 'px';\n wave.style.top = y + 'px';\n\n el.appendChild(wave);\n\n wave.addEventListener('animationend', () => {\n if (wave.parentNode) wave.parentNode.removeChild(wave);\n });\n };\n\n el.addEventListener('mousedown', createWave);\n el.addEventListener('touchstart', createWave, { passive: true });\n\n cleanup.push(\n () => el.removeEventListener('mousedown', createWave),\n () => el.removeEventListener('touchstart', createWave)\n );\n\n this.instances.set(el, { cleanup });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n // Remove any lingering wave elements\n el.querySelectorAll('.vd-ripple-wave').forEach(w => w.remove());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('ripple', Ripple);\n }\n\n window.VanduoRipple = Ripple;\n\n})();\n", "/**\n * Vanduo Framework - Affix (Sticky) Component\n * Uses IntersectionObserver to toggle .is-stuck within the nearest scrollable parent\n */\n\n(function () {\n 'use strict';\n\n function isScrollable(element) {\n if (!element || element === document.body) return false;\n\n const style = window.getComputedStyle(element);\n const overflowY = style.overflowY;\n const overflowX = style.overflowX;\n const canScrollY = /(auto|scroll|overlay)/.test(overflowY) && element.scrollHeight > element.clientHeight;\n const canScrollX = /(auto|scroll|overlay)/.test(overflowX) && element.scrollWidth > element.clientWidth;\n\n return canScrollY || canScrollX;\n }\n\n function getScrollParent(element) {\n let parent = element.parentElement;\n\n while (parent && parent !== document.body && parent !== document.documentElement) {\n if (isScrollable(parent)) return parent;\n parent = parent.parentElement;\n }\n\n return null;\n }\n\n const Affix = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-affix, .vd-sticky, [data-vd-affix]');\n elements.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const parsedOffset = parseInt(el.getAttribute('data-vd-affix-offset') || '0', 10);\n const offset = Number.isNaN(parsedOffset) ? 0 : parsedOffset;\n const scrollParent = getScrollParent(el);\n let isStuck = false;\n\n const sentinel = document.createElement('div');\n sentinel.style.cssText = 'display:block;height:1px;margin-bottom:-1px;visibility:hidden;pointer-events:none;';\n el.parentNode.insertBefore(sentinel, el);\n\n el.style.setProperty('--affix-top-offset', offset + 'px');\n\n function stick() {\n if (isStuck) return;\n isStuck = true;\n el.classList.add('is-stuck');\n el.dispatchEvent(new CustomEvent('affix:stuck', {\n bubbles: true,\n detail: {\n offset: offset,\n root: scrollParent || window\n }\n }));\n }\n\n function unstick() {\n if (!isStuck) return;\n isStuck = false;\n el.classList.remove('is-stuck');\n el.dispatchEvent(new CustomEvent('affix:unstuck', {\n bubbles: true,\n detail: {\n offset: offset,\n root: scrollParent || window\n }\n }));\n }\n\n const observer = new IntersectionObserver(function (entries) {\n entries.forEach(entry => {\n if (!entry.isIntersecting) {\n stick();\n } else {\n unstick();\n }\n });\n }, {\n root: scrollParent,\n rootMargin: '-' + offset + 'px 0px 0px 0px',\n threshold: 0\n });\n\n observer.observe(sentinel);\n\n cleanup.push(\n () => observer.disconnect(),\n () => { if (sentinel.parentNode) sentinel.parentNode.removeChild(sentinel); },\n () => {\n el.classList.remove('is-stuck');\n el.style.removeProperty('--affix-top-offset');\n }\n );\n\n this.instances.set(el, { cleanup, observer, sentinel, scrollParent });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n el.classList.remove('is-stuck');\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('affix', Affix);\n }\n\n window.VanduoAffix = Affix;\n\n})();\n", "/**\n * Vanduo Framework - Suggest (Autocomplete) Component\n * Dropdown suggestion list with keyboard navigation, static/async data sources\n */\n\n(function () {\n 'use strict';\n\n /**\n * Escape HTML entities to prevent XSS when inserting into innerHTML\n * @param {string} text\n * @returns {string}\n */\n function _escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Allow same-origin URLs by default, or an explicit allowlist of origins.\n * @param {string} url\n * @param {string[]} allowlist\n * @returns {boolean}\n */\n function _isSafeUrl(url, allowlist) {\n try {\n const resolved = new URL(url, window.location.href);\n if (resolved.origin === window.location.origin) return true;\n return allowlist.includes(resolved.origin);\n } catch (_e) {\n return false;\n }\n }\n\n const Suggest = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-suggest], [data-vd-autocomplete]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const minChars = parseInt(input.getAttribute('data-vd-suggest-min-chars') || '1', 10);\n const url = input.getAttribute('data-vd-suggest-url') || '';\n const allowlistAttr = input.getAttribute('data-vd-suggest-allowlist') || '';\n const allowlist = allowlistAttr\n .split(',')\n .map(value => value.trim())\n .filter(Boolean);\n const staticData = input.getAttribute('data-vd-suggest') || input.getAttribute('data-vd-autocomplete') || '';\n let items = [];\n\n try { items = JSON.parse(staticData); } catch (_e) {\n items = staticData.split(',').map(s => s.trim()).filter(Boolean);\n }\n\n // Wrap input if not already wrapped\n let wrapper = input.closest('.vd-suggest-wrapper, .vd-autocomplete-wrapper');\n if (!wrapper) {\n wrapper = document.createElement('div');\n wrapper.className = 'vd-suggest-wrapper';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n }\n\n // Create dropdown list\n const list = document.createElement('ul');\n list.className = 'vd-suggest-list';\n list.setAttribute('role', 'listbox');\n const listId = 'vd-suggest-' + Math.random().toString(36).slice(2, 9);\n list.id = listId;\n wrapper.appendChild(list);\n\n // ARIA\n input.setAttribute('role', 'combobox');\n input.setAttribute('aria-autocomplete', 'list');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('aria-controls', listId);\n input.setAttribute('autocomplete', 'off');\n\n let highlighted = -1;\n let currentItems = [];\n let debounceTimer = null;\n\n const renderItems = (filtered, query) => {\n list.innerHTML = '';\n currentItems = filtered;\n highlighted = -1;\n\n if (filtered.length === 0) {\n const empty = document.createElement('li');\n empty.className = 'vd-suggest-empty';\n empty.textContent = 'No results';\n list.appendChild(empty);\n return;\n }\n\n filtered.forEach((item, i) => {\n const li = document.createElement('li');\n li.className = 'vd-suggest-item';\n li.setAttribute('role', 'option');\n li.id = listId + '-item-' + i;\n\n const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);\n if (query) {\n // Escape HTML first to prevent XSS, then highlight matches in the safe string\n const escaped = _escapeHtml(text);\n const re = new RegExp('(' + query.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi');\n li.innerHTML = escaped.replace(re, '$1');\n } else {\n li.textContent = text;\n }\n\n li.addEventListener('click', () => selectItem(i));\n list.appendChild(li);\n });\n };\n\n const open = () => {\n list.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n };\n\n const close = () => {\n list.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n highlighted = -1;\n input.removeAttribute('aria-activedescendant');\n };\n\n const selectItem = (index) => {\n const item = currentItems[index];\n const value = typeof item === 'object' ? (item.value || item.label || String(item)) : String(item);\n input.value = value;\n close();\n input.dispatchEvent(new CustomEvent('suggest:select', {\n detail: { value, item, index },\n bubbles: true\n }));\n };\n\n const highlight = (index) => {\n const listItems = list.querySelectorAll('.vd-suggest-item');\n listItems.forEach(li => li.classList.remove('is-highlighted'));\n if (index >= 0 && index < listItems.length) {\n highlighted = index;\n listItems[index].classList.add('is-highlighted');\n input.setAttribute('aria-activedescendant', listItems[index].id);\n listItems[index].scrollIntoView({ block: 'nearest' });\n }\n };\n\n const doSearch = async (query) => {\n if (query.length < minChars) { close(); return; }\n\n let filtered;\n if (url) {\n try {\n if (!_isSafeUrl(url, allowlist)) {\n console.warn('[VanduoSuggest] Blocked non-allowlisted URL:', url);\n filtered = [];\n } else {\n const separator = url.includes('?') ? '&' : '?';\n const res = await window.fetch(url + separator + 'q=' + encodeURIComponent(query));\n filtered = await res.json();\n }\n } catch (_e) {\n filtered = [];\n }\n } else {\n const lower = query.toLowerCase();\n filtered = items.filter(item => {\n const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);\n return text.toLowerCase().includes(lower);\n });\n }\n\n renderItems(filtered, query);\n if (filtered.length > 0) open();\n else open(); // show \"no results\"\n };\n\n const inputHandler = () => {\n clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => doSearch(input.value), 200);\n };\n\n const keyHandler = (e) => {\n if (!list.classList.contains('is-open')) {\n if (e.key === 'ArrowDown') { doSearch(input.value); e.preventDefault(); }\n return;\n }\n\n const total = currentItems.length;\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n highlight(highlighted < total - 1 ? highlighted + 1 : 0);\n break;\n case 'ArrowUp':\n e.preventDefault();\n highlight(highlighted > 0 ? highlighted - 1 : total - 1);\n break;\n case 'Enter':\n e.preventDefault();\n if (highlighted >= 0) selectItem(highlighted);\n break;\n case 'Escape':\n close();\n break;\n }\n };\n\n const blurHandler = () => {\n setTimeout(close, 200);\n };\n\n input.addEventListener('input', inputHandler);\n input.addEventListener('keydown', keyHandler);\n input.addEventListener('blur', blurHandler);\n input.addEventListener('focus', () => { if (input.value.length >= minChars) doSearch(input.value); });\n\n cleanup.push(\n () => input.removeEventListener('input', inputHandler),\n () => input.removeEventListener('keydown', keyHandler),\n () => input.removeEventListener('blur', blurHandler),\n () => clearTimeout(debounceTimer),\n () => { if (list.parentNode) list.parentNode.removeChild(list); }\n );\n\n this.instances.set(input, { cleanup, list, close });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('suggest', Suggest);\n }\n\n window.VanduoSuggest = Suggest;\n\n})();\n", "/**\n * Vanduo Framework - Validate (Form Validation) Component\n * Declarative validation via data attributes with real-time and on-submit modes\n */\n\n(function () {\n 'use strict';\n\n const Validate = {\n instances: new Map(),\n\n rules: {\n required: (value) => value.trim().length > 0,\n email: (value) => /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(value),\n url: (value) => { try { new URL(value); return true; } catch (_e) { return false; } },\n number: (value) => !isNaN(parseFloat(value)) && isFinite(value),\n min: (value, param) => value.length >= parseInt(param, 10),\n max: (value, param) => value.length <= parseInt(param, 10),\n minVal: (value, param) => parseFloat(value) >= parseFloat(param),\n maxVal: (value, param) => parseFloat(value) <= parseFloat(param),\n pattern: (value, param) => {\n try {\n // Cap regex length to prevent ReDoS from excessively complex patterns\n if (param.length > 100) return false;\n return new RegExp(param).test(value);\n } catch (_e) { return false; }\n },\n match: (value, param) => {\n try {\n const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(param) : param;\n const other = document.querySelector('[name=\"' + escaped + '\"]');\n return other ? value === other.value : false;\n } catch (_e) {\n return false;\n }\n }\n },\n\n messages: {\n required: 'This field is required',\n email: 'Please enter a valid email address',\n url: 'Please enter a valid URL',\n number: 'Please enter a valid number',\n min: 'Minimum {0} characters required',\n max: 'Maximum {0} characters allowed',\n minVal: 'Value must be at least {0}',\n maxVal: 'Value must be at most {0}',\n pattern: 'Invalid format',\n match: 'Fields do not match'\n },\n\n init: function () {\n const forms = document.querySelectorAll('[data-vd-validate], .vd-validate');\n forms.forEach(form => {\n if (this.instances.has(form)) return;\n this.initInstance(form);\n });\n },\n\n initInstance: function (form) {\n const cleanup = [];\n const mode = form.getAttribute('data-vd-validate-mode') || 'blur'; // blur | input | submit\n const fields = form.querySelectorAll('[data-vd-rules]');\n\n const validateField = (field) => {\n const rulesStr = field.getAttribute('data-vd-rules') || '';\n const rules = rulesStr.split('|').map(r => r.trim()).filter(Boolean);\n const value = field.value;\n const errors = [];\n\n for (const rule of rules) {\n const [name, ...params] = rule.split(':');\n const param = params.join(':');\n const validator = this.rules[name];\n\n if (validator && !validator(value, param)) {\n const customMsg = field.getAttribute('data-vd-msg-' + name);\n let msg = customMsg || this.messages[name] || 'Invalid';\n if (param) msg = msg.replace('{0}', param);\n errors.push(msg);\n break; // one error at a time\n }\n }\n\n this.setFieldState(field, errors);\n return errors.length === 0;\n };\n\n const validateAll = () => {\n let valid = true;\n fields.forEach(field => {\n if (!validateField(field)) valid = false;\n });\n return valid;\n };\n\n // Per-field listeners\n fields.forEach(field => {\n if (mode === 'input' || mode === 'blur') {\n const eventType = mode === 'input' ? 'input' : 'blur';\n const handler = () => validateField(field);\n field.addEventListener(eventType, handler);\n cleanup.push(() => field.removeEventListener(eventType, handler));\n\n if (mode === 'blur') {\n const inputClear = () => {\n if (field.classList.contains('is-invalid') || field.classList.contains('is-valid')) {\n validateField(field);\n }\n };\n field.addEventListener('input', inputClear);\n cleanup.push(() => field.removeEventListener('input', inputClear));\n }\n }\n });\n\n // Form submit\n const submitHandler = (e) => {\n const valid = validateAll();\n if (!valid) {\n e.preventDefault();\n e.stopPropagation();\n // Focus first invalid field\n const firstInvalid = form.querySelector('.is-invalid');\n if (firstInvalid) firstInvalid.focus();\n }\n form.dispatchEvent(new CustomEvent('validate:submit', {\n detail: { valid },\n bubbles: true\n }));\n };\n\n form.addEventListener('submit', submitHandler);\n cleanup.push(() => form.removeEventListener('submit', submitHandler));\n\n this.instances.set(form, { cleanup, validateAll, validateField });\n },\n\n setFieldState: function (field, errors) {\n const wrapper = field.closest('.vd-form-group') || field.parentElement;\n let errorEl = wrapper.querySelector('.vd-validate-error');\n\n field.classList.remove('is-valid', 'is-invalid');\n\n if (errors.length > 0) {\n field.classList.add('is-invalid');\n field.setAttribute('aria-invalid', 'true');\n if (!errorEl) {\n errorEl = document.createElement('div');\n errorEl.className = 'vd-validate-error';\n errorEl.id = 'vd-err-' + Math.random().toString(36).slice(2, 9);\n errorEl.setAttribute('role', 'alert');\n wrapper.appendChild(errorEl);\n }\n errorEl.textContent = errors[0];\n errorEl.style.display = '';\n field.setAttribute('aria-describedby', errorEl.id);\n } else if (field.value.trim()) {\n field.classList.add('is-valid');\n field.removeAttribute('aria-invalid');\n if (errorEl) errorEl.style.display = 'none';\n } else {\n field.removeAttribute('aria-invalid');\n if (errorEl) errorEl.style.display = 'none';\n }\n },\n\n validateForm: function (form) {\n const instance = this.instances.get(form);\n return instance ? instance.validateAll() : false;\n },\n\n addRule: function (name, validator, message) {\n this.rules[name] = validator;\n if (message) this.messages[name] = message;\n },\n\n destroy: function (form) {\n const instance = this.instances.get(form);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(form);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, form) => this.destroy(form));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('validate', Validate);\n }\n\n window.VanduoValidate = Validate;\n\n})();\n", "/**\n * Vanduo Framework - Datepicker Component\n * Calendar popup attached to input field with month/year navigation\n */\n\n(function () {\n 'use strict';\n\n const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];\n const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];\n\n function escapeRegexChar(c) {\n return c.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n }\n\n function buildParseFormat(format) {\n let regex = '^';\n const order = [];\n let i = 0;\n while (i < format.length) {\n const slice = format.slice(i);\n if (slice.toLowerCase().startsWith('yyyy')) {\n regex += '(\\\\d{4})';\n order.push('y');\n i += 4;\n } else if (slice.toLowerCase().startsWith('mm')) {\n regex += '(\\\\d{2})';\n order.push('m');\n i += 2;\n } else if (slice.toLowerCase().startsWith('dd')) {\n regex += '(\\\\d{2})';\n order.push('d');\n i += 2;\n } else {\n regex += escapeRegexChar(format[i]);\n i++;\n }\n }\n regex += '$';\n return { regex: new RegExp(regex), order };\n }\n\n function parseDateFromFormat(value, format) {\n if (!value || !format) return null;\n const { regex, order } = buildParseFormat(format);\n const m = value.trim().match(regex);\n if (!m) return null;\n let y;\n let mo;\n let d;\n let ci = 1;\n for (let k = 0; k < order.length; k++) {\n const part = order[k];\n const v = parseInt(m[ci++], 10);\n if (Number.isNaN(v)) return null;\n if (part === 'y') y = v;\n else if (part === 'm') mo = v - 1;\n else if (part === 'd') d = v;\n }\n if (y === undefined || mo === undefined || d === undefined) return null;\n const dt = new Date(y, mo, d);\n if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== d) return null;\n return dt;\n }\n\n function formatDate(d, format) {\n const yyyy = String(d.getFullYear());\n const mm = String(d.getMonth() + 1).padStart(2, '0');\n const dd = String(d.getDate()).padStart(2, '0');\n let out = '';\n let i = 0;\n while (i < format.length) {\n const slice = format.slice(i);\n if (slice.toLowerCase().startsWith('yyyy')) {\n out += yyyy;\n i += 4;\n } else if (slice.toLowerCase().startsWith('mm')) {\n out += mm;\n i += 2;\n } else if (slice.toLowerCase().startsWith('dd')) {\n out += dd;\n i += 2;\n } else {\n out += format[i];\n i++;\n }\n }\n return out;\n }\n\n function dateKey(d) {\n return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');\n }\n\n function addDays(d, n) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n x.setDate(x.getDate() + n);\n return x;\n }\n\n function addMonthsClamped(d, n) {\n return new Date(d.getFullYear(), d.getMonth() + n, d.getDate());\n }\n\n function parseYmdLocal(ymd) {\n if (!ymd || typeof ymd !== 'string') return null;\n const m = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(ymd.trim());\n if (!m) return null;\n const y = +m[1];\n const mo = +m[2] - 1;\n const day = +m[3];\n const dt = new Date(y, mo, day);\n if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== day) return null;\n return dt;\n }\n\n function startOfWeekSunday(d) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const day = x.getDay();\n x.setDate(x.getDate() - day);\n return x;\n }\n\n function endOfWeekSunday(d) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const day = x.getDay();\n x.setDate(x.getDate() + (6 - day));\n return x;\n }\n\n const Datepicker = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-datepicker]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const format = input.getAttribute('data-vd-datepicker-format') || 'YYYY-MM-DD';\n const minStr = input.getAttribute('data-vd-datepicker-min');\n const maxStr = input.getAttribute('data-vd-datepicker-max');\n const minDate = minStr ? parseYmdLocal(minStr) : null;\n const maxDate = maxStr ? parseYmdLocal(maxStr) : null;\n\n const today = new Date();\n let viewYear = today.getFullYear();\n let viewMonth = today.getMonth();\n let selectedDate = null;\n let viewMode = 'days'; // days | months | years\n let focusedDate = null;\n /** Prevents focus() after close from immediately re-opening the popup */\n let skipNextFocusOpen = false;\n\n const isDisabled = (d) => {\n if (minDate) {\n const t = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n if (t < minDate.getTime()) return true;\n }\n if (maxDate) {\n const t = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n if (t > maxDate.getTime()) return true;\n }\n return false;\n };\n\n const ensureMonthInRange = (y, m) => {\n if (!minDate && !maxDate) return { y: y, m: m };\n const first = new Date(y, m, 1);\n const last = new Date(y, m + 1, 0);\n if (minDate && last.getTime() < minDate.getTime()) {\n return { y: minDate.getFullYear(), m: minDate.getMonth() };\n }\n if (maxDate && first.getTime() > maxDate.getTime()) {\n return { y: maxDate.getFullYear(), m: maxDate.getMonth() };\n }\n return { y: y, m: m };\n };\n\n const firstSelectableInMonth = (y, m) => {\n const last = new Date(y, m + 1, 0).getDate();\n for (let day = 1; day <= last; day++) {\n const dt = new Date(y, m, day);\n if (!isDisabled(dt)) return dt;\n }\n return new Date(y, m, 1);\n };\n\n if (input.value) {\n const trimmed = input.value.trim();\n let parsed = parseDateFromFormat(trimmed, format);\n if (!parsed) {\n const fallback = new Date(trimmed);\n if (!isNaN(fallback.getTime())) parsed = fallback;\n }\n if (parsed) {\n selectedDate = parsed;\n viewYear = parsed.getFullYear();\n viewMonth = parsed.getMonth();\n }\n }\n\n const clampedInit = ensureMonthInRange(viewYear, viewMonth);\n viewYear = clampedInit.y;\n viewMonth = clampedInit.m;\n\n // Create popup\n const popup = document.createElement('div');\n popup.className = 'vd-datepicker-popup';\n popup.setAttribute('role', 'dialog');\n popup.setAttribute('aria-label', 'Choose date');\n popup.tabIndex = -1;\n\n const wrapper = document.createElement('div');\n wrapper.className = 'vd-suggest-wrapper';\n wrapper.style.position = 'relative';\n wrapper.style.display = 'inline-block';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n wrapper.appendChild(popup);\n\n const isSameDay = (a, b) => a && b &&\n a.getFullYear() === b.getFullYear() &&\n a.getMonth() === b.getMonth() &&\n a.getDate() === b.getDate();\n\n const selectDate = (date) => {\n selectedDate = date;\n viewYear = date.getFullYear();\n viewMonth = date.getMonth();\n input.value = formatDate(date, format);\n skipNextFocusOpen = true;\n close();\n input.dispatchEvent(new CustomEvent('datepicker:select', {\n detail: { date: date, formatted: input.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n input.focus();\n };\n\n const focusFocusedDay = () => {\n if (viewMode !== 'days' || !focusedDate) return;\n const key = dateKey(focusedDate);\n const btn = popup.querySelector('[data-vd-date=\"' + key + '\"]');\n if (btn && !btn.classList.contains('is-outside') && btn.getAttribute('aria-disabled') !== 'true') {\n btn.focus();\n }\n };\n\n const skipDisabled = (d, stepDir, maxSteps) => {\n let x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const step = stepDir > 0 ? 1 : -1;\n for (let i = 0; i < maxSteps; i++) {\n if (!isDisabled(x)) return x;\n x = addDays(x, step);\n }\n return d;\n };\n\n const createDayBtn = (day, outside, date) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-day';\n btn.textContent = day;\n btn.setAttribute('role', 'gridcell');\n\n if (outside) {\n btn.classList.add('is-outside');\n btn.tabIndex = -1;\n btn.setAttribute('aria-disabled', 'true');\n return btn;\n }\n\n btn.setAttribute('data-vd-date', dateKey(date));\n\n if (date && isSameDay(date, today)) btn.classList.add('is-today');\n if (date && isSameDay(date, selectedDate)) btn.classList.add('is-selected');\n if (date && isDisabled(date)) {\n btn.classList.add('is-disabled');\n btn.setAttribute('aria-disabled', 'true');\n btn.tabIndex = -1;\n return btn;\n }\n\n if (date) {\n const isFocused = focusedDate && isSameDay(date, focusedDate);\n btn.tabIndex = isFocused ? 0 : -1;\n\n btn.addEventListener('click', () => {\n selectedDate = date;\n viewYear = date.getFullYear();\n viewMonth = date.getMonth();\n focusedDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());\n input.value = formatDate(date, format);\n skipNextFocusOpen = true;\n close();\n input.dispatchEvent(new CustomEvent('datepicker:select', {\n detail: { date: date, formatted: input.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n input.focus();\n });\n }\n\n return btn;\n };\n\n const render = () => {\n popup.innerHTML = '';\n\n // Header\n const header = document.createElement('div');\n header.className = 'vd-datepicker-header';\n\n const prevBtn = document.createElement('button');\n prevBtn.type = 'button';\n prevBtn.className = 'vd-datepicker-prev';\n prevBtn.innerHTML = '‹';\n prevBtn.setAttribute('aria-label', 'Previous');\n\n const nextBtn = document.createElement('button');\n nextBtn.type = 'button';\n nextBtn.className = 'vd-datepicker-next';\n nextBtn.innerHTML = '›';\n nextBtn.setAttribute('aria-label', 'Next');\n\n const title = document.createElement('span');\n title.className = 'vd-datepicker-title';\n\n if (viewMode === 'days') {\n title.textContent = MONTHS[viewMonth] + ' ' + viewYear;\n title.addEventListener('click', () => { viewMode = 'months'; render(); });\n prevBtn.addEventListener('click', () => { viewMonth--; if (viewMonth < 0) { viewMonth = 11; viewYear--; } render(); });\n nextBtn.addEventListener('click', () => { viewMonth++; if (viewMonth > 11) { viewMonth = 0; viewYear++; } render(); });\n } else if (viewMode === 'months') {\n title.textContent = String(viewYear);\n title.addEventListener('click', () => { viewMode = 'years'; render(); });\n prevBtn.addEventListener('click', () => { viewYear--; render(); });\n nextBtn.addEventListener('click', () => { viewYear++; render(); });\n } else {\n const decadeStart = Math.floor(viewYear / 10) * 10;\n title.textContent = decadeStart + ' - ' + (decadeStart + 9);\n prevBtn.addEventListener('click', () => { viewYear -= 10; render(); });\n nextBtn.addEventListener('click', () => { viewYear += 10; render(); });\n }\n\n header.appendChild(prevBtn);\n header.appendChild(title);\n header.appendChild(nextBtn);\n popup.appendChild(header);\n\n if (viewMode === 'days') {\n const gridWrap = document.createElement('div');\n gridWrap.className = 'vd-datepicker-grid';\n gridWrap.setAttribute('role', 'grid');\n gridWrap.setAttribute('aria-label', 'Calendar');\n\n const weekdays = document.createElement('div');\n weekdays.className = 'vd-datepicker-weekdays';\n weekdays.setAttribute('role', 'row');\n DAYS.forEach(function (d) {\n const span = document.createElement('span');\n span.setAttribute('role', 'columnheader');\n span.setAttribute('aria-label', d);\n span.textContent = d;\n weekdays.appendChild(span);\n });\n gridWrap.appendChild(weekdays);\n\n const firstDay = new Date(viewYear, viewMonth, 1).getDay();\n const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();\n const daysInPrev = new Date(viewYear, viewMonth, 0).getDate();\n\n const cells = [];\n\n for (let i = firstDay - 1; i >= 0; i--) {\n const dayNum = daysInPrev - i;\n const prevMonth = viewMonth === 0 ? 11 : viewMonth - 1;\n const prevYear = viewMonth === 0 ? viewYear - 1 : viewYear;\n const date = new Date(prevYear, prevMonth, dayNum);\n cells.push({ day: dayNum, outside: true, date: date });\n }\n\n for (let d = 1; d <= daysInMonth; d++) {\n const date = new Date(viewYear, viewMonth, d);\n cells.push({ day: d, outside: false, date: date });\n }\n\n const totalCells = firstDay + daysInMonth;\n const remaining = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7);\n for (let i = 1; i <= remaining; i++) {\n const date = new Date(viewYear, viewMonth + 1, i);\n cells.push({ day: i, outside: true, date: date });\n }\n\n for (let r = 0; r < cells.length; r += 7) {\n const row = document.createElement('div');\n row.className = 'vd-datepicker-row';\n row.setAttribute('role', 'row');\n for (let c = 0; c < 7; c++) {\n const cell = cells[r + c];\n row.appendChild(createDayBtn(cell.day, cell.outside, cell.date));\n }\n gridWrap.appendChild(row);\n }\n\n popup.appendChild(gridWrap);\n } else if (viewMode === 'months') {\n const grid = document.createElement('div');\n grid.className = 'vd-datepicker-months';\n MONTHS.forEach((name, i) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-month-btn';\n btn.textContent = name.slice(0, 3);\n if (selectedDate && selectedDate.getFullYear() === viewYear && selectedDate.getMonth() === i) {\n btn.classList.add('is-selected');\n }\n btn.addEventListener('click', () => { viewMonth = i; viewMode = 'days'; render(); });\n grid.appendChild(btn);\n });\n popup.appendChild(grid);\n } else {\n const grid = document.createElement('div');\n grid.className = 'vd-datepicker-years';\n const decadeStart = Math.floor(viewYear / 10) * 10;\n for (let y = decadeStart - 1; y <= decadeStart + 10; y++) {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-year-btn';\n btn.textContent = y;\n if (selectedDate && selectedDate.getFullYear() === y) btn.classList.add('is-selected');\n if (y < decadeStart || y > decadeStart + 9) btn.style.opacity = '0.4';\n btn.addEventListener('click', () => { viewYear = y; viewMode = 'months'; render(); });\n grid.appendChild(btn);\n }\n popup.appendChild(grid);\n }\n };\n\n const handleGridKeydown = (e) => {\n if (!popup.classList.contains('is-open') || viewMode !== 'days') return;\n const grid = popup.querySelector('.vd-datepicker-grid');\n if (!grid || !grid.contains(e.target)) return;\n\n const key = e.key;\n if (key !== 'ArrowLeft' && key !== 'ArrowRight' && key !== 'ArrowUp' && key !== 'ArrowDown' &&\n key !== 'Home' && key !== 'End' && key !== 'PageUp' && key !== 'PageDown' &&\n key !== 'Enter' && key !== ' ' && key !== 'Escape') {\n return;\n }\n\n if (key === 'Escape') {\n e.preventDefault();\n e.stopPropagation();\n skipNextFocusOpen = true;\n close();\n input.focus();\n return;\n }\n\n if (!focusedDate) {\n focusedDate = firstSelectableInMonth(viewYear, viewMonth);\n }\n\n if (key === 'Enter' || key === ' ') {\n e.preventDefault();\n if (focusedDate && !isDisabled(focusedDate)) {\n selectDate(new Date(focusedDate.getFullYear(), focusedDate.getMonth(), focusedDate.getDate()));\n }\n return;\n }\n\n e.preventDefault();\n\n let next = new Date(focusedDate.getFullYear(), focusedDate.getMonth(), focusedDate.getDate());\n let skipDir = 1;\n\n if (key === 'ArrowLeft') {\n next = addDays(next, -1);\n skipDir = -1;\n } else if (key === 'ArrowRight') {\n next = addDays(next, 1);\n skipDir = 1;\n } else if (key === 'ArrowUp') {\n next = addDays(next, -7);\n skipDir = -1;\n } else if (key === 'ArrowDown') {\n next = addDays(next, 7);\n skipDir = 1;\n } else if (key === 'Home') {\n next = startOfWeekSunday(next);\n skipDir = 1;\n } else if (key === 'End') {\n next = endOfWeekSunday(next);\n skipDir = -1;\n } else if (key === 'PageUp') {\n next = addMonthsClamped(next, -1);\n skipDir = -1;\n } else if (key === 'PageDown') {\n next = addMonthsClamped(next, 1);\n skipDir = 1;\n }\n\n next = skipDisabled(next, skipDir, 400);\n\n if (next.getMonth() !== viewMonth || next.getFullYear() !== viewYear) {\n viewYear = next.getFullYear();\n viewMonth = next.getMonth();\n const cl = ensureMonthInRange(viewYear, viewMonth);\n viewYear = cl.y;\n viewMonth = cl.m;\n }\n\n focusedDate = next;\n render();\n requestAnimationFrame(focusFocusedDay);\n };\n\n const open = () => {\n viewMode = 'days';\n if (selectedDate) {\n viewYear = selectedDate.getFullYear();\n viewMonth = selectedDate.getMonth();\n }\n const cl = ensureMonthInRange(viewYear, viewMonth);\n viewYear = cl.y;\n viewMonth = cl.m;\n\n if (selectedDate) {\n focusedDate = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate());\n } else {\n focusedDate = firstSelectableInMonth(viewYear, viewMonth);\n }\n\n render();\n popup.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n requestAnimationFrame(focusFocusedDay);\n };\n\n const close = () => {\n popup.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n viewMode = 'days';\n };\n\n const focusHandler = () => {\n if (skipNextFocusOpen) {\n skipNextFocusOpen = false;\n return;\n }\n open();\n };\n const outsideHandler = (e) => {\n if (!wrapper.contains(e.target)) close();\n };\n const escHandler = (e) => {\n if (e.key === 'Escape' && popup.classList.contains('is-open')) {\n skipNextFocusOpen = true;\n close();\n input.focus();\n }\n };\n\n input.addEventListener('focus', focusHandler);\n document.addEventListener('click', outsideHandler, true);\n document.addEventListener('keydown', escHandler);\n popup.addEventListener('keydown', handleGridKeydown);\n\n input.setAttribute('aria-haspopup', 'dialog');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('autocomplete', 'off');\n\n cleanup.push(\n () => input.removeEventListener('focus', focusHandler),\n () => document.removeEventListener('click', outsideHandler, true),\n () => document.removeEventListener('keydown', escHandler),\n () => popup.removeEventListener('keydown', handleGridKeydown)\n );\n\n this.instances.set(input, { cleanup: cleanup, open: open, close: close, popup: popup });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('datepicker', Datepicker);\n }\n\n window.VanduoDatepicker = Datepicker;\n\n})();\n", "/**\n * Vanduo Framework - Timepicker Component\n * Dropdown time selection with 12h/24h format and configurable step intervals\n */\n\n(function () {\n 'use strict';\n\n const Timepicker = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-timepicker]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const is24h = input.getAttribute('data-vd-timepicker-format') === '24h';\n const step = parseInt(input.getAttribute('data-vd-timepicker-step') || '30', 10);\n\n // Create wrapper\n let wrapper = input.closest('.vd-suggest-wrapper');\n if (!wrapper) {\n wrapper = document.createElement('div');\n wrapper.style.position = 'relative';\n wrapper.style.display = 'inline-block';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n }\n\n // Create popup\n const popup = document.createElement('div');\n popup.className = 'vd-timepicker-popup';\n popup.setAttribute('role', 'listbox');\n wrapper.appendChild(popup);\n\n // Generate time slots\n const times = [];\n for (let h = 0; h < 24; h++) {\n for (let m = 0; m < 60; m += step) {\n const hh24 = String(h).padStart(2, '0');\n const mm = String(m).padStart(2, '0');\n\n if (is24h) {\n times.push({ display: hh24 + ':' + mm, value: hh24 + ':' + mm });\n } else {\n const period = h < 12 ? 'AM' : 'PM';\n const h12 = h === 0 ? 12 : (h > 12 ? h - 12 : h);\n const display = h12 + ':' + mm + ' ' + period;\n times.push({ display, value: hh24 + ':' + mm });\n }\n }\n }\n\n const render = () => {\n popup.innerHTML = '';\n times.forEach(t => {\n const item = document.createElement('div');\n item.className = 'vd-timepicker-item';\n item.setAttribute('role', 'option');\n item.textContent = t.display;\n\n if (input.value === t.value || input.value === t.display) {\n item.classList.add('is-selected');\n }\n\n item.addEventListener('click', () => {\n input.value = t.display;\n popup.querySelectorAll('.vd-timepicker-item').forEach(i => i.classList.remove('is-selected'));\n item.classList.add('is-selected');\n close();\n input.dispatchEvent(new CustomEvent('timepicker:select', {\n detail: { display: t.display, value: t.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n });\n\n popup.appendChild(item);\n });\n };\n\n const open = () => {\n render();\n popup.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n // Scroll to selected\n const selected = popup.querySelector('.is-selected');\n if (selected) selected.scrollIntoView({ block: 'center' });\n };\n\n const close = () => {\n popup.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n };\n\n const focusHandler = () => open();\n const outsideHandler = (e) => {\n if (!wrapper.contains(e.target)) close();\n };\n const escHandler = (e) => { if (e.key === 'Escape') close(); };\n\n input.addEventListener('focus', focusHandler);\n document.addEventListener('click', outsideHandler, true);\n document.addEventListener('keydown', escHandler);\n input.setAttribute('aria-haspopup', 'listbox');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('autocomplete', 'off');\n input.readOnly = true;\n\n cleanup.push(\n () => input.removeEventListener('focus', focusHandler),\n () => document.removeEventListener('click', outsideHandler, true),\n () => document.removeEventListener('keydown', escHandler)\n );\n\n this.instances.set(input, { cleanup, open, close });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('timepicker', Timepicker);\n }\n\n window.VanduoTimepicker = Timepicker;\n\n})();\n", "/**\n * Vanduo Framework - Stepper Component\n * Multi-step progress indicator with state management\n */\n\n(function () {\n 'use strict';\n\n const Stepper = {\n instances: new Map(),\n\n init: function () {\n const steppers = document.querySelectorAll('.vd-stepper');\n steppers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const items = Array.from(el.querySelectorAll('.vd-stepper-item'));\n const isClickable = el.classList.contains('vd-stepper-clickable');\n let currentIndex = items.findIndex(i => i.classList.contains('is-active'));\n if (currentIndex === -1) currentIndex = 0;\n\n const setStep = (index) => {\n if (index < 0 || index >= items.length) return;\n const prev = currentIndex;\n currentIndex = index;\n\n items.forEach((item, i) => {\n item.classList.remove('is-active', 'is-completed');\n if (i < index) item.classList.add('is-completed');\n else if (i === index) item.classList.add('is-active');\n });\n\n el.dispatchEvent(new CustomEvent('stepper:change', {\n detail: { current: index, previous: prev, total: items.length },\n bubbles: true\n }));\n };\n\n if (isClickable) {\n items.forEach((item, i) => {\n const handler = () => setStep(i);\n item.addEventListener('click', handler);\n cleanup.push(() => item.removeEventListener('click', handler));\n });\n }\n\n // Initialize current state\n setStep(currentIndex);\n\n this.instances.set(el, {\n cleanup,\n setStep,\n next: () => setStep(currentIndex + 1),\n prev: () => setStep(currentIndex - 1),\n getCurrent: () => currentIndex\n });\n },\n\n setStep: function (el, index) {\n const inst = this.instances.get(el);\n if (inst) inst.setStep(index);\n },\n\n next: function (el) {\n const inst = this.instances.get(el);\n if (inst) inst.next();\n },\n\n prev: function (el) {\n const inst = this.instances.get(el);\n if (inst) inst.prev();\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('stepper', Stepper);\n }\n\n window.VanduoStepper = Stepper;\n\n})();\n", "/**\n * Vanduo Framework - Rating Component\n * Star-based rating input with hover preview and read-only mode\n */\n\n(function () {\n 'use strict';\n\n const Rating = {\n instances: new Map(),\n\n init: function () {\n const ratings = document.querySelectorAll('[data-vd-rating]');\n ratings.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const max = parseInt(el.getAttribute('data-vd-rating-max') || '5', 10);\n const initialValue = parseFloat(el.getAttribute('data-vd-rating-value') || '0');\n const readonly = el.classList.contains('vd-rating-readonly') || el.hasAttribute('data-vd-rating-readonly');\n let currentValue = initialValue;\n\n el.classList.add('vd-rating');\n el.setAttribute('role', 'radiogroup');\n el.setAttribute('aria-label', el.getAttribute('aria-label') || 'Rating');\n\n // Clear existing stars\n el.innerHTML = '';\n\n // Create stars\n const stars = [];\n for (let i = 1; i <= max; i++) {\n const star = document.createElement('button');\n star.type = 'button';\n star.className = 'vd-rating-star';\n star.setAttribute('role', 'radio');\n star.setAttribute('aria-label', i + ' star' + (i > 1 ? 's' : ''));\n star.setAttribute('aria-checked', i <= currentValue ? 'true' : 'false');\n if (readonly) star.tabIndex = -1;\n stars.push(star);\n el.appendChild(star);\n }\n\n // Value display\n const valueDisplay = document.createElement('span');\n valueDisplay.className = 'vd-rating-value';\n valueDisplay.textContent = currentValue > 0 ? currentValue.toString() : '';\n el.appendChild(valueDisplay);\n\n const updateStars = (value) => {\n stars.forEach((star, i) => {\n star.classList.remove('is-active', 'is-half');\n const starNum = i + 1;\n if (starNum <= Math.floor(value)) {\n star.classList.add('is-active');\n } else if (starNum - 0.5 <= value) {\n star.classList.add('is-half');\n }\n star.setAttribute('aria-checked', starNum <= value ? 'true' : 'false');\n });\n valueDisplay.textContent = value > 0 ? value.toString() : '';\n };\n\n updateStars(currentValue);\n\n if (!readonly) {\n stars.forEach((star, i) => {\n const enterHandler = () => {\n stars.forEach((s, j) => {\n s.classList.toggle('is-hovered', j <= i);\n });\n };\n const leaveHandler = () => {\n stars.forEach(s => s.classList.remove('is-hovered'));\n };\n const clickHandler = () => {\n currentValue = i + 1;\n el.setAttribute('data-vd-rating-value', currentValue);\n updateStars(currentValue);\n el.dispatchEvent(new CustomEvent('rating:change', {\n detail: { value: currentValue, max },\n bubbles: true\n }));\n };\n\n star.addEventListener('mouseenter', enterHandler);\n star.addEventListener('mouseleave', leaveHandler);\n star.addEventListener('click', clickHandler);\n\n cleanup.push(\n () => star.removeEventListener('mouseenter', enterHandler),\n () => star.removeEventListener('mouseleave', leaveHandler),\n () => star.removeEventListener('click', clickHandler)\n );\n });\n\n // Keyboard\n const keyHandler = (e) => {\n if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {\n e.preventDefault();\n if (currentValue < max) {\n currentValue++;\n updateStars(currentValue);\n stars[currentValue - 1].focus();\n el.dispatchEvent(new CustomEvent('rating:change', { detail: { value: currentValue, max }, bubbles: true }));\n }\n } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {\n e.preventDefault();\n if (currentValue > 1) {\n currentValue--;\n updateStars(currentValue);\n stars[currentValue - 1].focus();\n el.dispatchEvent(new CustomEvent('rating:change', { detail: { value: currentValue, max }, bubbles: true }));\n }\n }\n };\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n }\n\n this.instances.set(el, {\n cleanup,\n getValue: () => currentValue,\n setValue: (v) => { currentValue = v; updateStars(v); }\n });\n },\n\n getValue: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getValue() : 0;\n },\n\n setValue: function (el, value) {\n const inst = this.instances.get(el);\n if (inst) inst.setValue(value);\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('rating', Rating);\n }\n\n window.VanduoRating = Rating;\n\n})();\n", "/**\n * Vanduo Framework - Transfer (Dual-list) Component\n * Source-to-target list transfer with search, select-all, and move actions\n */\n\n(function () {\n 'use strict';\n\n const Transfer = {\n instances: new Map(),\n\n init: function () {\n const transfers = document.querySelectorAll('[data-vd-transfer]');\n transfers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n el.classList.add('vd-transfer');\n\n let sourceData, targetData;\n try {\n const raw = JSON.parse(el.getAttribute('data-vd-transfer') || '[]');\n sourceData = raw.map((item, i) => ({\n id: item.id || 'item-' + i,\n label: item.label || item.text || String(item),\n selected: false\n }));\n } catch (_e) {\n sourceData = [];\n }\n targetData = [];\n\n const sourceSelected = new Set();\n const targetSelected = new Set();\n\n const render = () => {\n el.innerHTML = '';\n\n // Source panel\n const sourcePanel = createPanel('Source', sourceData, sourceSelected, 'source');\n // Actions\n const actions = document.createElement('div');\n actions.className = 'vd-transfer-actions';\n\n const moveRightBtn = document.createElement('button');\n moveRightBtn.type = 'button';\n moveRightBtn.className = 'vd-transfer-btn';\n moveRightBtn.innerHTML = '›';\n moveRightBtn.setAttribute('aria-label', 'Move to target');\n moveRightBtn.disabled = sourceSelected.size === 0;\n moveRightBtn.addEventListener('click', () => moveRight());\n\n const moveLeftBtn = document.createElement('button');\n moveLeftBtn.type = 'button';\n moveLeftBtn.className = 'vd-transfer-btn';\n moveLeftBtn.innerHTML = '‹';\n moveLeftBtn.setAttribute('aria-label', 'Move to source');\n moveLeftBtn.disabled = targetSelected.size === 0;\n moveLeftBtn.addEventListener('click', () => moveLeft());\n\n actions.appendChild(moveRightBtn);\n actions.appendChild(moveLeftBtn);\n\n // Target panel\n const targetPanel = createPanel('Target', targetData, targetSelected, 'target');\n\n el.appendChild(sourcePanel);\n el.appendChild(actions);\n el.appendChild(targetPanel);\n };\n\n const createPanel = (title, data, selected, _side) => {\n const panel = document.createElement('div');\n panel.className = 'vd-transfer-panel';\n\n const header = document.createElement('div');\n header.className = 'vd-transfer-header';\n const titleSpan = document.createElement('span');\n titleSpan.textContent = title;\n const count = document.createElement('span');\n count.className = 'vd-transfer-count';\n count.textContent = selected.size + '/' + data.length;\n header.appendChild(titleSpan);\n header.appendChild(count);\n panel.appendChild(header);\n\n // Search\n const searchDiv = document.createElement('div');\n searchDiv.className = 'vd-transfer-search';\n const searchInput = document.createElement('input');\n searchInput.type = 'text';\n searchInput.placeholder = 'Search...';\n searchInput.setAttribute('aria-label', 'Search ' + title.toLowerCase());\n searchDiv.appendChild(searchInput);\n panel.appendChild(searchDiv);\n\n // List\n const list = document.createElement('ul');\n list.className = 'vd-transfer-list';\n list.setAttribute('role', 'listbox');\n\n const renderList = (filter) => {\n list.innerHTML = '';\n const filtered = filter ? data.filter(d => {\n const label = (d.label || d.text || String(d)).toLowerCase();\n return label.includes(filter.toLowerCase());\n }) : data;\n filtered.forEach(item => {\n const li = document.createElement('li');\n li.className = 'vd-transfer-item';\n li.setAttribute('role', 'option');\n if (selected.has(item.id)) li.classList.add('is-selected');\n\n const checkbox = document.createElement('input');\n checkbox.type = 'checkbox';\n checkbox.checked = selected.has(item.id);\n checkbox.setAttribute('aria-label', item.label);\n\n const label = document.createElement('span');\n label.textContent = item.label;\n\n li.addEventListener('click', () => {\n if (selected.has(item.id)) selected.delete(item.id);\n else selected.add(item.id);\n render();\n });\n\n li.appendChild(checkbox);\n li.appendChild(label);\n list.appendChild(li);\n });\n };\n\n searchInput.addEventListener('input', () => renderList(searchInput.value));\n renderList('');\n\n panel.appendChild(list);\n return panel;\n };\n\n const moveRight = () => {\n const toMove = sourceData.filter(d => sourceSelected.has(d.id));\n sourceData = sourceData.filter(d => !sourceSelected.has(d.id));\n targetData = targetData.concat(toMove);\n sourceSelected.clear();\n render();\n fireChange();\n };\n\n const moveLeft = () => {\n const toMove = targetData.filter(d => targetSelected.has(d.id));\n targetData = targetData.filter(d => !targetSelected.has(d.id));\n sourceData = sourceData.concat(toMove);\n targetSelected.clear();\n render();\n fireChange();\n };\n\n const fireChange = () => {\n el.dispatchEvent(new CustomEvent('transfer:change', {\n detail: {\n source: sourceData.map(d => d.id),\n target: targetData.map(d => d.id)\n },\n bubbles: true\n }));\n };\n\n render();\n\n this.instances.set(el, {\n cleanup,\n getTarget: () => targetData.map(d => d.id),\n getSource: () => sourceData.map(d => d.id)\n });\n },\n\n getSelected: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getTarget() : [];\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n el.innerHTML = '';\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('transfer', Transfer);\n }\n\n window.VanduoTransfer = Transfer;\n\n})();\n", "/**\n * Vanduo Framework - Tree View Component\n * Hierarchical collapsible tree with checkbox selection and keyboard navigation\n */\n\n(function () {\n 'use strict';\n\n const Tree = {\n instances: new Map(),\n\n init: function () {\n const trees = document.querySelectorAll('[data-vd-tree]');\n trees.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const cascade = el.getAttribute('data-vd-tree-cascade') !== 'false';\n\n let data;\n try { data = JSON.parse(el.getAttribute('data-vd-tree') || '[]'); } catch (_e) { data = []; }\n\n el.classList.add('vd-tree');\n el.setAttribute('role', 'tree');\n\n const render = (items, parent) => {\n parent.innerHTML = '';\n items.forEach(item => {\n const node = document.createElement('li');\n node.className = 'vd-tree-node';\n node.setAttribute('role', 'treeitem');\n node.setAttribute('aria-expanded', item.open ? 'true' : 'false');\n if (item.open) node.classList.add('is-open');\n\n const content = document.createElement('div');\n content.className = 'vd-tree-node-content';\n\n // Toggle\n if (item.children && item.children.length > 0) {\n const toggle = document.createElement('button');\n toggle.type = 'button';\n toggle.className = 'vd-tree-toggle';\n toggle.setAttribute('aria-label', 'Toggle');\n toggle.addEventListener('click', (e) => {\n e.stopPropagation();\n item.open = !item.open;\n node.classList.toggle('is-open');\n node.setAttribute('aria-expanded', item.open ? 'true' : 'false');\n });\n content.appendChild(toggle);\n } else {\n const ph = document.createElement('span');\n ph.className = 'vd-tree-toggle-placeholder';\n content.appendChild(ph);\n }\n\n // Checkbox\n if (el.hasAttribute('data-vd-tree-checkbox')) {\n const cb = document.createElement('input');\n cb.type = 'checkbox';\n cb.className = 'vd-tree-checkbox';\n cb.checked = !!item.checked;\n cb.setAttribute('aria-label', item.label);\n cb.addEventListener('change', (e) => {\n e.stopPropagation();\n item.checked = cb.checked;\n if (cascade && item.children) {\n setChildChecked(item.children, cb.checked);\n render(data, el);\n }\n el.dispatchEvent(new CustomEvent('tree:check', {\n detail: { id: item.id, checked: cb.checked, label: item.label },\n bubbles: true\n }));\n });\n content.appendChild(cb);\n }\n\n // Icon\n if (item.icon) {\n const icon = document.createElement('span');\n icon.className = 'vd-tree-icon ' + item.icon;\n content.appendChild(icon);\n }\n\n // Label\n const label = document.createElement('span');\n label.className = 'vd-tree-label';\n label.textContent = item.label || '';\n content.appendChild(label);\n\n node.appendChild(content);\n\n // Children\n if (item.children && item.children.length > 0) {\n const childList = document.createElement('ul');\n childList.className = 'vd-tree-children';\n childList.setAttribute('role', 'group');\n render(item.children, childList);\n node.appendChild(childList);\n }\n\n parent.appendChild(node);\n });\n };\n\n const setChildChecked = (items, checked) => {\n items.forEach(item => {\n item.checked = checked;\n if (item.children) setChildChecked(item.children, checked);\n });\n };\n\n // Keyboard\n const keyHandler = (e) => {\n const focused = document.activeElement;\n if (!el.contains(focused)) return;\n\n const nodes = Array.from(el.querySelectorAll('.vd-tree-node-content'));\n const idx = nodes.indexOf(focused.closest('.vd-tree-node-content'));\n if (idx === -1) return;\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n if (idx < nodes.length - 1) {\n const next = nodes[idx + 1].querySelector('.vd-tree-toggle, .vd-tree-label');\n if (next) next.focus();\n }\n break;\n case 'ArrowUp':\n e.preventDefault();\n if (idx > 0) {\n const prev = nodes[idx - 1].querySelector('.vd-tree-toggle, .vd-tree-label');\n if (prev) prev.focus();\n }\n break;\n }\n };\n\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n\n render(data, el);\n\n this.instances.set(el, {\n cleanup,\n getData: () => data,\n getChecked: () => {\n const checked = [];\n const collect = (items) => {\n items.forEach(i => {\n if (i.checked) checked.push(i.id || i.label);\n if (i.children) collect(i.children);\n });\n };\n collect(data);\n return checked;\n }\n });\n },\n\n getChecked: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getChecked() : [];\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n el.innerHTML = '';\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('tree', Tree);\n }\n\n window.VanduoTree = Tree;\n\n})();\n", "/**\n * Vanduo Framework - Spotlight (Feature Discovery) Component\n * Guided tour with overlay highlight and step-through tooltip\n */\n\n(function () {\n 'use strict';\n\n const Spotlight = {\n _active: false,\n _steps: [],\n _currentStep: 0,\n _elements: {},\n _cleanup: [],\n _boundTriggers: new WeakMap(),\n _triggerElement: null,\n\n init: function () {\n const triggers = document.querySelectorAll('[data-vd-spotlight]');\n\n triggers.forEach(trigger => {\n if (this._boundTriggers.has(trigger)) return;\n\n const clickHandler = (event) => {\n event.preventDefault();\n\n const steps = this._parseSteps(trigger.getAttribute('data-vd-spotlight'));\n if (steps.length === 0) return;\n\n this.start(steps, { trigger });\n };\n\n trigger.addEventListener('click', clickHandler);\n this._boundTriggers.set(trigger, clickHandler);\n });\n },\n\n _parseSteps: function (raw) {\n if (typeof raw !== 'string' || raw.trim() === '') return [];\n\n try {\n const parsed = JSON.parse(raw);\n return this._normalizeSteps(parsed);\n } catch (error) {\n console.error('VanduoSpotlight: invalid data-vd-spotlight payload.', error);\n return [];\n }\n },\n\n _normalizeStep: function (step) {\n if (!step || typeof step !== 'object') return null;\n\n const target = step.target;\n const hasSelectorTarget = typeof target === 'string' && target.trim() !== '';\n const hasElementTarget = typeof Element !== 'undefined' && target instanceof Element;\n\n if (!hasSelectorTarget && !hasElementTarget) return null;\n\n const title = typeof step.title === 'string' ? step.title : '';\n const description = typeof step.description === 'string'\n ? step.description\n : (typeof step.content === 'string' ? step.content : '');\n\n return {\n target,\n title,\n description\n };\n },\n\n _normalizeSteps: function (steps) {\n if (!Array.isArray(steps)) return [];\n\n return steps\n .map(step => this._normalizeStep(step))\n .filter(Boolean);\n },\n\n start: function (steps, options) {\n if (this._active) this.stop();\n\n const normalizedSteps = this._normalizeSteps(steps);\n if (normalizedSteps.length === 0) return;\n\n const startOptions = options || {};\n\n this._steps = normalizedSteps;\n this._currentStep = 0;\n this._active = true;\n this._triggerElement = startOptions.trigger || (document.activeElement instanceof HTMLElement ? document.activeElement : null);\n\n // Create overlay\n const overlay = document.createElement('div');\n overlay.className = 'vd-spotlight-overlay';\n overlay.setAttribute('aria-hidden', 'true');\n document.body.appendChild(overlay);\n\n // Create tooltip\n const tooltip = document.createElement('div');\n tooltip.className = 'vd-spotlight-tooltip';\n tooltip.setAttribute('role', 'dialog');\n tooltip.setAttribute('aria-modal', 'true');\n tooltip.tabIndex = -1;\n document.body.appendChild(tooltip);\n\n this._elements = { overlay, tooltip };\n\n // ESC to close\n const escHandler = (e) => { if (e.key === 'Escape') this.stop(); };\n document.addEventListener('keydown', escHandler);\n this._cleanup.push(() => document.removeEventListener('keydown', escHandler));\n\n // Overlay click to close\n overlay.addEventListener('click', () => this.stop());\n\n this._showStep(this._currentStep);\n },\n\n _showStep: function (index) {\n const step = this._steps[index];\n if (!step) return;\n\n const target = typeof step.target === 'string' ? document.querySelector(step.target) : step.target;\n const { tooltip } = this._elements;\n\n // Remove previous highlight\n document.querySelectorAll('.vd-spotlight-target').forEach(el => {\n el.classList.remove('vd-spotlight-target');\n });\n\n // Highlight target\n if (target) {\n target.classList.add('vd-spotlight-target');\n target.scrollIntoView({ behavior: 'smooth', block: 'center' });\n }\n\n // Build tooltip content\n const total = this._steps.length;\n tooltip.innerHTML = '';\n tooltip.removeAttribute('aria-labelledby');\n tooltip.removeAttribute('aria-describedby');\n\n if (step.title) {\n const title = document.createElement('h4');\n title.className = 'vd-spotlight-title';\n title.id = 'vd-spotlight-title-' + index + '-' + Date.now();\n title.textContent = step.title;\n tooltip.appendChild(title);\n tooltip.setAttribute('aria-labelledby', title.id);\n }\n\n if (step.description) {\n const desc = document.createElement('p');\n desc.className = 'vd-spotlight-description';\n desc.id = 'vd-spotlight-description-' + index + '-' + Date.now();\n desc.textContent = step.description;\n tooltip.appendChild(desc);\n tooltip.setAttribute('aria-describedby', desc.id);\n }\n\n // Footer\n const footer = document.createElement('div');\n footer.className = 'vd-spotlight-footer';\n footer.setAttribute('aria-label', 'Step ' + (index + 1) + ' of ' + total);\n\n const counter = document.createElement('span');\n counter.className = 'vd-spotlight-counter';\n counter.textContent = (index + 1) + ' / ' + total;\n\n const actions = document.createElement('div');\n actions.className = 'vd-spotlight-actions';\n\n if (index > 0) {\n const prevBtn = document.createElement('button');\n prevBtn.type = 'button';\n prevBtn.className = 'vd-spotlight-btn';\n prevBtn.textContent = 'Back';\n prevBtn.addEventListener('click', () => this.prev());\n actions.appendChild(prevBtn);\n }\n\n const skipBtn = document.createElement('button');\n skipBtn.type = 'button';\n skipBtn.className = 'vd-spotlight-btn';\n skipBtn.textContent = 'Skip';\n skipBtn.addEventListener('click', () => this.stop());\n actions.appendChild(skipBtn);\n\n if (index < total - 1) {\n const nextBtn = document.createElement('button');\n nextBtn.type = 'button';\n nextBtn.className = 'vd-spotlight-btn vd-spotlight-btn-primary';\n nextBtn.textContent = 'Next';\n nextBtn.addEventListener('click', () => this.next());\n actions.appendChild(nextBtn);\n } else {\n const doneBtn = document.createElement('button');\n doneBtn.type = 'button';\n doneBtn.className = 'vd-spotlight-btn vd-spotlight-btn-primary';\n doneBtn.textContent = 'Done';\n doneBtn.addEventListener('click', () => this.stop());\n actions.appendChild(doneBtn);\n }\n\n footer.appendChild(counter);\n footer.appendChild(actions);\n tooltip.appendChild(footer);\n\n // Position tooltip near target\n if (target) {\n requestAnimationFrame(() => {\n const rect = target.getBoundingClientRect();\n const tRect = tooltip.getBoundingClientRect();\n let top = rect.bottom + 12 + window.scrollY;\n let left = rect.left + (rect.width - tRect.width) / 2 + window.scrollX;\n\n // Keep in viewport\n left = Math.max(8, Math.min(left, window.innerWidth - tRect.width - 8));\n if (top + tRect.height > window.innerHeight + window.scrollY) {\n top = rect.top - tRect.height - 12 + window.scrollY;\n }\n\n tooltip.style.top = top + 'px';\n tooltip.style.left = left + 'px';\n });\n }\n\n document.dispatchEvent(new CustomEvent('spotlight:step', {\n detail: { index, step: index, total, data: step }\n }));\n },\n\n next: function () {\n if (this._currentStep < this._steps.length - 1) {\n this._currentStep++;\n this._showStep(this._currentStep);\n }\n },\n\n prev: function () {\n if (this._currentStep > 0) {\n this._currentStep--;\n this._showStep(this._currentStep);\n }\n },\n\n stop: function () {\n if (!this._active) return;\n\n const total = this._steps.length;\n const detail = {\n completedSteps: total === 0 ? 0 : Math.min(this._currentStep + 1, total),\n total,\n completed: total > 0 && this._currentStep >= total - 1\n };\n\n this._active = false;\n\n document.querySelectorAll('.vd-spotlight-target').forEach(el => {\n el.classList.remove('vd-spotlight-target');\n });\n\n if (this._elements.overlay && this._elements.overlay.parentNode) {\n this._elements.overlay.parentNode.removeChild(this._elements.overlay);\n }\n if (this._elements.tooltip && this._elements.tooltip.parentNode) {\n this._elements.tooltip.parentNode.removeChild(this._elements.tooltip);\n }\n\n this._cleanup.forEach(fn => fn());\n this._cleanup = [];\n this._elements = {};\n this._steps = [];\n this._currentStep = 0;\n\n if (this._triggerElement && this._triggerElement.isConnected && typeof this._triggerElement.focus === 'function') {\n this._triggerElement.focus();\n }\n this._triggerElement = null;\n\n document.dispatchEvent(new CustomEvent('spotlight:end', { detail }));\n },\n\n destroyAll: function () {\n this.stop();\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('spotlight', Spotlight);\n }\n\n window.VanduoSpotlight = Spotlight;\n\n})();\n", "/**\n * Vanduo Framework - Music Player Component\n * HTML5 Audio-based music player with transport controls, volume,\n * and optional shuffle, seek bar, and playlist features.\n *\n * Options (passed to MusicPlayer.init or data-music-player-options):\n * tracks {Array} - [{name, url}] \u2014 required\n * volume {number} - Initial volume 0\u20131 (default 0.5)\n * shuffle {boolean} - Shuffle on init (default false)\n * showProgress {boolean} - Show seek/progress bar (default false)\n * showPlaylist {boolean} - Show expandable playlist panel (default false)\n * autoAdvance {boolean} - Auto-play next track on end (default true)\n * glass {boolean} - Frosted-glass surface styling (default false)\n * detachable {boolean} - Show detach/attach; float above page when detached (default false)\n * floatingPosition {string|null} - After detach: 'bottom-left' or 'bottom-right' (default null = bottom-right)\n * draggable {boolean} - When detached+detachable, drag by handle (default false)\n * minimizable {boolean} - Minimize/expand control (default false)\n * startMinimized {boolean} - On first detach, start minimized (default false)\n * persistPosition {boolean} - Save floating x/y in localStorage (default false)\n * persistKey {string} - Storage key for persist (default from element id)\n *\n * Custom events (all bubble, dispatched on container):\n * musicplayer:play \u2014 playback started\n * musicplayer:pause \u2014 playback paused\n * musicplayer:trackchange \u2014 detail: { index, name, url }\n * musicplayer:volumechange \u2014 detail: { volume }\n * musicplayer:ended \u2014 last track ended (autoAdvance=false only)\n * musicplayer:detach \u2014 after floating player is created\n * musicplayer:attach \u2014 after returned to document flow\n * musicplayer:minimize \u2014 collapsed to minimal controls\n * musicplayer:expand \u2014 full controls visible again\n *\n * Programmatic API:\n * MusicPlayer.init(container?, options?)\n * MusicPlayer.play(container)\n * MusicPlayer.pause(container)\n * MusicPlayer.toggle(container)\n * MusicPlayer.next(container)\n * MusicPlayer.previous(container)\n * MusicPlayer.setVolume(container, value)\n * MusicPlayer.setTrack(container, index)\n * MusicPlayer.shuffle(container)\n * MusicPlayer.detach(container, position?)\n * MusicPlayer.attach(container)\n * MusicPlayer.minimize(container)\n * MusicPlayer.expand(container)\n * MusicPlayer.toggleMinimize(container)\n * MusicPlayer.setPosition(container, 'bottom-left'|'bottom-right'|{x,y})\n * MusicPlayer.getState(container)\n * MusicPlayer.destroy(container)\n * MusicPlayer.destroyAll()\n */\n\n(function () {\n 'use strict';\n\n /* \u2500\u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Fisher-Yates shuffle (returns new array).\n * @param {Array} arr\n * @returns {Array}\n */\n function shuffleArray(arr) {\n const shuffled = arr.slice();\n for (let i = shuffled.length - 1; i > 0; i--) {\n const j = Math.floor(Math.random() * (i + 1));\n const tmp = shuffled[i];\n shuffled[i] = shuffled[j];\n shuffled[j] = tmp;\n }\n return shuffled;\n }\n\n /**\n * Format seconds as m:ss.\n * @param {number} seconds\n * @returns {string}\n */\n function formatTime(seconds) {\n if (!isFinite(seconds) || seconds < 0) return '0:00';\n const m = Math.floor(seconds / 60);\n const s = Math.floor(seconds % 60);\n return m + ':' + (s < 10 ? '0' : '') + s;\n }\n\n /**\n * Set CSS background-size on a range input to visually fill the track.\n * @param {HTMLInputElement} input\n */\n /**\n * @param {string|undefined} id\n * @returns {string}\n */\n function persistStorageKey(id) {\n return 'vanduo:music-player:' + (id && id.trim() ? id.trim() : 'default') + ':pos';\n }\n\n function updateRangeFill(input) {\n const min = parseFloat(input.min) || 0;\n const max = parseFloat(input.max) || 1;\n const val = parseFloat(input.value) || 0;\n const pct = ((val - min) / (max - min)) * 100;\n input.style.setProperty('--fill', pct + '%');\n // Fallback inline gradient for browsers without ::-moz-range-progress\n input.style.backgroundImage =\n 'linear-gradient(to right, var(--music-player-track-fill, currentColor) 0%, ' +\n 'var(--music-player-track-fill, currentColor) ' + pct + '%, ' +\n 'var(--music-player-track-bg, #ccc) ' + pct + '%, ' +\n 'var(--music-player-track-bg, #ccc) 100%)';\n }\n\n /* \u2500\u2500\u2500 Phosphor icon helper (matches framework icon style) \u2500 */\n\n /**\n * Return an element (Phosphor icon).\n * @param {string} name\n * @returns {HTMLElement}\n */\n function icon(name) {\n const el = document.createElement('i');\n el.className = 'ph ph-' + name;\n el.setAttribute('aria-hidden', 'true');\n return el;\n }\n\n /* \u2500\u2500\u2500 Component \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const MusicPlayer = {\n /** @type {Map} */\n instances: new Map(),\n\n /**\n * Default options.\n */\n defaults: {\n tracks: [],\n volume: 0.5,\n shuffle: false,\n showProgress: false,\n showPlaylist: false,\n autoAdvance: true,\n glass: false,\n detachable: false,\n /** @type {null|string} */\n floatingPosition: null,\n draggable: false,\n minimizable: false,\n startMinimized: false,\n persistPosition: false,\n persistKey: '',\n },\n\n /**\n * Auto-initialize all .vd-music-player / [data-music-player] elements.\n * Options can be provided via data-music-player-options (JSON string).\n */\n init: function () {\n document.querySelectorAll('.vd-music-player, [data-music-player]').forEach((el) => {\n if (this.instances.has(el)) return;\n\n let opts = {};\n const attr = el.getAttribute('data-music-player-options');\n if (attr) {\n try { opts = JSON.parse(attr); } catch (_) { /* ignore malformed JSON */ }\n }\n this.initPlayer(el, opts);\n });\n },\n\n /**\n * Initialize a single player element.\n * @param {HTMLElement} container\n * @param {Object} [options]\n */\n initPlayer: function (container, options) {\n const opts = Object.assign({}, this.defaults, options || {});\n\n // Validate and normalise tracks\n const rawTracks = Array.isArray(opts.tracks) ? opts.tracks : [];\n const tracks = rawTracks.filter((t) => t && typeof t.url === 'string' && t.url.trim());\n\n // Build shuffled working copy without mutating opts\n const trackList = opts.shuffle ? shuffleArray(tracks) : tracks.slice();\n\n /* \u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const state = {\n tracks: trackList,\n originalTracks: tracks.slice(),\n currentIndex: 0,\n isPlaying: false,\n volume: Math.max(0, Math.min(1, opts.volume)),\n shuffle: opts.shuffle,\n showProgress: opts.showProgress,\n showPlaylist: opts.showPlaylist,\n autoAdvance: opts.autoAdvance,\n audio: null,\n glass: Boolean(opts.glass),\n detachable: Boolean(opts.detachable),\n floatingPosition: opts.floatingPosition || 'bottom-right',\n draggable: Boolean(opts.draggable) && Boolean(opts.detachable),\n minimizable: Boolean(opts.minimizable),\n startMinimized: Boolean(opts.startMinimized),\n persistPosition: Boolean(opts.persistPosition),\n persistKey: typeof opts.persistKey === 'string' ? opts.persistKey : '',\n isDetached: false,\n isMinimized: false,\n _startMinimizeApplied: false,\n };\n\n /* \u2500\u2500 Audio element \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const audio = new Audio();\n audio.volume = state.volume;\n audio.preload = 'metadata';\n state.audio = audio;\n\n /* \u2500\u2500 Build DOM \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n this._buildDOM(container, state);\n\n // Grab references after DOM build\n const refs = {\n btnPlay: container.querySelector('.vd-music-player-btn-play'),\n btnPrev: container.querySelector('.vd-music-player-btn-prev'),\n btnNext: container.querySelector('.vd-music-player-btn-next'),\n btnShuffle: container.querySelector('.vd-music-player-btn-shuffle'),\n btnPlaylist: container.querySelector('.vd-music-player-btn-playlist'),\n btnDetach: container.querySelector('.vd-music-player-btn-detach'),\n btnAttach: container.querySelector('.vd-music-player-btn-attach'),\n btnMinimize: container.querySelector('.vd-music-player-btn-minimize'),\n dragHandle: container.querySelector('.vd-music-player-drag-handle'),\n trackName: container.querySelector('.vd-music-player-track-name'),\n volumeSlider: container.querySelector('.vd-music-player-volume-slider'),\n volumeIcon: container.querySelector('.vd-music-player-volume-icon'),\n progressBar: container.querySelector('.vd-music-player-progress-bar'),\n timeElapsed: container.querySelector('.vd-music-player-time-elapsed'),\n timeDuration: container.querySelector('.vd-music-player-time-duration'),\n playlistPanel: container.querySelector('.vd-music-player-playlist'),\n };\n\n /* \u2500\u2500 Internal render helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const renderPlayIcon = () => {\n const btn = refs.btnPlay;\n if (!btn) return;\n btn.innerHTML = '';\n btn.appendChild(icon(state.isPlaying ? 'pause' : 'play'));\n btn.setAttribute('aria-label', state.isPlaying ? 'Pause' : 'Play');\n btn.classList.toggle('is-active', state.isPlaying);\n };\n\n const renderTrackName = () => {\n const el = refs.trackName;\n if (!el) return;\n const track = state.tracks[state.currentIndex];\n if (track) {\n el.textContent = track.name || 'Unknown Track';\n el.classList.remove('is-idle');\n } else {\n el.textContent = 'No tracks loaded';\n el.classList.add('is-idle');\n }\n };\n\n const renderVolumeIcon = () => {\n const el = refs.volumeIcon;\n if (!el) return;\n el.innerHTML = '';\n const v = state.volume;\n const name = v === 0 ? 'speaker-none' : v < 0.5 ? 'speaker-low' : 'speaker-high';\n el.appendChild(icon(name));\n };\n\n const renderShuffleBtn = () => {\n const btn = refs.btnShuffle;\n if (!btn) return;\n btn.classList.toggle('is-active', state.shuffle);\n btn.setAttribute('aria-pressed', state.shuffle ? 'true' : 'false');\n };\n\n const renderPlaylistItems = () => {\n const panel = refs.playlistPanel;\n if (!panel) return;\n panel.innerHTML = '';\n state.tracks.forEach((track, i) => {\n const item = document.createElement('button');\n item.className =\n 'vd-music-player-playlist-item' + (i === state.currentIndex ? ' is-active' : '');\n item.type = 'button';\n item.setAttribute('data-index', String(i));\n item.setAttribute('aria-current', i === state.currentIndex ? 'true' : 'false');\n\n const num = document.createElement('span');\n num.className = 'vd-music-player-playlist-num';\n num.textContent = String(i + 1);\n\n const name = document.createElement('span');\n name.className = 'vd-music-player-playlist-name';\n name.textContent = track.name || 'Track ' + (i + 1);\n\n item.appendChild(num);\n item.appendChild(name);\n panel.appendChild(item);\n });\n };\n\n const renderProgress = () => {\n const bar = refs.progressBar;\n if (!bar || !audio.duration) return;\n const pct = (audio.currentTime / audio.duration) * 100;\n bar.value = String(pct);\n updateRangeFill(bar);\n if (refs.timeElapsed) refs.timeElapsed.textContent = formatTime(audio.currentTime);\n if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);\n };\n\n /* \u2500\u2500 Load track \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const loadTrack = (index, autoPlay) => {\n const track = state.tracks[index];\n if (!track) return;\n state.currentIndex = index;\n audio.src = track.url;\n renderTrackName();\n renderPlaylistItems();\n\n // Reset progress\n if (refs.progressBar) {\n refs.progressBar.value = '0';\n updateRangeFill(refs.progressBar);\n }\n if (refs.timeElapsed) refs.timeElapsed.textContent = '0:00';\n if (refs.timeDuration) refs.timeDuration.textContent = '0:00';\n\n container.dispatchEvent(\n new CustomEvent('musicplayer:trackchange', {\n bubbles: true,\n detail: { index, name: track.name, url: track.url },\n })\n );\n\n if (autoPlay) {\n audio.play().catch(() => { /* browser may block autoplay */ });\n }\n };\n\n /* \u2500\u2500 Audio event listeners \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const cleanupFunctions = [];\n\n const onPlay = () => {\n state.isPlaying = true;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:play', { bubbles: true }));\n };\n\n const onPause = () => {\n state.isPlaying = false;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:pause', { bubbles: true }));\n };\n\n const onEnded = () => {\n if (state.autoAdvance && state.tracks.length > 1) {\n const next = (state.currentIndex + 1) % state.tracks.length;\n loadTrack(next, true);\n } else {\n state.isPlaying = false;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:ended', { bubbles: true }));\n }\n };\n\n const onTimeUpdate = () => {\n if (state.showProgress) renderProgress();\n };\n\n const onLoadedMetadata = () => {\n if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);\n if (refs.progressBar) {\n refs.progressBar.max = '100';\n updateRangeFill(refs.progressBar);\n }\n };\n\n audio.addEventListener('play', onPlay);\n audio.addEventListener('pause', onPause);\n audio.addEventListener('ended', onEnded);\n audio.addEventListener('timeupdate', onTimeUpdate);\n audio.addEventListener('loadedmetadata', onLoadedMetadata);\n cleanupFunctions.push(() => {\n audio.removeEventListener('play', onPlay);\n audio.removeEventListener('pause', onPause);\n audio.removeEventListener('ended', onEnded);\n audio.removeEventListener('timeupdate', onTimeUpdate);\n audio.removeEventListener('loadedmetadata', onLoadedMetadata);\n audio.pause();\n audio.src = '';\n });\n\n /* \u2500\u2500 Control button listeners \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n if (refs.btnPlay) {\n const handler = () => {\n if (!audio.src && state.tracks.length) loadTrack(state.currentIndex, false);\n if (state.isPlaying) {\n audio.pause();\n } else {\n audio.play().catch(() => {});\n }\n };\n refs.btnPlay.addEventListener('click', handler);\n cleanupFunctions.push(() => refs.btnPlay.removeEventListener('click', handler));\n\n // Keyboard: Space / Enter (already native for `;\n }\n\n // Generate neutral color swatches\n let neutralSwatches = '';\n for (const [key, value] of Object.entries(this.NEUTRAL_COLORS)) {\n neutralSwatches += ``;\n }\n\n // Generate radius buttons\n let radiusButtons = '';\n this.RADIUS_OPTIONS.forEach(r => {\n radiusButtons += ``;\n });\n\n // Generate font options\n let fontOptions = '';\n for (const [key, value] of Object.entries(this.FONT_OPTIONS)) {\n fontOptions += ``;\n }\n\n return `\n
    \n

    Customize Theme

    \n \n
    \n
    \n\n
    \n \n
    \n ${primarySwatches}\n
    \n
    \n
    \n \n
    \n ${neutralSwatches}\n
    \n
    \n
    \n \n
    \n ${radiusButtons}\n
    \n
    \n
    \n \n \n
    \n
    \n
    \n \n
    \n `;\n },\n\n /**\n * Bind event listeners\n */\n /**\n * Check whether the current primary color is one of the auto-defaults\n * (i.e. the user hasn't explicitly picked a non-default color).\n */\n isUsingDefaultPrimary: function () {\n return this.state.primary === this.DEFAULTS.PRIMARY_LIGHT ||\n this.state.primary === this.DEFAULTS.PRIMARY_DARK;\n },\n\n /**\n * When primary is still one of the auto-default palette keys (black/amber) but\n * localStorage was written under a different theme (or OS changed in system mode),\n * align in-memory state before applyAllPreferences runs \u2014 avoids amber+light / black+dark drift.\n */\n _normalizeDefaultPrimaryIfStaleWithStoredTheme: function () {\n if (!this.isUsingDefaultPrimary()) {\n return;\n }\n const expected = this.getDefaultPrimary(this.state.theme);\n if (this.state.primary !== expected) {\n this.state.primary = expected;\n }\n },\n\n bindEvents: function () {\n this.bindTriggerEvents();\n\n this.bindPanelEvents();\n\n // Listen for OS dark/light changes so we can swap the default primary\n if (window.matchMedia) {\n const mq = window.matchMedia('(prefers-color-scheme: dark)');\n const handler = () => {\n if (this.state.theme === 'system' && this.isUsingDefaultPrimary()) {\n const newDefault = this.getDefaultPrimary('system');\n if (newDefault !== this.state.primary) {\n this.applyPrimary(newDefault);\n this.updateUI();\n }\n }\n };\n mq.addEventListener('change', handler);\n this._cleanup.push(() => mq.removeEventListener('change', handler));\n }\n\n // Close on outside click\n this.addListener(document, 'click', (e) => {\n if (this.state.isOpen && this.elements.customizer && !this.elements.customizer.contains(e.target)) {\n this.close();\n }\n });\n\n // Close on Escape key\n this.addListener(document, 'keydown', (e) => {\n if (e.key === 'Escape' && this.state.isOpen) {\n this.close();\n }\n });\n },\n\n bindTriggerEvents: function () {\n this.elements.triggers.forEach((trigger) => {\n if (trigger.getAttribute('data-customizer-trigger-initialized') === 'true') {\n return;\n }\n this.addListener(trigger, 'click', (e) => {\n e.preventDefault();\n e.stopPropagation();\n this.elements.activeTrigger = trigger;\n this.elements.trigger = trigger;\n this.toggle();\n });\n trigger.setAttribute('data-customizer-trigger-initialized', 'true');\n });\n },\n\n /**\n * Toggle panel open/close\n */\n toggle: function () {\n if (this.state.isOpen) {\n this.close();\n } else {\n this.open();\n }\n },\n\n /**\n * Open the panel\n */\n open: function () {\n this.state.isOpen = true;\n\n // Ensure panel is positioned correctly before opening\n this.positionPanel();\n\n if (this.elements.panel) {\n this.elements.panel.classList.add('is-open');\n }\n this.elements.triggers.forEach((trigger) => trigger.setAttribute('aria-expanded', 'false'));\n if (this.elements.trigger) this.elements.trigger.setAttribute('aria-expanded', 'true');\n if (this.elements.overlay) {\n this.elements.overlay.classList.add('is-active');\n }\n\n this.dispatchEvent('panel-open', { isOpen: true });\n },\n\n /**\n * Close the panel\n */\n close: function () {\n this.state.isOpen = false;\n\n if (this.elements.panel) {\n this.elements.panel.classList.remove('is-open');\n }\n this.elements.triggers.forEach((trigger) => trigger.setAttribute('aria-expanded', 'false'));\n if (this.elements.overlay) {\n this.elements.overlay.classList.remove('is-active');\n }\n\n this.dispatchEvent('panel-close', { isOpen: false });\n },\n\n /**\n * Update UI to reflect current state\n */\n updateUI: function () {\n if (!this.elements.panel) return;\n\n // Update primary color swatches\n this.elements.panel.querySelectorAll('[data-color]').forEach(swatch => {\n swatch.classList.toggle('is-active', swatch.dataset.color === this.state.primary);\n });\n\n // Update neutral color swatches\n this.elements.panel.querySelectorAll('[data-neutral]').forEach(swatch => {\n swatch.classList.toggle('is-active', swatch.dataset.neutral === this.state.neutral);\n });\n\n // Update radius buttons\n this.elements.panel.querySelectorAll('[data-radius]').forEach(btn => {\n btn.classList.toggle('is-active', btn.dataset.radius === this.state.radius);\n });\n\n // Update font selector\n const fontSelect = this.elements.panel.querySelector('[data-customizer-font]');\n if (fontSelect) {\n fontSelect.value = this.state.font;\n }\n\n },\n\n /**\n * Reset all preferences to defaults\n */\n reset: function () {\n this.applyTheme(this.DEFAULTS.THEME);\n this.applyPrimary(this.getDefaultPrimary(this.DEFAULTS.THEME));\n this.applyNeutral(this.DEFAULTS.NEUTRAL);\n this.applyRadius(this.DEFAULTS.RADIUS);\n this.applyFont(this.DEFAULTS.FONT);\n this.updateUI();\n\n this.dispatchEvent('reset', { state: { ...this.state } });\n },\n\n /**\n * Get current state\n */\n getState: function () {\n return { ...this.state };\n },\n\n /**\n * Programmatically set preferences\n */\n setPreferences: function (prefs) {\n if (prefs.primary) this.applyPrimary(prefs.primary);\n if (prefs.neutral) this.applyNeutral(prefs.neutral);\n if (prefs.radius) this.applyRadius(prefs.radius);\n if (prefs.font) this.applyFont(prefs.font);\n if (prefs.theme) this.applyTheme(prefs.theme);\n this.updateUI();\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 destroyAll: function () {\n this._cleanup.forEach(fn => fn());\n this._cleanup = [];\n\n if (this.elements.panel) {\n this.elements.panel.removeAttribute('data-customizer-initialized');\n }\n\n this.close();\n this.isInitialized = false;\n }\n };\n\n // Register component\n if (window.Vanduo) {\n window.Vanduo.register('themeCustomizer', ThemeCustomizer);\n }\n\n // Expose globally for convenience\n window.ThemeCustomizer = ThemeCustomizer;\n})();\n", "/**\n * Vanduo Framework - Theme Switcher\n * Handles light/dark/system theme toggling and persistence\n */\n\n(function () {\n 'use strict';\n\n const ThemeSwitcher = {\n isInitialized: false,\n _mediaQuery: null,\n _onMediaChange: null,\n\n init: function () {\n this.STORAGE_KEY = 'vanduo-theme-preference';\n this.state = {\n preference: this.getPreference() // 'light', 'dark', or 'system'\n };\n\n if (this.isInitialized) {\n this.applyTheme();\n this.renderUI();\n this.updateUI();\n return;\n }\n\n this.isInitialized = true;\n\n this.applyTheme();\n this.listenForSystemChanges();\n this.renderUI();\n\n console.log('Vanduo Theme Switcher initialized');\n },\n\n getPreference: function () {\n return this.getStorageValue(this.STORAGE_KEY, 'system');\n },\n\n setPreference: function (pref) {\n this.state.preference = pref;\n this.setStorageValue(this.STORAGE_KEY, pref);\n this.applyTheme();\n \n // Coordinate with ThemeCustomizer for primary color swap if available\n // Check _isApplying flag to prevent circular updates\n if (window.ThemeCustomizer && window.ThemeCustomizer.applyTheme && !window.ThemeCustomizer._isApplying) {\n window.ThemeCustomizer.applyTheme(pref);\n }\n \n this.updateUI();\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 applyTheme: function () {\n const pref = this.state.preference;\n\n if (pref === 'system') {\n // When in system mode, we remove the data attribute to let the media query take over\n // or we can explicitly set it. Explicitly setting it ensures consistency if we rely on [data-theme]\n // But if we rely on @media in CSS, we might want to remove attributes.\n // However, our CSS strategy uses :root:not([data-theme=\"light\"]) inside media query for system dark fallback\n // which is a bit complex.\n\n // Simpler approach: \n // If preference is system, REMOVE data-theme attribute. Let CSS media queries handle it.\n document.documentElement.removeAttribute('data-theme');\n } else {\n document.documentElement.setAttribute('data-theme', pref);\n }\n },\n\n listenForSystemChanges: function () {\n if (this._mediaQuery && this._onMediaChange) {\n return;\n }\n\n this._mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n this._onMediaChange = _e => {\n if (this.state.preference === 'system') {\n // Re-apply (effectively just to ensure consistency, though removing attribute usually suffices)\n this.applyTheme();\n // Keep default primary (black/amber) aligned when OS scheme changes while in system mode\n if (window.ThemeCustomizer && typeof window.ThemeCustomizer.applyTheme === 'function' && !window.ThemeCustomizer._isApplying) {\n window.ThemeCustomizer.applyTheme('system');\n }\n }\n };\n this._mediaQuery.addEventListener('change', this._onMediaChange);\n },\n\n // Helper to facilitate UI creation if needed, though often UI is in HTML\n renderUI: function () {\n // Look for any uninitialized theme toggles\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"]');\n toggles.forEach(toggle => {\n if (toggle.getAttribute('data-theme-initialized') === 'true') {\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n }\n return;\n }\n\n // Simplified UI Binding - assumes a select or a button cycle\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n const onChange = (e) => {\n this.setPreference(e.target.value);\n };\n toggle.addEventListener('change', onChange);\n toggle._themeToggleHandler = onChange;\n } else {\n // Button implementation (cycle)\n const onClick = () => {\n const modes = ['system', 'light', 'dark'];\n const nextIndex = (modes.indexOf(this.state.preference) + 1) % modes.length;\n this.setPreference(modes[nextIndex]);\n };\n toggle.addEventListener('click', onClick);\n toggle._themeToggleHandler = onClick;\n }\n // Mark as initialized\n toggle.setAttribute('data-theme-initialized', 'true');\n });\n },\n\n updateUI: function () {\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"]');\n toggles.forEach(toggle => {\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n } else {\n // Update button text/icon if needed\n // e.g. toggle.textContent = this.state.preference;\n // For now, assume the user handles visual state or generic text\n\n // If there is an icon or text span inside, update it\n const span = toggle.querySelector('.theme-current-label');\n if (span) {\n span.textContent = this.state.preference.charAt(0).toUpperCase() + this.state.preference.slice(1);\n }\n }\n });\n },\n\n destroyAll: function () {\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"][data-theme-initialized=\"true\"]');\n toggles.forEach(toggle => {\n if (toggle._themeToggleHandler) {\n const eventName = toggle.tagName === 'SELECT' ? 'change' : 'click';\n toggle.removeEventListener(eventName, toggle._themeToggleHandler);\n delete toggle._themeToggleHandler;\n }\n toggle.removeAttribute('data-theme-initialized');\n });\n\n if (this._mediaQuery && this._onMediaChange) {\n this._mediaQuery.removeEventListener('change', this._onMediaChange);\n }\n\n this._mediaQuery = null;\n this._onMediaChange = null;\n this.isInitialized = false;\n }\n };\n\n // Register component\n if (window.Vanduo) {\n window.Vanduo.register('themeSwitcher', ThemeSwitcher);\n }\n})();\n", "/**\n * Vanduo Framework - Toast Component\n * Popup notifications for user feedback\n */\n\n(function() {\n 'use strict';\n\n /**\n * Toast Component\n */\n const Toast = {\n // Default options\n defaults: {\n position: 'top-right',\n duration: 5000,\n dismissible: true,\n showProgress: true,\n pauseOnHover: true\n },\n\n // Container cache\n containers: {},\n\n /**\n * Get or create a toast container for a position\n * @param {string} position - Container position\n * @returns {HTMLElement} Toast container element\n */\n getContainer: function(position) {\n if (this.containers[position]) {\n return this.containers[position];\n }\n\n const container = document.createElement('div');\n container.className = `vd-toast-container vd-toast-container-${position}`;\n container.setAttribute('role', 'status');\n container.setAttribute('aria-live', 'polite');\n container.setAttribute('aria-atomic', 'false');\n document.body.appendChild(container);\n this.containers[position] = container;\n\n return container;\n },\n\n /**\n * Show a toast notification\n * @param {Object|string} options - Toast options or message string\n * @param {string} [type] - Toast type (success, error, warning, info)\n * @param {number} [duration] - Auto-dismiss duration in ms\n * @returns {HTMLElement} Toast element\n */\n show: function(options, type, duration) {\n // Support simple API:\n // - Toast.show('Message', 'success', 3000)\n // - Toast.show('Message', { type: 'success', duration: 3000 })\n if (typeof options === 'string') {\n if (type && typeof type === 'object') {\n options = Object.assign({}, type, {\n message: options\n });\n } else {\n options = {\n message: options,\n type: type,\n duration: duration\n };\n }\n }\n\n const config = Object.assign({}, this.defaults, options);\n const container = this.getContainer(config.position);\n\n // Create toast element\n const toast = document.createElement('div');\n toast.className = 'vd-toast';\n\n if (config.type) {\n toast.classList.add(`vd-toast-${config.type}`);\n }\n\n if (config.solid) {\n toast.classList.add('vd-toast-solid');\n }\n\n if (config.showProgress && config.duration > 0) {\n toast.classList.add('vd-toast-with-progress');\n }\n\n // Build toast content\n let html = '';\n\n // Icon (sanitize custom icons, default icons are trusted SVG)\n if (config.icon) {\n const allowSvg = config.iconAllowSvg === true;\n const safeIcon = typeof sanitizeHtml === 'function'\n ? sanitizeHtml(config.icon, { allowSvg })\n : escapeHtml(config.icon);\n html += `${safeIcon}`;\n } else if (config.type) {\n html += `${this.getDefaultIcon(config.type)}`;\n }\n\n // Local escape helper \u2014 guarantees HTML-safe output even if the\n // global escapeHtml utility is not loaded in the current bundle.\n const _esc = typeof escapeHtml === 'function'\n ? escapeHtml\n : function (s) {\n const d = document.createElement('div');\n d.appendChild(document.createTextNode(s));\n return d.innerHTML;\n };\n\n // Content (escape text to prevent injection)\n html += '
    ';\n if (config.title) {\n html += `
    ${_esc(String(config.title))}
    `;\n }\n if (config.message) {\n html += `
    ${_esc(String(config.message))}
    `;\n }\n html += '
    ';\n\n // Close button\n if (config.dismissible) {\n html += '';\n }\n\n // Progress bar\n if (config.showProgress && config.duration > 0) {\n const safeDuration = parseInt(config.duration, 10) || 0;\n html += `
    `;\n }\n\n toast.innerHTML = html;\n\n // Add to container\n container.appendChild(toast);\n\n toast._toastCleanup = [];\n\n // Set up close button handler\n if (config.dismissible) {\n const closeBtn = toast.querySelector('.vd-toast-close');\n const onClose = () => {\n this.dismiss(toast);\n };\n closeBtn.addEventListener('click', onClose);\n toast._toastCleanup.push(() => closeBtn.removeEventListener('click', onClose));\n }\n\n // Pause on hover\n let timeoutId = null;\n let remainingTime = config.duration;\n let startTime = null;\n\n const startTimer = () => {\n if (config.duration > 0) {\n startTime = Date.now();\n timeoutId = setTimeout(() => {\n this.dismiss(toast);\n }, remainingTime);\n toast._toastTimeoutId = timeoutId;\n\n // Resume progress animation\n const progress = toast.querySelector('.vd-toast-progress');\n if (progress) {\n progress.style.animationPlayState = 'running';\n }\n }\n };\n\n const pauseTimer = () => {\n if (timeoutId) {\n clearTimeout(timeoutId);\n timeoutId = null;\n toast._toastTimeoutId = null;\n remainingTime -= Date.now() - startTime;\n\n // Pause progress animation\n const progress = toast.querySelector('.vd-toast-progress');\n if (progress) {\n progress.style.animationPlayState = 'paused';\n }\n }\n };\n\n if (config.pauseOnHover) {\n toast.addEventListener('mouseenter', pauseTimer);\n toast.addEventListener('mouseleave', startTimer);\n toast._toastCleanup.push(\n () => toast.removeEventListener('mouseenter', pauseTimer),\n () => toast.removeEventListener('mouseleave', startTimer)\n );\n }\n\n // Trigger enter animation\n requestAnimationFrame(() => {\n toast.classList.add('is-visible');\n startTimer();\n });\n\n // Store config on element for later access\n toast._toastConfig = config;\n\n // Dispatch show event\n const showEvent = new CustomEvent('toast:show', {\n bubbles: true,\n detail: { toast, config }\n });\n toast.dispatchEvent(showEvent);\n\n return toast;\n },\n\n /**\n * Dismiss a toast\n * @param {HTMLElement} toast - Toast element to dismiss\n */\n dismiss: function(toast) {\n if (!toast || toast.classList.contains('is-exiting')) return;\n\n if (toast._toastTimeoutId) {\n clearTimeout(toast._toastTimeoutId);\n toast._toastTimeoutId = null;\n }\n\n toast.classList.remove('is-visible');\n toast.classList.add('is-exiting');\n\n // Dispatch dismiss event\n const dismissEvent = new CustomEvent('toast:dismiss', {\n bubbles: true,\n detail: { toast }\n });\n toast.dispatchEvent(dismissEvent);\n\n // Remove after animation\n const handleTransitionEnd = () => {\n toast.removeEventListener('transitionend', handleTransitionEnd);\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n };\n\n toast.addEventListener('transitionend', handleTransitionEnd);\n\n // Fallback removal if transition doesn't fire\n setTimeout(() => {\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n }, 400);\n },\n\n /**\n * Destroy all toasts and containers\n */\n destroyAll: function() {\n Object.keys(this.containers).forEach(position => {\n const container = this.containers[position];\n if (!container) return;\n\n const toasts = container.querySelectorAll('.vd-toast');\n toasts.forEach(toast => {\n if (toast._toastTimeoutId) {\n clearTimeout(toast._toastTimeoutId);\n }\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n });\n\n if (container.parentElement) {\n container.parentElement.removeChild(container);\n }\n });\n\n this.containers = {};\n },\n\n /**\n * Dismiss all toasts\n * @param {string} [position] - Optional position to clear (clears all if not specified)\n */\n dismissAll: function(position) {\n if (position && this.containers[position]) {\n const toasts = this.containers[position].querySelectorAll('.vd-toast');\n toasts.forEach(toast => this.dismiss(toast));\n } else {\n Object.values(this.containers).forEach(container => {\n const toasts = container.querySelectorAll('.vd-toast');\n toasts.forEach(toast => this.dismiss(toast));\n });\n }\n },\n\n /**\n * Get default icon SVG for a type\n * @param {string} type - Toast type\n * @returns {string} SVG icon markup\n */\n getDefaultIcon: function(type) {\n const icons = {\n success: '',\n error: '',\n warning: '',\n info: ''\n };\n\n return icons[type] || '';\n },\n\n /**\n * Convenience methods for common toast types\n */\n success: function(message, options) {\n return this.show(Object.assign({ message, type: 'success' }, options));\n },\n\n error: function(message, options) {\n return this.show(Object.assign({ message, type: 'error' }, options));\n },\n\n warning: function(message, options) {\n return this.show(Object.assign({ message, type: 'warning' }, options));\n },\n\n info: function(message, options) {\n return this.show(Object.assign({ message, type: 'info' }, options));\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('toast', Toast);\n }\n\n // Also expose globally for convenience\n window.Toast = Toast;\n\n})();\n", "/**\n * Vanduo Framework - Tooltips Component\n * JavaScript functionality for tooltips\n */\n\n(function () {\n 'use strict';\n\n /**\n * Tooltips Component\n */\n const Tooltips = {\n tooltips: new Map(),\n delayTimers: new Map(),\n\n /**\n * Sanitize HTML \u2014 delegates to shared sanitizeHtml from helpers.js\n * @param {string} input\n * @param {{ allowSvg?: boolean }} [options]\n * @returns {string} sanitized HTML\n */\n sanitizeHtml: function (input, options) {\n if (typeof sanitizeHtml === 'function') {\n return sanitizeHtml(input, options);\n }\n // Fallback: content originates from developer-authored data attributes,\n // so returning it as-is is no worse than any other inline HTML.\n return input || '';\n },\n\n /**\n * Initialize tooltips\n */\n init: function () {\n const elements = document.querySelectorAll('[data-tooltip], [data-tooltip-html]');\n\n elements.forEach(element => {\n if (this.tooltips.has(element)) {\n return;\n }\n this.initTooltip(element);\n });\n },\n\n /**\n * Initialize a single tooltip\n * @param {HTMLElement} element - Element with tooltip\n */\n initTooltip: function (element) {\n const tooltip = this.createTooltip(element);\n const cleanupFunctions = [];\n\n // Show on hover/focus\n const enterHandler = () => { this.showTooltip(element, tooltip); };\n const leaveHandler = () => { this.hideTooltip(element, tooltip); };\n const focusHandler = () => { this.showTooltip(element, tooltip); };\n const blurHandler = () => { this.hideTooltip(element, tooltip); };\n\n element.addEventListener('mouseenter', enterHandler);\n element.addEventListener('mouseleave', leaveHandler);\n element.addEventListener('focus', focusHandler);\n element.addEventListener('blur', blurHandler);\n\n cleanupFunctions.push(\n () => element.removeEventListener('mouseenter', enterHandler),\n () => element.removeEventListener('mouseleave', leaveHandler),\n () => element.removeEventListener('focus', focusHandler),\n () => element.removeEventListener('blur', blurHandler)\n );\n\n this.tooltips.set(element, { tooltip, cleanup: cleanupFunctions });\n },\n\n /**\n * Create tooltip element\n * @param {HTMLElement} element - Target element\n * @returns {HTMLElement} Tooltip element\n */\n createTooltip: function (element) {\n const tooltip = document.createElement('div');\n tooltip.className = 'vd-tooltip';\n tooltip.setAttribute('role', 'tooltip');\n tooltip.setAttribute('aria-hidden', 'true');\n\n // Generate unique ID and link via aria-describedby\n const tooltipId = 'tooltip-' + Math.random().toString(36).substr(2, 9);\n tooltip.id = tooltipId;\n element.setAttribute('aria-describedby', tooltipId);\n\n // Get content\n const htmlContent = element.dataset.tooltipHtml;\n const textContent = element.dataset.tooltip;\n\n if (htmlContent) {\n const allowSvg = element.hasAttribute('data-tooltip-allow-svg');\n tooltip.innerHTML = this.sanitizeHtml(htmlContent, { allowSvg });\n tooltip.classList.add('vd-tooltip-html');\n } else if (textContent) {\n tooltip.textContent = textContent;\n }\n\n // Get placement\n const placement = element.dataset.tooltipPlacement || element.dataset.placement || 'top';\n tooltip.setAttribute('data-placement', placement);\n tooltip.classList.add(`vd-tooltip-${placement}`);\n\n // Get variant\n if (element.dataset.tooltipVariant) {\n tooltip.classList.add(`vd-tooltip-${element.dataset.tooltipVariant}`);\n }\n\n // Get size\n if (element.dataset.tooltipSize) {\n tooltip.classList.add(`vd-tooltip-${element.dataset.tooltipSize}`);\n }\n\n // Get delay\n const delay = parseInt(element.dataset.tooltipDelay) || 0;\n tooltip.dataset.delay = delay;\n\n document.body.appendChild(tooltip);\n\n return tooltip;\n },\n\n /**\n * Show tooltip\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n showTooltip: function (element, tooltip) {\n const delay = parseInt(tooltip.dataset.delay) || 0;\n\n if (delay > 0) {\n const timer = setTimeout(() => {\n this.positionTooltip(element, tooltip);\n tooltip.classList.add('is-visible');\n tooltip.setAttribute('aria-hidden', 'false');\n }, delay);\n this.delayTimers.set(element, timer);\n } else {\n this.positionTooltip(element, tooltip);\n tooltip.classList.add('is-visible');\n tooltip.setAttribute('aria-hidden', 'false');\n }\n },\n\n /**\n * Hide tooltip\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n hideTooltip: function (element, tooltip) {\n // Clear delay timer if exists\n const timer = this.delayTimers.get(element);\n if (timer) {\n clearTimeout(timer);\n this.delayTimers.delete(element);\n }\n\n tooltip.classList.remove('is-visible');\n tooltip.setAttribute('aria-hidden', 'true');\n },\n\n /**\n * Position tooltip relative to element\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n positionTooltip: function (element, tooltip) {\n const placement = tooltip.dataset.placement || 'top';\n const rect = element.getBoundingClientRect();\n const tooltipRect = tooltip.getBoundingClientRect();\n const scrollTop = window.pageYOffset || document.documentElement.scrollTop;\n const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;\n\n let top = 0;\n let left = 0;\n\n switch (placement) {\n case 'top':\n top = rect.top + scrollTop - tooltipRect.height - 8;\n left = rect.left + scrollLeft + (rect.width / 2) - (tooltipRect.width / 2);\n break;\n case 'bottom':\n top = rect.bottom + scrollTop + 8;\n left = rect.left + scrollLeft + (rect.width / 2) - (tooltipRect.width / 2);\n break;\n case 'left':\n top = rect.top + scrollTop + (rect.height / 2) - (tooltipRect.height / 2);\n left = rect.left + scrollLeft - tooltipRect.width - 8;\n break;\n case 'right':\n top = rect.top + scrollTop + (rect.height / 2) - (tooltipRect.height / 2);\n left = rect.right + scrollLeft + 8;\n break;\n }\n\n // Prevent overflow\n const viewportWidth = window.innerWidth;\n const viewportHeight = window.innerHeight;\n const padding = 8;\n\n if (left < padding) {\n left = padding;\n } else if (left + tooltipRect.width > viewportWidth - padding) {\n left = viewportWidth - tooltipRect.width - padding;\n }\n\n if (top < scrollTop + padding) {\n top = scrollTop + padding;\n } else if (top + tooltipRect.height > scrollTop + viewportHeight - padding) {\n top = scrollTop + viewportHeight - tooltipRect.height - padding;\n }\n\n // Use single style assignment with transform for better performance\n tooltip.style.cssText = `position: absolute; top: 0; left: 0; transform: translate(${left}px, ${top}px);`;\n },\n\n /**\n * Show tooltip programmatically\n * @param {HTMLElement|string} element - Target element or selector\n */\n show: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n this.showTooltip(el, tooltip);\n }\n },\n\n /**\n * Hide tooltip programmatically\n * @param {HTMLElement|string} element - Target element or selector\n */\n hide: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n this.hideTooltip(el, tooltip);\n }\n },\n\n /**\n * Update tooltip content\n * @param {HTMLElement|string} element - Target element or selector\n * @param {string} content - New content\n * @param {boolean} isHtml - Whether content is HTML\n */\n update: function (element, content, isHtml = false) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n if (isHtml) {\n const allowSvg = el.hasAttribute('data-tooltip-allow-svg');\n tooltip.innerHTML = this.sanitizeHtml(content, { allowSvg });\n tooltip.classList.add('vd-tooltip-html');\n } else {\n tooltip.textContent = content;\n tooltip.classList.remove('vd-tooltip-html');\n }\n }\n },\n\n /**\n * Destroy a tooltip instance and clean up\n * @param {HTMLElement} element - Element with tooltip\n */\n destroy: function (element) {\n const data = this.tooltips.get(element);\n if (!data) return;\n\n // Clear any pending timer\n const timer = this.delayTimers.get(element);\n if (timer) {\n clearTimeout(timer);\n this.delayTimers.delete(element);\n }\n\n data.cleanup.forEach(fn => fn());\n\n // Remove tooltip element from DOM\n if (data.tooltip && data.tooltip.parentNode) {\n data.tooltip.parentNode.removeChild(data.tooltip);\n }\n\n this.tooltips.delete(element);\n },\n\n /**\n * Destroy all tooltip instances\n */\n destroyAll: function () {\n this.tooltips.forEach((data, element) => {\n this.destroy(element);\n });\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('tooltips', Tooltips);\n }\n\n // Expose globally\n window.VanduoTooltips = Tooltips;\n\n})();\n", "/**\n * Vanduo Framework - Search Component\n * Client-side search functionality for content pages\n * \n * @example Basic usage (initialize with defaults)\n * // HTML:\n * //
    \n * // \n * //
    \n * //
    \n * \n * @example Custom configuration\n * var search = Search.create({\n * containerSelector: '.my-search',\n * contentSelector: 'article[id]',\n * titleSelector: 'h2, h3',\n * maxResults: 5,\n * onSelect: function(result) {\n * console.log('Selected:', result.title);\n * }\n * });\n * \n * @example With custom data source\n * var search = Search.create({\n * containerSelector: '.my-search',\n * data: [\n * { id: 'item1', title: 'First Item', content: 'Description...', category: 'Category A' },\n * { id: 'item2', title: 'Second Item', content: 'Description...', category: 'Category B' }\n * ]\n * });\n */\n\n(function() {\n 'use strict';\n\n /**\n * Default configuration\n */\n const DEFAULTS = {\n // Behavior\n minQueryLength: 2,\n maxResults: 10,\n debounceMs: 150,\n highlightTag: 'mark',\n keyboardShortcut: true, // Enable Cmd/Ctrl+K shortcut\n \n // Selectors (for DOM-based indexing)\n containerSelector: '.vd-doc-search',\n inputSelector: '.vd-doc-search-input',\n resultsSelector: '.vd-doc-search-results',\n contentSelector: '.doc-content section[id]',\n titleSelector: '.demo-title, h2, h3',\n navSelector: '.doc-nav-link',\n sectionSelector: '.doc-nav-section',\n \n // Content extraction\n excludeFromContent: 'pre, code, script, style',\n maxContentLength: 500,\n \n // Custom data source (alternative to DOM indexing)\n data: null,\n \n // Category icons mapping\n categoryIcons: {\n 'getting-started': 'ph-rocket-launch',\n 'core': 'ph-cube',\n 'components': 'ph-puzzle-piece',\n 'interactive': 'ph-cursor-click',\n 'data-display': 'ph-table',\n 'feedback': 'ph-bell',\n 'meta': 'ph-info',\n 'default': 'ph-file-text'\n },\n \n // Callbacks\n onSelect: null, // function(result) - called when result is selected\n onSearch: null, // function(query, results) - called after search\n onOpen: null, // function() - called when results open\n onClose: null, // function() - called when results close\n \n // Text customization\n emptyTitle: 'No results found',\n emptyText: 'Try different keywords or check spelling',\n placeholder: 'Search...'\n };\n\n /**\n * Search Component Factory\n * Creates a new search instance with the given configuration\n * \n * @param {Object} options - Configuration options\n * @returns {Object} Search instance\n */\n function createSearch(options) {\n const config = Object.assign({}, DEFAULTS, options || {});\n \n // Instance state\n const state = {\n initialized: false,\n index: [],\n results: [],\n activeIndex: -1,\n isOpen: false,\n query: '',\n container: null,\n input: null,\n resultsContainer: null,\n debounceTimer: null,\n boundHandlers: {}\n };\n\n function safeInvokeCallback(name, fn, ...args) {\n try {\n fn(...args);\n } catch (error) {\n console.warn('[Vanduo Search] Callback error in \"' + name + '\":', error);\n }\n }\n\n function setResultsHtml(html) {\n if (!state.resultsContainer) return;\n try {\n state.resultsContainer.innerHTML = html;\n } catch (error) {\n console.warn('[Vanduo Search] Failed to render results:', error);\n }\n }\n\n /**\n * Initialize the search component\n * Idempotent \u2014 safe to call more than once on the same instance.\n * Returns the instance on success, null if required DOM elements are missing.\n */\n function init() {\n if (state.initialized) {\n return instance;\n }\n\n state.container = document.querySelector(config.containerSelector);\n if (!state.container) {\n state.initialized = false;\n return null;\n }\n\n state.input = state.container.querySelector(config.inputSelector);\n state.resultsContainer = state.container.querySelector(config.resultsSelector);\n\n if (!state.input || !state.resultsContainer) {\n state.initialized = false;\n return null;\n }\n\n // Set placeholder if configured\n if (config.placeholder) {\n state.input.setAttribute('placeholder', config.placeholder);\n }\n\n // Build search index\n buildIndex();\n\n // Bind events\n bindEvents();\n\n // Set up ARIA attributes\n setupAria();\n\n state.initialized = true;\n return instance;\n }\n\n /**\n * Build search index from DOM or custom data\n */\n function buildIndex() {\n state.index = [];\n \n // Use custom data if provided\n if (config.data && Array.isArray(config.data)) {\n config.data.forEach(function(item) {\n state.index.push({\n id: item.id || slugify(item.title),\n title: item.title || '',\n category: item.category || '',\n categorySlug: slugify(item.category || ''),\n content: item.content || '',\n keywords: item.keywords || extractKeywordsFromText(item.title + ' ' + item.content),\n url: item.url || '#' + (item.id || slugify(item.title)),\n icon: item.icon || ''\n });\n });\n return;\n }\n\n // Build from DOM\n const sections = document.querySelectorAll(config.contentSelector);\n const categoryMap = buildCategoryMap();\n\n sections.forEach(function(section) {\n const id = section.id;\n if (!id) return;\n\n const titleEl = section.querySelector(config.titleSelector);\n const title = titleEl ? titleEl.textContent.replace(/v[\\d.]+/g, '').trim() : id;\n const category = categoryMap[id] || 'Documentation';\n const content = extractContent(section);\n const keywords = extractKeywords(section, title);\n const iconEl = titleEl ? titleEl.querySelector('i.ph') : null;\n let icon = '';\n if (iconEl && iconEl.classList) {\n for (let ci = 0; ci < iconEl.classList.length; ci++) {\n if (iconEl.classList[ci].indexOf('ph-') === 0) {\n icon = iconEl.classList[ci];\n break;\n }\n }\n }\n\n state.index.push({\n id: id,\n title: title,\n category: category,\n categorySlug: slugify(category),\n content: content,\n keywords: keywords,\n url: '#' + id,\n icon: icon\n });\n });\n }\n\n /**\n * Build a map of section IDs to their categories\n */\n function buildCategoryMap() {\n const map = {};\n let currentCategory = 'Documentation';\n const navItems = document.querySelectorAll(config.navSelector + ', ' + config.sectionSelector);\n\n navItems.forEach(function(item) {\n if (item.classList.contains('doc-nav-section')) {\n currentCategory = item.textContent.trim();\n } else {\n const href = item.getAttribute('href');\n if (href && href.startsWith('#')) {\n const id = href.substring(1);\n map[id] = currentCategory;\n }\n }\n });\n\n return map;\n }\n\n /**\n * Extract searchable content from a section\n */\n function extractContent(section) {\n const clone = section.cloneNode(true);\n \n const toRemove = clone.querySelectorAll(config.excludeFromContent);\n toRemove.forEach(function(el) {\n el.remove();\n });\n\n let text = clone.textContent || '';\n text = text.replace(/\\s+/g, ' ').trim();\n \n return text.substring(0, config.maxContentLength);\n }\n\n /**\n * Extract keywords from a section\n */\n function extractKeywords(section, title) {\n const keywords = [];\n \n // Add title words\n title.toLowerCase().split(/\\s+/).forEach(function(word) {\n if (word.length > 2) {\n keywords.push(word);\n }\n });\n\n // Add class names from code examples\n const codeBlocks = section.querySelectorAll('code');\n codeBlocks.forEach(function(code) {\n const text = code.textContent || '';\n const classMatches = text.match(/\\.([\\w-]+)/g);\n if (classMatches) {\n classMatches.forEach(function(match) {\n keywords.push(match.substring(1).toLowerCase());\n });\n }\n });\n\n // Add data attributes\n const dataAttrs = section.querySelectorAll('[data-tooltip], [data-modal]');\n dataAttrs.forEach(function(el) {\n const attrs = el.getAttributeNames().filter(function(name) {\n return name.startsWith('data-');\n });\n attrs.forEach(function(attr) {\n keywords.push(attr.replace('data-', ''));\n });\n });\n\n return Array.from(new Set(keywords));\n }\n\n /**\n * Extract keywords from text string\n */\n function extractKeywordsFromText(text) {\n const words = text.toLowerCase().split(/\\s+/);\n return words.filter(function(word) {\n return word.length > 2;\n });\n }\n\n /**\n * Convert string to slug\n */\n function slugify(str) {\n return str.toLowerCase().replace(/\\s+/g, '-').replace(/[^a-z0-9-]/g, '');\n }\n\n /**\n * Bind event listeners\n */\n function bindEvents() {\n // Store bound handlers for cleanup\n state.boundHandlers.handleInput = function(e) {\n handleInput(e);\n };\n state.boundHandlers.handleFocus = function() {\n if (state.query.length >= config.minQueryLength) {\n open();\n }\n };\n state.boundHandlers.handleKeydown = function(e) {\n handleKeydown(e);\n };\n state.boundHandlers.handleOutsideClick = function(e) {\n if (!state.container.contains(e.target)) {\n close();\n }\n };\n state.boundHandlers.handleGlobalKeydown = function(e) {\n if (config.keyboardShortcut && (e.metaKey || e.ctrlKey) && e.key === 'k') {\n e.preventDefault();\n state.input.focus();\n state.input.select();\n }\n };\n state.boundHandlers.handleResultClick = function(e) {\n const result = e.target.closest('.vd-doc-search-result');\n if (result) {\n const index = parseInt(result.dataset.index, 10);\n select(index);\n }\n };\n\n // Input events\n state.input.addEventListener('input', state.boundHandlers.handleInput);\n state.input.addEventListener('focus', state.boundHandlers.handleFocus);\n state.input.addEventListener('keydown', state.boundHandlers.handleKeydown);\n\n // Close on outside click\n document.addEventListener('click', state.boundHandlers.handleOutsideClick);\n\n // Global keyboard shortcut\n document.addEventListener('keydown', state.boundHandlers.handleGlobalKeydown);\n\n // Result click handling\n state.resultsContainer.addEventListener('click', state.boundHandlers.handleResultClick);\n }\n\n /**\n * Unbind event listeners\n */\n function unbindEvents() {\n if (state.input) {\n state.input.removeEventListener('input', state.boundHandlers.handleInput);\n state.input.removeEventListener('focus', state.boundHandlers.handleFocus);\n state.input.removeEventListener('keydown', state.boundHandlers.handleKeydown);\n }\n document.removeEventListener('click', state.boundHandlers.handleOutsideClick);\n document.removeEventListener('keydown', state.boundHandlers.handleGlobalKeydown);\n if (state.resultsContainer) {\n state.resultsContainer.removeEventListener('click', state.boundHandlers.handleResultClick);\n }\n }\n\n /**\n * Set up ARIA attributes\n */\n function setupAria() {\n const resultsId = state.resultsContainer.id || 'search-results-' + Math.random().toString(36).substr(2, 9);\n state.resultsContainer.id = resultsId;\n \n state.input.setAttribute('role', 'combobox');\n state.input.setAttribute('aria-autocomplete', 'list');\n state.input.setAttribute('aria-controls', resultsId);\n state.input.setAttribute('aria-expanded', 'false');\n \n state.resultsContainer.setAttribute('role', 'listbox');\n state.resultsContainer.setAttribute('aria-label', 'Search results');\n }\n\n /**\n * Handle input changes\n */\n function handleInput(e) {\n const query = e.target.value.trim();\n\n if (state.debounceTimer) {\n clearTimeout(state.debounceTimer);\n }\n\n state.debounceTimer = setTimeout(function() {\n state.query = query;\n\n if (query.length < config.minQueryLength) {\n close();\n return;\n }\n\n state.results = search(query);\n state.activeIndex = -1;\n render();\n open();\n\n // Callback\n if (typeof config.onSearch === 'function') {\n safeInvokeCallback('onSearch', config.onSearch, query, state.results);\n }\n }, config.debounceMs);\n }\n\n /**\n * Handle keyboard navigation\n */\n function handleKeydown(e) {\n if (!state.isOpen) {\n if (e.key === 'ArrowDown' && state.query.length >= config.minQueryLength) {\n e.preventDefault();\n state.results = search(state.query);\n render();\n open();\n }\n return;\n }\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n navigate(1);\n break;\n\n case 'ArrowUp':\n e.preventDefault();\n navigate(-1);\n break;\n\n case 'Enter':\n e.preventDefault();\n if (state.activeIndex >= 0) {\n select(state.activeIndex);\n } else if (state.results.length > 0) {\n select(0);\n }\n break;\n\n case 'Escape':\n e.preventDefault();\n close();\n break;\n\n case 'Tab':\n close();\n break;\n }\n }\n\n /**\n * Perform search\n */\n function search(query) {\n const terms = query.toLowerCase().split(/\\s+/).filter(function(t) {\n return t.length > 0;\n });\n const scored = [];\n\n state.index.forEach(function(entry) {\n let score = 0;\n const titleLower = entry.title.toLowerCase();\n const categoryLower = entry.category.toLowerCase();\n const contentLower = entry.content.toLowerCase();\n\n terms.forEach(function(term) {\n // Title match - highest priority\n if (titleLower.includes(term)) {\n score += 100;\n if (titleLower === term) {\n score += 50;\n } else if (titleLower.startsWith(term)) {\n score += 25;\n }\n }\n\n // Category match\n if (categoryLower.includes(term)) {\n score += 50;\n }\n\n // Keyword match\n const keywordMatch = entry.keywords.some(function(k) {\n return k.includes(term);\n });\n if (keywordMatch) {\n score += 30;\n }\n\n // Content match\n if (contentLower.includes(term)) {\n score += 10;\n }\n });\n\n if (score > 0) {\n scored.push({\n id: entry.id,\n title: entry.title,\n category: entry.category,\n categorySlug: entry.categorySlug,\n content: entry.content,\n url: entry.url,\n icon: entry.icon,\n score: score\n });\n }\n });\n\n scored.sort(function(a, b) {\n return b.score - a.score;\n });\n\n return scored.slice(0, config.maxResults);\n }\n\n /**\n * Render search results\n */\n function render() {\n if (state.results.length === 0) {\n setResultsHtml(renderEmpty());\n return;\n }\n\n let html = '
      ';\n\n state.results.forEach(function(result, index) {\n const isActive = index === state.activeIndex;\n const icon = result.icon || getCategoryIcon(result.categorySlug);\n const excerpt = getExcerpt(result.content, state.query);\n\n html += '
    • ' +\n '
      ' +\n '' +\n '
      ' +\n '
      ' +\n '
      ' + highlight(result.title, state.query) + '
      ' +\n '
      ' + escapeHtml(result.category) + '
      ' +\n '
      ' + highlight(excerpt, state.query) + '
      ' +\n '
      ' +\n '
    • ';\n });\n\n html += '
    ';\n html += renderFooter();\n\n setResultsHtml(html);\n }\n\n /**\n * Render empty state\n */\n function renderEmpty() {\n return '
    ' +\n '
    ' +\n '
    ' + escapeHtml(config.emptyTitle) + '
    ' +\n '
    ' + escapeHtml(config.emptyText) + '
    ' +\n '
    ';\n }\n\n /**\n * Render footer with keyboard hints\n */\n function renderFooter() {\n return '
    ' +\n '\u2191\u2193 to navigate' +\n '\u21B5 to select' +\n 'esc to close' +\n '
    ';\n }\n\n /**\n * Get icon for category\n */\n function getCategoryIcon(categorySlug) {\n return config.categoryIcons[categorySlug] || config.categoryIcons['default'] || 'ph-file-text';\n }\n\n /**\n * Get excerpt from content\n */\n function getExcerpt(content, query) {\n const terms = query.toLowerCase().split(/\\s+/);\n const contentLower = content.toLowerCase();\n const excerptLength = 100;\n\n let matchPos = -1;\n for (let i = 0; i < terms.length; i++) {\n const pos = contentLower.indexOf(terms[i]);\n if (pos !== -1 && (matchPos === -1 || pos < matchPos)) {\n matchPos = pos;\n }\n }\n\n if (matchPos === -1) {\n return content.substring(0, excerptLength) + '...';\n }\n\n const start = Math.max(0, matchPos - 30);\n const end = Math.min(content.length, matchPos + excerptLength);\n let excerpt = content.substring(start, end);\n\n if (start > 0) {\n excerpt = '...' + excerpt;\n }\n if (end < content.length) {\n excerpt = excerpt + '...';\n }\n\n return excerpt;\n }\n\n /**\n * Highlight matched terms in text\n */\n function highlight(text, query) {\n if (!query) return escapeHtml(text);\n\n const terms = query.toLowerCase().split(/\\s+/).filter(function(t) {\n return t.length > 0;\n });\n let escaped = escapeHtml(text);\n\n terms.forEach(function(term) {\n // Skip overly long terms to prevent ReDoS\n if (term.length > 50) return;\n const regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi');\n escaped = escaped.replace(regex, '<' + config.highlightTag + '>$1');\n });\n\n return escaped;\n }\n\n /**\n * Escape HTML entities\n */\n function escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Navigate results with keyboard\n */\n function navigate(direction) {\n let newIndex = state.activeIndex + direction;\n\n if (newIndex < 0) {\n newIndex = state.results.length - 1;\n } else if (newIndex >= state.results.length) {\n newIndex = 0;\n }\n\n setActiveIndex(newIndex);\n }\n\n /**\n * Set active result index\n */\n function setActiveIndex(index) {\n const prevActive = state.resultsContainer.querySelector('.vd-doc-search-result.is-active');\n if (prevActive) {\n prevActive.classList.remove('is-active');\n prevActive.setAttribute('aria-selected', 'false');\n }\n\n state.activeIndex = index;\n\n const newActive = state.resultsContainer.querySelector('[data-index=\"' + index + '\"]');\n if (newActive) {\n newActive.classList.add('is-active');\n newActive.setAttribute('aria-selected', 'true');\n state.input.setAttribute('aria-activedescendant', 'vd-doc-search-result-' + index);\n newActive.scrollIntoView({ block: 'nearest' });\n }\n }\n\n /**\n * Select a result\n */\n function select(index) {\n const result = state.results[index];\n if (!result) return;\n\n // Close search\n close();\n state.input.value = '';\n state.query = '';\n\n // Custom callback\n if (typeof config.onSelect === 'function') {\n safeInvokeCallback('onSelect', config.onSelect, result);\n return;\n }\n\n // Default behavior: navigate to section\n const section = document.querySelector(result.url);\n if (section) {\n section.scrollIntoView({ behavior: 'smooth', block: 'start' });\n window.history.pushState(null, '', result.url);\n updateSidebarActive(result.id);\n }\n }\n\n /**\n * Update sidebar navigation active state\n */\n function updateSidebarActive(sectionId) {\n const navLinks = document.querySelectorAll(config.navSelector);\n navLinks.forEach(function(link) {\n link.classList.remove('active');\n if (link.getAttribute('href') === '#' + sectionId) {\n link.classList.add('active');\n }\n });\n }\n\n /**\n * Open results dropdown\n */\n function open() {\n if (state.isOpen) return;\n\n state.isOpen = true;\n state.resultsContainer.classList.add('is-open');\n state.input.setAttribute('aria-expanded', 'true');\n\n if (typeof config.onOpen === 'function') {\n safeInvokeCallback('onOpen', config.onOpen);\n }\n }\n\n /**\n * Close results dropdown\n */\n function close() {\n if (!state.isOpen) return;\n\n state.isOpen = false;\n state.activeIndex = -1;\n state.resultsContainer.classList.remove('is-open');\n state.input.setAttribute('aria-expanded', 'false');\n state.input.removeAttribute('aria-activedescendant');\n\n if (typeof config.onClose === 'function') {\n safeInvokeCallback('onClose', config.onClose);\n }\n }\n\n /**\n * Destroy the component\n */\n function destroy() {\n unbindEvents();\n \n state.initialized = false;\n state.index = [];\n state.results = [];\n state.isOpen = false;\n state.query = '';\n\n if (state.debounceTimer) {\n clearTimeout(state.debounceTimer);\n }\n\n if (state.resultsContainer) {\n setResultsHtml('');\n }\n }\n\n /**\n * Rebuild the search index\n */\n function rebuild() {\n buildIndex();\n }\n\n /**\n * Update configuration\n */\n function setConfig(newConfig) {\n Object.assign(config, newConfig);\n }\n\n /**\n * Get current configuration\n */\n function getConfig() {\n return Object.assign({}, config);\n }\n\n /**\n * Get search index\n */\n function getIndex() {\n return state.index.slice();\n }\n\n // Public instance API\n const instance = {\n init: init,\n destroy: destroy,\n rebuild: rebuild,\n search: search,\n open: open,\n close: close,\n setConfig: setConfig,\n getConfig: getConfig,\n getIndex: getIndex\n };\n\n return instance;\n }\n\n /**\n * Search Component (singleton for backward compatibility)\n */\n const Search = {\n // Factory method \u2014 creates and auto-initializes a new independent instance.\n // Always returns the instance so callers retain a reference even if the\n // DOM container is not yet available (they can retry init() later).\n create: function(options) {\n const instance = createSearch(options);\n if (instance) {\n instance.init();\n }\n return instance || null;\n },\n \n // Default instance\n _instance: null,\n \n // Configuration (for default instance)\n config: Object.assign({}, DEFAULTS),\n\n /**\n * Initialize the default search instance\n */\n init: function(options) {\n if (this._instance) {\n this._instance.destroy();\n }\n \n if (options) {\n Object.assign(this.config, options);\n }\n \n this._instance = createSearch(this.config);\n return this._instance ? this._instance.init() : null;\n },\n\n /**\n * Destroy the default instance\n */\n destroy: function() {\n if (this._instance) {\n this._instance.destroy();\n this._instance = null;\n }\n },\n\n destroyAll: function() {\n this.destroy();\n },\n\n /**\n * Rebuild the default instance index\n */\n rebuild: function() {\n if (this._instance) {\n this._instance.rebuild();\n }\n },\n\n /**\n * Search using the default instance\n */\n search: function(query) {\n return this._instance ? this._instance.search(query) : [];\n },\n\n /**\n * Open the default instance\n */\n open: function() {\n if (this._instance) {\n this._instance.open();\n }\n },\n\n /**\n * Close the default instance\n */\n close: function() {\n if (this._instance) {\n this._instance.close();\n }\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('docSearch', Search);\n }\n\n // Expose globally (both names for compatibility)\n window.Search = Search;\n window.DocSearch = Search; // Backward compatibility\n window.VanduoDocSearch = Search; // New name compatibility\n\n})();\n", "/**\n * Vanduo Framework - Draggable Component\n * JavaScript functionality for draggable elements and drop zones\n */\n\n(function () {\n 'use strict';\n\n /**\n * Draggable Component\n */\n const Draggable = {\n // Store initialized draggables and their cleanup functions\n instances: new Map(),\n // Store current drag state\n currentDrag: null,\n // Store touch state\n touchState: null,\n // Feedback element\n feedbackElement: null,\n // Shared selector used by init and touch reorder\n containerSelector: '.vd-draggable-container, .vd-draggable-container-vertical',\n\n /**\n * Initialize draggable components\n */\n init: function () {\n const draggables = document.querySelectorAll('.vd-draggable, [data-draggable]');\n\n draggables.forEach(element => {\n if (this.instances.has(element)) {\n return;\n }\n this.initDraggable(element);\n });\n\n const containers = document.querySelectorAll(this.containerSelector);\n containers.forEach(container => {\n if (!this.instances.has(container)) {\n this.initContainer(container);\n }\n });\n\n const dropZones = document.querySelectorAll('.vd-drop-zone');\n dropZones.forEach(zone => {\n if (!this.instances.has(zone)) {\n this.initDropZone(zone);\n }\n });\n\n this.createFeedbackElement();\n },\n\n /**\n * Initialize a single draggable element\n * @param {HTMLElement} element - Draggable element\n */\n initDraggable: function (element) {\n const cleanupFunctions = [];\n\n // Make element draggable if not already\n if (!element.hasAttribute('draggable')) {\n element.setAttribute('draggable', 'true');\n }\n\n // Accessibility: add ARIA attributes\n if (!element.hasAttribute('tabindex')) {\n element.setAttribute('tabindex', '0');\n }\n element.setAttribute('role', 'option');\n element.setAttribute('aria-roledescription', 'draggable item');\n element.setAttribute('aria-grabbed', 'false');\n\n // Handle drag start\n const dragStartHandler = (e) => {\n this.handleDragStart(e, element);\n };\n element.addEventListener('dragstart', dragStartHandler);\n cleanupFunctions.push(() => element.removeEventListener('dragstart', dragStartHandler));\n\n // Handle drag\n const dragHandler = (e) => {\n this.handleDrag(e, element);\n };\n element.addEventListener('drag', dragHandler);\n cleanupFunctions.push(() => element.removeEventListener('drag', dragHandler));\n\n // Handle drag end\n const dragEndHandler = (e) => {\n this.handleDragEnd(e, element);\n };\n element.addEventListener('dragend', dragEndHandler);\n cleanupFunctions.push(() => element.removeEventListener('dragend', dragEndHandler));\n\n // Handle touch start (for mobile)\n const touchStartHandler = (e) => {\n this.handleTouchStart(e, element);\n };\n element.addEventListener('touchstart', touchStartHandler);\n cleanupFunctions.push(() => element.removeEventListener('touchstart', touchStartHandler));\n\n // Handle touch move (for mobile)\n // { passive: false } is required so that e.preventDefault() works\n // once the drag threshold is reached \u2014 without it, modern browsers\n // treat the listener as passive and silently ignore preventDefault().\n const touchMoveHandler = (e) => {\n this.handleTouchMove(e, element);\n };\n element.addEventListener('touchmove', touchMoveHandler, { passive: false });\n cleanupFunctions.push(() => element.removeEventListener('touchmove', touchMoveHandler));\n\n // Handle touch end (for mobile)\n const touchEndHandler = (e) => {\n this.handleTouchEnd(e, element);\n };\n element.addEventListener('touchend', touchEndHandler, { passive: false });\n cleanupFunctions.push(() => element.removeEventListener('touchend', touchEndHandler));\n\n // Handle touch cancel (for mobile)\n const touchCancelHandler = (e) => {\n this.handleTouchEnd(e, element);\n };\n element.addEventListener('touchcancel', touchCancelHandler);\n cleanupFunctions.push(() => element.removeEventListener('touchcancel', touchCancelHandler));\n\n // Keyboard navigation\n const keydownHandler = (e) => {\n this.handleKeydown(e, element);\n };\n element.addEventListener('keydown', keydownHandler);\n cleanupFunctions.push(() => element.removeEventListener('keydown', keydownHandler));\n\n this.instances.set(element, { cleanup: cleanupFunctions });\n },\n\n /**\n * Initialize a draggable container\n * @param {HTMLElement} container - Draggable container\n */\n initContainer: function (container) {\n // Accessibility: add ARIA role to container\n container.setAttribute('role', 'listbox');\n container.setAttribute('aria-label', container.getAttribute('aria-label') || 'Draggable items');\n\n const items = container.querySelectorAll('.vd-draggable-item');\n items.forEach(item => {\n if (!this.instances.has(item)) {\n this.initDraggable(item);\n }\n });\n\n const cleanupFunctions = [];\n\n // Handle drag enter\n const dragEnterHandler = (e) => {\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n };\n\n // Handle drag over for auto-sorting\n const dragOverHandler = (e) => {\n e.preventDefault(); // Necessary to allow drop\n e.dataTransfer.dropEffect = 'move';\n\n if (!this.currentDrag) return;\n const draggingEl = this.currentDrag.element;\n\n // Only reorder if dragging an item that belongs to this container (or if we support cross-container drag, but keep simple)\n if (!container.contains(draggingEl)) return;\n\n // Prevent jumps when the dragging finalizes with 0,0 coordinates near the end\n if (e.clientX === 0 && e.clientY === 0) return;\n\n this.handleReorder(container, draggingEl, e.clientX, e.clientY);\n };\n\n // Handle drop\n const dropHandler = (e) => {\n e.preventDefault(); // crucial to prevent the browser's default handling and snapping back\n };\n\n container.addEventListener('dragenter', dragEnterHandler);\n container.addEventListener('dragover', dragOverHandler);\n container.addEventListener('drop', dropHandler);\n\n cleanupFunctions.push(() => {\n container.removeEventListener('dragenter', dragEnterHandler);\n container.removeEventListener('dragover', dragOverHandler);\n container.removeEventListener('drop', dropHandler);\n });\n\n this.instances.set(container, { cleanup: cleanupFunctions });\n },\n\n /**\n * Initialize a drop zone\n * @param {HTMLElement} zone - Drop zone element\n */\n initDropZone: function (zone) {\n const cleanupFunctions = [];\n\n // Accessibility: add ARIA role to drop zone\n zone.setAttribute('role', 'region');\n zone.setAttribute('aria-dropeffect', 'move');\n if (!zone.hasAttribute('aria-label')) {\n zone.setAttribute('aria-label', 'Drop zone');\n }\n\n // Handle drag over\n const dragOverHandler = (e) => {\n e.preventDefault();\n this.handleDragOver(e, zone);\n };\n zone.addEventListener('dragover', dragOverHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragover', dragOverHandler));\n\n // Handle drag enter\n const dragEnterHandler = (e) => {\n e.preventDefault();\n this.handleDragEnter(e, zone);\n };\n zone.addEventListener('dragenter', dragEnterHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragenter', dragEnterHandler));\n\n // Handle drag leave\n const dragLeaveHandler = (e) => {\n this.handleDragLeave(e, zone);\n };\n zone.addEventListener('dragleave', dragLeaveHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragleave', dragLeaveHandler));\n\n // Handle drop\n const dropHandler = (e) => {\n e.preventDefault();\n this.handleDrop(e, zone);\n };\n zone.addEventListener('drop', dropHandler);\n cleanupFunctions.push(() => zone.removeEventListener('drop', dropHandler));\n\n this.instances.set(zone, { cleanup: cleanupFunctions });\n },\n\n /**\n * Create feedback element for drag operations\n */\n createFeedbackElement: function () {\n if (!this.feedbackElement) {\n // Reuse existing element if present\n const existing = document.querySelector('.vd-drag-feedback');\n if (existing) {\n this.feedbackElement = existing;\n return;\n }\n\n this.feedbackElement = document.createElement('div');\n this.feedbackElement.className = 'vd-drag-feedback hidden';\n this.feedbackElement.setAttribute('role', 'presentation');\n document.body.appendChild(this.feedbackElement);\n }\n },\n\n /**\n * Handle drag start event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDragStart: function (e, element) {\n // Add dragging class\n element.classList.add('is-dragging');\n\n // Accessibility: update aria-grabbed\n element.setAttribute('aria-grabbed', 'true');\n\n // Store drag state\n this.currentDrag = {\n element: element,\n initialPosition: { x: e.clientX, y: e.clientY },\n initialBounds: element.getBoundingClientRect(),\n data: this.getData(element)\n };\n\n // Set drag data\n e.dataTransfer.effectAllowed = 'move';\n e.dataTransfer.setData('text/plain', this.currentDrag.data);\n\n // We no longer suppress the native ghost image or manually update feedback\n // for mouse drags, relying on the browser's native rendering instead.\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:start', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: e.clientX, y: e.clientY }\n }\n }));\n },\n\n /**\n * Handle drag event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDrag: function (e, element) {\n // Guard against null state (race condition on fast interactions)\n if (!this.currentDrag) return;\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:drag', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: e.clientX, y: e.clientY },\n delta: {\n x: e.clientX - this.currentDrag.initialPosition.x,\n y: e.clientY - this.currentDrag.initialPosition.y\n }\n }\n }));\n },\n\n /**\n * Handle drag end event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDragEnd: function (e, element) {\n // Remove dragging class\n element.classList.remove('is-dragging');\n element.classList.add('is-dropped');\n setTimeout(() => element.classList.remove('is-dropped'), 300);\n\n // Accessibility: update aria-grabbed\n element.setAttribute('aria-grabbed', 'false');\n\n // Hide feedback\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n\n // Guard against null state\n const data = this.currentDrag?.data || this.getData(element);\n const initialPos = this.currentDrag?.initialPosition || { x: 0, y: 0 };\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:end', {\n bubbles: true,\n detail: {\n element: element,\n data: data,\n position: { x: e.clientX, y: e.clientY },\n delta: {\n x: e.clientX - initialPos.x,\n y: e.clientY - initialPos.y\n }\n }\n }));\n\n // Reset drag state\n this.currentDrag = null;\n },\n\n /**\n * Handle touch start event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchStart: function (e, element) {\n // Don't prevent default here \u2014 it blocks scrolling.\n // We only prevent default in touchmove once drag threshold is reached.\n const touch = e.touches[0];\n const rect = element.getBoundingClientRect();\n this.touchState = {\n element: element,\n startX: touch.clientX,\n startY: touch.clientY,\n lastX: touch.clientX,\n lastY: touch.clientY,\n // Keep preview anchored to the original grab point.\n offsetX: touch.clientX - rect.left,\n offsetY: touch.clientY - rect.top,\n startTime: Date.now(),\n isDragging: false\n };\n },\n\n /**\n * Handle touch move event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchMove: function (e, element) {\n if (!this.touchState) return;\n\n const touch = e.touches[0];\n this.touchState.lastX = touch.clientX;\n this.touchState.lastY = touch.clientY;\n const deltaX = touch.clientX - this.touchState.startX;\n const deltaY = touch.clientY - this.touchState.startY;\n\n // Only start dragging if moved a minimum distance\n if (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10) {\n // Now we know it's a drag, not a scroll \u2014 prevent default\n if (e.cancelable) e.preventDefault();\n\n if (!this.touchState.isDragging) {\n this.touchState.isDragging = true;\n element.classList.add('is-dragging');\n element.setAttribute('aria-grabbed', 'true');\n\n // Store drag state\n this.currentDrag = {\n element: element,\n initialPosition: { x: this.touchState.startX, y: this.touchState.startY },\n initialBounds: element.getBoundingClientRect(),\n data: this.getData(element),\n // Preserve where inside the element the drag started for accurate ghost positioning.\n offsetX: this.touchState.offsetX,\n offsetY: this.touchState.offsetY\n };\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:start', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: touch.clientX, y: touch.clientY }\n }\n }));\n }\n\n // Update feedback\n this.updateFeedback(touch.clientX, touch.clientY);\n\n // Dispatch event\n if (this.currentDrag) {\n element.dispatchEvent(new CustomEvent('draggable:drag', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: touch.clientX, y: touch.clientY },\n delta: { x: deltaX, y: deltaY }\n }\n }));\n\n this.updateTouchDropZone(touch.clientX, touch.clientY);\n\n // Reorder for touch\n const container = element.closest(this.containerSelector);\n if (container && container.contains(element)) {\n this.handleReorder(container, element, touch.clientX, touch.clientY);\n }\n }\n }\n },\n\n /**\n * Handle touch end event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchEnd: function (e, element) {\n if (this.touchState && this.touchState.isDragging) {\n if (e.cancelable) e.preventDefault();\n const endTouch = e.changedTouches?.[0];\n const endPosition = {\n x: endTouch?.clientX ?? this.touchState.lastX ?? this.touchState.startX,\n y: endTouch?.clientY ?? this.touchState.lastY ?? this.touchState.startY\n };\n\n const dropZone = this.resolveDropZoneAtPoint(endPosition.x, endPosition.y) || this.touchState.overZone;\n if (dropZone) {\n this.dispatchDrop(dropZone, endPosition);\n } else if (this.touchState.overZone) {\n this.touchState.overZone.classList.remove('is-drag-over');\n }\n\n element.classList.remove('is-dragging');\n element.classList.add('is-dropped');\n element.setAttribute('aria-grabbed', 'false');\n setTimeout(() => element.classList.remove('is-dropped'), 300);\n\n // Hide feedback\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n\n // Dispatch event\n const data = this.currentDrag?.data || this.getData(element);\n const startX = this.touchState?.startX || 0;\n const startY = this.touchState?.startY || 0;\n\n element.dispatchEvent(new CustomEvent('draggable:end', {\n bubbles: true,\n detail: {\n element: element,\n data: data,\n position: endPosition,\n delta: {\n x: endPosition.x - startX,\n y: endPosition.y - startY\n }\n }\n }));\n }\n\n // Reset states\n this.touchState = null;\n this.currentDrag = null;\n },\n\n /**\n * Handle drag over event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} _zone - Drop zone element\n */\n handleDragOver: function (e, _zone) {\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n },\n\n /**\n * Handle drag enter event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDragEnter: function (e, zone) {\n e.preventDefault();\n zone.classList.add('is-drag-over');\n },\n\n /**\n * Handle drag leave event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDragLeave: function (e, zone) {\n zone.classList.remove('is-drag-over');\n },\n\n /**\n * Handle drop event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDrop: function (e, zone) {\n e.preventDefault();\n this.dispatchDrop(zone, { x: e.clientX, y: e.clientY });\n },\n\n /**\n * Resolve a drop zone from viewport coordinates\n * @param {number} x\n * @param {number} y\n * @returns {HTMLElement|null}\n */\n resolveDropZoneAtPoint: function (x, y) {\n if (!Number.isFinite(x) || !Number.isFinite(y)) return null;\n\n // Prefer the full stacking list so overlays/top elements don't hide real drop targets.\n if (typeof document.elementsFromPoint === 'function') {\n const stacked = document.elementsFromPoint(x, y);\n for (const element of stacked) {\n const zone = element.closest('.vd-drop-zone');\n if (zone) return zone;\n }\n }\n\n const target = document.elementFromPoint(x, y);\n const targetZone = target ? target.closest('.vd-drop-zone') : null;\n if (targetZone) return targetZone;\n\n // Last-resort fallback for mobile emulation edge cases.\n const zones = document.querySelectorAll('.vd-drop-zone');\n for (const zone of zones) {\n const rect = zone.getBoundingClientRect();\n if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {\n return zone;\n }\n }\n\n return null;\n },\n\n /**\n * Track and update active drop-zone hover state on touch devices\n * @param {number} x\n * @param {number} y\n */\n updateTouchDropZone: function (x, y) {\n if (!this.touchState) return;\n\n const nextZone = this.resolveDropZoneAtPoint(x, y);\n const prevZone = this.touchState.overZone || null;\n\n if (prevZone && prevZone !== nextZone) {\n prevZone.classList.remove('is-drag-over');\n }\n\n if (nextZone && nextZone !== prevZone) {\n nextZone.classList.add('is-drag-over');\n }\n\n this.touchState.overZone = nextZone || null;\n },\n\n /**\n * Dispatch a normalized drop event for mouse and touch flows\n * @param {HTMLElement} zone\n * @param {{x:number, y:number}} position\n */\n dispatchDrop: function (zone, position) {\n zone.classList.remove('is-drag-over');\n zone.dispatchEvent(new CustomEvent('draggable:drop', {\n bubbles: true,\n detail: {\n zone: zone,\n element: this.currentDrag?.element,\n data: this.currentDrag?.data,\n position: position\n }\n }));\n },\n\n /**\n * Reorder elements in container based on cursor position\n * @param {HTMLElement} container \n * @param {HTMLElement} element \n * @param {number} clientX \n * @param {number} clientY \n */\n handleReorder: function (container, element, clientX, clientY) {\n const isVertical = container.classList.contains('vd-draggable-container-vertical');\n const siblings = [...container.querySelectorAll('.vd-draggable-item:not(.is-dragging), .vd-draggable:not(.is-dragging)')];\n\n const nextSibling = siblings.reduce((closest, child) => {\n const box = child.getBoundingClientRect();\n const offset = isVertical\n ? clientY - box.top - box.height / 2\n : clientX - box.left - box.width / 2;\n\n if (offset < 0 && offset > closest.offset) {\n return { offset: offset, element: child };\n } else {\n return closest;\n }\n }, { offset: Number.NEGATIVE_INFINITY }).element;\n\n if (nextSibling == null) {\n container.appendChild(element);\n } else {\n container.insertBefore(element, nextSibling);\n }\n },\n\n /**\n * Handle keyboard events\n * @param {KeyboardEvent} e - Keyboard event\n * @param {HTMLElement} element - Draggable element\n */\n handleKeydown: function (e, element) {\n switch (e.key) {\n case 'Enter':\n case ' ':\n e.preventDefault();\n // Trigger click or custom action\n element.click();\n break;\n case 'Escape':\n // Cancel drag if in progress\n if (element.classList.contains('is-dragging')) {\n element.classList.remove('is-dragging');\n element.setAttribute('aria-grabbed', 'false');\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n this.currentDrag = null;\n }\n break;\n case 'ArrowUp':\n case 'ArrowLeft': {\n e.preventDefault();\n const prev = element.previousElementSibling;\n if (prev && (prev.classList.contains('vd-draggable') || prev.classList.contains('vd-draggable-item'))) {\n element.parentNode.insertBefore(element, prev);\n element.focus();\n element.dispatchEvent(new CustomEvent('draggable:reorder', {\n bubbles: true,\n detail: { element: element, direction: 'up' }\n }));\n }\n break;\n }\n case 'ArrowDown':\n case 'ArrowRight': {\n e.preventDefault();\n const next = element.nextElementSibling;\n if (next && (next.classList.contains('vd-draggable') || next.classList.contains('vd-draggable-item'))) {\n element.parentNode.insertBefore(next, element);\n element.focus();\n element.dispatchEvent(new CustomEvent('draggable:reorder', {\n bubbles: true,\n detail: { element: element, direction: 'down' }\n }));\n }\n break;\n }\n }\n },\n\n /**\n * Get data from draggable element\n * @param {HTMLElement} element - Draggable element\n * @returns {string} Data associated with the element\n */\n getData: function (element) {\n return element.dataset.draggable || element.textContent.trim();\n },\n\n /**\n * Update drag feedback element\n * @param {number} x - Current X coordinate\n * @param {number} y - Current Y coordinate\n */\n updateFeedback: function (x, y) {\n if (!this.currentDrag) return;\n\n // Show feedback\n this.feedbackElement.classList.remove('hidden');\n\n // Update feedback content\n const rect = this.currentDrag.initialBounds;\n this.feedbackElement.innerHTML = '';\n const clone = this.currentDrag.element.cloneNode(true);\n this.feedbackElement.appendChild(clone);\n\n // Set styles\n const offsetX = this.currentDrag.offsetX ?? 20;\n const offsetY = this.currentDrag.offsetY ?? 20;\n Object.assign(this.feedbackElement.style, {\n left: (x - offsetX) + 'px',\n top: (y - offsetY) + 'px',\n width: rect.width + 'px',\n height: rect.height + 'px'\n });\n },\n\n /**\n * Make an element draggable programmatically\n * @param {HTMLElement|string} element - Element or selector\n * @param {Object} options - Configuration options\n */\n makeDraggable: function (element, options = {}) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n\n if (el && !this.instances.has(el)) {\n // Add classes and attributes\n el.classList.add('vd-draggable');\n el.setAttribute('draggable', 'true');\n\n // Set options\n if (options.data) {\n el.dataset.draggable = options.data;\n }\n\n // Initialize\n this.initDraggable(el);\n }\n },\n\n /**\n * Remove draggable functionality from an element\n * @param {HTMLElement|string} element - Element or selector\n */\n removeDraggable: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n\n if (el && this.instances.has(el)) {\n // Clean up\n const instance = this.instances.get(el);\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n\n // Remove classes and attributes\n el.classList.remove('vd-draggable');\n el.removeAttribute('draggable');\n el.removeAttribute('data-draggable');\n }\n },\n\n /**\n * Destroy a draggable instance and clean up event listeners\n * @param {HTMLElement} element - Draggable element\n */\n destroy: function (element) {\n this.removeDraggable(element);\n },\n\n /**\n * Destroy all draggable instances\n */\n destroyAll: function () {\n const instances = Array.from(this.instances.keys());\n instances.forEach(element => this.destroy(element));\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('draggable', Draggable);\n }\n\n // Expose globally\n window.VanduoDraggable = Draggable;\n})();\n", "/**\n * Vanduo Framework \u2013 LazyLoad Component\n * v1.2.1\n *\n * Provides two levels of API:\n *\n * LOW-LEVEL (generic IntersectionObserver wrapper)\n * VanduoLazyLoad.observe(element, callback, options?)\n * VanduoLazyLoad.unobserve(element)\n * VanduoLazyLoad.unobserveAll()\n *\n * HIGH-LEVEL (HTML-section fetcher, mirrors vanduo-docs SPA behaviour)\n * VanduoLazyLoad.loadSection(url, containerEl, options?)\n * options: { placeholder, threshold, rootMargin, onLoaded, onError }\n * placeholder: 'skeleton' | 'spinner' | HTMLString (default: 'skeleton')\n *\n * ATTRIBUTE-DRIVEN (zero-JS usage, wired by init())\n *
    \u2026
    \n *\n * EVENTS dispatched on the host element:\n * lazysection:loading \u2014 fetch started\n * lazysection:loaded \u2014 content injected\n * lazysection:error \u2014 fetch failed\n */\n\n(function () {\n 'use strict';\n\n /* \u2500\u2500 Private state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /** @type {Map} */\n const _observerMap = new Map();\n\n /* \u2500\u2500 Security helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Returns true only if `url` resolves to the same origin as the page\n * (relative paths and same-origin absolute URLs are allowed).\n * Cross-origin URLs are rejected to prevent SSRF-style fetch abuse.\n * @param {string} url\n * @returns {boolean}\n */\n function _isSafeUrl(url) {\n try {\n // Relative URLs (no origin) are always safe\n const resolved = new URL(url, window.location.href);\n return resolved.origin === window.location.origin;\n } catch (_) {\n return false;\n }\n }\n\n /**\n * Safely inject fetched HTML into a container by parsing it with\n * DOMParser (avoids script execution) and replacing children via\n * standard DOM APIs instead of raw innerHTML assignment.\n * @param {Element} containerEl\n * @param {string} html\n */\n function _safeInjectHtml(containerEl, html) {\n const parser = new DOMParser();\n const doc = parser.parseFromString(html.trim(), 'text/html'); // CodeQL [js/xss-through-dom] Intentional: parsed nodes are sanitized below before adoption into live DOM\n\n const DANGEROUS_TAGS = ['SCRIPT', 'IFRAME', 'OBJECT', 'EMBED', 'FORM', 'BASE', 'LINK', 'META', 'STYLE'];\n for (const tag of DANGEROUS_TAGS) {\n const els = doc.querySelectorAll(tag);\n for (let i = els.length - 1; i >= 0; i--) {\n els[i].parentNode.removeChild(els[i]);\n }\n }\n\n // Strip dangerous attributes (e.g. onerror, javascript: urls) from all elements\n function _sanitizeNode(node) {\n if (node.nodeType === Node.ELEMENT_NODE) {\n const attrs = node.attributes;\n for (let i = attrs.length - 1; i >= 0; i--) {\n const attrName = attrs[i].name.toLowerCase();\n const attrValue = attrs[i].value.toLowerCase();\n const trimmedValue = attrValue.trim();\n if (\n attrName.startsWith('on') ||\n trimmedValue.startsWith('javascript:') ||\n trimmedValue.startsWith('data:') ||\n trimmedValue.startsWith('vbscript:')\n ) {\n node.removeAttribute(attrs[i].name);\n }\n }\n const children = node.childNodes;\n for (let i = 0; i < children.length; i++) {\n _sanitizeNode(children[i]);\n }\n }\n }\n _sanitizeNode(doc.body);\n\n // Collect all top-level body children\n const nodes = Array.from(doc.body.childNodes);\n // Clear container and append parsed nodes\n while (containerEl.firstChild) {\n containerEl.removeChild(containerEl.firstChild);\n }\n nodes.forEach(function (node) {\n containerEl.appendChild(document.adoptNode(node));\n });\n }\n\n /* \u2500\u2500 Placeholder HTML \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n function _skeletonHtml() {\n return '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + 'Loading\u2026
    ';\n }\n\n function _spinnerHtml() {\n return '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + 'Loading\u2026
    ';\n }\n\n function _resolvePlaceholder(placeholder) {\n if (!placeholder || placeholder === 'skeleton') return _skeletonHtml();\n if (placeholder === 'spinner') return _spinnerHtml();\n // Caller-supplied HTML string\n return placeholder;\n }\n\n /* \u2500\u2500 Dispatch helper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n function _dispatch(el, eventName, detail) {\n el.dispatchEvent(new CustomEvent(eventName, { bubbles: true, detail: detail || {} }));\n }\n\n /* \u2500\u2500 VanduoLazyLoad \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const VanduoLazyLoad = {\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * LOW-LEVEL API\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Observe an element. `callback` is invoked once when the element\n * enters the viewport, then the element is automatically unobserved.\n *\n * @param {Element} element\n * @param {function(Element): void} callback\n * @param {{ threshold?: number, rootMargin?: string }} [options]\n */\n observe: function (element, callback, options) {\n if (!(element instanceof Element)) {\n console.warn('[VanduoLazyLoad] observe() requires a DOM Element.');\n return;\n }\n if (typeof callback !== 'function') {\n console.warn('[VanduoLazyLoad] observe() requires a callback function.');\n return;\n }\n // Already observed \u2014 ignore\n if (_observerMap.has(element)) return;\n\n const threshold = (options && options.threshold != null) ? options.threshold : 0;\n const rootMargin = (options && options.rootMargin) ? options.rootMargin : '0px';\n\n const observer = new IntersectionObserver(function (entries, obs) {\n entries.forEach(function (entry) {\n if (entry.isIntersecting) {\n obs.unobserve(entry.target);\n _observerMap.delete(entry.target);\n try {\n callback(entry.target);\n } catch (e) {\n console.error('[VanduoLazyLoad] Callback threw:', e);\n }\n }\n });\n }, { threshold: threshold, rootMargin: rootMargin });\n\n _observerMap.set(element, observer);\n observer.observe(element);\n },\n\n /**\n * Stop observing an element that was previously passed to observe().\n * @param {Element} element\n */\n unobserve: function (element) {\n const observer = _observerMap.get(element);\n if (observer) {\n observer.unobserve(element);\n _observerMap.delete(element);\n }\n },\n\n /**\n * Stop observing ALL currently observed elements.\n */\n unobserveAll: function () {\n _observerMap.forEach(function (observer, element) {\n observer.unobserve(element);\n });\n _observerMap.clear();\n },\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * HIGH-LEVEL API\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Fetch an HTML partial and inject it into `containerEl` when the\n * container enters the viewport. A placeholder is shown immediately.\n *\n * @param {string} url URL of the HTML partial to fetch\n * @param {Element} containerEl Target element whose content will be replaced\n * @param {{\n * placeholder?: 'skeleton'|'spinner'|string,\n * threshold?: number,\n * rootMargin?: string,\n * onLoaded?: function(Element): void,\n * onError?: function(Error): void\n * }} [options]\n */\n loadSection: function (url, containerEl, options) {\n if (typeof url !== 'string' || !url) {\n console.warn('[VanduoLazyLoad] loadSection() requires a non-empty URL string.');\n return;\n }\n if (!(containerEl instanceof Element)) {\n console.warn('[VanduoLazyLoad] loadSection() requires a DOM Element as containerEl.');\n return;\n }\n // Reject cross-origin URLs to prevent SSRF-style fetch abuse\n if (!_isSafeUrl(url)) {\n console.error('[VanduoLazyLoad] loadSection() blocked cross-origin URL:', url);\n return;\n }\n\n const opts = options || {};\n // Placeholders are known-safe static HTML strings built internally\n const placeholderHtml = _resolvePlaceholder(opts.placeholder);\n\n // Use _safeInjectHtml instead of innerHTML to prevent XSS from caller-supplied placeholders\n _safeInjectHtml(containerEl, placeholderHtml);\n _dispatch(containerEl, 'lazysection:loading', { url: url });\n\n // Fetch when visible\n this.observe(containerEl, function () {\n const controller = new window.AbortController();\n const timeoutId = setTimeout(function () { controller.abort(); }, 10000);\n\n window.fetch(url, { signal: controller.signal })\n .then(function (res) {\n clearTimeout(timeoutId);\n if (!res.ok) throw new Error('HTTP ' + res.status);\n return res.text();\n })\n .then(function (html) {\n // Use DOMParser to parse fetched content safely, avoiding\n // raw innerHTML assignment of externally-sourced strings\n _safeInjectHtml(containerEl, html);\n _dispatch(containerEl, 'lazysection:loaded', { url: url });\n // Re-init Vanduo components inside the new content\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.init();\n }\n if (typeof opts.onLoaded === 'function') {\n opts.onLoaded(containerEl);\n }\n })\n .catch(function (err) {\n // Build error node via DOM APIs \u2014 no dynamic HTML strings\n const alertEl = document.createElement('div');\n alertEl.className = 'vd-alert vd-alert-error';\n alertEl.setAttribute('role', 'alert');\n const msgEl = document.createElement('span');\n msgEl.textContent = 'Failed to load content. ';\n const detailEl = document.createElement('small');\n detailEl.style.opacity = '0.7';\n detailEl.textContent = err.message;\n alertEl.appendChild(msgEl);\n alertEl.appendChild(detailEl);\n while (containerEl.firstChild) {\n containerEl.removeChild(containerEl.firstChild);\n }\n containerEl.appendChild(alertEl);\n _dispatch(containerEl, 'lazysection:error', { url: url, error: err });\n console.error('[VanduoLazyLoad] loadSection failed:', err);\n if (typeof opts.onError === 'function') {\n opts.onError(err);\n }\n });\n }, { threshold: opts.threshold, rootMargin: opts.rootMargin });\n },\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * ATTRIBUTE-DRIVEN INIT\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Scan the DOM for [data-vd-lazy] elements and wire them up.\n * Safe to call multiple times \u2014 already-observed elements are skipped.\n */\n init: function () {\n const self = this;\n const elements = document.querySelectorAll('[data-vd-lazy]');\n elements.forEach(function (el) {\n // Skip already-observed or already-loaded elements\n if (_observerMap.has(el) || el.dataset.vdLazyState === 'loading' || el.dataset.vdLazyState === 'loaded') return;\n\n const url = el.getAttribute('data-vd-lazy');\n if (!url) return;\n\n el.dataset.vdLazyState = 'loading';\n const placeholder = el.getAttribute('data-vd-lazy-placeholder') || 'skeleton';\n\n self.loadSection(url, el, {\n placeholder: placeholder,\n onLoaded: function () {\n el.dataset.vdLazyState = 'loaded';\n },\n onError: function () {\n el.dataset.vdLazyState = 'error';\n }\n });\n });\n }\n };\n\n /* \u2500\u2500 Register with Vanduo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('LazyLoad', VanduoLazyLoad);\n }\n\n /* \u2500\u2500 Global convenience alias \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n window.VanduoLazyLoad = VanduoLazyLoad;\n\n})();\n", "/**\n * Vanduo Framework - Glass Scroll Activation\n * Generic scroll-aware glass activation via IntersectionObserver.\n *\n * Usage:\n * Add `data-glass-scroll` to any element carrying a `.vd-glass*` class.\n * By default the previous sibling is used as the sentinel.\n * Point to a custom sentinel via `data-glass-sentinel=\"\"`.\n *\n * Behaviour:\n * - Adds `.is-glass-active` when the sentinel leaves the viewport (scrolled past).\n * - Removes `.is-glass-active` when the sentinel re-enters the viewport.\n */\n(function () {\n 'use strict';\n\n const GlassScroll = {\n /** @type {Map} */\n observers: new Map(),\n\n init: function () {\n document.querySelectorAll('[data-glass-scroll]').forEach(el => {\n if (this.observers.has(el)) return;\n this.initElement(el);\n });\n },\n\n /**\n * Wire up a single scroll-activated glass element.\n * @param {HTMLElement} el\n */\n initElement: function (el) {\n const sentinelSelector = el.dataset.glassSentinel;\n let sentinel;\n\n if (sentinelSelector) {\n sentinel = document.querySelector(sentinelSelector);\n }\n\n if (!sentinel) {\n // Fall back to the previous sibling element\n sentinel = el.previousElementSibling;\n }\n\n if (!sentinel) {\n // No sentinel available \u2014 activate immediately so glass is always shown\n el.classList.add('is-glass-active');\n return;\n }\n\n const observer = new IntersectionObserver(\n (entries) => {\n entries.forEach(entry => {\n // Active when sentinel is NOT intersecting (scrolled past it)\n el.classList.toggle('is-glass-active', !entry.isIntersecting);\n });\n },\n { threshold: 0, rootMargin: '0px' }\n );\n\n observer.observe(sentinel);\n this.observers.set(el, observer);\n },\n\n /**\n * Disconnect and remove a single element's observer.\n * @param {HTMLElement} el\n */\n destroy: function (el) {\n const observer = this.observers.get(el);\n if (observer) {\n observer.disconnect();\n this.observers.delete(el);\n }\n },\n\n destroyAll: function () {\n this.observers.forEach((observer, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('glassScroll', GlassScroll);\n }\n\n window.VanduoGlassScroll = GlassScroll;\n})();\n", "/**\n * Vanduo Framework - Water Morph Effect Component\n * Liquid wave content-swap animation on click\n *\n * Usage:\n * Add .vd-morph or [data-vd-morph] to any element.\n * Provide .vd-morph-content.vd-morph-current and .vd-morph-content.vd-morph-next children.\n * Wave and shine layers are auto-created if absent.\n *\n * JS API:\n * VanduoMorph.morph(el) \u2014 trigger morph programmatically (from center)\n * VanduoMorph.destroy(el) \u2014 tear down listeners for one element\n * VanduoMorph.destroyAll() \u2014 tear down all instances\n */\n\n(function () {\n 'use strict';\n\n const MORPH_DURATION_MS = 750;\n\n const Morph = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-morph, [data-vd-morph]');\n elements.forEach(function (el) {\n if (Morph.instances.has(el)) return;\n if (el.getAttribute('data-vd-morph') === 'manual') return;\n Morph.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n Morph._ensureLayers(el);\n\n const cleanup = [];\n let morphing = false;\n\n const handleClick = function (e) {\n if (morphing) return;\n Morph._runMorph(el, e, function () { morphing = false; });\n morphing = true;\n };\n\n el.addEventListener('click', handleClick);\n cleanup.push(function () { el.removeEventListener('click', handleClick); });\n\n this.instances.set(el, { cleanup: cleanup });\n },\n\n morph: function (el) {\n if (!el) return;\n if (!this.instances.has(el)) this.initInstance(el);\n this._runMorph(el, null, null);\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(function (fn) { fn(); });\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) { Morph.destroy(el); });\n },\n\n /* \u2500\u2500 Internal helpers \u2500\u2500 */\n\n _ensureLayers: function (el) {\n if (!el.querySelector('.vd-morph-wave')) {\n const wave = document.createElement('span');\n wave.className = 'vd-morph-wave';\n wave.setAttribute('aria-hidden', 'true');\n el.insertBefore(wave, el.firstChild);\n }\n if (!el.querySelector('.vd-morph-shine')) {\n const shine = document.createElement('span');\n shine.className = 'vd-morph-shine';\n shine.setAttribute('aria-hidden', 'true');\n const waveEl = el.querySelector('.vd-morph-wave');\n if (waveEl && waveEl.nextSibling) {\n el.insertBefore(shine, waveEl.nextSibling);\n } else {\n el.insertBefore(shine, el.firstChild);\n }\n }\n },\n\n _runMorph: function (el, pointerEvent, onComplete) {\n const wave = el.querySelector('.vd-morph-wave');\n if (wave) {\n const rect = el.getBoundingClientRect();\n const cx = rect.left + rect.width / 2;\n const cy = rect.top + rect.height / 2;\n const px = pointerEvent ? (pointerEvent.clientX || cx) : cx;\n const py = pointerEvent ? (pointerEvent.clientY || cy) : cy;\n wave.style.left = (px - rect.left) + 'px';\n wave.style.top = (py - rect.top) + 'px';\n }\n\n el.classList.add('is-morphing');\n\n let duration = MORPH_DURATION_MS;\n const custom = getComputedStyle(el).getPropertyValue('--morph-duration');\n if (custom) {\n const parsed = parseFloat(custom);\n if (!isNaN(parsed)) duration = parsed * (custom.indexOf('ms') !== -1 ? 1 : 1000);\n }\n\n setTimeout(function () {\n el.classList.remove('is-morphing');\n\n const current = el.querySelector('.vd-morph-current');\n const next = el.querySelector('.vd-morph-next');\n if (current && next) {\n current.classList.remove('vd-morph-current');\n current.classList.add('vd-morph-next');\n next.classList.remove('vd-morph-next');\n next.classList.add('vd-morph-current');\n }\n\n if (typeof onComplete === 'function') onComplete();\n }, duration);\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('morph', Morph);\n }\n\n window.VanduoMorph = Morph;\n\n})();\n", "/**\n * Vanduo Framework \u2014 Expanding flex cards\n * Click / Enter / Space / Arrow keys to change the active card.\n *\n * Usage:\n *
    \n * Use data-vd-expanding-cards=\"manual\" to skip auto-init.\n */\n\n(function () {\n 'use strict';\n\n const ExpandingCards = {\n instances: new Map(),\n\n init: function () {\n document.querySelectorAll('.vd-expanding-cards').forEach(function (el) {\n if (el.getAttribute('data-vd-expanding-cards') === 'manual') return;\n if (ExpandingCards.instances.has(el)) return;\n ExpandingCards.initContainer(el);\n });\n },\n\n initContainer: function (container) {\n const cleanup = [];\n\n const getCards = function () {\n return Array.prototype.slice.call(container.querySelectorAll('.vd-expanding-card'));\n };\n\n const setActive = function (card) {\n const cards = getCards();\n if (!card || cards.indexOf(card) === -1) return;\n cards.forEach(function (c) {\n c.classList.toggle('is-active', c === card);\n });\n card.focus({ preventScroll: true });\n };\n\n const onClick = function (e) {\n const t = e.target;\n const card = t.closest ? t.closest('.vd-expanding-card') : null;\n if (!card || !container.contains(card)) return;\n setActive(card);\n };\n\n const onKeydown = function (e) {\n if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'Home' && e.key !== 'End') {\n return;\n }\n const cards = getCards().filter(function (c) {\n return c.offsetParent !== null || c.getClientRects().length > 0;\n });\n if (!cards.length) return;\n const activeEl = document.activeElement;\n let idx = cards.indexOf(activeEl);\n if (idx < 0) {\n idx = cards.findIndex(function (c) {\n return c.classList.contains('is-active');\n });\n }\n if (idx < 0) idx = 0;\n\n if (e.key === 'ArrowLeft') {\n e.preventDefault();\n setActive(cards[Math.max(0, idx - 1)]);\n } else if (e.key === 'ArrowRight') {\n e.preventDefault();\n setActive(cards[Math.min(cards.length - 1, idx + 1)]);\n } else if (e.key === 'Home') {\n e.preventDefault();\n setActive(cards[0]);\n } else if (e.key === 'End') {\n e.preventDefault();\n setActive(cards[cards.length - 1]);\n }\n };\n\n container.addEventListener('click', onClick);\n cleanup.push(function () {\n container.removeEventListener('click', onClick);\n });\n\n container.addEventListener('keydown', onKeydown);\n cleanup.push(function () {\n container.removeEventListener('keydown', onKeydown);\n });\n\n getCards().forEach(function (card) {\n if (!card.hasAttribute('tabindex')) {\n card.setAttribute('tabindex', '0');\n }\n card.setAttribute('role', 'button');\n if (!card.hasAttribute('aria-pressed')) {\n card.setAttribute('aria-pressed', card.classList.contains('is-active') ? 'true' : 'false');\n }\n });\n\n const syncAria = function () {\n getCards().forEach(function (card) {\n card.setAttribute('aria-pressed', card.classList.contains('is-active') ? 'true' : 'false');\n });\n };\n\n const mo = new MutationObserver(syncAria);\n mo.observe(container, { attributes: true, subtree: true, attributeFilter: ['class'] });\n cleanup.push(function () {\n mo.disconnect();\n });\n syncAria();\n\n ExpandingCards.instances.set(container, { cleanup: cleanup });\n },\n\n destroy: function (container) {\n const inst = this.instances.get(container);\n if (!inst) return;\n inst.cleanup.forEach(function (fn) {\n fn();\n });\n this.instances.delete(container);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) {\n ExpandingCards.destroy(el);\n });\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('expandingCards', ExpandingCards);\n }\n\n window.VanduoExpandingCards = ExpandingCards;\n})();\n", "/**\n * Vanduo Framework \u2014 Timeline animated reveal\n * Opt-in: add `.vd-timeline-animated` to a `.vd-timeline` container.\n * Uses IntersectionObserver to add `.is-revealed` per item with staggered delays.\n * Optional: `.vd-timeline-playback` with `[data-vd-timeline-prev|next|play|pause]` for stepped control.\n */\n\n(function () {\n 'use strict';\n\n const STAGGER_MS = 140;\n const MAX_STAGGER_INDEX = 7;\n const PLAY_INTERVAL_MS = 800;\n\n function countRevealedPrefix(items) {\n let count = 0;\n for (let i = 0; i < items.length; i++) {\n if (!items[i].classList.contains('is-revealed')) break;\n count++;\n }\n return count;\n }\n\n function findPlaybackControls(container) {\n return container.parentElement || document.body;\n }\n\n function initPlayback(container, items, cleanup) {\n items.forEach(function (item) {\n item.classList.remove('is-revealed');\n });\n\n const scope = findPlaybackControls(container);\n const prevBtn = scope.querySelector('[data-vd-timeline-prev]');\n const nextBtn = scope.querySelector('[data-vd-timeline-next]');\n const playBtn = scope.querySelector('[data-vd-timeline-play]');\n const pauseBtn = scope.querySelector('[data-vd-timeline-pause]');\n\n let playTimer = null;\n\n function updateNavButtons() {\n const k = countRevealedPrefix(items);\n const n = items.length;\n if (prevBtn) {\n const atStart = k === 0;\n prevBtn.disabled = atStart;\n prevBtn.setAttribute('aria-disabled', atStart ? 'true' : 'false');\n }\n if (nextBtn) {\n const atEnd = k >= n;\n nextBtn.disabled = atEnd;\n nextBtn.setAttribute('aria-disabled', atEnd ? 'true' : 'false');\n }\n if (playBtn) {\n playBtn.setAttribute('aria-pressed', playTimer ? 'true' : 'false');\n }\n if (pauseBtn) {\n pauseBtn.disabled = !playTimer;\n }\n }\n\n function stepNext() {\n const k = countRevealedPrefix(items);\n if (k < items.length) {\n items[k].classList.add('is-revealed');\n }\n updateNavButtons();\n }\n\n function stepPrev() {\n const k = countRevealedPrefix(items);\n if (k > 0) {\n items[k - 1].classList.remove('is-revealed');\n }\n updateNavButtons();\n }\n\n function play() {\n if (playTimer) return;\n playTimer = setInterval(function () {\n if (countRevealedPrefix(items) >= items.length) {\n pause();\n return;\n }\n stepNext();\n }, PLAY_INTERVAL_MS);\n updateNavButtons();\n }\n\n function pause() {\n if (playTimer) {\n clearInterval(playTimer);\n playTimer = null;\n }\n updateNavButtons();\n }\n\n function addClick(el, fn) {\n if (!el) return;\n const handler = function (e) {\n e.preventDefault();\n fn();\n };\n el.addEventListener('click', handler);\n cleanup.push(function () {\n el.removeEventListener('click', handler);\n });\n }\n\n addClick(prevBtn, stepPrev);\n addClick(nextBtn, stepNext);\n addClick(playBtn, play);\n addClick(pauseBtn, pause);\n\n cleanup.push(function () {\n pause();\n });\n\n updateNavButtons();\n\n return {\n stepNext: stepNext,\n stepPrev: stepPrev,\n play: play,\n pause: pause\n };\n }\n\n const Timeline = {\n instances: new Map(),\n\n init: function () {\n document.querySelectorAll('.vd-timeline.vd-timeline-animated').forEach(function (el) {\n if (Timeline.instances.has(el)) return;\n Timeline.initInstance(el);\n });\n },\n\n reinit: function () {\n Timeline.destroyAll();\n Timeline.init();\n },\n\n initInstance: function (container) {\n const cleanup = [];\n const items = Array.prototype.filter.call(container.children, function (child) {\n return child.classList && child.classList.contains('vd-timeline-item');\n });\n\n items.forEach(function (item, i) {\n const idx = Math.min(i, MAX_STAGGER_INDEX);\n item.style.setProperty('--vd-timeline-reveal-delay', (idx * STAGGER_MS) + 'ms');\n });\n\n const reducedMotion = typeof window.matchMedia === 'function'\n && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n if (reducedMotion) {\n items.forEach(function (item) {\n item.classList.add('is-revealed');\n });\n Timeline.instances.set(container, { cleanup: cleanup });\n return;\n }\n\n const playback = container.classList && container.classList.contains('vd-timeline-playback');\n\n if (playback) {\n const playbackApi = initPlayback(container, items, cleanup);\n Timeline.instances.set(container, { cleanup: cleanup, playback: playbackApi });\n return;\n }\n\n if (typeof IntersectionObserver === 'undefined') {\n items.forEach(function (item) {\n item.classList.add('is-revealed');\n });\n Timeline.instances.set(container, { cleanup: cleanup });\n return;\n }\n\n const observer = new IntersectionObserver(function (entries) {\n entries.forEach(function (entry) {\n if (!entry.isIntersecting) return;\n entry.target.classList.add('is-revealed');\n observer.unobserve(entry.target);\n });\n }, {\n root: null,\n rootMargin: '0px 0px -10% 0px',\n threshold: 0.15\n });\n\n items.forEach(function (item) {\n observer.observe(item);\n });\n\n cleanup.push(function () {\n observer.disconnect();\n });\n\n Timeline.instances.set(container, { cleanup: cleanup });\n },\n\n destroy: function (container) {\n const inst = this.instances.get(container);\n if (!inst) return;\n inst.cleanup.forEach(function (fn) {\n fn();\n });\n this.instances.delete(container);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) {\n Timeline.destroy(el);\n });\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('timeline', Timeline);\n }\n\n window.VanduoTimeline = Timeline;\n})();\n", "/**\n * Vanduo Framework - Flow (Carousel/Slider) Component\n * Touch-enabled carousel with slide/fade transitions, autoplay, indicators\n */\n\n(function () {\n 'use strict';\n\n const Flow = {\n instances: new Map(),\n\n init: function () {\n const carousels = document.querySelectorAll('.vd-flow, .vd-carousel');\n carousels.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const track = el.querySelector('.vd-flow-track');\n if (!track) return;\n\n const slides = Array.from(track.querySelectorAll('.vd-flow-slide'));\n if (slides.length === 0) return;\n\n const isFade = el.classList.contains('vd-flow-fade');\n const autoplay = el.hasAttribute('data-vd-autoplay');\n const interval = parseInt(el.getAttribute('data-vd-interval'), 10) || 5000;\n const loop = el.getAttribute('data-vd-loop') !== 'false';\n\n const state = {\n current: 0,\n total: slides.length,\n autoplayTimer: null,\n isFade: isFade,\n loop: loop,\n isDragging: false,\n startX: 0,\n currentX: 0,\n threshold: 50\n };\n\n const cleanup = [];\n\n // Set initial active slide\n slides.forEach((slide, i) => {\n slide.setAttribute('role', 'group');\n slide.setAttribute('aria-roledescription', 'slide');\n slide.setAttribute('aria-label', 'Slide ' + (i + 1) + ' of ' + slides.length);\n if (i === 0) slide.classList.add('is-active');\n });\n\n el.setAttribute('role', 'region');\n el.setAttribute('aria-roledescription', 'carousel');\n if (!el.getAttribute('aria-label')) {\n el.setAttribute('aria-label', 'Carousel');\n }\n\n // Live region for announcements\n const liveRegion = document.createElement('div');\n liveRegion.setAttribute('aria-live', 'polite');\n liveRegion.setAttribute('aria-atomic', 'true');\n liveRegion.className = 'sr-only';\n liveRegion.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);';\n el.appendChild(liveRegion);\n\n const goTo = (index, announce) => {\n if (announce === undefined) announce = true;\n let target = index;\n if (state.loop) {\n target = ((index % state.total) + state.total) % state.total;\n } else {\n target = Math.max(0, Math.min(index, state.total - 1));\n }\n\n const prev = state.current;\n state.current = target;\n\n if (state.isFade) {\n slides.forEach((s, i) => {\n s.classList.toggle('is-active', i === target);\n });\n } else {\n track.style.transform = 'translateX(-' + (target * 100) + '%)';\n }\n\n // Update indicators\n const indicators = el.querySelectorAll('.vd-flow-indicator');\n indicators.forEach((ind, i) => {\n ind.classList.toggle('is-active', i === target);\n ind.setAttribute('aria-selected', i === target ? 'true' : 'false');\n });\n\n // Update slide ARIA\n slides.forEach((s, i) => {\n s.setAttribute('aria-hidden', i !== target ? 'true' : 'false');\n });\n\n if (announce) {\n liveRegion.textContent = 'Slide ' + (target + 1) + ' of ' + state.total;\n }\n\n el.dispatchEvent(new CustomEvent('flow:change', {\n detail: { current: target, previous: prev, total: state.total }\n }));\n };\n\n const next = () => goTo(state.current + 1);\n const prev = () => goTo(state.current - 1);\n\n // Controls\n const prevBtn = el.querySelector('.vd-flow-prev');\n const nextBtn = el.querySelector('.vd-flow-next');\n\n if (prevBtn) {\n const h = () => prev();\n prevBtn.addEventListener('click', h);\n cleanup.push(() => prevBtn.removeEventListener('click', h));\n }\n if (nextBtn) {\n const h = () => next();\n nextBtn.addEventListener('click', h);\n cleanup.push(() => nextBtn.removeEventListener('click', h));\n }\n\n // Indicators\n const indicators = el.querySelectorAll('.vd-flow-indicator');\n indicators.forEach((ind, i) => {\n ind.setAttribute('role', 'tab');\n ind.setAttribute('aria-selected', i === 0 ? 'true' : 'false');\n ind.setAttribute('aria-label', 'Go to slide ' + (i + 1));\n const h = () => goTo(i);\n ind.addEventListener('click', h);\n cleanup.push(() => ind.removeEventListener('click', h));\n });\n\n // Keyboard navigation\n const keyHandler = (e) => {\n if (e.key === 'ArrowLeft') { prev(); e.preventDefault(); }\n if (e.key === 'ArrowRight') { next(); e.preventDefault(); }\n };\n el.setAttribute('tabindex', '0');\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n\n // Touch / pointer support\n const pointerDown = (e) => {\n state.isDragging = true;\n state.startX = e.clientX || (e.touches && e.touches[0].clientX) || 0;\n state.currentX = state.startX;\n el.classList.add('is-dragging');\n };\n\n const pointerMove = (e) => {\n if (!state.isDragging) return;\n state.currentX = e.clientX || (e.touches && e.touches[0].clientX) || 0;\n };\n\n const pointerUp = () => {\n if (!state.isDragging) return;\n state.isDragging = false;\n el.classList.remove('is-dragging');\n const diff = state.startX - state.currentX;\n if (Math.abs(diff) > state.threshold) {\n if (diff > 0) next();\n else prev();\n }\n };\n\n el.addEventListener('mousedown', pointerDown);\n el.addEventListener('mousemove', pointerMove);\n el.addEventListener('mouseup', pointerUp);\n el.addEventListener('mouseleave', pointerUp);\n el.addEventListener('touchstart', pointerDown, { passive: true });\n el.addEventListener('touchmove', pointerMove, { passive: true });\n el.addEventListener('touchend', pointerUp);\n\n cleanup.push(\n () => el.removeEventListener('mousedown', pointerDown),\n () => el.removeEventListener('mousemove', pointerMove),\n () => el.removeEventListener('mouseup', pointerUp),\n () => el.removeEventListener('mouseleave', pointerUp),\n () => el.removeEventListener('touchstart', pointerDown),\n () => el.removeEventListener('touchmove', pointerMove),\n () => el.removeEventListener('touchend', pointerUp)\n );\n\n // Autoplay\n const startAutoplay = () => {\n stopAutoplay();\n state.autoplayTimer = setInterval(next, interval);\n };\n\n const stopAutoplay = () => {\n if (state.autoplayTimer) {\n clearInterval(state.autoplayTimer);\n state.autoplayTimer = null;\n }\n };\n\n if (autoplay) {\n startAutoplay();\n const pauseHandler = () => stopAutoplay();\n const resumeHandler = () => startAutoplay();\n el.addEventListener('mouseenter', pauseHandler);\n el.addEventListener('mouseleave', resumeHandler);\n el.addEventListener('focusin', pauseHandler);\n el.addEventListener('focusout', resumeHandler);\n cleanup.push(\n () => el.removeEventListener('mouseenter', pauseHandler),\n () => el.removeEventListener('mouseleave', resumeHandler),\n () => el.removeEventListener('focusin', pauseHandler),\n () => el.removeEventListener('focusout', resumeHandler),\n () => stopAutoplay()\n );\n }\n\n // Initial ARIA state\n goTo(0, false);\n\n this.instances.set(el, {\n cleanup: cleanup,\n goTo: goTo,\n next: next,\n prev: prev,\n getState: () => ({ ...state })\n });\n },\n\n goTo: function (el, index) {\n const instance = this.instances.get(el);\n if (instance) instance.goTo(index);\n },\n\n next: function (el) {\n const instance = this.instances.get(el);\n if (instance) instance.next();\n },\n\n prev: function (el) {\n const instance = this.instances.get(el);\n if (instance) instance.prev();\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('flow', Flow);\n }\n\n window.VanduoFlow = Flow;\n\n})();\n", "/**\n * Vanduo Framework - Bubble (Popover) Component\n * Click-triggered rich HTML popover, reuses tooltip positioning concepts\n */\n\n(function () {\n 'use strict';\n\n const Bubble = {\n instances: new Map(),\n _globalCleanups: [],\n\n init: function () {\n const triggers = document.querySelectorAll('[data-vd-bubble], [data-vd-popover]');\n triggers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n\n if (this._globalCleanups.length === 0) {\n const outsideClick = (e) => {\n this.instances.forEach((inst, trigger) => {\n if (!inst.popover.contains(e.target) && !trigger.contains(e.target)) {\n this.hide(trigger);\n }\n });\n };\n const escHandler = (e) => {\n if (e.key === 'Escape') {\n this.instances.forEach((_, trigger) => this.hide(trigger));\n }\n };\n document.addEventListener('click', outsideClick, true);\n document.addEventListener('keydown', escHandler);\n this._globalCleanups.push(\n () => document.removeEventListener('click', outsideClick, true),\n () => document.removeEventListener('keydown', escHandler)\n );\n }\n },\n\n initInstance: function (trigger) {\n const cleanup = [];\n const placement = trigger.getAttribute('data-vd-bubble-placement') ||\n trigger.getAttribute('data-vd-popover-placement') || 'bottom';\n\n // Build popover element\n const popover = document.createElement('div');\n popover.className = 'vd-bubble-content';\n popover.setAttribute('role', 'dialog');\n popover.setAttribute('aria-modal', 'false');\n popover.setAttribute('data-placement', placement);\n\n const title = trigger.getAttribute('data-vd-bubble-title') ||\n trigger.getAttribute('data-vd-popover-title');\n const content = trigger.getAttribute('data-vd-bubble') ||\n trigger.getAttribute('data-vd-popover') || '';\n const htmlContent = trigger.getAttribute('data-vd-bubble-html') ||\n trigger.getAttribute('data-vd-popover-html');\n const allowSvg = trigger.hasAttribute('data-vd-bubble-allow-svg') ||\n trigger.hasAttribute('data-vd-popover-allow-svg');\n\n if (title) {\n const header = document.createElement('div');\n header.className = 'vd-bubble-header';\n const titleSpan = document.createElement('span');\n titleSpan.textContent = title;\n const closeBtn = document.createElement('button');\n closeBtn.className = 'vd-bubble-close';\n closeBtn.setAttribute('aria-label', 'Close');\n closeBtn.innerHTML = '×';\n header.appendChild(titleSpan);\n header.appendChild(closeBtn);\n popover.appendChild(header);\n\n const closeHandler = (e) => { e.stopPropagation(); this.hide(trigger); };\n closeBtn.addEventListener('click', closeHandler);\n cleanup.push(() => closeBtn.removeEventListener('click', closeHandler));\n }\n\n const body = document.createElement('div');\n body.className = 'vd-bubble-body';\n if (htmlContent) {\n if (typeof sanitizeHtml === 'function') {\n body.innerHTML = sanitizeHtml(htmlContent, { allowSvg });\n } else {\n body.textContent = htmlContent;\n }\n } else {\n body.textContent = content;\n }\n popover.appendChild(body);\n\n document.body.appendChild(popover);\n\n // ARIA on trigger\n const popId = 'vd-bubble-' + Math.random().toString(36).slice(2, 9);\n popover.id = popId;\n trigger.setAttribute('aria-haspopup', 'dialog');\n trigger.setAttribute('aria-expanded', 'false');\n trigger.setAttribute('aria-controls', popId);\n\n // Toggle on click\n const toggleHandler = (e) => {\n e.stopPropagation();\n if (popover.classList.contains('is-visible')) {\n this.hide(trigger);\n } else {\n this.hideAll();\n this.show(trigger);\n }\n };\n trigger.addEventListener('click', toggleHandler);\n cleanup.push(() => trigger.removeEventListener('click', toggleHandler));\n\n this.instances.set(trigger, { popover, cleanup, placement });\n },\n\n position: function (trigger, popover, placement) {\n const rect = trigger.getBoundingClientRect();\n const popRect = popover.getBoundingClientRect();\n const gap = 10;\n let top, left;\n\n switch (placement) {\n case 'top':\n top = rect.top - popRect.height - gap + window.scrollY;\n left = rect.left + (rect.width - popRect.width) / 2 + window.scrollX;\n break;\n case 'left':\n top = rect.top + (rect.height - popRect.height) / 2 + window.scrollY;\n left = rect.left - popRect.width - gap + window.scrollX;\n break;\n case 'right':\n top = rect.top + (rect.height - popRect.height) / 2 + window.scrollY;\n left = rect.right + gap + window.scrollX;\n break;\n default: // bottom\n top = rect.bottom + gap + window.scrollY;\n left = rect.left + (rect.width - popRect.width) / 2 + window.scrollX;\n }\n\n // Clamp to viewport\n left = Math.max(8, Math.min(left, window.innerWidth - popRect.width - 8));\n top = Math.max(8, top);\n\n popover.style.top = top + 'px';\n popover.style.left = left + 'px';\n },\n\n show: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n const { popover, placement } = instance;\n\n popover.style.display = 'block';\n popover.classList.add('is-visible');\n trigger.setAttribute('aria-expanded', 'true');\n\n requestAnimationFrame(() => {\n this.position(trigger, popover, placement);\n });\n\n trigger.dispatchEvent(new CustomEvent('bubble:show', {\n bubbles: true,\n detail: { trigger: trigger, placement: placement }\n }));\n },\n\n hide: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n instance.popover.classList.remove('is-visible');\n trigger.setAttribute('aria-expanded', 'false');\n trigger.dispatchEvent(new CustomEvent('bubble:hide', {\n bubbles: true,\n detail: { trigger: trigger }\n }));\n },\n\n hideAll: function () {\n this.instances.forEach((_, trigger) => this.hide(trigger));\n },\n\n destroy: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n if (instance.popover.parentNode) {\n instance.popover.parentNode.removeChild(instance.popover);\n }\n trigger.removeAttribute('aria-haspopup');\n trigger.removeAttribute('aria-expanded');\n trigger.removeAttribute('aria-controls');\n this.instances.delete(trigger);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, trigger) => this.destroy(trigger));\n this._globalCleanups.forEach(fn => fn());\n this._globalCleanups = [];\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('bubble', Bubble);\n }\n\n window.VanduoBubble = Bubble;\n\n})();\n", "/**\n * Vanduo Framework - Waypoint (Scrollspy) Component\n * Highlights navigation links based on scroll position using IntersectionObserver\n */\n\n(function () {\n 'use strict';\n\n const Waypoint = {\n instances: new Map(),\n\n init: function () {\n const navs = document.querySelectorAll('[data-vd-waypoint-nav], [data-vd-scrollspy-nav]');\n navs.forEach(nav => {\n if (this.instances.has(nav)) return;\n this.initInstance(nav);\n });\n },\n\n initInstance: function (nav) {\n const links = Array.from(nav.querySelectorAll('a[href^=\"#\"]'));\n if (links.length === 0) return;\n\n const cleanup = [];\n const offset = parseInt(nav.getAttribute('data-vd-waypoint-offset') || '80', 10);\n const sections = [];\n\n links.forEach(link => {\n const id = link.getAttribute('href').slice(1);\n const section = document.getElementById(id);\n if (section) {\n section.setAttribute('data-vd-waypoint-section', '');\n sections.push({ id, link, section });\n }\n });\n\n if (sections.length === 0) return;\n\n const activeSections = new Set();\n\n const setActive = (id) => {\n links.forEach(l => l.classList.remove('is-active'));\n const target = links.find(l => l.getAttribute('href') === '#' + id);\n if (target) {\n target.classList.add('is-active');\n nav.dispatchEvent(new CustomEvent('waypoint:change', {\n detail: { activeId: id, link: target }\n }));\n }\n };\n\n const rootMargin = '-' + offset + 'px 0px -40% 0px';\n\n const observer = new IntersectionObserver((entries) => {\n entries.forEach(entry => {\n if (entry.isIntersecting) {\n activeSections.add(entry.target.id);\n } else {\n activeSections.delete(entry.target.id);\n }\n });\n\n // Pick the topmost visible section\n for (let i = 0; i < sections.length; i++) {\n if (activeSections.has(sections[i].id)) {\n setActive(sections[i].id);\n return;\n }\n }\n }, {\n rootMargin: rootMargin,\n threshold: 0\n });\n\n sections.forEach(s => observer.observe(s.section));\n\n // Smooth scroll on click\n links.forEach(link => {\n const clickHandler = (e) => {\n e.preventDefault();\n const id = link.getAttribute('href').slice(1);\n const section = document.getElementById(id);\n if (section) {\n section.scrollIntoView({ behavior: 'smooth' });\n setActive(id);\n }\n };\n link.addEventListener('click', clickHandler);\n cleanup.push(() => link.removeEventListener('click', clickHandler));\n });\n\n cleanup.push(() => observer.disconnect());\n\n this.instances.set(nav, { observer, cleanup, sections, setActive });\n },\n\n refresh: function (nav) {\n this.destroy(nav);\n this.initInstance(nav);\n },\n\n destroy: function (nav) {\n const instance = this.instances.get(nav);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(nav);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, nav) => this.destroy(nav));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('waypoint', Waypoint);\n }\n\n window.VanduoWaypoint = Waypoint;\n\n})();\n", "/**\n * Vanduo Framework - Ripple (Waves Effect) Component\n * Adds expanding circle animation on click at pointer position\n */\n\n(function () {\n 'use strict';\n\n const Ripple = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-ripple, [data-vd-ripple]');\n elements.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n\n const createWave = (e) => {\n const rect = el.getBoundingClientRect();\n const size = Math.max(rect.width, rect.height);\n const x = (e.clientX || (e.touches && e.touches[0].clientX) || rect.left + rect.width / 2) - rect.left - size / 2;\n const y = (e.clientY || (e.touches && e.touches[0].clientY) || rect.top + rect.height / 2) - rect.top - size / 2;\n\n const wave = document.createElement('span');\n wave.className = 'vd-ripple-wave';\n wave.style.width = size + 'px';\n wave.style.height = size + 'px';\n wave.style.left = x + 'px';\n wave.style.top = y + 'px';\n\n el.appendChild(wave);\n\n wave.addEventListener('animationend', () => {\n if (wave.parentNode) wave.parentNode.removeChild(wave);\n });\n };\n\n el.addEventListener('mousedown', createWave);\n el.addEventListener('touchstart', createWave, { passive: true });\n\n cleanup.push(\n () => el.removeEventListener('mousedown', createWave),\n () => el.removeEventListener('touchstart', createWave)\n );\n\n this.instances.set(el, { cleanup });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n // Remove any lingering wave elements\n el.querySelectorAll('.vd-ripple-wave').forEach(w => w.remove());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('ripple', Ripple);\n }\n\n window.VanduoRipple = Ripple;\n\n})();\n", "/**\n * Vanduo Framework - Affix (Sticky) Component\n * Uses IntersectionObserver to toggle .is-stuck within the nearest scrollable parent\n */\n\n(function () {\n 'use strict';\n\n function isScrollable(element) {\n if (!element || element === document.body) return false;\n\n const style = window.getComputedStyle(element);\n const overflowY = style.overflowY;\n const overflowX = style.overflowX;\n const canScrollY = /(auto|scroll|overlay)/.test(overflowY) && element.scrollHeight > element.clientHeight;\n const canScrollX = /(auto|scroll|overlay)/.test(overflowX) && element.scrollWidth > element.clientWidth;\n\n return canScrollY || canScrollX;\n }\n\n function getScrollParent(element) {\n let parent = element.parentElement;\n\n while (parent && parent !== document.body && parent !== document.documentElement) {\n if (isScrollable(parent)) return parent;\n parent = parent.parentElement;\n }\n\n return null;\n }\n\n const Affix = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-affix, .vd-sticky, [data-vd-affix]');\n elements.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const parsedOffset = parseInt(el.getAttribute('data-vd-affix-offset') || '0', 10);\n const offset = Number.isNaN(parsedOffset) ? 0 : parsedOffset;\n const scrollParent = getScrollParent(el);\n let isStuck = false;\n\n const sentinel = document.createElement('div');\n sentinel.style.cssText = 'display:block;height:1px;margin-bottom:-1px;visibility:hidden;pointer-events:none;';\n el.parentNode.insertBefore(sentinel, el);\n\n el.style.setProperty('--affix-top-offset', offset + 'px');\n\n function stick() {\n if (isStuck) return;\n isStuck = true;\n el.classList.add('is-stuck');\n el.dispatchEvent(new CustomEvent('affix:stuck', {\n bubbles: true,\n detail: {\n offset: offset,\n root: scrollParent || window\n }\n }));\n }\n\n function unstick() {\n if (!isStuck) return;\n isStuck = false;\n el.classList.remove('is-stuck');\n el.dispatchEvent(new CustomEvent('affix:unstuck', {\n bubbles: true,\n detail: {\n offset: offset,\n root: scrollParent || window\n }\n }));\n }\n\n const observer = new IntersectionObserver(function (entries) {\n entries.forEach(entry => {\n if (!entry.isIntersecting) {\n stick();\n } else {\n unstick();\n }\n });\n }, {\n root: scrollParent,\n rootMargin: '-' + offset + 'px 0px 0px 0px',\n threshold: 0\n });\n\n observer.observe(sentinel);\n\n cleanup.push(\n () => observer.disconnect(),\n () => { if (sentinel.parentNode) sentinel.parentNode.removeChild(sentinel); },\n () => {\n el.classList.remove('is-stuck');\n el.style.removeProperty('--affix-top-offset');\n }\n );\n\n this.instances.set(el, { cleanup, observer, sentinel, scrollParent });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n el.classList.remove('is-stuck');\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('affix', Affix);\n }\n\n window.VanduoAffix = Affix;\n\n})();\n", "/**\n * Vanduo Framework - Suggest (Autocomplete) Component\n * Dropdown suggestion list with keyboard navigation, static/async data sources\n */\n\n(function () {\n 'use strict';\n\n /**\n * Escape HTML entities to prevent XSS when inserting into innerHTML\n * @param {string} text\n * @returns {string}\n */\n function _escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Allow same-origin URLs by default, or an explicit allowlist of origins.\n * @param {string} url\n * @param {string[]} allowlist\n * @returns {boolean}\n */\n function _isSafeUrl(url, allowlist) {\n try {\n const resolved = new URL(url, window.location.href);\n if (resolved.origin === window.location.origin) return true;\n return allowlist.includes(resolved.origin);\n } catch (_e) {\n return false;\n }\n }\n\n const Suggest = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-suggest], [data-vd-autocomplete]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const minChars = parseInt(input.getAttribute('data-vd-suggest-min-chars') || '1', 10);\n const url = input.getAttribute('data-vd-suggest-url') || '';\n const allowlistAttr = input.getAttribute('data-vd-suggest-allowlist') || '';\n const allowlist = allowlistAttr\n .split(',')\n .map(value => value.trim())\n .filter(Boolean);\n const staticData = input.getAttribute('data-vd-suggest') || input.getAttribute('data-vd-autocomplete') || '';\n let items = [];\n\n try { items = JSON.parse(staticData); } catch (_e) {\n items = staticData.split(',').map(s => s.trim()).filter(Boolean);\n }\n\n // Wrap input if not already wrapped\n let wrapper = input.closest('.vd-suggest-wrapper, .vd-autocomplete-wrapper');\n if (!wrapper) {\n wrapper = document.createElement('div');\n wrapper.className = 'vd-suggest-wrapper';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n }\n\n // Create dropdown list\n const list = document.createElement('ul');\n list.className = 'vd-suggest-list';\n list.setAttribute('role', 'listbox');\n const listId = 'vd-suggest-' + Math.random().toString(36).slice(2, 9);\n list.id = listId;\n wrapper.appendChild(list);\n\n // ARIA\n input.setAttribute('role', 'combobox');\n input.setAttribute('aria-autocomplete', 'list');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('aria-controls', listId);\n input.setAttribute('autocomplete', 'off');\n\n let highlighted = -1;\n let currentItems = [];\n let debounceTimer = null;\n\n const renderItems = (filtered, query) => {\n list.innerHTML = '';\n currentItems = filtered;\n highlighted = -1;\n\n if (filtered.length === 0) {\n const empty = document.createElement('li');\n empty.className = 'vd-suggest-empty';\n empty.textContent = 'No results';\n list.appendChild(empty);\n return;\n }\n\n filtered.forEach((item, i) => {\n const li = document.createElement('li');\n li.className = 'vd-suggest-item';\n li.setAttribute('role', 'option');\n li.id = listId + '-item-' + i;\n\n const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);\n if (query) {\n // Escape HTML first to prevent XSS, then highlight matches in the safe string\n const escaped = _escapeHtml(text);\n const re = new RegExp('(' + query.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi');\n li.innerHTML = escaped.replace(re, '$1');\n } else {\n li.textContent = text;\n }\n\n li.addEventListener('click', () => selectItem(i));\n list.appendChild(li);\n });\n };\n\n const open = () => {\n list.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n };\n\n const close = () => {\n list.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n highlighted = -1;\n input.removeAttribute('aria-activedescendant');\n };\n\n const selectItem = (index) => {\n const item = currentItems[index];\n const value = typeof item === 'object' ? (item.value || item.label || String(item)) : String(item);\n input.value = value;\n close();\n input.dispatchEvent(new CustomEvent('suggest:select', {\n detail: { value, item, index },\n bubbles: true\n }));\n };\n\n const highlight = (index) => {\n const listItems = list.querySelectorAll('.vd-suggest-item');\n listItems.forEach(li => li.classList.remove('is-highlighted'));\n if (index >= 0 && index < listItems.length) {\n highlighted = index;\n listItems[index].classList.add('is-highlighted');\n input.setAttribute('aria-activedescendant', listItems[index].id);\n listItems[index].scrollIntoView({ block: 'nearest' });\n }\n };\n\n const doSearch = async (query) => {\n if (query.length < minChars) { close(); return; }\n\n let filtered;\n if (url) {\n try {\n if (!_isSafeUrl(url, allowlist)) {\n console.warn('[VanduoSuggest] Blocked non-allowlisted URL:', url);\n filtered = [];\n } else {\n const separator = url.includes('?') ? '&' : '?';\n const res = await window.fetch(url + separator + 'q=' + encodeURIComponent(query));\n filtered = await res.json();\n }\n } catch (_e) {\n filtered = [];\n }\n } else {\n const lower = query.toLowerCase();\n filtered = items.filter(item => {\n const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);\n return text.toLowerCase().includes(lower);\n });\n }\n\n renderItems(filtered, query);\n if (filtered.length > 0) open();\n else open(); // show \"no results\"\n };\n\n const inputHandler = () => {\n clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => doSearch(input.value), 200);\n };\n\n const keyHandler = (e) => {\n if (!list.classList.contains('is-open')) {\n if (e.key === 'ArrowDown') { doSearch(input.value); e.preventDefault(); }\n return;\n }\n\n const total = currentItems.length;\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n highlight(highlighted < total - 1 ? highlighted + 1 : 0);\n break;\n case 'ArrowUp':\n e.preventDefault();\n highlight(highlighted > 0 ? highlighted - 1 : total - 1);\n break;\n case 'Enter':\n e.preventDefault();\n if (highlighted >= 0) selectItem(highlighted);\n break;\n case 'Escape':\n close();\n break;\n }\n };\n\n const blurHandler = () => {\n setTimeout(close, 200);\n };\n\n input.addEventListener('input', inputHandler);\n input.addEventListener('keydown', keyHandler);\n input.addEventListener('blur', blurHandler);\n input.addEventListener('focus', () => { if (input.value.length >= minChars) doSearch(input.value); });\n\n cleanup.push(\n () => input.removeEventListener('input', inputHandler),\n () => input.removeEventListener('keydown', keyHandler),\n () => input.removeEventListener('blur', blurHandler),\n () => clearTimeout(debounceTimer),\n () => { if (list.parentNode) list.parentNode.removeChild(list); }\n );\n\n this.instances.set(input, { cleanup, list, close });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('suggest', Suggest);\n }\n\n window.VanduoSuggest = Suggest;\n\n})();\n", "/**\n * Vanduo Framework - Validate (Form Validation) Component\n * Declarative validation via data attributes with real-time and on-submit modes\n */\n\n(function () {\n 'use strict';\n\n const Validate = {\n instances: new Map(),\n\n rules: {\n required: (value) => value.trim().length > 0,\n email: (value) => /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(value),\n url: (value) => { try { new URL(value); return true; } catch (_e) { return false; } },\n number: (value) => !isNaN(parseFloat(value)) && isFinite(value),\n min: (value, param) => value.length >= parseInt(param, 10),\n max: (value, param) => value.length <= parseInt(param, 10),\n minVal: (value, param) => parseFloat(value) >= parseFloat(param),\n maxVal: (value, param) => parseFloat(value) <= parseFloat(param),\n pattern: (value, param) => {\n try {\n // Cap regex length to prevent ReDoS from excessively complex patterns\n if (param.length > 100) return false;\n return new RegExp(param).test(value);\n } catch (_e) { return false; }\n },\n match: (value, param) => {\n try {\n const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(param) : param;\n const other = document.querySelector('[name=\"' + escaped + '\"]');\n return other ? value === other.value : false;\n } catch (_e) {\n return false;\n }\n }\n },\n\n messages: {\n required: 'This field is required',\n email: 'Please enter a valid email address',\n url: 'Please enter a valid URL',\n number: 'Please enter a valid number',\n min: 'Minimum {0} characters required',\n max: 'Maximum {0} characters allowed',\n minVal: 'Value must be at least {0}',\n maxVal: 'Value must be at most {0}',\n pattern: 'Invalid format',\n match: 'Fields do not match'\n },\n\n init: function () {\n const forms = document.querySelectorAll('[data-vd-validate], .vd-validate');\n forms.forEach(form => {\n if (this.instances.has(form)) return;\n this.initInstance(form);\n });\n },\n\n initInstance: function (form) {\n const cleanup = [];\n const mode = form.getAttribute('data-vd-validate-mode') || 'blur'; // blur | input | submit\n const fields = form.querySelectorAll('[data-vd-rules]');\n\n const validateField = (field) => {\n const rulesStr = field.getAttribute('data-vd-rules') || '';\n const rules = rulesStr.split('|').map(r => r.trim()).filter(Boolean);\n const value = field.value;\n const errors = [];\n\n for (const rule of rules) {\n const [name, ...params] = rule.split(':');\n const param = params.join(':');\n const validator = this.rules[name];\n\n if (validator && !validator(value, param)) {\n const customMsg = field.getAttribute('data-vd-msg-' + name);\n let msg = customMsg || this.messages[name] || 'Invalid';\n if (param) msg = msg.replace('{0}', param);\n errors.push(msg);\n break; // one error at a time\n }\n }\n\n this.setFieldState(field, errors);\n return errors.length === 0;\n };\n\n const validateAll = () => {\n let valid = true;\n fields.forEach(field => {\n if (!validateField(field)) valid = false;\n });\n return valid;\n };\n\n // Per-field listeners\n fields.forEach(field => {\n if (mode === 'input' || mode === 'blur') {\n const eventType = mode === 'input' ? 'input' : 'blur';\n const handler = () => validateField(field);\n field.addEventListener(eventType, handler);\n cleanup.push(() => field.removeEventListener(eventType, handler));\n\n if (mode === 'blur') {\n const inputClear = () => {\n if (field.classList.contains('is-invalid') || field.classList.contains('is-valid')) {\n validateField(field);\n }\n };\n field.addEventListener('input', inputClear);\n cleanup.push(() => field.removeEventListener('input', inputClear));\n }\n }\n });\n\n // Form submit\n const submitHandler = (e) => {\n const valid = validateAll();\n if (!valid) {\n e.preventDefault();\n e.stopPropagation();\n // Focus first invalid field\n const firstInvalid = form.querySelector('.is-invalid');\n if (firstInvalid) firstInvalid.focus();\n }\n form.dispatchEvent(new CustomEvent('validate:submit', {\n detail: { valid },\n bubbles: true\n }));\n };\n\n form.addEventListener('submit', submitHandler);\n cleanup.push(() => form.removeEventListener('submit', submitHandler));\n\n this.instances.set(form, { cleanup, validateAll, validateField });\n },\n\n setFieldState: function (field, errors) {\n const wrapper = field.closest('.vd-form-group') || field.parentElement;\n let errorEl = wrapper.querySelector('.vd-validate-error');\n\n field.classList.remove('is-valid', 'is-invalid');\n\n if (errors.length > 0) {\n field.classList.add('is-invalid');\n field.setAttribute('aria-invalid', 'true');\n if (!errorEl) {\n errorEl = document.createElement('div');\n errorEl.className = 'vd-validate-error';\n errorEl.id = 'vd-err-' + Math.random().toString(36).slice(2, 9);\n errorEl.setAttribute('role', 'alert');\n wrapper.appendChild(errorEl);\n }\n errorEl.textContent = errors[0];\n errorEl.style.display = '';\n field.setAttribute('aria-describedby', errorEl.id);\n } else if (field.value.trim()) {\n field.classList.add('is-valid');\n field.removeAttribute('aria-invalid');\n if (errorEl) errorEl.style.display = 'none';\n } else {\n field.removeAttribute('aria-invalid');\n if (errorEl) errorEl.style.display = 'none';\n }\n },\n\n validateForm: function (form) {\n const instance = this.instances.get(form);\n return instance ? instance.validateAll() : false;\n },\n\n addRule: function (name, validator, message) {\n this.rules[name] = validator;\n if (message) this.messages[name] = message;\n },\n\n destroy: function (form) {\n const instance = this.instances.get(form);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(form);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, form) => this.destroy(form));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('validate', Validate);\n }\n\n window.VanduoValidate = Validate;\n\n})();\n", "/**\n * Vanduo Framework - Datepicker Component\n * Calendar popup attached to input field with month/year navigation\n */\n\n(function () {\n 'use strict';\n\n const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];\n const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];\n\n function escapeRegexChar(c) {\n return c.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n }\n\n function buildParseFormat(format) {\n let regex = '^';\n const order = [];\n let i = 0;\n while (i < format.length) {\n const slice = format.slice(i);\n if (slice.toLowerCase().startsWith('yyyy')) {\n regex += '(\\\\d{4})';\n order.push('y');\n i += 4;\n } else if (slice.toLowerCase().startsWith('mm')) {\n regex += '(\\\\d{2})';\n order.push('m');\n i += 2;\n } else if (slice.toLowerCase().startsWith('dd')) {\n regex += '(\\\\d{2})';\n order.push('d');\n i += 2;\n } else {\n regex += escapeRegexChar(format[i]);\n i++;\n }\n }\n regex += '$';\n return { regex: new RegExp(regex), order };\n }\n\n function parseDateFromFormat(value, format) {\n if (!value || !format) return null;\n const { regex, order } = buildParseFormat(format);\n const m = value.trim().match(regex);\n if (!m) return null;\n let y;\n let mo;\n let d;\n let ci = 1;\n for (let k = 0; k < order.length; k++) {\n const part = order[k];\n const v = parseInt(m[ci++], 10);\n if (Number.isNaN(v)) return null;\n if (part === 'y') y = v;\n else if (part === 'm') mo = v - 1;\n else if (part === 'd') d = v;\n }\n if (y === undefined || mo === undefined || d === undefined) return null;\n const dt = new Date(y, mo, d);\n if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== d) return null;\n return dt;\n }\n\n function formatDate(d, format) {\n const yyyy = String(d.getFullYear());\n const mm = String(d.getMonth() + 1).padStart(2, '0');\n const dd = String(d.getDate()).padStart(2, '0');\n let out = '';\n let i = 0;\n while (i < format.length) {\n const slice = format.slice(i);\n if (slice.toLowerCase().startsWith('yyyy')) {\n out += yyyy;\n i += 4;\n } else if (slice.toLowerCase().startsWith('mm')) {\n out += mm;\n i += 2;\n } else if (slice.toLowerCase().startsWith('dd')) {\n out += dd;\n i += 2;\n } else {\n out += format[i];\n i++;\n }\n }\n return out;\n }\n\n function dateKey(d) {\n return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');\n }\n\n function addDays(d, n) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n x.setDate(x.getDate() + n);\n return x;\n }\n\n function addMonthsClamped(d, n) {\n return new Date(d.getFullYear(), d.getMonth() + n, d.getDate());\n }\n\n function parseYmdLocal(ymd) {\n if (!ymd || typeof ymd !== 'string') return null;\n const m = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(ymd.trim());\n if (!m) return null;\n const y = +m[1];\n const mo = +m[2] - 1;\n const day = +m[3];\n const dt = new Date(y, mo, day);\n if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== day) return null;\n return dt;\n }\n\n function startOfWeekSunday(d) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const day = x.getDay();\n x.setDate(x.getDate() - day);\n return x;\n }\n\n function endOfWeekSunday(d) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const day = x.getDay();\n x.setDate(x.getDate() + (6 - day));\n return x;\n }\n\n const Datepicker = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-datepicker]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const format = input.getAttribute('data-vd-datepicker-format') || 'YYYY-MM-DD';\n const minStr = input.getAttribute('data-vd-datepicker-min');\n const maxStr = input.getAttribute('data-vd-datepicker-max');\n const minDate = minStr ? parseYmdLocal(minStr) : null;\n const maxDate = maxStr ? parseYmdLocal(maxStr) : null;\n\n const today = new Date();\n let viewYear = today.getFullYear();\n let viewMonth = today.getMonth();\n let selectedDate = null;\n let viewMode = 'days'; // days | months | years\n let focusedDate = null;\n /** Prevents focus() after close from immediately re-opening the popup */\n let skipNextFocusOpen = false;\n\n const isDisabled = (d) => {\n if (minDate) {\n const t = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n if (t < minDate.getTime()) return true;\n }\n if (maxDate) {\n const t = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n if (t > maxDate.getTime()) return true;\n }\n return false;\n };\n\n const ensureMonthInRange = (y, m) => {\n if (!minDate && !maxDate) return { y: y, m: m };\n const first = new Date(y, m, 1);\n const last = new Date(y, m + 1, 0);\n if (minDate && last.getTime() < minDate.getTime()) {\n return { y: minDate.getFullYear(), m: minDate.getMonth() };\n }\n if (maxDate && first.getTime() > maxDate.getTime()) {\n return { y: maxDate.getFullYear(), m: maxDate.getMonth() };\n }\n return { y: y, m: m };\n };\n\n const firstSelectableInMonth = (y, m) => {\n const last = new Date(y, m + 1, 0).getDate();\n for (let day = 1; day <= last; day++) {\n const dt = new Date(y, m, day);\n if (!isDisabled(dt)) return dt;\n }\n return new Date(y, m, 1);\n };\n\n if (input.value) {\n const trimmed = input.value.trim();\n let parsed = parseDateFromFormat(trimmed, format);\n if (!parsed) {\n const fallback = new Date(trimmed);\n if (!isNaN(fallback.getTime())) parsed = fallback;\n }\n if (parsed) {\n selectedDate = parsed;\n viewYear = parsed.getFullYear();\n viewMonth = parsed.getMonth();\n }\n }\n\n const clampedInit = ensureMonthInRange(viewYear, viewMonth);\n viewYear = clampedInit.y;\n viewMonth = clampedInit.m;\n\n // Create popup\n const popup = document.createElement('div');\n popup.className = 'vd-datepicker-popup';\n popup.setAttribute('role', 'dialog');\n popup.setAttribute('aria-label', 'Choose date');\n popup.tabIndex = -1;\n\n const wrapper = document.createElement('div');\n wrapper.className = 'vd-suggest-wrapper';\n wrapper.style.position = 'relative';\n wrapper.style.display = 'inline-block';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n wrapper.appendChild(popup);\n\n const isSameDay = (a, b) => a && b &&\n a.getFullYear() === b.getFullYear() &&\n a.getMonth() === b.getMonth() &&\n a.getDate() === b.getDate();\n\n const selectDate = (date) => {\n selectedDate = date;\n viewYear = date.getFullYear();\n viewMonth = date.getMonth();\n input.value = formatDate(date, format);\n skipNextFocusOpen = true;\n close();\n input.dispatchEvent(new CustomEvent('datepicker:select', {\n detail: { date: date, formatted: input.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n input.focus();\n };\n\n const focusFocusedDay = () => {\n if (viewMode !== 'days' || !focusedDate) return;\n const key = dateKey(focusedDate);\n const btn = popup.querySelector('[data-vd-date=\"' + key + '\"]');\n if (btn && !btn.classList.contains('is-outside') && btn.getAttribute('aria-disabled') !== 'true') {\n btn.focus();\n }\n };\n\n const skipDisabled = (d, stepDir, maxSteps) => {\n let x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const step = stepDir > 0 ? 1 : -1;\n for (let i = 0; i < maxSteps; i++) {\n if (!isDisabled(x)) return x;\n x = addDays(x, step);\n }\n return d;\n };\n\n const createDayBtn = (day, outside, date) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-day';\n btn.textContent = day;\n btn.setAttribute('role', 'gridcell');\n\n if (outside) {\n btn.classList.add('is-outside');\n btn.tabIndex = -1;\n btn.setAttribute('aria-disabled', 'true');\n return btn;\n }\n\n btn.setAttribute('data-vd-date', dateKey(date));\n\n if (date && isSameDay(date, today)) btn.classList.add('is-today');\n if (date && isSameDay(date, selectedDate)) btn.classList.add('is-selected');\n if (date && isDisabled(date)) {\n btn.classList.add('is-disabled');\n btn.setAttribute('aria-disabled', 'true');\n btn.tabIndex = -1;\n return btn;\n }\n\n if (date) {\n const isFocused = focusedDate && isSameDay(date, focusedDate);\n btn.tabIndex = isFocused ? 0 : -1;\n\n btn.addEventListener('click', () => {\n selectedDate = date;\n viewYear = date.getFullYear();\n viewMonth = date.getMonth();\n focusedDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());\n input.value = formatDate(date, format);\n skipNextFocusOpen = true;\n close();\n input.dispatchEvent(new CustomEvent('datepicker:select', {\n detail: { date: date, formatted: input.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n input.focus();\n });\n }\n\n return btn;\n };\n\n const render = () => {\n popup.innerHTML = '';\n\n // Header\n const header = document.createElement('div');\n header.className = 'vd-datepicker-header';\n\n const prevBtn = document.createElement('button');\n prevBtn.type = 'button';\n prevBtn.className = 'vd-datepicker-prev';\n prevBtn.innerHTML = '‹';\n prevBtn.setAttribute('aria-label', 'Previous');\n\n const nextBtn = document.createElement('button');\n nextBtn.type = 'button';\n nextBtn.className = 'vd-datepicker-next';\n nextBtn.innerHTML = '›';\n nextBtn.setAttribute('aria-label', 'Next');\n\n const title = document.createElement('span');\n title.className = 'vd-datepicker-title';\n\n if (viewMode === 'days') {\n title.textContent = MONTHS[viewMonth] + ' ' + viewYear;\n title.addEventListener('click', () => { viewMode = 'months'; render(); });\n prevBtn.addEventListener('click', () => { viewMonth--; if (viewMonth < 0) { viewMonth = 11; viewYear--; } render(); });\n nextBtn.addEventListener('click', () => { viewMonth++; if (viewMonth > 11) { viewMonth = 0; viewYear++; } render(); });\n } else if (viewMode === 'months') {\n title.textContent = String(viewYear);\n title.addEventListener('click', () => { viewMode = 'years'; render(); });\n prevBtn.addEventListener('click', () => { viewYear--; render(); });\n nextBtn.addEventListener('click', () => { viewYear++; render(); });\n } else {\n const decadeStart = Math.floor(viewYear / 10) * 10;\n title.textContent = decadeStart + ' - ' + (decadeStart + 9);\n prevBtn.addEventListener('click', () => { viewYear -= 10; render(); });\n nextBtn.addEventListener('click', () => { viewYear += 10; render(); });\n }\n\n header.appendChild(prevBtn);\n header.appendChild(title);\n header.appendChild(nextBtn);\n popup.appendChild(header);\n\n if (viewMode === 'days') {\n const gridWrap = document.createElement('div');\n gridWrap.className = 'vd-datepicker-grid';\n gridWrap.setAttribute('role', 'grid');\n gridWrap.setAttribute('aria-label', 'Calendar');\n\n const weekdays = document.createElement('div');\n weekdays.className = 'vd-datepicker-weekdays';\n weekdays.setAttribute('role', 'row');\n DAYS.forEach(function (d) {\n const span = document.createElement('span');\n span.setAttribute('role', 'columnheader');\n span.setAttribute('aria-label', d);\n span.textContent = d;\n weekdays.appendChild(span);\n });\n gridWrap.appendChild(weekdays);\n\n const firstDay = new Date(viewYear, viewMonth, 1).getDay();\n const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();\n const daysInPrev = new Date(viewYear, viewMonth, 0).getDate();\n\n const cells = [];\n\n for (let i = firstDay - 1; i >= 0; i--) {\n const dayNum = daysInPrev - i;\n const prevMonth = viewMonth === 0 ? 11 : viewMonth - 1;\n const prevYear = viewMonth === 0 ? viewYear - 1 : viewYear;\n const date = new Date(prevYear, prevMonth, dayNum);\n cells.push({ day: dayNum, outside: true, date: date });\n }\n\n for (let d = 1; d <= daysInMonth; d++) {\n const date = new Date(viewYear, viewMonth, d);\n cells.push({ day: d, outside: false, date: date });\n }\n\n const totalCells = firstDay + daysInMonth;\n const remaining = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7);\n for (let i = 1; i <= remaining; i++) {\n const date = new Date(viewYear, viewMonth + 1, i);\n cells.push({ day: i, outside: true, date: date });\n }\n\n for (let r = 0; r < cells.length; r += 7) {\n const row = document.createElement('div');\n row.className = 'vd-datepicker-row';\n row.setAttribute('role', 'row');\n for (let c = 0; c < 7; c++) {\n const cell = cells[r + c];\n row.appendChild(createDayBtn(cell.day, cell.outside, cell.date));\n }\n gridWrap.appendChild(row);\n }\n\n popup.appendChild(gridWrap);\n } else if (viewMode === 'months') {\n const grid = document.createElement('div');\n grid.className = 'vd-datepicker-months';\n MONTHS.forEach((name, i) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-month-btn';\n btn.textContent = name.slice(0, 3);\n if (selectedDate && selectedDate.getFullYear() === viewYear && selectedDate.getMonth() === i) {\n btn.classList.add('is-selected');\n }\n btn.addEventListener('click', () => { viewMonth = i; viewMode = 'days'; render(); });\n grid.appendChild(btn);\n });\n popup.appendChild(grid);\n } else {\n const grid = document.createElement('div');\n grid.className = 'vd-datepicker-years';\n const decadeStart = Math.floor(viewYear / 10) * 10;\n for (let y = decadeStart - 1; y <= decadeStart + 10; y++) {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-year-btn';\n btn.textContent = y;\n if (selectedDate && selectedDate.getFullYear() === y) btn.classList.add('is-selected');\n if (y < decadeStart || y > decadeStart + 9) btn.style.opacity = '0.4';\n btn.addEventListener('click', () => { viewYear = y; viewMode = 'months'; render(); });\n grid.appendChild(btn);\n }\n popup.appendChild(grid);\n }\n };\n\n const handleGridKeydown = (e) => {\n if (!popup.classList.contains('is-open') || viewMode !== 'days') return;\n const grid = popup.querySelector('.vd-datepicker-grid');\n if (!grid || !grid.contains(e.target)) return;\n\n const key = e.key;\n if (key !== 'ArrowLeft' && key !== 'ArrowRight' && key !== 'ArrowUp' && key !== 'ArrowDown' &&\n key !== 'Home' && key !== 'End' && key !== 'PageUp' && key !== 'PageDown' &&\n key !== 'Enter' && key !== ' ' && key !== 'Escape') {\n return;\n }\n\n if (key === 'Escape') {\n e.preventDefault();\n e.stopPropagation();\n skipNextFocusOpen = true;\n close();\n input.focus();\n return;\n }\n\n if (!focusedDate) {\n focusedDate = firstSelectableInMonth(viewYear, viewMonth);\n }\n\n if (key === 'Enter' || key === ' ') {\n e.preventDefault();\n if (focusedDate && !isDisabled(focusedDate)) {\n selectDate(new Date(focusedDate.getFullYear(), focusedDate.getMonth(), focusedDate.getDate()));\n }\n return;\n }\n\n e.preventDefault();\n\n let next = new Date(focusedDate.getFullYear(), focusedDate.getMonth(), focusedDate.getDate());\n let skipDir = 1;\n\n if (key === 'ArrowLeft') {\n next = addDays(next, -1);\n skipDir = -1;\n } else if (key === 'ArrowRight') {\n next = addDays(next, 1);\n skipDir = 1;\n } else if (key === 'ArrowUp') {\n next = addDays(next, -7);\n skipDir = -1;\n } else if (key === 'ArrowDown') {\n next = addDays(next, 7);\n skipDir = 1;\n } else if (key === 'Home') {\n next = startOfWeekSunday(next);\n skipDir = 1;\n } else if (key === 'End') {\n next = endOfWeekSunday(next);\n skipDir = -1;\n } else if (key === 'PageUp') {\n next = addMonthsClamped(next, -1);\n skipDir = -1;\n } else if (key === 'PageDown') {\n next = addMonthsClamped(next, 1);\n skipDir = 1;\n }\n\n next = skipDisabled(next, skipDir, 400);\n\n if (next.getMonth() !== viewMonth || next.getFullYear() !== viewYear) {\n viewYear = next.getFullYear();\n viewMonth = next.getMonth();\n const cl = ensureMonthInRange(viewYear, viewMonth);\n viewYear = cl.y;\n viewMonth = cl.m;\n }\n\n focusedDate = next;\n render();\n requestAnimationFrame(focusFocusedDay);\n };\n\n const open = () => {\n viewMode = 'days';\n if (selectedDate) {\n viewYear = selectedDate.getFullYear();\n viewMonth = selectedDate.getMonth();\n }\n const cl = ensureMonthInRange(viewYear, viewMonth);\n viewYear = cl.y;\n viewMonth = cl.m;\n\n if (selectedDate) {\n focusedDate = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate());\n } else {\n focusedDate = firstSelectableInMonth(viewYear, viewMonth);\n }\n\n render();\n popup.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n requestAnimationFrame(focusFocusedDay);\n };\n\n const close = () => {\n popup.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n viewMode = 'days';\n };\n\n const focusHandler = () => {\n if (skipNextFocusOpen) {\n skipNextFocusOpen = false;\n return;\n }\n open();\n };\n const outsideHandler = (e) => {\n if (!wrapper.contains(e.target)) close();\n };\n const escHandler = (e) => {\n if (e.key === 'Escape' && popup.classList.contains('is-open')) {\n skipNextFocusOpen = true;\n close();\n input.focus();\n }\n };\n\n input.addEventListener('focus', focusHandler);\n document.addEventListener('click', outsideHandler, true);\n document.addEventListener('keydown', escHandler);\n popup.addEventListener('keydown', handleGridKeydown);\n\n input.setAttribute('aria-haspopup', 'dialog');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('autocomplete', 'off');\n\n cleanup.push(\n () => input.removeEventListener('focus', focusHandler),\n () => document.removeEventListener('click', outsideHandler, true),\n () => document.removeEventListener('keydown', escHandler),\n () => popup.removeEventListener('keydown', handleGridKeydown)\n );\n\n this.instances.set(input, { cleanup: cleanup, open: open, close: close, popup: popup });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('datepicker', Datepicker);\n }\n\n window.VanduoDatepicker = Datepicker;\n\n})();\n", "/**\n * Vanduo Framework - Timepicker Component\n * Dropdown time selection with 12h/24h format and configurable step intervals\n */\n\n(function () {\n 'use strict';\n\n const Timepicker = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-timepicker]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const is24h = input.getAttribute('data-vd-timepicker-format') === '24h';\n const step = parseInt(input.getAttribute('data-vd-timepicker-step') || '30', 10);\n\n // Create wrapper\n let wrapper = input.closest('.vd-suggest-wrapper');\n if (!wrapper) {\n wrapper = document.createElement('div');\n wrapper.style.position = 'relative';\n wrapper.style.display = 'inline-block';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n }\n\n // Create popup\n const popup = document.createElement('div');\n popup.className = 'vd-timepicker-popup';\n popup.setAttribute('role', 'listbox');\n wrapper.appendChild(popup);\n\n // Generate time slots\n const times = [];\n for (let h = 0; h < 24; h++) {\n for (let m = 0; m < 60; m += step) {\n const hh24 = String(h).padStart(2, '0');\n const mm = String(m).padStart(2, '0');\n\n if (is24h) {\n times.push({ display: hh24 + ':' + mm, value: hh24 + ':' + mm });\n } else {\n const period = h < 12 ? 'AM' : 'PM';\n const h12 = h === 0 ? 12 : (h > 12 ? h - 12 : h);\n const display = h12 + ':' + mm + ' ' + period;\n times.push({ display, value: hh24 + ':' + mm });\n }\n }\n }\n\n const render = () => {\n popup.innerHTML = '';\n times.forEach(t => {\n const item = document.createElement('div');\n item.className = 'vd-timepicker-item';\n item.setAttribute('role', 'option');\n item.textContent = t.display;\n\n if (input.value === t.value || input.value === t.display) {\n item.classList.add('is-selected');\n }\n\n item.addEventListener('click', () => {\n input.value = t.display;\n popup.querySelectorAll('.vd-timepicker-item').forEach(i => i.classList.remove('is-selected'));\n item.classList.add('is-selected');\n close();\n input.dispatchEvent(new CustomEvent('timepicker:select', {\n detail: { display: t.display, value: t.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n });\n\n popup.appendChild(item);\n });\n };\n\n const open = () => {\n render();\n popup.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n // Scroll to selected\n const selected = popup.querySelector('.is-selected');\n if (selected) selected.scrollIntoView({ block: 'center' });\n };\n\n const close = () => {\n popup.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n };\n\n const focusHandler = () => open();\n const outsideHandler = (e) => {\n if (!wrapper.contains(e.target)) close();\n };\n const escHandler = (e) => { if (e.key === 'Escape') close(); };\n\n input.addEventListener('focus', focusHandler);\n document.addEventListener('click', outsideHandler, true);\n document.addEventListener('keydown', escHandler);\n input.setAttribute('aria-haspopup', 'listbox');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('autocomplete', 'off');\n input.readOnly = true;\n\n cleanup.push(\n () => input.removeEventListener('focus', focusHandler),\n () => document.removeEventListener('click', outsideHandler, true),\n () => document.removeEventListener('keydown', escHandler)\n );\n\n this.instances.set(input, { cleanup, open, close });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('timepicker', Timepicker);\n }\n\n window.VanduoTimepicker = Timepicker;\n\n})();\n", "/**\n * Vanduo Framework - Stepper Component\n * Multi-step progress indicator with state management\n */\n\n(function () {\n 'use strict';\n\n const Stepper = {\n instances: new Map(),\n\n init: function () {\n const steppers = document.querySelectorAll('.vd-stepper');\n steppers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const items = Array.from(el.querySelectorAll('.vd-stepper-item'));\n const isClickable = el.classList.contains('vd-stepper-clickable');\n let currentIndex = items.findIndex(i => i.classList.contains('is-active'));\n if (currentIndex === -1) currentIndex = 0;\n\n const setStep = (index) => {\n if (index < 0 || index >= items.length) return;\n const prev = currentIndex;\n currentIndex = index;\n\n items.forEach((item, i) => {\n item.classList.remove('is-active', 'is-completed');\n if (i < index) item.classList.add('is-completed');\n else if (i === index) item.classList.add('is-active');\n });\n\n el.dispatchEvent(new CustomEvent('stepper:change', {\n detail: { current: index, previous: prev, total: items.length },\n bubbles: true\n }));\n };\n\n if (isClickable) {\n items.forEach((item, i) => {\n const handler = () => setStep(i);\n item.addEventListener('click', handler);\n cleanup.push(() => item.removeEventListener('click', handler));\n });\n }\n\n // Initialize current state\n setStep(currentIndex);\n\n this.instances.set(el, {\n cleanup,\n setStep,\n next: () => setStep(currentIndex + 1),\n prev: () => setStep(currentIndex - 1),\n getCurrent: () => currentIndex\n });\n },\n\n setStep: function (el, index) {\n const inst = this.instances.get(el);\n if (inst) inst.setStep(index);\n },\n\n next: function (el) {\n const inst = this.instances.get(el);\n if (inst) inst.next();\n },\n\n prev: function (el) {\n const inst = this.instances.get(el);\n if (inst) inst.prev();\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('stepper', Stepper);\n }\n\n window.VanduoStepper = Stepper;\n\n})();\n", "/**\n * Vanduo Framework - Rating Component\n * Star-based rating input with hover preview and read-only mode\n */\n\n(function () {\n 'use strict';\n\n const Rating = {\n instances: new Map(),\n\n init: function () {\n const ratings = document.querySelectorAll('[data-vd-rating]');\n ratings.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const max = parseInt(el.getAttribute('data-vd-rating-max') || '5', 10);\n const initialValue = parseFloat(el.getAttribute('data-vd-rating-value') || '0');\n const readonly = el.classList.contains('vd-rating-readonly') || el.hasAttribute('data-vd-rating-readonly');\n let currentValue = initialValue;\n\n el.classList.add('vd-rating');\n el.setAttribute('role', 'radiogroup');\n el.setAttribute('aria-label', el.getAttribute('aria-label') || 'Rating');\n\n // Clear existing stars\n el.innerHTML = '';\n\n // Create stars\n const stars = [];\n for (let i = 1; i <= max; i++) {\n const star = document.createElement('button');\n star.type = 'button';\n star.className = 'vd-rating-star';\n star.setAttribute('role', 'radio');\n star.setAttribute('aria-label', i + ' star' + (i > 1 ? 's' : ''));\n star.setAttribute('aria-checked', i <= currentValue ? 'true' : 'false');\n if (readonly) star.tabIndex = -1;\n stars.push(star);\n el.appendChild(star);\n }\n\n // Value display\n const valueDisplay = document.createElement('span');\n valueDisplay.className = 'vd-rating-value';\n valueDisplay.textContent = currentValue > 0 ? currentValue.toString() : '';\n el.appendChild(valueDisplay);\n\n const updateStars = (value) => {\n stars.forEach((star, i) => {\n star.classList.remove('is-active', 'is-half');\n const starNum = i + 1;\n if (starNum <= Math.floor(value)) {\n star.classList.add('is-active');\n } else if (starNum - 0.5 <= value) {\n star.classList.add('is-half');\n }\n star.setAttribute('aria-checked', starNum <= value ? 'true' : 'false');\n });\n valueDisplay.textContent = value > 0 ? value.toString() : '';\n };\n\n updateStars(currentValue);\n\n if (!readonly) {\n stars.forEach((star, i) => {\n const enterHandler = () => {\n stars.forEach((s, j) => {\n s.classList.toggle('is-hovered', j <= i);\n });\n };\n const leaveHandler = () => {\n stars.forEach(s => s.classList.remove('is-hovered'));\n };\n const clickHandler = () => {\n currentValue = i + 1;\n el.setAttribute('data-vd-rating-value', currentValue);\n updateStars(currentValue);\n el.dispatchEvent(new CustomEvent('rating:change', {\n detail: { value: currentValue, max },\n bubbles: true\n }));\n };\n\n star.addEventListener('mouseenter', enterHandler);\n star.addEventListener('mouseleave', leaveHandler);\n star.addEventListener('click', clickHandler);\n\n cleanup.push(\n () => star.removeEventListener('mouseenter', enterHandler),\n () => star.removeEventListener('mouseleave', leaveHandler),\n () => star.removeEventListener('click', clickHandler)\n );\n });\n\n // Keyboard\n const keyHandler = (e) => {\n if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {\n e.preventDefault();\n if (currentValue < max) {\n currentValue++;\n updateStars(currentValue);\n stars[currentValue - 1].focus();\n el.dispatchEvent(new CustomEvent('rating:change', { detail: { value: currentValue, max }, bubbles: true }));\n }\n } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {\n e.preventDefault();\n if (currentValue > 1) {\n currentValue--;\n updateStars(currentValue);\n stars[currentValue - 1].focus();\n el.dispatchEvent(new CustomEvent('rating:change', { detail: { value: currentValue, max }, bubbles: true }));\n }\n }\n };\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n }\n\n this.instances.set(el, {\n cleanup,\n getValue: () => currentValue,\n setValue: (v) => { currentValue = v; updateStars(v); }\n });\n },\n\n getValue: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getValue() : 0;\n },\n\n setValue: function (el, value) {\n const inst = this.instances.get(el);\n if (inst) inst.setValue(value);\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('rating', Rating);\n }\n\n window.VanduoRating = Rating;\n\n})();\n", "/**\n * Vanduo Framework - Transfer (Dual-list) Component\n * Source-to-target list transfer with search, select-all, and move actions\n */\n\n(function () {\n 'use strict';\n\n const Transfer = {\n instances: new Map(),\n\n init: function () {\n const transfers = document.querySelectorAll('[data-vd-transfer]');\n transfers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n el.classList.add('vd-transfer');\n\n let sourceData, targetData;\n try {\n const raw = JSON.parse(el.getAttribute('data-vd-transfer') || '[]');\n sourceData = raw.map((item, i) => ({\n id: item.id || 'item-' + i,\n label: item.label || item.text || String(item),\n selected: false\n }));\n } catch (_e) {\n sourceData = [];\n }\n targetData = [];\n\n const sourceSelected = new Set();\n const targetSelected = new Set();\n\n const render = () => {\n el.innerHTML = '';\n\n // Source panel\n const sourcePanel = createPanel('Source', sourceData, sourceSelected, 'source');\n // Actions\n const actions = document.createElement('div');\n actions.className = 'vd-transfer-actions';\n\n const moveRightBtn = document.createElement('button');\n moveRightBtn.type = 'button';\n moveRightBtn.className = 'vd-transfer-btn';\n moveRightBtn.innerHTML = '›';\n moveRightBtn.setAttribute('aria-label', 'Move to target');\n moveRightBtn.disabled = sourceSelected.size === 0;\n moveRightBtn.addEventListener('click', () => moveRight());\n\n const moveLeftBtn = document.createElement('button');\n moveLeftBtn.type = 'button';\n moveLeftBtn.className = 'vd-transfer-btn';\n moveLeftBtn.innerHTML = '‹';\n moveLeftBtn.setAttribute('aria-label', 'Move to source');\n moveLeftBtn.disabled = targetSelected.size === 0;\n moveLeftBtn.addEventListener('click', () => moveLeft());\n\n actions.appendChild(moveRightBtn);\n actions.appendChild(moveLeftBtn);\n\n // Target panel\n const targetPanel = createPanel('Target', targetData, targetSelected, 'target');\n\n el.appendChild(sourcePanel);\n el.appendChild(actions);\n el.appendChild(targetPanel);\n };\n\n const createPanel = (title, data, selected, _side) => {\n const panel = document.createElement('div');\n panel.className = 'vd-transfer-panel';\n\n const header = document.createElement('div');\n header.className = 'vd-transfer-header';\n const titleSpan = document.createElement('span');\n titleSpan.textContent = title;\n const count = document.createElement('span');\n count.className = 'vd-transfer-count';\n count.textContent = selected.size + '/' + data.length;\n header.appendChild(titleSpan);\n header.appendChild(count);\n panel.appendChild(header);\n\n // Search\n const searchDiv = document.createElement('div');\n searchDiv.className = 'vd-transfer-search';\n const searchInput = document.createElement('input');\n searchInput.type = 'text';\n searchInput.placeholder = 'Search...';\n searchInput.setAttribute('aria-label', 'Search ' + title.toLowerCase());\n searchDiv.appendChild(searchInput);\n panel.appendChild(searchDiv);\n\n // List\n const list = document.createElement('ul');\n list.className = 'vd-transfer-list';\n list.setAttribute('role', 'listbox');\n\n const renderList = (filter) => {\n list.innerHTML = '';\n const filtered = filter ? data.filter(d => {\n const label = (d.label || d.text || String(d)).toLowerCase();\n return label.includes(filter.toLowerCase());\n }) : data;\n filtered.forEach(item => {\n const li = document.createElement('li');\n li.className = 'vd-transfer-item';\n li.setAttribute('role', 'option');\n if (selected.has(item.id)) li.classList.add('is-selected');\n\n const checkbox = document.createElement('input');\n checkbox.type = 'checkbox';\n checkbox.checked = selected.has(item.id);\n checkbox.setAttribute('aria-label', item.label);\n\n const label = document.createElement('span');\n label.textContent = item.label;\n\n li.addEventListener('click', () => {\n if (selected.has(item.id)) selected.delete(item.id);\n else selected.add(item.id);\n render();\n });\n\n li.appendChild(checkbox);\n li.appendChild(label);\n list.appendChild(li);\n });\n };\n\n searchInput.addEventListener('input', () => renderList(searchInput.value));\n renderList('');\n\n panel.appendChild(list);\n return panel;\n };\n\n const moveRight = () => {\n const toMove = sourceData.filter(d => sourceSelected.has(d.id));\n sourceData = sourceData.filter(d => !sourceSelected.has(d.id));\n targetData = targetData.concat(toMove);\n sourceSelected.clear();\n render();\n fireChange();\n };\n\n const moveLeft = () => {\n const toMove = targetData.filter(d => targetSelected.has(d.id));\n targetData = targetData.filter(d => !targetSelected.has(d.id));\n sourceData = sourceData.concat(toMove);\n targetSelected.clear();\n render();\n fireChange();\n };\n\n const fireChange = () => {\n el.dispatchEvent(new CustomEvent('transfer:change', {\n detail: {\n source: sourceData.map(d => d.id),\n target: targetData.map(d => d.id)\n },\n bubbles: true\n }));\n };\n\n render();\n\n this.instances.set(el, {\n cleanup,\n getTarget: () => targetData.map(d => d.id),\n getSource: () => sourceData.map(d => d.id)\n });\n },\n\n getSelected: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getTarget() : [];\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n el.innerHTML = '';\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('transfer', Transfer);\n }\n\n window.VanduoTransfer = Transfer;\n\n})();\n", "/**\n * Vanduo Framework - Tree View Component\n * Hierarchical collapsible tree with checkbox selection and keyboard navigation\n */\n\n(function () {\n 'use strict';\n\n const Tree = {\n instances: new Map(),\n\n init: function () {\n const trees = document.querySelectorAll('[data-vd-tree]');\n trees.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const cascade = el.getAttribute('data-vd-tree-cascade') !== 'false';\n\n let data;\n try { data = JSON.parse(el.getAttribute('data-vd-tree') || '[]'); } catch (_e) { data = []; }\n\n el.classList.add('vd-tree');\n el.setAttribute('role', 'tree');\n\n const render = (items, parent) => {\n parent.innerHTML = '';\n items.forEach(item => {\n const node = document.createElement('li');\n node.className = 'vd-tree-node';\n node.setAttribute('role', 'treeitem');\n node.setAttribute('aria-expanded', item.open ? 'true' : 'false');\n if (item.open) node.classList.add('is-open');\n\n const content = document.createElement('div');\n content.className = 'vd-tree-node-content';\n\n // Toggle\n if (item.children && item.children.length > 0) {\n const toggle = document.createElement('button');\n toggle.type = 'button';\n toggle.className = 'vd-tree-toggle';\n toggle.setAttribute('aria-label', 'Toggle');\n toggle.addEventListener('click', (e) => {\n e.stopPropagation();\n item.open = !item.open;\n node.classList.toggle('is-open');\n node.setAttribute('aria-expanded', item.open ? 'true' : 'false');\n });\n content.appendChild(toggle);\n } else {\n const ph = document.createElement('span');\n ph.className = 'vd-tree-toggle-placeholder';\n content.appendChild(ph);\n }\n\n // Checkbox\n if (el.hasAttribute('data-vd-tree-checkbox')) {\n const cb = document.createElement('input');\n cb.type = 'checkbox';\n cb.className = 'vd-tree-checkbox';\n cb.checked = !!item.checked;\n cb.setAttribute('aria-label', item.label);\n cb.addEventListener('change', (e) => {\n e.stopPropagation();\n item.checked = cb.checked;\n if (cascade && item.children) {\n setChildChecked(item.children, cb.checked);\n render(data, el);\n }\n el.dispatchEvent(new CustomEvent('tree:check', {\n detail: { id: item.id, checked: cb.checked, label: item.label },\n bubbles: true\n }));\n });\n content.appendChild(cb);\n }\n\n // Icon\n if (item.icon) {\n const icon = document.createElement('span');\n icon.className = 'vd-tree-icon ' + item.icon;\n content.appendChild(icon);\n }\n\n // Label\n const label = document.createElement('span');\n label.className = 'vd-tree-label';\n label.textContent = item.label || '';\n content.appendChild(label);\n\n node.appendChild(content);\n\n // Children\n if (item.children && item.children.length > 0) {\n const childList = document.createElement('ul');\n childList.className = 'vd-tree-children';\n childList.setAttribute('role', 'group');\n render(item.children, childList);\n node.appendChild(childList);\n }\n\n parent.appendChild(node);\n });\n };\n\n const setChildChecked = (items, checked) => {\n items.forEach(item => {\n item.checked = checked;\n if (item.children) setChildChecked(item.children, checked);\n });\n };\n\n // Keyboard\n const keyHandler = (e) => {\n const focused = document.activeElement;\n if (!el.contains(focused)) return;\n\n const nodes = Array.from(el.querySelectorAll('.vd-tree-node-content'));\n const idx = nodes.indexOf(focused.closest('.vd-tree-node-content'));\n if (idx === -1) return;\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n if (idx < nodes.length - 1) {\n const next = nodes[idx + 1].querySelector('.vd-tree-toggle, .vd-tree-label');\n if (next) next.focus();\n }\n break;\n case 'ArrowUp':\n e.preventDefault();\n if (idx > 0) {\n const prev = nodes[idx - 1].querySelector('.vd-tree-toggle, .vd-tree-label');\n if (prev) prev.focus();\n }\n break;\n }\n };\n\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n\n render(data, el);\n\n this.instances.set(el, {\n cleanup,\n getData: () => data,\n getChecked: () => {\n const checked = [];\n const collect = (items) => {\n items.forEach(i => {\n if (i.checked) checked.push(i.id || i.label);\n if (i.children) collect(i.children);\n });\n };\n collect(data);\n return checked;\n }\n });\n },\n\n getChecked: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getChecked() : [];\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n el.innerHTML = '';\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('tree', Tree);\n }\n\n window.VanduoTree = Tree;\n\n})();\n", "/**\n * Vanduo Framework - Spotlight (Feature Discovery) Component\n * Guided tour with overlay highlight and step-through tooltip\n */\n\n(function () {\n 'use strict';\n\n const Spotlight = {\n _active: false,\n _steps: [],\n _currentStep: 0,\n _elements: {},\n _cleanup: [],\n _boundTriggers: new WeakMap(),\n _triggerElement: null,\n\n init: function () {\n const triggers = document.querySelectorAll('[data-vd-spotlight]');\n\n triggers.forEach(trigger => {\n if (this._boundTriggers.has(trigger)) return;\n\n const clickHandler = (event) => {\n event.preventDefault();\n\n const steps = this._parseSteps(trigger.getAttribute('data-vd-spotlight'));\n if (steps.length === 0) return;\n\n this.start(steps, { trigger });\n };\n\n trigger.addEventListener('click', clickHandler);\n this._boundTriggers.set(trigger, clickHandler);\n });\n },\n\n _parseSteps: function (raw) {\n if (typeof raw !== 'string' || raw.trim() === '') return [];\n\n try {\n const parsed = JSON.parse(raw);\n return this._normalizeSteps(parsed);\n } catch (error) {\n console.error('VanduoSpotlight: invalid data-vd-spotlight payload.', error);\n return [];\n }\n },\n\n _normalizeStep: function (step) {\n if (!step || typeof step !== 'object') return null;\n\n const target = step.target;\n const hasSelectorTarget = typeof target === 'string' && target.trim() !== '';\n const hasElementTarget = typeof Element !== 'undefined' && target instanceof Element;\n\n if (!hasSelectorTarget && !hasElementTarget) return null;\n\n const title = typeof step.title === 'string' ? step.title : '';\n const description = typeof step.description === 'string'\n ? step.description\n : (typeof step.content === 'string' ? step.content : '');\n\n return {\n target,\n title,\n description\n };\n },\n\n _normalizeSteps: function (steps) {\n if (!Array.isArray(steps)) return [];\n\n return steps\n .map(step => this._normalizeStep(step))\n .filter(Boolean);\n },\n\n start: function (steps, options) {\n if (this._active) this.stop();\n\n const normalizedSteps = this._normalizeSteps(steps);\n if (normalizedSteps.length === 0) return;\n\n const startOptions = options || {};\n\n this._steps = normalizedSteps;\n this._currentStep = 0;\n this._active = true;\n this._triggerElement = startOptions.trigger || (document.activeElement instanceof HTMLElement ? document.activeElement : null);\n\n // Create overlay\n const overlay = document.createElement('div');\n overlay.className = 'vd-spotlight-overlay';\n overlay.setAttribute('aria-hidden', 'true');\n document.body.appendChild(overlay);\n\n // Create tooltip\n const tooltip = document.createElement('div');\n tooltip.className = 'vd-spotlight-tooltip';\n tooltip.setAttribute('role', 'dialog');\n tooltip.setAttribute('aria-modal', 'true');\n tooltip.tabIndex = -1;\n document.body.appendChild(tooltip);\n\n this._elements = { overlay, tooltip };\n\n // ESC to close\n const escHandler = (e) => { if (e.key === 'Escape') this.stop(); };\n document.addEventListener('keydown', escHandler);\n this._cleanup.push(() => document.removeEventListener('keydown', escHandler));\n\n // Overlay click to close\n overlay.addEventListener('click', () => this.stop());\n\n this._showStep(this._currentStep);\n },\n\n _showStep: function (index) {\n const step = this._steps[index];\n if (!step) return;\n\n const target = typeof step.target === 'string' ? document.querySelector(step.target) : step.target;\n const { tooltip } = this._elements;\n\n // Remove previous highlight\n document.querySelectorAll('.vd-spotlight-target').forEach(el => {\n el.classList.remove('vd-spotlight-target');\n });\n\n // Highlight target\n if (target) {\n target.classList.add('vd-spotlight-target');\n target.scrollIntoView({ behavior: 'smooth', block: 'center' });\n }\n\n // Build tooltip content\n const total = this._steps.length;\n tooltip.innerHTML = '';\n tooltip.removeAttribute('aria-labelledby');\n tooltip.removeAttribute('aria-describedby');\n\n if (step.title) {\n const title = document.createElement('h4');\n title.className = 'vd-spotlight-title';\n title.id = 'vd-spotlight-title-' + index + '-' + Date.now();\n title.textContent = step.title;\n tooltip.appendChild(title);\n tooltip.setAttribute('aria-labelledby', title.id);\n }\n\n if (step.description) {\n const desc = document.createElement('p');\n desc.className = 'vd-spotlight-description';\n desc.id = 'vd-spotlight-description-' + index + '-' + Date.now();\n desc.textContent = step.description;\n tooltip.appendChild(desc);\n tooltip.setAttribute('aria-describedby', desc.id);\n }\n\n // Footer\n const footer = document.createElement('div');\n footer.className = 'vd-spotlight-footer';\n footer.setAttribute('aria-label', 'Step ' + (index + 1) + ' of ' + total);\n\n const counter = document.createElement('span');\n counter.className = 'vd-spotlight-counter';\n counter.textContent = (index + 1) + ' / ' + total;\n\n const actions = document.createElement('div');\n actions.className = 'vd-spotlight-actions';\n\n if (index > 0) {\n const prevBtn = document.createElement('button');\n prevBtn.type = 'button';\n prevBtn.className = 'vd-spotlight-btn';\n prevBtn.textContent = 'Back';\n prevBtn.addEventListener('click', () => this.prev());\n actions.appendChild(prevBtn);\n }\n\n const skipBtn = document.createElement('button');\n skipBtn.type = 'button';\n skipBtn.className = 'vd-spotlight-btn';\n skipBtn.textContent = 'Skip';\n skipBtn.addEventListener('click', () => this.stop());\n actions.appendChild(skipBtn);\n\n if (index < total - 1) {\n const nextBtn = document.createElement('button');\n nextBtn.type = 'button';\n nextBtn.className = 'vd-spotlight-btn vd-spotlight-btn-primary';\n nextBtn.textContent = 'Next';\n nextBtn.addEventListener('click', () => this.next());\n actions.appendChild(nextBtn);\n } else {\n const doneBtn = document.createElement('button');\n doneBtn.type = 'button';\n doneBtn.className = 'vd-spotlight-btn vd-spotlight-btn-primary';\n doneBtn.textContent = 'Done';\n doneBtn.addEventListener('click', () => this.stop());\n actions.appendChild(doneBtn);\n }\n\n footer.appendChild(counter);\n footer.appendChild(actions);\n tooltip.appendChild(footer);\n\n // Position tooltip near target\n if (target) {\n requestAnimationFrame(() => {\n const rect = target.getBoundingClientRect();\n const tRect = tooltip.getBoundingClientRect();\n let top = rect.bottom + 12 + window.scrollY;\n let left = rect.left + (rect.width - tRect.width) / 2 + window.scrollX;\n\n // Keep in viewport\n left = Math.max(8, Math.min(left, window.innerWidth - tRect.width - 8));\n if (top + tRect.height > window.innerHeight + window.scrollY) {\n top = rect.top - tRect.height - 12 + window.scrollY;\n }\n\n tooltip.style.top = top + 'px';\n tooltip.style.left = left + 'px';\n });\n }\n\n document.dispatchEvent(new CustomEvent('spotlight:step', {\n detail: { index, step: index, total, data: step }\n }));\n },\n\n next: function () {\n if (this._currentStep < this._steps.length - 1) {\n this._currentStep++;\n this._showStep(this._currentStep);\n }\n },\n\n prev: function () {\n if (this._currentStep > 0) {\n this._currentStep--;\n this._showStep(this._currentStep);\n }\n },\n\n stop: function () {\n if (!this._active) return;\n\n const total = this._steps.length;\n const detail = {\n completedSteps: total === 0 ? 0 : Math.min(this._currentStep + 1, total),\n total,\n completed: total > 0 && this._currentStep >= total - 1\n };\n\n this._active = false;\n\n document.querySelectorAll('.vd-spotlight-target').forEach(el => {\n el.classList.remove('vd-spotlight-target');\n });\n\n if (this._elements.overlay && this._elements.overlay.parentNode) {\n this._elements.overlay.parentNode.removeChild(this._elements.overlay);\n }\n if (this._elements.tooltip && this._elements.tooltip.parentNode) {\n this._elements.tooltip.parentNode.removeChild(this._elements.tooltip);\n }\n\n this._cleanup.forEach(fn => fn());\n this._cleanup = [];\n this._elements = {};\n this._steps = [];\n this._currentStep = 0;\n\n if (this._triggerElement && this._triggerElement.isConnected && typeof this._triggerElement.focus === 'function') {\n this._triggerElement.focus();\n }\n this._triggerElement = null;\n\n document.dispatchEvent(new CustomEvent('spotlight:end', { detail }));\n },\n\n destroyAll: function () {\n this.stop();\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('spotlight', Spotlight);\n }\n\n window.VanduoSpotlight = Spotlight;\n\n})();\n", "/**\n * Vanduo Framework - Music Player Component\n * HTML5 Audio-based music player with transport controls, volume,\n * and optional shuffle, seek bar, and playlist features.\n *\n * Options (passed to MusicPlayer.init or data-music-player-options):\n * tracks {Array} - [{name, url}] \u2014 required\n * volume {number} - Initial volume 0\u20131 (default 0.5)\n * shuffle {boolean} - Shuffle on init (default false)\n * showProgress {boolean} - Show seek/progress bar (default false)\n * showPlaylist {boolean} - Show expandable playlist panel (default false)\n * autoAdvance {boolean} - Auto-play next track on end (default true)\n * glass {boolean} - Frosted-glass surface styling (default false)\n * detachable {boolean} - Show detach/attach; float above page when detached (default false)\n * floatingPosition {string|null} - After detach: 'bottom-left' or 'bottom-right' (default null = bottom-right)\n * draggable {boolean} - When detached+detachable, drag by handle (default false)\n * minimizable {boolean} - Minimize/expand control (default false)\n * startMinimized {boolean} - On first detach, start minimized (default false)\n * persistPosition {boolean} - Save floating x/y in localStorage (default false)\n * persistKey {string} - Storage key for persist (default from element id)\n *\n * Custom events (all bubble, dispatched on container):\n * musicplayer:play \u2014 playback started\n * musicplayer:pause \u2014 playback paused\n * musicplayer:trackchange \u2014 detail: { index, name, url }\n * musicplayer:volumechange \u2014 detail: { volume }\n * musicplayer:ended \u2014 last track ended (autoAdvance=false only)\n * musicplayer:detach \u2014 after floating player is created\n * musicplayer:attach \u2014 after returned to document flow\n * musicplayer:minimize \u2014 collapsed to minimal controls\n * musicplayer:expand \u2014 full controls visible again\n *\n * Programmatic API:\n * MusicPlayer.init(container?, options?)\n * MusicPlayer.play(container)\n * MusicPlayer.pause(container)\n * MusicPlayer.toggle(container)\n * MusicPlayer.next(container)\n * MusicPlayer.previous(container)\n * MusicPlayer.setVolume(container, value)\n * MusicPlayer.setTrack(container, index)\n * MusicPlayer.shuffle(container)\n * MusicPlayer.detach(container, position?)\n * MusicPlayer.attach(container)\n * MusicPlayer.minimize(container)\n * MusicPlayer.expand(container)\n * MusicPlayer.toggleMinimize(container)\n * MusicPlayer.setPosition(container, 'bottom-left'|'bottom-right'|{x,y})\n * MusicPlayer.getState(container)\n * MusicPlayer.destroy(container)\n * MusicPlayer.destroyAll()\n */\n\n(function () {\n 'use strict';\n\n /* \u2500\u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Fisher-Yates shuffle (returns new array).\n * @param {Array} arr\n * @returns {Array}\n */\n function shuffleArray(arr) {\n const shuffled = arr.slice();\n for (let i = shuffled.length - 1; i > 0; i--) {\n const j = Math.floor(Math.random() * (i + 1));\n const tmp = shuffled[i];\n shuffled[i] = shuffled[j];\n shuffled[j] = tmp;\n }\n return shuffled;\n }\n\n /**\n * Format seconds as m:ss.\n * @param {number} seconds\n * @returns {string}\n */\n function formatTime(seconds) {\n if (!isFinite(seconds) || seconds < 0) return '0:00';\n const m = Math.floor(seconds / 60);\n const s = Math.floor(seconds % 60);\n return m + ':' + (s < 10 ? '0' : '') + s;\n }\n\n /**\n * Set CSS background-size on a range input to visually fill the track.\n * @param {HTMLInputElement} input\n */\n /**\n * @param {string|undefined} id\n * @returns {string}\n */\n function persistStorageKey(id) {\n return 'vanduo:music-player:' + (id && id.trim() ? id.trim() : 'default') + ':pos';\n }\n\n function updateRangeFill(input) {\n const min = parseFloat(input.min) || 0;\n const max = parseFloat(input.max) || 1;\n const val = parseFloat(input.value) || 0;\n const pct = ((val - min) / (max - min)) * 100;\n input.style.setProperty('--fill', pct + '%');\n // Fallback inline gradient for browsers without ::-moz-range-progress\n input.style.backgroundImage =\n 'linear-gradient(to right, var(--music-player-track-fill, currentColor) 0%, ' +\n 'var(--music-player-track-fill, currentColor) ' + pct + '%, ' +\n 'var(--music-player-track-bg, #ccc) ' + pct + '%, ' +\n 'var(--music-player-track-bg, #ccc) 100%)';\n }\n\n /* \u2500\u2500\u2500 Phosphor icon helper (matches framework icon style) \u2500 */\n\n /**\n * Return an element (Phosphor icon).\n * @param {string} name\n * @returns {HTMLElement}\n */\n function icon(name) {\n const el = document.createElement('i');\n el.className = 'ph ph-' + name;\n el.setAttribute('aria-hidden', 'true');\n return el;\n }\n\n /* \u2500\u2500\u2500 Component \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const MusicPlayer = {\n /** @type {Map} */\n instances: new Map(),\n\n /**\n * Default options.\n */\n defaults: {\n tracks: [],\n volume: 0.5,\n shuffle: false,\n showProgress: false,\n showPlaylist: false,\n autoAdvance: true,\n glass: false,\n detachable: false,\n /** @type {null|string} */\n floatingPosition: null,\n draggable: false,\n minimizable: false,\n startMinimized: false,\n persistPosition: false,\n persistKey: '',\n },\n\n /**\n * Auto-initialize all .vd-music-player / [data-music-player] elements.\n * Options can be provided via data-music-player-options (JSON string).\n */\n init: function () {\n document.querySelectorAll('.vd-music-player, [data-music-player]').forEach((el) => {\n if (this.instances.has(el)) return;\n\n let opts = {};\n const attr = el.getAttribute('data-music-player-options');\n if (attr) {\n try { opts = JSON.parse(attr); } catch (_) { /* ignore malformed JSON */ }\n }\n this.initPlayer(el, opts);\n });\n },\n\n /**\n * Initialize a single player element.\n * @param {HTMLElement} container\n * @param {Object} [options]\n */\n initPlayer: function (container, options) {\n const opts = Object.assign({}, this.defaults, options || {});\n\n // Validate and normalise tracks\n const rawTracks = Array.isArray(opts.tracks) ? opts.tracks : [];\n const tracks = rawTracks.filter((t) => t && typeof t.url === 'string' && t.url.trim());\n\n // Build shuffled working copy without mutating opts\n const trackList = opts.shuffle ? shuffleArray(tracks) : tracks.slice();\n\n /* \u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const state = {\n tracks: trackList,\n originalTracks: tracks.slice(),\n currentIndex: 0,\n isPlaying: false,\n volume: Math.max(0, Math.min(1, opts.volume)),\n shuffle: opts.shuffle,\n showProgress: opts.showProgress,\n showPlaylist: opts.showPlaylist,\n autoAdvance: opts.autoAdvance,\n audio: null,\n glass: Boolean(opts.glass),\n detachable: Boolean(opts.detachable),\n floatingPosition: opts.floatingPosition || 'bottom-right',\n draggable: Boolean(opts.draggable) && Boolean(opts.detachable),\n minimizable: Boolean(opts.minimizable),\n startMinimized: Boolean(opts.startMinimized),\n persistPosition: Boolean(opts.persistPosition),\n persistKey: typeof opts.persistKey === 'string' ? opts.persistKey : '',\n isDetached: false,\n isMinimized: false,\n _startMinimizeApplied: false,\n };\n\n /* \u2500\u2500 Audio element \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const audio = new Audio();\n audio.volume = state.volume;\n audio.preload = 'metadata';\n state.audio = audio;\n\n /* \u2500\u2500 Build DOM \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n this._buildDOM(container, state);\n\n // Grab references after DOM build\n const refs = {\n btnPlay: container.querySelector('.vd-music-player-btn-play'),\n btnPrev: container.querySelector('.vd-music-player-btn-prev'),\n btnNext: container.querySelector('.vd-music-player-btn-next'),\n btnShuffle: container.querySelector('.vd-music-player-btn-shuffle'),\n btnPlaylist: container.querySelector('.vd-music-player-btn-playlist'),\n btnDetach: container.querySelector('.vd-music-player-btn-detach'),\n btnAttach: container.querySelector('.vd-music-player-btn-attach'),\n btnMinimize: container.querySelector('.vd-music-player-btn-minimize'),\n dragHandle: container.querySelector('.vd-music-player-drag-handle'),\n trackName: container.querySelector('.vd-music-player-track-name'),\n volumeSlider: container.querySelector('.vd-music-player-volume-slider'),\n volumeIcon: container.querySelector('.vd-music-player-volume-icon'),\n progressBar: container.querySelector('.vd-music-player-progress-bar'),\n timeElapsed: container.querySelector('.vd-music-player-time-elapsed'),\n timeDuration: container.querySelector('.vd-music-player-time-duration'),\n playlistPanel: container.querySelector('.vd-music-player-playlist'),\n };\n\n /* \u2500\u2500 Internal render helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const renderPlayIcon = () => {\n const btn = refs.btnPlay;\n if (!btn) return;\n btn.innerHTML = '';\n btn.appendChild(icon(state.isPlaying ? 'pause' : 'play'));\n btn.setAttribute('aria-label', state.isPlaying ? 'Pause' : 'Play');\n btn.classList.toggle('is-active', state.isPlaying);\n };\n\n const renderTrackName = () => {\n const el = refs.trackName;\n if (!el) return;\n const track = state.tracks[state.currentIndex];\n if (track) {\n el.textContent = track.name || 'Unknown Track';\n el.classList.remove('is-idle');\n } else {\n el.textContent = 'No tracks loaded';\n el.classList.add('is-idle');\n }\n };\n\n const renderVolumeIcon = () => {\n const el = refs.volumeIcon;\n if (!el) return;\n el.innerHTML = '';\n const v = state.volume;\n const name = v === 0 ? 'speaker-none' : v < 0.5 ? 'speaker-low' : 'speaker-high';\n el.appendChild(icon(name));\n };\n\n const renderShuffleBtn = () => {\n const btn = refs.btnShuffle;\n if (!btn) return;\n btn.classList.toggle('is-active', state.shuffle);\n btn.setAttribute('aria-pressed', state.shuffle ? 'true' : 'false');\n };\n\n const renderPlaylistItems = () => {\n const panel = refs.playlistPanel;\n if (!panel) return;\n panel.innerHTML = '';\n state.tracks.forEach((track, i) => {\n const item = document.createElement('button');\n item.className =\n 'vd-music-player-playlist-item' + (i === state.currentIndex ? ' is-active' : '');\n item.type = 'button';\n item.setAttribute('data-index', String(i));\n item.setAttribute('aria-current', i === state.currentIndex ? 'true' : 'false');\n\n const num = document.createElement('span');\n num.className = 'vd-music-player-playlist-num';\n num.textContent = String(i + 1);\n\n const name = document.createElement('span');\n name.className = 'vd-music-player-playlist-name';\n name.textContent = track.name || 'Track ' + (i + 1);\n\n item.appendChild(num);\n item.appendChild(name);\n panel.appendChild(item);\n });\n };\n\n const renderProgress = () => {\n const bar = refs.progressBar;\n if (!bar || !audio.duration) return;\n const pct = (audio.currentTime / audio.duration) * 100;\n bar.value = String(pct);\n updateRangeFill(bar);\n if (refs.timeElapsed) refs.timeElapsed.textContent = formatTime(audio.currentTime);\n if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);\n };\n\n /* \u2500\u2500 Load track \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const loadTrack = (index, autoPlay) => {\n const track = state.tracks[index];\n if (!track) return;\n state.currentIndex = index;\n audio.src = track.url;\n renderTrackName();\n renderPlaylistItems();\n\n // Reset progress\n if (refs.progressBar) {\n refs.progressBar.value = '0';\n updateRangeFill(refs.progressBar);\n }\n if (refs.timeElapsed) refs.timeElapsed.textContent = '0:00';\n if (refs.timeDuration) refs.timeDuration.textContent = '0:00';\n\n container.dispatchEvent(\n new CustomEvent('musicplayer:trackchange', {\n bubbles: true,\n detail: { index, name: track.name, url: track.url },\n })\n );\n\n if (autoPlay) {\n audio.play().catch(() => { /* browser may block autoplay */ });\n }\n };\n\n /* \u2500\u2500 Audio event listeners \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const cleanupFunctions = [];\n\n const onPlay = () => {\n state.isPlaying = true;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:play', { bubbles: true }));\n };\n\n const onPause = () => {\n state.isPlaying = false;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:pause', { bubbles: true }));\n };\n\n const onEnded = () => {\n if (state.autoAdvance && state.tracks.length > 1) {\n const next = (state.currentIndex + 1) % state.tracks.length;\n loadTrack(next, true);\n } else {\n state.isPlaying = false;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:ended', { bubbles: true }));\n }\n };\n\n const onTimeUpdate = () => {\n if (state.showProgress) renderProgress();\n };\n\n const onLoadedMetadata = () => {\n if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);\n if (refs.progressBar) {\n refs.progressBar.max = '100';\n updateRangeFill(refs.progressBar);\n }\n };\n\n audio.addEventListener('play', onPlay);\n audio.addEventListener('pause', onPause);\n audio.addEventListener('ended', onEnded);\n audio.addEventListener('timeupdate', onTimeUpdate);\n audio.addEventListener('loadedmetadata', onLoadedMetadata);\n cleanupFunctions.push(() => {\n audio.removeEventListener('play', onPlay);\n audio.removeEventListener('pause', onPause);\n audio.removeEventListener('ended', onEnded);\n audio.removeEventListener('timeupdate', onTimeUpdate);\n audio.removeEventListener('loadedmetadata', onLoadedMetadata);\n audio.pause();\n audio.src = '';\n });\n\n /* \u2500\u2500 Control button listeners \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n if (refs.btnPlay) {\n const handler = () => {\n if (!audio.src && state.tracks.length) loadTrack(state.currentIndex, false);\n if (state.isPlaying) {\n audio.pause();\n } else {\n audio.play().catch(() => {});\n }\n };\n refs.btnPlay.addEventListener('click', handler);\n cleanupFunctions.push(() => refs.btnPlay.removeEventListener('click', handler));\n\n // Keyboard: Space / Enter (already native for `;\n }\n\n // Generate neutral color swatches\n let neutralSwatches = '';\n for (const [key, value] of Object.entries(this.NEUTRAL_COLORS)) {\n neutralSwatches += ``;\n }\n\n // Generate radius buttons\n let radiusButtons = '';\n this.RADIUS_OPTIONS.forEach(r => {\n radiusButtons += ``;\n });\n\n // Generate font options\n let fontOptions = '';\n for (const [key, value] of Object.entries(this.FONT_OPTIONS)) {\n fontOptions += ``;\n }\n\n return `\n
    \n

    Customize Theme

    \n \n
    \n
    \n\n
    \n \n
    \n ${primarySwatches}\n
    \n
    \n
    \n \n
    \n ${neutralSwatches}\n
    \n
    \n
    \n \n
    \n ${radiusButtons}\n
    \n
    \n
    \n \n \n
    \n
    \n
    \n \n
    \n `;\n },\n\n /**\n * Bind event listeners\n */\n /**\n * Check whether the current primary color is one of the auto-defaults\n * (i.e. the user hasn't explicitly picked a non-default color).\n */\n isUsingDefaultPrimary: function () {\n return this.state.primary === this.DEFAULTS.PRIMARY_LIGHT ||\n this.state.primary === this.DEFAULTS.PRIMARY_DARK;\n },\n\n /**\n * When primary is still one of the auto-default palette keys (black/amber) but\n * localStorage was written under a different theme (or OS changed in system mode),\n * align in-memory state before applyAllPreferences runs \u2014 avoids amber+light / black+dark drift.\n */\n _normalizeDefaultPrimaryIfStaleWithStoredTheme: function () {\n if (!this.isUsingDefaultPrimary()) {\n return;\n }\n const expected = this.getDefaultPrimary(this.state.theme);\n if (this.state.primary !== expected) {\n this.state.primary = expected;\n }\n },\n\n bindEvents: function () {\n this.bindTriggerEvents();\n\n this.bindPanelEvents();\n\n // Listen for OS dark/light changes so we can swap the default primary\n if (window.matchMedia) {\n const mq = window.matchMedia('(prefers-color-scheme: dark)');\n const handler = () => {\n if (this.state.theme === 'system' && this.isUsingDefaultPrimary()) {\n const newDefault = this.getDefaultPrimary('system');\n if (newDefault !== this.state.primary) {\n this.applyPrimary(newDefault);\n this.updateUI();\n }\n }\n };\n mq.addEventListener('change', handler);\n this._cleanup.push(() => mq.removeEventListener('change', handler));\n }\n\n // Close on outside click\n this.addListener(document, 'click', (e) => {\n if (this.state.isOpen && this.elements.customizer && !this.elements.customizer.contains(e.target)) {\n this.close();\n }\n });\n\n // Close on Escape key\n this.addListener(document, 'keydown', (e) => {\n if (e.key === 'Escape' && this.state.isOpen) {\n this.close();\n }\n });\n },\n\n bindTriggerEvents: function () {\n this.elements.triggers.forEach((trigger) => {\n if (trigger.getAttribute('data-customizer-trigger-initialized') === 'true') {\n return;\n }\n this.addListener(trigger, 'click', (e) => {\n e.preventDefault();\n e.stopPropagation();\n this.elements.activeTrigger = trigger;\n this.elements.trigger = trigger;\n this.toggle();\n });\n trigger.setAttribute('data-customizer-trigger-initialized', 'true');\n });\n },\n\n /**\n * Toggle panel open/close\n */\n toggle: function () {\n if (this.state.isOpen) {\n this.close();\n } else {\n this.open();\n }\n },\n\n /**\n * Open the panel\n */\n open: function () {\n this.state.isOpen = true;\n\n // Ensure panel is positioned correctly before opening\n this.positionPanel();\n\n if (this.elements.panel) {\n this.elements.panel.classList.add('is-open');\n }\n this.elements.triggers.forEach((trigger) => trigger.setAttribute('aria-expanded', 'false'));\n if (this.elements.trigger) this.elements.trigger.setAttribute('aria-expanded', 'true');\n if (this.elements.overlay) {\n this.elements.overlay.classList.add('is-active');\n }\n\n this.dispatchEvent('panel-open', { isOpen: true });\n },\n\n /**\n * Close the panel\n */\n close: function () {\n this.state.isOpen = false;\n\n if (this.elements.panel) {\n this.elements.panel.classList.remove('is-open');\n }\n this.elements.triggers.forEach((trigger) => trigger.setAttribute('aria-expanded', 'false'));\n if (this.elements.overlay) {\n this.elements.overlay.classList.remove('is-active');\n }\n\n this.dispatchEvent('panel-close', { isOpen: false });\n },\n\n /**\n * Update UI to reflect current state\n */\n updateUI: function () {\n if (!this.elements.panel) return;\n\n // Update primary color swatches\n this.elements.panel.querySelectorAll('[data-color]').forEach(swatch => {\n swatch.classList.toggle('is-active', swatch.dataset.color === this.state.primary);\n });\n\n // Update neutral color swatches\n this.elements.panel.querySelectorAll('[data-neutral]').forEach(swatch => {\n swatch.classList.toggle('is-active', swatch.dataset.neutral === this.state.neutral);\n });\n\n // Update radius buttons\n this.elements.panel.querySelectorAll('[data-radius]').forEach(btn => {\n btn.classList.toggle('is-active', btn.dataset.radius === this.state.radius);\n });\n\n // Update font selector\n const fontSelect = this.elements.panel.querySelector('[data-customizer-font]');\n if (fontSelect) {\n fontSelect.value = this.state.font;\n }\n\n },\n\n /**\n * Reset all preferences to defaults\n */\n reset: function () {\n this.applyTheme(this.DEFAULTS.THEME);\n this.applyPrimary(this.getDefaultPrimary(this.DEFAULTS.THEME));\n this.applyNeutral(this.DEFAULTS.NEUTRAL);\n this.applyRadius(this.DEFAULTS.RADIUS);\n this.applyFont(this.DEFAULTS.FONT);\n this.updateUI();\n\n this.dispatchEvent('reset', { state: { ...this.state } });\n },\n\n /**\n * Get current state\n */\n getState: function () {\n return { ...this.state };\n },\n\n /**\n * Programmatically set preferences\n */\n setPreferences: function (prefs) {\n if (prefs.primary) this.applyPrimary(prefs.primary);\n if (prefs.neutral) this.applyNeutral(prefs.neutral);\n if (prefs.radius) this.applyRadius(prefs.radius);\n if (prefs.font) this.applyFont(prefs.font);\n if (prefs.theme) this.applyTheme(prefs.theme);\n this.updateUI();\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 destroyAll: function () {\n this._cleanup.forEach(fn => fn());\n this._cleanup = [];\n\n if (this.elements.panel) {\n this.elements.panel.removeAttribute('data-customizer-initialized');\n }\n\n this.close();\n this.isInitialized = false;\n }\n };\n\n // Register component\n if (window.Vanduo) {\n window.Vanduo.register('themeCustomizer', ThemeCustomizer);\n }\n\n // Expose globally for convenience\n window.ThemeCustomizer = ThemeCustomizer;\n})();\n", "/**\n * Vanduo Framework - Theme Switcher\n * Handles light/dark/system theme toggling and persistence\n */\n\n(function () {\n 'use strict';\n\n const ThemeSwitcher = {\n isInitialized: false,\n _mediaQuery: null,\n _onMediaChange: null,\n\n init: function () {\n this.STORAGE_KEY = 'vanduo-theme-preference';\n this.state = {\n preference: this.getPreference() // 'light', 'dark', or 'system'\n };\n\n if (this.isInitialized) {\n this.applyTheme();\n this.renderUI();\n this.updateUI();\n return;\n }\n\n this.isInitialized = true;\n\n this.applyTheme();\n this.listenForSystemChanges();\n this.renderUI();\n\n console.log('Vanduo Theme Switcher initialized');\n },\n\n getPreference: function () {\n return this.getStorageValue(this.STORAGE_KEY, 'system');\n },\n\n setPreference: function (pref) {\n this.state.preference = pref;\n this.setStorageValue(this.STORAGE_KEY, pref);\n this.applyTheme();\n \n // Coordinate with ThemeCustomizer for primary color swap if available\n // Check _isApplying flag to prevent circular updates\n if (window.ThemeCustomizer && window.ThemeCustomizer.applyTheme && !window.ThemeCustomizer._isApplying) {\n window.ThemeCustomizer.applyTheme(pref);\n }\n \n this.updateUI();\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 applyTheme: function () {\n const pref = this.state.preference;\n\n if (pref === 'system') {\n // When in system mode, we remove the data attribute to let the media query take over\n // or we can explicitly set it. Explicitly setting it ensures consistency if we rely on [data-theme]\n // But if we rely on @media in CSS, we might want to remove attributes.\n // However, our CSS strategy uses :root:not([data-theme=\"light\"]) inside media query for system dark fallback\n // which is a bit complex.\n\n // Simpler approach: \n // If preference is system, REMOVE data-theme attribute. Let CSS media queries handle it.\n document.documentElement.removeAttribute('data-theme');\n } else {\n document.documentElement.setAttribute('data-theme', pref);\n }\n },\n\n listenForSystemChanges: function () {\n if (this._mediaQuery && this._onMediaChange) {\n return;\n }\n\n this._mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n this._onMediaChange = _e => {\n if (this.state.preference === 'system') {\n // Re-apply (effectively just to ensure consistency, though removing attribute usually suffices)\n this.applyTheme();\n // Keep default primary (black/amber) aligned when OS scheme changes while in system mode\n if (window.ThemeCustomizer && typeof window.ThemeCustomizer.applyTheme === 'function' && !window.ThemeCustomizer._isApplying) {\n window.ThemeCustomizer.applyTheme('system');\n }\n }\n };\n this._mediaQuery.addEventListener('change', this._onMediaChange);\n },\n\n // Helper to facilitate UI creation if needed, though often UI is in HTML\n renderUI: function () {\n // Look for any uninitialized theme toggles\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"]');\n toggles.forEach(toggle => {\n if (toggle.getAttribute('data-theme-initialized') === 'true') {\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n }\n return;\n }\n\n // Simplified UI Binding - assumes a select or a button cycle\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n const onChange = (e) => {\n this.setPreference(e.target.value);\n };\n toggle.addEventListener('change', onChange);\n toggle._themeToggleHandler = onChange;\n } else {\n // Button implementation (cycle)\n const onClick = () => {\n const modes = ['system', 'light', 'dark'];\n const nextIndex = (modes.indexOf(this.state.preference) + 1) % modes.length;\n this.setPreference(modes[nextIndex]);\n };\n toggle.addEventListener('click', onClick);\n toggle._themeToggleHandler = onClick;\n }\n // Mark as initialized\n toggle.setAttribute('data-theme-initialized', 'true');\n });\n },\n\n updateUI: function () {\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"]');\n toggles.forEach(toggle => {\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n } else {\n // Update button text/icon if needed\n // e.g. toggle.textContent = this.state.preference;\n // For now, assume the user handles visual state or generic text\n\n // If there is an icon or text span inside, update it\n const span = toggle.querySelector('.theme-current-label');\n if (span) {\n span.textContent = this.state.preference.charAt(0).toUpperCase() + this.state.preference.slice(1);\n }\n }\n });\n },\n\n destroyAll: function () {\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"][data-theme-initialized=\"true\"]');\n toggles.forEach(toggle => {\n if (toggle._themeToggleHandler) {\n const eventName = toggle.tagName === 'SELECT' ? 'change' : 'click';\n toggle.removeEventListener(eventName, toggle._themeToggleHandler);\n delete toggle._themeToggleHandler;\n }\n toggle.removeAttribute('data-theme-initialized');\n });\n\n if (this._mediaQuery && this._onMediaChange) {\n this._mediaQuery.removeEventListener('change', this._onMediaChange);\n }\n\n this._mediaQuery = null;\n this._onMediaChange = null;\n this.isInitialized = false;\n }\n };\n\n // Register component\n if (window.Vanduo) {\n window.Vanduo.register('themeSwitcher', ThemeSwitcher);\n }\n})();\n", "/**\n * Vanduo Framework - Toast Component\n * Popup notifications for user feedback\n */\n\n(function() {\n 'use strict';\n\n /**\n * Toast Component\n */\n const Toast = {\n // Default options\n defaults: {\n position: 'top-right',\n duration: 5000,\n dismissible: true,\n showProgress: true,\n pauseOnHover: true\n },\n\n // Container cache\n containers: {},\n\n /**\n * Get or create a toast container for a position\n * @param {string} position - Container position\n * @returns {HTMLElement} Toast container element\n */\n getContainer: function(position) {\n if (this.containers[position]) {\n return this.containers[position];\n }\n\n const container = document.createElement('div');\n container.className = `vd-toast-container vd-toast-container-${position}`;\n container.setAttribute('role', 'status');\n container.setAttribute('aria-live', 'polite');\n container.setAttribute('aria-atomic', 'false');\n document.body.appendChild(container);\n this.containers[position] = container;\n\n return container;\n },\n\n /**\n * Show a toast notification\n * @param {Object|string} options - Toast options or message string\n * @param {string} [type] - Toast type (success, error, warning, info)\n * @param {number} [duration] - Auto-dismiss duration in ms\n * @returns {HTMLElement} Toast element\n */\n show: function(options, type, duration) {\n // Support simple API:\n // - Toast.show('Message', 'success', 3000)\n // - Toast.show('Message', { type: 'success', duration: 3000 })\n if (typeof options === 'string') {\n if (type && typeof type === 'object') {\n options = Object.assign({}, type, {\n message: options\n });\n } else {\n options = {\n message: options,\n type: type,\n duration: duration\n };\n }\n }\n\n const config = Object.assign({}, this.defaults, options);\n const container = this.getContainer(config.position);\n\n // Create toast element\n const toast = document.createElement('div');\n toast.className = 'vd-toast';\n\n if (config.type) {\n toast.classList.add(`vd-toast-${config.type}`);\n }\n\n if (config.solid) {\n toast.classList.add('vd-toast-solid');\n }\n\n if (config.showProgress && config.duration > 0) {\n toast.classList.add('vd-toast-with-progress');\n }\n\n // Build toast content\n let html = '';\n\n // Icon (sanitize custom icons, default icons are trusted SVG)\n if (config.icon) {\n const allowSvg = config.iconAllowSvg === true;\n const safeIcon = typeof sanitizeHtml === 'function'\n ? sanitizeHtml(config.icon, { allowSvg })\n : escapeHtml(config.icon);\n html += `${safeIcon}`;\n } else if (config.type) {\n html += `${this.getDefaultIcon(config.type)}`;\n }\n\n // Local escape helper \u2014 guarantees HTML-safe output even if the\n // global escapeHtml utility is not loaded in the current bundle.\n const _esc = typeof escapeHtml === 'function'\n ? escapeHtml\n : function (s) {\n const d = document.createElement('div');\n d.appendChild(document.createTextNode(s));\n return d.innerHTML;\n };\n\n // Content (escape text to prevent injection)\n html += '
    ';\n if (config.title) {\n html += `
    ${_esc(String(config.title))}
    `;\n }\n if (config.message) {\n html += `
    ${_esc(String(config.message))}
    `;\n }\n html += '
    ';\n\n // Close button\n if (config.dismissible) {\n html += '';\n }\n\n // Progress bar\n if (config.showProgress && config.duration > 0) {\n const safeDuration = parseInt(config.duration, 10) || 0;\n html += `
    `;\n }\n\n toast.innerHTML = html;\n\n // Add to container\n container.appendChild(toast);\n\n toast._toastCleanup = [];\n\n // Set up close button handler\n if (config.dismissible) {\n const closeBtn = toast.querySelector('.vd-toast-close');\n const onClose = () => {\n this.dismiss(toast);\n };\n closeBtn.addEventListener('click', onClose);\n toast._toastCleanup.push(() => closeBtn.removeEventListener('click', onClose));\n }\n\n // Pause on hover\n let timeoutId = null;\n let remainingTime = config.duration;\n let startTime = null;\n\n const startTimer = () => {\n if (config.duration > 0) {\n startTime = Date.now();\n timeoutId = setTimeout(() => {\n this.dismiss(toast);\n }, remainingTime);\n toast._toastTimeoutId = timeoutId;\n\n // Resume progress animation\n const progress = toast.querySelector('.vd-toast-progress');\n if (progress) {\n progress.style.animationPlayState = 'running';\n }\n }\n };\n\n const pauseTimer = () => {\n if (timeoutId) {\n clearTimeout(timeoutId);\n timeoutId = null;\n toast._toastTimeoutId = null;\n remainingTime -= Date.now() - startTime;\n\n // Pause progress animation\n const progress = toast.querySelector('.vd-toast-progress');\n if (progress) {\n progress.style.animationPlayState = 'paused';\n }\n }\n };\n\n if (config.pauseOnHover) {\n toast.addEventListener('mouseenter', pauseTimer);\n toast.addEventListener('mouseleave', startTimer);\n toast._toastCleanup.push(\n () => toast.removeEventListener('mouseenter', pauseTimer),\n () => toast.removeEventListener('mouseleave', startTimer)\n );\n }\n\n // Trigger enter animation\n requestAnimationFrame(() => {\n toast.classList.add('is-visible');\n startTimer();\n });\n\n // Store config on element for later access\n toast._toastConfig = config;\n\n // Dispatch show event\n const showEvent = new CustomEvent('toast:show', {\n bubbles: true,\n detail: { toast, config }\n });\n toast.dispatchEvent(showEvent);\n\n return toast;\n },\n\n /**\n * Dismiss a toast\n * @param {HTMLElement} toast - Toast element to dismiss\n */\n dismiss: function(toast) {\n if (!toast || toast.classList.contains('is-exiting')) return;\n\n if (toast._toastTimeoutId) {\n clearTimeout(toast._toastTimeoutId);\n toast._toastTimeoutId = null;\n }\n\n toast.classList.remove('is-visible');\n toast.classList.add('is-exiting');\n\n // Dispatch dismiss event\n const dismissEvent = new CustomEvent('toast:dismiss', {\n bubbles: true,\n detail: { toast }\n });\n toast.dispatchEvent(dismissEvent);\n\n // Remove after animation\n const handleTransitionEnd = () => {\n toast.removeEventListener('transitionend', handleTransitionEnd);\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n };\n\n toast.addEventListener('transitionend', handleTransitionEnd);\n\n // Fallback removal if transition doesn't fire\n setTimeout(() => {\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n }, 400);\n },\n\n /**\n * Destroy all toasts and containers\n */\n destroyAll: function() {\n Object.keys(this.containers).forEach(position => {\n const container = this.containers[position];\n if (!container) return;\n\n const toasts = container.querySelectorAll('.vd-toast');\n toasts.forEach(toast => {\n if (toast._toastTimeoutId) {\n clearTimeout(toast._toastTimeoutId);\n }\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n });\n\n if (container.parentElement) {\n container.parentElement.removeChild(container);\n }\n });\n\n this.containers = {};\n },\n\n /**\n * Dismiss all toasts\n * @param {string} [position] - Optional position to clear (clears all if not specified)\n */\n dismissAll: function(position) {\n if (position && this.containers[position]) {\n const toasts = this.containers[position].querySelectorAll('.vd-toast');\n toasts.forEach(toast => this.dismiss(toast));\n } else {\n Object.values(this.containers).forEach(container => {\n const toasts = container.querySelectorAll('.vd-toast');\n toasts.forEach(toast => this.dismiss(toast));\n });\n }\n },\n\n /**\n * Get default icon SVG for a type\n * @param {string} type - Toast type\n * @returns {string} SVG icon markup\n */\n getDefaultIcon: function(type) {\n const icons = {\n success: '',\n error: '',\n warning: '',\n info: ''\n };\n\n return icons[type] || '';\n },\n\n /**\n * Convenience methods for common toast types\n */\n success: function(message, options) {\n return this.show(Object.assign({ message, type: 'success' }, options));\n },\n\n error: function(message, options) {\n return this.show(Object.assign({ message, type: 'error' }, options));\n },\n\n warning: function(message, options) {\n return this.show(Object.assign({ message, type: 'warning' }, options));\n },\n\n info: function(message, options) {\n return this.show(Object.assign({ message, type: 'info' }, options));\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('toast', Toast);\n }\n\n // Also expose globally for convenience\n window.Toast = Toast;\n\n})();\n", "/**\n * Vanduo Framework - Tooltips Component\n * JavaScript functionality for tooltips\n */\n\n(function () {\n 'use strict';\n\n /**\n * Tooltips Component\n */\n const Tooltips = {\n tooltips: new Map(),\n delayTimers: new Map(),\n\n /**\n * Sanitize HTML \u2014 delegates to shared sanitizeHtml from helpers.js\n * @param {string} input\n * @param {{ allowSvg?: boolean }} [options]\n * @returns {string} sanitized HTML\n */\n sanitizeHtml: function (input, options) {\n if (typeof sanitizeHtml === 'function') {\n return sanitizeHtml(input, options);\n }\n // Fallback: content originates from developer-authored data attributes,\n // so returning it as-is is no worse than any other inline HTML.\n return input || '';\n },\n\n /**\n * Initialize tooltips\n */\n init: function () {\n const elements = document.querySelectorAll('[data-tooltip], [data-tooltip-html]');\n\n elements.forEach(element => {\n if (this.tooltips.has(element)) {\n return;\n }\n this.initTooltip(element);\n });\n },\n\n /**\n * Initialize a single tooltip\n * @param {HTMLElement} element - Element with tooltip\n */\n initTooltip: function (element) {\n const tooltip = this.createTooltip(element);\n const cleanupFunctions = [];\n\n // Show on hover/focus\n const enterHandler = () => { this.showTooltip(element, tooltip); };\n const leaveHandler = () => { this.hideTooltip(element, tooltip); };\n const focusHandler = () => { this.showTooltip(element, tooltip); };\n const blurHandler = () => { this.hideTooltip(element, tooltip); };\n\n element.addEventListener('mouseenter', enterHandler);\n element.addEventListener('mouseleave', leaveHandler);\n element.addEventListener('focus', focusHandler);\n element.addEventListener('blur', blurHandler);\n\n cleanupFunctions.push(\n () => element.removeEventListener('mouseenter', enterHandler),\n () => element.removeEventListener('mouseleave', leaveHandler),\n () => element.removeEventListener('focus', focusHandler),\n () => element.removeEventListener('blur', blurHandler)\n );\n\n this.tooltips.set(element, { tooltip, cleanup: cleanupFunctions });\n },\n\n /**\n * Create tooltip element\n * @param {HTMLElement} element - Target element\n * @returns {HTMLElement} Tooltip element\n */\n createTooltip: function (element) {\n const tooltip = document.createElement('div');\n tooltip.className = 'vd-tooltip';\n tooltip.setAttribute('role', 'tooltip');\n tooltip.setAttribute('aria-hidden', 'true');\n\n // Generate unique ID and link via aria-describedby\n const tooltipId = 'tooltip-' + Math.random().toString(36).substr(2, 9);\n tooltip.id = tooltipId;\n element.setAttribute('aria-describedby', tooltipId);\n\n // Get content\n const htmlContent = element.dataset.tooltipHtml;\n const textContent = element.dataset.tooltip;\n\n if (htmlContent) {\n const allowSvg = element.hasAttribute('data-tooltip-allow-svg');\n tooltip.innerHTML = this.sanitizeHtml(htmlContent, { allowSvg });\n tooltip.classList.add('vd-tooltip-html');\n } else if (textContent) {\n tooltip.textContent = textContent;\n }\n\n // Get placement\n const placement = element.dataset.tooltipPlacement || element.dataset.placement || 'top';\n tooltip.setAttribute('data-placement', placement);\n tooltip.classList.add(`vd-tooltip-${placement}`);\n\n // Get variant\n if (element.dataset.tooltipVariant) {\n tooltip.classList.add(`vd-tooltip-${element.dataset.tooltipVariant}`);\n }\n\n // Get size\n if (element.dataset.tooltipSize) {\n tooltip.classList.add(`vd-tooltip-${element.dataset.tooltipSize}`);\n }\n\n // Get delay\n const delay = parseInt(element.dataset.tooltipDelay) || 0;\n tooltip.dataset.delay = delay;\n\n document.body.appendChild(tooltip);\n\n return tooltip;\n },\n\n /**\n * Show tooltip\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n showTooltip: function (element, tooltip) {\n const delay = parseInt(tooltip.dataset.delay) || 0;\n\n if (delay > 0) {\n const timer = setTimeout(() => {\n this.positionTooltip(element, tooltip);\n tooltip.classList.add('is-visible');\n tooltip.setAttribute('aria-hidden', 'false');\n }, delay);\n this.delayTimers.set(element, timer);\n } else {\n this.positionTooltip(element, tooltip);\n tooltip.classList.add('is-visible');\n tooltip.setAttribute('aria-hidden', 'false');\n }\n },\n\n /**\n * Hide tooltip\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n hideTooltip: function (element, tooltip) {\n // Clear delay timer if exists\n const timer = this.delayTimers.get(element);\n if (timer) {\n clearTimeout(timer);\n this.delayTimers.delete(element);\n }\n\n tooltip.classList.remove('is-visible');\n tooltip.setAttribute('aria-hidden', 'true');\n },\n\n /**\n * Position tooltip relative to element\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n positionTooltip: function (element, tooltip) {\n const placement = tooltip.dataset.placement || 'top';\n const rect = element.getBoundingClientRect();\n const tooltipRect = tooltip.getBoundingClientRect();\n const scrollTop = window.pageYOffset || document.documentElement.scrollTop;\n const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;\n\n let top = 0;\n let left = 0;\n\n switch (placement) {\n case 'top':\n top = rect.top + scrollTop - tooltipRect.height - 8;\n left = rect.left + scrollLeft + (rect.width / 2) - (tooltipRect.width / 2);\n break;\n case 'bottom':\n top = rect.bottom + scrollTop + 8;\n left = rect.left + scrollLeft + (rect.width / 2) - (tooltipRect.width / 2);\n break;\n case 'left':\n top = rect.top + scrollTop + (rect.height / 2) - (tooltipRect.height / 2);\n left = rect.left + scrollLeft - tooltipRect.width - 8;\n break;\n case 'right':\n top = rect.top + scrollTop + (rect.height / 2) - (tooltipRect.height / 2);\n left = rect.right + scrollLeft + 8;\n break;\n }\n\n // Prevent overflow\n const viewportWidth = window.innerWidth;\n const viewportHeight = window.innerHeight;\n const padding = 8;\n\n if (left < padding) {\n left = padding;\n } else if (left + tooltipRect.width > viewportWidth - padding) {\n left = viewportWidth - tooltipRect.width - padding;\n }\n\n if (top < scrollTop + padding) {\n top = scrollTop + padding;\n } else if (top + tooltipRect.height > scrollTop + viewportHeight - padding) {\n top = scrollTop + viewportHeight - tooltipRect.height - padding;\n }\n\n // Use single style assignment with transform for better performance\n tooltip.style.cssText = `position: absolute; top: 0; left: 0; transform: translate(${left}px, ${top}px);`;\n },\n\n /**\n * Show tooltip programmatically\n * @param {HTMLElement|string} element - Target element or selector\n */\n show: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n this.showTooltip(el, tooltip);\n }\n },\n\n /**\n * Hide tooltip programmatically\n * @param {HTMLElement|string} element - Target element or selector\n */\n hide: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n this.hideTooltip(el, tooltip);\n }\n },\n\n /**\n * Update tooltip content\n * @param {HTMLElement|string} element - Target element or selector\n * @param {string} content - New content\n * @param {boolean} isHtml - Whether content is HTML\n */\n update: function (element, content, isHtml = false) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n if (isHtml) {\n const allowSvg = el.hasAttribute('data-tooltip-allow-svg');\n tooltip.innerHTML = this.sanitizeHtml(content, { allowSvg });\n tooltip.classList.add('vd-tooltip-html');\n } else {\n tooltip.textContent = content;\n tooltip.classList.remove('vd-tooltip-html');\n }\n }\n },\n\n /**\n * Destroy a tooltip instance and clean up\n * @param {HTMLElement} element - Element with tooltip\n */\n destroy: function (element) {\n const data = this.tooltips.get(element);\n if (!data) return;\n\n // Clear any pending timer\n const timer = this.delayTimers.get(element);\n if (timer) {\n clearTimeout(timer);\n this.delayTimers.delete(element);\n }\n\n data.cleanup.forEach(fn => fn());\n\n // Remove tooltip element from DOM\n if (data.tooltip && data.tooltip.parentNode) {\n data.tooltip.parentNode.removeChild(data.tooltip);\n }\n\n this.tooltips.delete(element);\n },\n\n /**\n * Destroy all tooltip instances\n */\n destroyAll: function () {\n this.tooltips.forEach((data, element) => {\n this.destroy(element);\n });\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('tooltips', Tooltips);\n }\n\n // Expose globally\n window.VanduoTooltips = Tooltips;\n\n})();\n", "/**\n * Vanduo Framework - Search Component\n * Client-side search functionality for content pages\n * \n * @example Basic usage (initialize with defaults)\n * // HTML:\n * //
    \n * // \n * //
    \n * //
    \n * \n * @example Custom configuration\n * var search = Search.create({\n * containerSelector: '.my-search',\n * contentSelector: 'article[id]',\n * titleSelector: 'h2, h3',\n * maxResults: 5,\n * onSelect: function(result) {\n * console.log('Selected:', result.title);\n * }\n * });\n * \n * @example With custom data source\n * var search = Search.create({\n * containerSelector: '.my-search',\n * data: [\n * { id: 'item1', title: 'First Item', content: 'Description...', category: 'Category A' },\n * { id: 'item2', title: 'Second Item', content: 'Description...', category: 'Category B' }\n * ]\n * });\n */\n\n(function() {\n 'use strict';\n\n /**\n * Default configuration\n */\n const DEFAULTS = {\n // Behavior\n minQueryLength: 2,\n maxResults: 10,\n debounceMs: 150,\n highlightTag: 'mark',\n keyboardShortcut: true, // Enable Cmd/Ctrl+K shortcut\n \n // Selectors (for DOM-based indexing)\n containerSelector: '.vd-doc-search',\n inputSelector: '.vd-doc-search-input',\n resultsSelector: '.vd-doc-search-results',\n contentSelector: '.doc-content section[id]',\n titleSelector: '.demo-title, h2, h3',\n navSelector: '.doc-nav-link',\n sectionSelector: '.doc-nav-section',\n \n // Content extraction\n excludeFromContent: 'pre, code, script, style',\n maxContentLength: 500,\n \n // Custom data source (alternative to DOM indexing)\n data: null,\n \n // Category icons mapping\n categoryIcons: {\n 'getting-started': 'ph-rocket-launch',\n 'core': 'ph-cube',\n 'components': 'ph-puzzle-piece',\n 'interactive': 'ph-cursor-click',\n 'data-display': 'ph-table',\n 'feedback': 'ph-bell',\n 'meta': 'ph-info',\n 'default': 'ph-file-text'\n },\n \n // Callbacks\n onSelect: null, // function(result) - called when result is selected\n onSearch: null, // function(query, results) - called after search\n onOpen: null, // function() - called when results open\n onClose: null, // function() - called when results close\n \n // Text customization\n emptyTitle: 'No results found',\n emptyText: 'Try different keywords or check spelling',\n placeholder: 'Search...'\n };\n\n /**\n * Search Component Factory\n * Creates a new search instance with the given configuration\n * \n * @param {Object} options - Configuration options\n * @returns {Object} Search instance\n */\n function createSearch(options) {\n const config = Object.assign({}, DEFAULTS, options || {});\n \n // Instance state\n const state = {\n initialized: false,\n index: [],\n results: [],\n activeIndex: -1,\n isOpen: false,\n query: '',\n container: null,\n input: null,\n resultsContainer: null,\n debounceTimer: null,\n boundHandlers: {}\n };\n\n function safeInvokeCallback(name, fn, ...args) {\n try {\n fn(...args);\n } catch (error) {\n console.warn('[Vanduo Search] Callback error in \"' + name + '\":', error);\n }\n }\n\n function setResultsHtml(html) {\n if (!state.resultsContainer) return;\n try {\n state.resultsContainer.innerHTML = html;\n } catch (error) {\n console.warn('[Vanduo Search] Failed to render results:', error);\n }\n }\n\n /**\n * Initialize the search component\n * Idempotent \u2014 safe to call more than once on the same instance.\n * Returns the instance on success, null if required DOM elements are missing.\n */\n function init() {\n if (state.initialized) {\n return instance;\n }\n\n state.container = document.querySelector(config.containerSelector);\n if (!state.container) {\n state.initialized = false;\n return null;\n }\n\n state.input = state.container.querySelector(config.inputSelector);\n state.resultsContainer = state.container.querySelector(config.resultsSelector);\n\n if (!state.input || !state.resultsContainer) {\n state.initialized = false;\n return null;\n }\n\n // Set placeholder if configured\n if (config.placeholder) {\n state.input.setAttribute('placeholder', config.placeholder);\n }\n\n // Build search index\n buildIndex();\n\n // Bind events\n bindEvents();\n\n // Set up ARIA attributes\n setupAria();\n\n state.initialized = true;\n return instance;\n }\n\n /**\n * Build search index from DOM or custom data\n */\n function buildIndex() {\n state.index = [];\n \n // Use custom data if provided\n if (config.data && Array.isArray(config.data)) {\n config.data.forEach(function(item) {\n state.index.push({\n id: item.id || slugify(item.title),\n title: item.title || '',\n category: item.category || '',\n categorySlug: slugify(item.category || ''),\n content: item.content || '',\n keywords: item.keywords || extractKeywordsFromText(item.title + ' ' + item.content),\n url: item.url || '#' + (item.id || slugify(item.title)),\n icon: item.icon || ''\n });\n });\n return;\n }\n\n // Build from DOM\n const sections = document.querySelectorAll(config.contentSelector);\n const categoryMap = buildCategoryMap();\n\n sections.forEach(function(section) {\n const id = section.id;\n if (!id) return;\n\n const titleEl = section.querySelector(config.titleSelector);\n const title = titleEl ? titleEl.textContent.replace(/v[\\d.]+/g, '').trim() : id;\n const category = categoryMap[id] || 'Documentation';\n const content = extractContent(section);\n const keywords = extractKeywords(section, title);\n const iconEl = titleEl ? titleEl.querySelector('i.ph') : null;\n let icon = '';\n if (iconEl && iconEl.classList) {\n for (let ci = 0; ci < iconEl.classList.length; ci++) {\n if (iconEl.classList[ci].indexOf('ph-') === 0) {\n icon = iconEl.classList[ci];\n break;\n }\n }\n }\n\n state.index.push({\n id: id,\n title: title,\n category: category,\n categorySlug: slugify(category),\n content: content,\n keywords: keywords,\n url: '#' + id,\n icon: icon\n });\n });\n }\n\n /**\n * Build a map of section IDs to their categories\n */\n function buildCategoryMap() {\n const map = {};\n let currentCategory = 'Documentation';\n const navItems = document.querySelectorAll(config.navSelector + ', ' + config.sectionSelector);\n\n navItems.forEach(function(item) {\n if (item.classList.contains('doc-nav-section')) {\n currentCategory = item.textContent.trim();\n } else {\n const href = item.getAttribute('href');\n if (href && href.startsWith('#')) {\n const id = href.substring(1);\n map[id] = currentCategory;\n }\n }\n });\n\n return map;\n }\n\n /**\n * Extract searchable content from a section\n */\n function extractContent(section) {\n const clone = section.cloneNode(true);\n \n const toRemove = clone.querySelectorAll(config.excludeFromContent);\n toRemove.forEach(function(el) {\n el.remove();\n });\n\n let text = clone.textContent || '';\n text = text.replace(/\\s+/g, ' ').trim();\n \n return text.substring(0, config.maxContentLength);\n }\n\n /**\n * Extract keywords from a section\n */\n function extractKeywords(section, title) {\n const keywords = [];\n \n // Add title words\n title.toLowerCase().split(/\\s+/).forEach(function(word) {\n if (word.length > 2) {\n keywords.push(word);\n }\n });\n\n // Add class names from code examples\n const codeBlocks = section.querySelectorAll('code');\n codeBlocks.forEach(function(code) {\n const text = code.textContent || '';\n const classMatches = text.match(/\\.([\\w-]+)/g);\n if (classMatches) {\n classMatches.forEach(function(match) {\n keywords.push(match.substring(1).toLowerCase());\n });\n }\n });\n\n // Add data attributes\n const dataAttrs = section.querySelectorAll('[data-tooltip], [data-modal]');\n dataAttrs.forEach(function(el) {\n const attrs = el.getAttributeNames().filter(function(name) {\n return name.startsWith('data-');\n });\n attrs.forEach(function(attr) {\n keywords.push(attr.replace('data-', ''));\n });\n });\n\n return Array.from(new Set(keywords));\n }\n\n /**\n * Extract keywords from text string\n */\n function extractKeywordsFromText(text) {\n const words = text.toLowerCase().split(/\\s+/);\n return words.filter(function(word) {\n return word.length > 2;\n });\n }\n\n /**\n * Convert string to slug\n */\n function slugify(str) {\n return str.toLowerCase().replace(/\\s+/g, '-').replace(/[^a-z0-9-]/g, '');\n }\n\n /**\n * Bind event listeners\n */\n function bindEvents() {\n // Store bound handlers for cleanup\n state.boundHandlers.handleInput = function(e) {\n handleInput(e);\n };\n state.boundHandlers.handleFocus = function() {\n if (state.query.length >= config.minQueryLength) {\n open();\n }\n };\n state.boundHandlers.handleKeydown = function(e) {\n handleKeydown(e);\n };\n state.boundHandlers.handleOutsideClick = function(e) {\n if (!state.container.contains(e.target)) {\n close();\n }\n };\n state.boundHandlers.handleGlobalKeydown = function(e) {\n if (config.keyboardShortcut && (e.metaKey || e.ctrlKey) && e.key === 'k') {\n e.preventDefault();\n state.input.focus();\n state.input.select();\n }\n };\n state.boundHandlers.handleResultClick = function(e) {\n const result = e.target.closest('.vd-doc-search-result');\n if (result) {\n const index = parseInt(result.dataset.index, 10);\n select(index);\n }\n };\n\n // Input events\n state.input.addEventListener('input', state.boundHandlers.handleInput);\n state.input.addEventListener('focus', state.boundHandlers.handleFocus);\n state.input.addEventListener('keydown', state.boundHandlers.handleKeydown);\n\n // Close on outside click\n document.addEventListener('click', state.boundHandlers.handleOutsideClick);\n\n // Global keyboard shortcut\n document.addEventListener('keydown', state.boundHandlers.handleGlobalKeydown);\n\n // Result click handling\n state.resultsContainer.addEventListener('click', state.boundHandlers.handleResultClick);\n }\n\n /**\n * Unbind event listeners\n */\n function unbindEvents() {\n if (state.input) {\n state.input.removeEventListener('input', state.boundHandlers.handleInput);\n state.input.removeEventListener('focus', state.boundHandlers.handleFocus);\n state.input.removeEventListener('keydown', state.boundHandlers.handleKeydown);\n }\n document.removeEventListener('click', state.boundHandlers.handleOutsideClick);\n document.removeEventListener('keydown', state.boundHandlers.handleGlobalKeydown);\n if (state.resultsContainer) {\n state.resultsContainer.removeEventListener('click', state.boundHandlers.handleResultClick);\n }\n }\n\n /**\n * Set up ARIA attributes\n */\n function setupAria() {\n const resultsId = state.resultsContainer.id || 'search-results-' + Math.random().toString(36).substr(2, 9);\n state.resultsContainer.id = resultsId;\n \n state.input.setAttribute('role', 'combobox');\n state.input.setAttribute('aria-autocomplete', 'list');\n state.input.setAttribute('aria-controls', resultsId);\n state.input.setAttribute('aria-expanded', 'false');\n \n state.resultsContainer.setAttribute('role', 'listbox');\n state.resultsContainer.setAttribute('aria-label', 'Search results');\n }\n\n /**\n * Handle input changes\n */\n function handleInput(e) {\n const query = e.target.value.trim();\n\n if (state.debounceTimer) {\n clearTimeout(state.debounceTimer);\n }\n\n state.debounceTimer = setTimeout(function() {\n state.query = query;\n\n if (query.length < config.minQueryLength) {\n close();\n return;\n }\n\n state.results = search(query);\n state.activeIndex = -1;\n render();\n open();\n\n // Callback\n if (typeof config.onSearch === 'function') {\n safeInvokeCallback('onSearch', config.onSearch, query, state.results);\n }\n }, config.debounceMs);\n }\n\n /**\n * Handle keyboard navigation\n */\n function handleKeydown(e) {\n if (!state.isOpen) {\n if (e.key === 'ArrowDown' && state.query.length >= config.minQueryLength) {\n e.preventDefault();\n state.results = search(state.query);\n render();\n open();\n }\n return;\n }\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n navigate(1);\n break;\n\n case 'ArrowUp':\n e.preventDefault();\n navigate(-1);\n break;\n\n case 'Enter':\n e.preventDefault();\n if (state.activeIndex >= 0) {\n select(state.activeIndex);\n } else if (state.results.length > 0) {\n select(0);\n }\n break;\n\n case 'Escape':\n e.preventDefault();\n close();\n break;\n\n case 'Tab':\n close();\n break;\n }\n }\n\n /**\n * Perform search\n */\n function search(query) {\n const terms = query.toLowerCase().split(/\\s+/).filter(function(t) {\n return t.length > 0;\n });\n const scored = [];\n\n state.index.forEach(function(entry) {\n let score = 0;\n const titleLower = entry.title.toLowerCase();\n const categoryLower = entry.category.toLowerCase();\n const contentLower = entry.content.toLowerCase();\n\n terms.forEach(function(term) {\n // Title match - highest priority\n if (titleLower.includes(term)) {\n score += 100;\n if (titleLower === term) {\n score += 50;\n } else if (titleLower.startsWith(term)) {\n score += 25;\n }\n }\n\n // Category match\n if (categoryLower.includes(term)) {\n score += 50;\n }\n\n // Keyword match\n const keywordMatch = entry.keywords.some(function(k) {\n return k.includes(term);\n });\n if (keywordMatch) {\n score += 30;\n }\n\n // Content match\n if (contentLower.includes(term)) {\n score += 10;\n }\n });\n\n if (score > 0) {\n scored.push({\n id: entry.id,\n title: entry.title,\n category: entry.category,\n categorySlug: entry.categorySlug,\n content: entry.content,\n url: entry.url,\n icon: entry.icon,\n score: score\n });\n }\n });\n\n scored.sort(function(a, b) {\n return b.score - a.score;\n });\n\n return scored.slice(0, config.maxResults);\n }\n\n /**\n * Render search results\n */\n function render() {\n if (state.results.length === 0) {\n setResultsHtml(renderEmpty());\n return;\n }\n\n let html = '
      ';\n\n state.results.forEach(function(result, index) {\n const isActive = index === state.activeIndex;\n const icon = result.icon || getCategoryIcon(result.categorySlug);\n const excerpt = getExcerpt(result.content, state.query);\n\n html += '
    • ' +\n '
      ' +\n '' +\n '
      ' +\n '
      ' +\n '
      ' + highlight(result.title, state.query) + '
      ' +\n '
      ' + escapeHtml(result.category) + '
      ' +\n '
      ' + highlight(excerpt, state.query) + '
      ' +\n '
      ' +\n '
    • ';\n });\n\n html += '
    ';\n html += renderFooter();\n\n setResultsHtml(html);\n }\n\n /**\n * Render empty state\n */\n function renderEmpty() {\n return '
    ' +\n '
    ' +\n '
    ' + escapeHtml(config.emptyTitle) + '
    ' +\n '
    ' + escapeHtml(config.emptyText) + '
    ' +\n '
    ';\n }\n\n /**\n * Render footer with keyboard hints\n */\n function renderFooter() {\n return '
    ' +\n '\u2191\u2193 to navigate' +\n '\u21B5 to select' +\n 'esc to close' +\n '
    ';\n }\n\n /**\n * Get icon for category\n */\n function getCategoryIcon(categorySlug) {\n return config.categoryIcons[categorySlug] || config.categoryIcons['default'] || 'ph-file-text';\n }\n\n /**\n * Get excerpt from content\n */\n function getExcerpt(content, query) {\n const terms = query.toLowerCase().split(/\\s+/);\n const contentLower = content.toLowerCase();\n const excerptLength = 100;\n\n let matchPos = -1;\n for (let i = 0; i < terms.length; i++) {\n const pos = contentLower.indexOf(terms[i]);\n if (pos !== -1 && (matchPos === -1 || pos < matchPos)) {\n matchPos = pos;\n }\n }\n\n if (matchPos === -1) {\n return content.substring(0, excerptLength) + '...';\n }\n\n const start = Math.max(0, matchPos - 30);\n const end = Math.min(content.length, matchPos + excerptLength);\n let excerpt = content.substring(start, end);\n\n if (start > 0) {\n excerpt = '...' + excerpt;\n }\n if (end < content.length) {\n excerpt = excerpt + '...';\n }\n\n return excerpt;\n }\n\n /**\n * Highlight matched terms in text\n */\n function highlight(text, query) {\n if (!query) return escapeHtml(text);\n\n const terms = query.toLowerCase().split(/\\s+/).filter(function(t) {\n return t.length > 0;\n });\n let escaped = escapeHtml(text);\n\n terms.forEach(function(term) {\n // Skip overly long terms to prevent ReDoS\n if (term.length > 50) return;\n const regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi');\n escaped = escaped.replace(regex, '<' + config.highlightTag + '>$1');\n });\n\n return escaped;\n }\n\n /**\n * Escape HTML entities\n */\n function escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Navigate results with keyboard\n */\n function navigate(direction) {\n let newIndex = state.activeIndex + direction;\n\n if (newIndex < 0) {\n newIndex = state.results.length - 1;\n } else if (newIndex >= state.results.length) {\n newIndex = 0;\n }\n\n setActiveIndex(newIndex);\n }\n\n /**\n * Set active result index\n */\n function setActiveIndex(index) {\n const prevActive = state.resultsContainer.querySelector('.vd-doc-search-result.is-active');\n if (prevActive) {\n prevActive.classList.remove('is-active');\n prevActive.setAttribute('aria-selected', 'false');\n }\n\n state.activeIndex = index;\n\n const newActive = state.resultsContainer.querySelector('[data-index=\"' + index + '\"]');\n if (newActive) {\n newActive.classList.add('is-active');\n newActive.setAttribute('aria-selected', 'true');\n state.input.setAttribute('aria-activedescendant', 'vd-doc-search-result-' + index);\n newActive.scrollIntoView({ block: 'nearest' });\n }\n }\n\n /**\n * Select a result\n */\n function select(index) {\n const result = state.results[index];\n if (!result) return;\n\n // Close search\n close();\n state.input.value = '';\n state.query = '';\n\n // Custom callback\n if (typeof config.onSelect === 'function') {\n safeInvokeCallback('onSelect', config.onSelect, result);\n return;\n }\n\n // Default behavior: navigate to section\n const section = document.querySelector(result.url);\n if (section) {\n section.scrollIntoView({ behavior: 'smooth', block: 'start' });\n window.history.pushState(null, '', result.url);\n updateSidebarActive(result.id);\n }\n }\n\n /**\n * Update sidebar navigation active state\n */\n function updateSidebarActive(sectionId) {\n const navLinks = document.querySelectorAll(config.navSelector);\n navLinks.forEach(function(link) {\n link.classList.remove('active');\n if (link.getAttribute('href') === '#' + sectionId) {\n link.classList.add('active');\n }\n });\n }\n\n /**\n * Open results dropdown\n */\n function open() {\n if (state.isOpen) return;\n\n state.isOpen = true;\n state.resultsContainer.classList.add('is-open');\n state.input.setAttribute('aria-expanded', 'true');\n\n if (typeof config.onOpen === 'function') {\n safeInvokeCallback('onOpen', config.onOpen);\n }\n }\n\n /**\n * Close results dropdown\n */\n function close() {\n if (!state.isOpen) return;\n\n state.isOpen = false;\n state.activeIndex = -1;\n state.resultsContainer.classList.remove('is-open');\n state.input.setAttribute('aria-expanded', 'false');\n state.input.removeAttribute('aria-activedescendant');\n\n if (typeof config.onClose === 'function') {\n safeInvokeCallback('onClose', config.onClose);\n }\n }\n\n /**\n * Destroy the component\n */\n function destroy() {\n unbindEvents();\n \n state.initialized = false;\n state.index = [];\n state.results = [];\n state.isOpen = false;\n state.query = '';\n\n if (state.debounceTimer) {\n clearTimeout(state.debounceTimer);\n }\n\n if (state.resultsContainer) {\n setResultsHtml('');\n }\n }\n\n /**\n * Rebuild the search index\n */\n function rebuild() {\n buildIndex();\n }\n\n /**\n * Update configuration\n */\n function setConfig(newConfig) {\n Object.assign(config, newConfig);\n }\n\n /**\n * Get current configuration\n */\n function getConfig() {\n return Object.assign({}, config);\n }\n\n /**\n * Get search index\n */\n function getIndex() {\n return state.index.slice();\n }\n\n // Public instance API\n const instance = {\n init: init,\n destroy: destroy,\n rebuild: rebuild,\n search: search,\n open: open,\n close: close,\n setConfig: setConfig,\n getConfig: getConfig,\n getIndex: getIndex\n };\n\n return instance;\n }\n\n /**\n * Search Component (singleton for backward compatibility)\n */\n const Search = {\n // Factory method \u2014 creates and auto-initializes a new independent instance.\n // Always returns the instance so callers retain a reference even if the\n // DOM container is not yet available (they can retry init() later).\n create: function(options) {\n const instance = createSearch(options);\n if (instance) {\n instance.init();\n }\n return instance || null;\n },\n \n // Default instance\n _instance: null,\n \n // Configuration (for default instance)\n config: Object.assign({}, DEFAULTS),\n\n /**\n * Initialize the default search instance\n */\n init: function(options) {\n if (this._instance) {\n this._instance.destroy();\n }\n \n if (options) {\n Object.assign(this.config, options);\n }\n \n this._instance = createSearch(this.config);\n return this._instance ? this._instance.init() : null;\n },\n\n /**\n * Destroy the default instance\n */\n destroy: function() {\n if (this._instance) {\n this._instance.destroy();\n this._instance = null;\n }\n },\n\n destroyAll: function() {\n this.destroy();\n },\n\n /**\n * Rebuild the default instance index\n */\n rebuild: function() {\n if (this._instance) {\n this._instance.rebuild();\n }\n },\n\n /**\n * Search using the default instance\n */\n search: function(query) {\n return this._instance ? this._instance.search(query) : [];\n },\n\n /**\n * Open the default instance\n */\n open: function() {\n if (this._instance) {\n this._instance.open();\n }\n },\n\n /**\n * Close the default instance\n */\n close: function() {\n if (this._instance) {\n this._instance.close();\n }\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('docSearch', Search);\n }\n\n // Expose globally (both names for compatibility)\n window.Search = Search;\n window.DocSearch = Search; // Backward compatibility\n window.VanduoDocSearch = Search; // New name compatibility\n\n})();\n", "/**\n * Vanduo Framework - Draggable Component\n * JavaScript functionality for draggable elements and drop zones\n */\n\n(function () {\n 'use strict';\n\n /**\n * Draggable Component\n */\n const Draggable = {\n // Store initialized draggables and their cleanup functions\n instances: new Map(),\n // Store current drag state\n currentDrag: null,\n // Store touch state\n touchState: null,\n // Feedback element\n feedbackElement: null,\n // Shared selector used by init and touch reorder\n containerSelector: '.vd-draggable-container, .vd-draggable-container-vertical',\n\n /**\n * Initialize draggable components\n */\n init: function () {\n const draggables = document.querySelectorAll('.vd-draggable, [data-draggable]');\n\n draggables.forEach(element => {\n if (this.instances.has(element)) {\n return;\n }\n this.initDraggable(element);\n });\n\n const containers = document.querySelectorAll(this.containerSelector);\n containers.forEach(container => {\n if (!this.instances.has(container)) {\n this.initContainer(container);\n }\n });\n\n const dropZones = document.querySelectorAll('.vd-drop-zone');\n dropZones.forEach(zone => {\n if (!this.instances.has(zone)) {\n this.initDropZone(zone);\n }\n });\n\n this.createFeedbackElement();\n },\n\n /**\n * Initialize a single draggable element\n * @param {HTMLElement} element - Draggable element\n */\n initDraggable: function (element) {\n const cleanupFunctions = [];\n\n // Make element draggable if not already\n if (!element.hasAttribute('draggable')) {\n element.setAttribute('draggable', 'true');\n }\n\n // Accessibility: add ARIA attributes\n if (!element.hasAttribute('tabindex')) {\n element.setAttribute('tabindex', '0');\n }\n element.setAttribute('role', 'option');\n element.setAttribute('aria-roledescription', 'draggable item');\n element.setAttribute('aria-grabbed', 'false');\n\n // Handle drag start\n const dragStartHandler = (e) => {\n this.handleDragStart(e, element);\n };\n element.addEventListener('dragstart', dragStartHandler);\n cleanupFunctions.push(() => element.removeEventListener('dragstart', dragStartHandler));\n\n // Handle drag\n const dragHandler = (e) => {\n this.handleDrag(e, element);\n };\n element.addEventListener('drag', dragHandler);\n cleanupFunctions.push(() => element.removeEventListener('drag', dragHandler));\n\n // Handle drag end\n const dragEndHandler = (e) => {\n this.handleDragEnd(e, element);\n };\n element.addEventListener('dragend', dragEndHandler);\n cleanupFunctions.push(() => element.removeEventListener('dragend', dragEndHandler));\n\n // Handle touch start (for mobile)\n const touchStartHandler = (e) => {\n this.handleTouchStart(e, element);\n };\n element.addEventListener('touchstart', touchStartHandler);\n cleanupFunctions.push(() => element.removeEventListener('touchstart', touchStartHandler));\n\n // Handle touch move (for mobile)\n // { passive: false } is required so that e.preventDefault() works\n // once the drag threshold is reached \u2014 without it, modern browsers\n // treat the listener as passive and silently ignore preventDefault().\n const touchMoveHandler = (e) => {\n this.handleTouchMove(e, element);\n };\n element.addEventListener('touchmove', touchMoveHandler, { passive: false });\n cleanupFunctions.push(() => element.removeEventListener('touchmove', touchMoveHandler));\n\n // Handle touch end (for mobile)\n const touchEndHandler = (e) => {\n this.handleTouchEnd(e, element);\n };\n element.addEventListener('touchend', touchEndHandler, { passive: false });\n cleanupFunctions.push(() => element.removeEventListener('touchend', touchEndHandler));\n\n // Handle touch cancel (for mobile)\n const touchCancelHandler = (e) => {\n this.handleTouchEnd(e, element);\n };\n element.addEventListener('touchcancel', touchCancelHandler);\n cleanupFunctions.push(() => element.removeEventListener('touchcancel', touchCancelHandler));\n\n // Keyboard navigation\n const keydownHandler = (e) => {\n this.handleKeydown(e, element);\n };\n element.addEventListener('keydown', keydownHandler);\n cleanupFunctions.push(() => element.removeEventListener('keydown', keydownHandler));\n\n this.instances.set(element, { cleanup: cleanupFunctions });\n },\n\n /**\n * Initialize a draggable container\n * @param {HTMLElement} container - Draggable container\n */\n initContainer: function (container) {\n // Accessibility: add ARIA role to container\n container.setAttribute('role', 'listbox');\n container.setAttribute('aria-label', container.getAttribute('aria-label') || 'Draggable items');\n\n const items = container.querySelectorAll('.vd-draggable-item');\n items.forEach(item => {\n if (!this.instances.has(item)) {\n this.initDraggable(item);\n }\n });\n\n const cleanupFunctions = [];\n\n // Handle drag enter\n const dragEnterHandler = (e) => {\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n };\n\n // Handle drag over for auto-sorting\n const dragOverHandler = (e) => {\n e.preventDefault(); // Necessary to allow drop\n e.dataTransfer.dropEffect = 'move';\n\n if (!this.currentDrag) return;\n const draggingEl = this.currentDrag.element;\n\n // Only reorder if dragging an item that belongs to this container (or if we support cross-container drag, but keep simple)\n if (!container.contains(draggingEl)) return;\n\n // Prevent jumps when the dragging finalizes with 0,0 coordinates near the end\n if (e.clientX === 0 && e.clientY === 0) return;\n\n this.handleReorder(container, draggingEl, e.clientX, e.clientY);\n };\n\n // Handle drop\n const dropHandler = (e) => {\n e.preventDefault(); // crucial to prevent the browser's default handling and snapping back\n };\n\n container.addEventListener('dragenter', dragEnterHandler);\n container.addEventListener('dragover', dragOverHandler);\n container.addEventListener('drop', dropHandler);\n\n cleanupFunctions.push(() => {\n container.removeEventListener('dragenter', dragEnterHandler);\n container.removeEventListener('dragover', dragOverHandler);\n container.removeEventListener('drop', dropHandler);\n });\n\n this.instances.set(container, { cleanup: cleanupFunctions });\n },\n\n /**\n * Initialize a drop zone\n * @param {HTMLElement} zone - Drop zone element\n */\n initDropZone: function (zone) {\n const cleanupFunctions = [];\n\n // Accessibility: add ARIA role to drop zone\n zone.setAttribute('role', 'region');\n zone.setAttribute('aria-dropeffect', 'move');\n if (!zone.hasAttribute('aria-label')) {\n zone.setAttribute('aria-label', 'Drop zone');\n }\n\n // Handle drag over\n const dragOverHandler = (e) => {\n e.preventDefault();\n this.handleDragOver(e, zone);\n };\n zone.addEventListener('dragover', dragOverHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragover', dragOverHandler));\n\n // Handle drag enter\n const dragEnterHandler = (e) => {\n e.preventDefault();\n this.handleDragEnter(e, zone);\n };\n zone.addEventListener('dragenter', dragEnterHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragenter', dragEnterHandler));\n\n // Handle drag leave\n const dragLeaveHandler = (e) => {\n this.handleDragLeave(e, zone);\n };\n zone.addEventListener('dragleave', dragLeaveHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragleave', dragLeaveHandler));\n\n // Handle drop\n const dropHandler = (e) => {\n e.preventDefault();\n this.handleDrop(e, zone);\n };\n zone.addEventListener('drop', dropHandler);\n cleanupFunctions.push(() => zone.removeEventListener('drop', dropHandler));\n\n this.instances.set(zone, { cleanup: cleanupFunctions });\n },\n\n /**\n * Create feedback element for drag operations\n */\n createFeedbackElement: function () {\n if (!this.feedbackElement) {\n // Reuse existing element if present\n const existing = document.querySelector('.vd-drag-feedback');\n if (existing) {\n this.feedbackElement = existing;\n return;\n }\n\n this.feedbackElement = document.createElement('div');\n this.feedbackElement.className = 'vd-drag-feedback hidden';\n this.feedbackElement.setAttribute('role', 'presentation');\n document.body.appendChild(this.feedbackElement);\n }\n },\n\n /**\n * Handle drag start event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDragStart: function (e, element) {\n // Add dragging class\n element.classList.add('is-dragging');\n\n // Accessibility: update aria-grabbed\n element.setAttribute('aria-grabbed', 'true');\n\n // Store drag state\n this.currentDrag = {\n element: element,\n initialPosition: { x: e.clientX, y: e.clientY },\n initialBounds: element.getBoundingClientRect(),\n data: this.getData(element)\n };\n\n // Set drag data\n e.dataTransfer.effectAllowed = 'move';\n e.dataTransfer.setData('text/plain', this.currentDrag.data);\n\n // We no longer suppress the native ghost image or manually update feedback\n // for mouse drags, relying on the browser's native rendering instead.\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:start', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: e.clientX, y: e.clientY }\n }\n }));\n },\n\n /**\n * Handle drag event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDrag: function (e, element) {\n // Guard against null state (race condition on fast interactions)\n if (!this.currentDrag) return;\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:drag', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: e.clientX, y: e.clientY },\n delta: {\n x: e.clientX - this.currentDrag.initialPosition.x,\n y: e.clientY - this.currentDrag.initialPosition.y\n }\n }\n }));\n },\n\n /**\n * Handle drag end event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDragEnd: function (e, element) {\n // Remove dragging class\n element.classList.remove('is-dragging');\n element.classList.add('is-dropped');\n setTimeout(() => element.classList.remove('is-dropped'), 300);\n\n // Accessibility: update aria-grabbed\n element.setAttribute('aria-grabbed', 'false');\n\n // Hide feedback\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n\n // Guard against null state\n const data = this.currentDrag?.data || this.getData(element);\n const initialPos = this.currentDrag?.initialPosition || { x: 0, y: 0 };\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:end', {\n bubbles: true,\n detail: {\n element: element,\n data: data,\n position: { x: e.clientX, y: e.clientY },\n delta: {\n x: e.clientX - initialPos.x,\n y: e.clientY - initialPos.y\n }\n }\n }));\n\n // Reset drag state\n this.currentDrag = null;\n },\n\n /**\n * Handle touch start event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchStart: function (e, element) {\n // Don't prevent default here \u2014 it blocks scrolling.\n // We only prevent default in touchmove once drag threshold is reached.\n const touch = e.touches[0];\n const rect = element.getBoundingClientRect();\n this.touchState = {\n element: element,\n startX: touch.clientX,\n startY: touch.clientY,\n lastX: touch.clientX,\n lastY: touch.clientY,\n // Keep preview anchored to the original grab point.\n offsetX: touch.clientX - rect.left,\n offsetY: touch.clientY - rect.top,\n startTime: Date.now(),\n isDragging: false\n };\n },\n\n /**\n * Handle touch move event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchMove: function (e, element) {\n if (!this.touchState) return;\n\n const touch = e.touches[0];\n this.touchState.lastX = touch.clientX;\n this.touchState.lastY = touch.clientY;\n const deltaX = touch.clientX - this.touchState.startX;\n const deltaY = touch.clientY - this.touchState.startY;\n\n // Only start dragging if moved a minimum distance\n if (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10) {\n // Now we know it's a drag, not a scroll \u2014 prevent default\n if (e.cancelable) e.preventDefault();\n\n if (!this.touchState.isDragging) {\n this.touchState.isDragging = true;\n element.classList.add('is-dragging');\n element.setAttribute('aria-grabbed', 'true');\n\n // Store drag state\n this.currentDrag = {\n element: element,\n initialPosition: { x: this.touchState.startX, y: this.touchState.startY },\n initialBounds: element.getBoundingClientRect(),\n data: this.getData(element),\n // Preserve where inside the element the drag started for accurate ghost positioning.\n offsetX: this.touchState.offsetX,\n offsetY: this.touchState.offsetY\n };\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:start', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: touch.clientX, y: touch.clientY }\n }\n }));\n }\n\n // Update feedback\n this.updateFeedback(touch.clientX, touch.clientY);\n\n // Dispatch event\n if (this.currentDrag) {\n element.dispatchEvent(new CustomEvent('draggable:drag', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: touch.clientX, y: touch.clientY },\n delta: { x: deltaX, y: deltaY }\n }\n }));\n\n this.updateTouchDropZone(touch.clientX, touch.clientY);\n\n // Reorder for touch\n const container = element.closest(this.containerSelector);\n if (container && container.contains(element)) {\n this.handleReorder(container, element, touch.clientX, touch.clientY);\n }\n }\n }\n },\n\n /**\n * Handle touch end event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchEnd: function (e, element) {\n if (this.touchState && this.touchState.isDragging) {\n if (e.cancelable) e.preventDefault();\n const endTouch = e.changedTouches?.[0];\n const endPosition = {\n x: endTouch?.clientX ?? this.touchState.lastX ?? this.touchState.startX,\n y: endTouch?.clientY ?? this.touchState.lastY ?? this.touchState.startY\n };\n\n const dropZone = this.resolveDropZoneAtPoint(endPosition.x, endPosition.y) || this.touchState.overZone;\n if (dropZone) {\n this.dispatchDrop(dropZone, endPosition);\n } else if (this.touchState.overZone) {\n this.touchState.overZone.classList.remove('is-drag-over');\n }\n\n element.classList.remove('is-dragging');\n element.classList.add('is-dropped');\n element.setAttribute('aria-grabbed', 'false');\n setTimeout(() => element.classList.remove('is-dropped'), 300);\n\n // Hide feedback\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n\n // Dispatch event\n const data = this.currentDrag?.data || this.getData(element);\n const startX = this.touchState?.startX || 0;\n const startY = this.touchState?.startY || 0;\n\n element.dispatchEvent(new CustomEvent('draggable:end', {\n bubbles: true,\n detail: {\n element: element,\n data: data,\n position: endPosition,\n delta: {\n x: endPosition.x - startX,\n y: endPosition.y - startY\n }\n }\n }));\n }\n\n // Reset states\n this.touchState = null;\n this.currentDrag = null;\n },\n\n /**\n * Handle drag over event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} _zone - Drop zone element\n */\n handleDragOver: function (e, _zone) {\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n },\n\n /**\n * Handle drag enter event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDragEnter: function (e, zone) {\n e.preventDefault();\n zone.classList.add('is-drag-over');\n },\n\n /**\n * Handle drag leave event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDragLeave: function (e, zone) {\n zone.classList.remove('is-drag-over');\n },\n\n /**\n * Handle drop event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDrop: function (e, zone) {\n e.preventDefault();\n this.dispatchDrop(zone, { x: e.clientX, y: e.clientY });\n },\n\n /**\n * Resolve a drop zone from viewport coordinates\n * @param {number} x\n * @param {number} y\n * @returns {HTMLElement|null}\n */\n resolveDropZoneAtPoint: function (x, y) {\n if (!Number.isFinite(x) || !Number.isFinite(y)) return null;\n\n // Prefer the full stacking list so overlays/top elements don't hide real drop targets.\n if (typeof document.elementsFromPoint === 'function') {\n const stacked = document.elementsFromPoint(x, y);\n for (const element of stacked) {\n const zone = element.closest('.vd-drop-zone');\n if (zone) return zone;\n }\n }\n\n const target = document.elementFromPoint(x, y);\n const targetZone = target ? target.closest('.vd-drop-zone') : null;\n if (targetZone) return targetZone;\n\n // Last-resort fallback for mobile emulation edge cases.\n const zones = document.querySelectorAll('.vd-drop-zone');\n for (const zone of zones) {\n const rect = zone.getBoundingClientRect();\n if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {\n return zone;\n }\n }\n\n return null;\n },\n\n /**\n * Track and update active drop-zone hover state on touch devices\n * @param {number} x\n * @param {number} y\n */\n updateTouchDropZone: function (x, y) {\n if (!this.touchState) return;\n\n const nextZone = this.resolveDropZoneAtPoint(x, y);\n const prevZone = this.touchState.overZone || null;\n\n if (prevZone && prevZone !== nextZone) {\n prevZone.classList.remove('is-drag-over');\n }\n\n if (nextZone && nextZone !== prevZone) {\n nextZone.classList.add('is-drag-over');\n }\n\n this.touchState.overZone = nextZone || null;\n },\n\n /**\n * Dispatch a normalized drop event for mouse and touch flows\n * @param {HTMLElement} zone\n * @param {{x:number, y:number}} position\n */\n dispatchDrop: function (zone, position) {\n zone.classList.remove('is-drag-over');\n zone.dispatchEvent(new CustomEvent('draggable:drop', {\n bubbles: true,\n detail: {\n zone: zone,\n element: this.currentDrag?.element,\n data: this.currentDrag?.data,\n position: position\n }\n }));\n },\n\n /**\n * Reorder elements in container based on cursor position\n * @param {HTMLElement} container \n * @param {HTMLElement} element \n * @param {number} clientX \n * @param {number} clientY \n */\n handleReorder: function (container, element, clientX, clientY) {\n const isVertical = container.classList.contains('vd-draggable-container-vertical');\n const siblings = [...container.querySelectorAll('.vd-draggable-item:not(.is-dragging), .vd-draggable:not(.is-dragging)')];\n\n const nextSibling = siblings.reduce((closest, child) => {\n const box = child.getBoundingClientRect();\n const offset = isVertical\n ? clientY - box.top - box.height / 2\n : clientX - box.left - box.width / 2;\n\n if (offset < 0 && offset > closest.offset) {\n return { offset: offset, element: child };\n } else {\n return closest;\n }\n }, { offset: Number.NEGATIVE_INFINITY }).element;\n\n if (nextSibling == null) {\n container.appendChild(element);\n } else {\n container.insertBefore(element, nextSibling);\n }\n },\n\n /**\n * Handle keyboard events\n * @param {KeyboardEvent} e - Keyboard event\n * @param {HTMLElement} element - Draggable element\n */\n handleKeydown: function (e, element) {\n switch (e.key) {\n case 'Enter':\n case ' ':\n e.preventDefault();\n // Trigger click or custom action\n element.click();\n break;\n case 'Escape':\n // Cancel drag if in progress\n if (element.classList.contains('is-dragging')) {\n element.classList.remove('is-dragging');\n element.setAttribute('aria-grabbed', 'false');\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n this.currentDrag = null;\n }\n break;\n case 'ArrowUp':\n case 'ArrowLeft': {\n e.preventDefault();\n const prev = element.previousElementSibling;\n if (prev && (prev.classList.contains('vd-draggable') || prev.classList.contains('vd-draggable-item'))) {\n element.parentNode.insertBefore(element, prev);\n element.focus();\n element.dispatchEvent(new CustomEvent('draggable:reorder', {\n bubbles: true,\n detail: { element: element, direction: 'up' }\n }));\n }\n break;\n }\n case 'ArrowDown':\n case 'ArrowRight': {\n e.preventDefault();\n const next = element.nextElementSibling;\n if (next && (next.classList.contains('vd-draggable') || next.classList.contains('vd-draggable-item'))) {\n element.parentNode.insertBefore(next, element);\n element.focus();\n element.dispatchEvent(new CustomEvent('draggable:reorder', {\n bubbles: true,\n detail: { element: element, direction: 'down' }\n }));\n }\n break;\n }\n }\n },\n\n /**\n * Get data from draggable element\n * @param {HTMLElement} element - Draggable element\n * @returns {string} Data associated with the element\n */\n getData: function (element) {\n return element.dataset.draggable || element.textContent.trim();\n },\n\n /**\n * Update drag feedback element\n * @param {number} x - Current X coordinate\n * @param {number} y - Current Y coordinate\n */\n updateFeedback: function (x, y) {\n if (!this.currentDrag) return;\n\n // Show feedback\n this.feedbackElement.classList.remove('hidden');\n\n // Update feedback content\n const rect = this.currentDrag.initialBounds;\n this.feedbackElement.innerHTML = '';\n const clone = this.currentDrag.element.cloneNode(true);\n this.feedbackElement.appendChild(clone);\n\n // Set styles\n const offsetX = this.currentDrag.offsetX ?? 20;\n const offsetY = this.currentDrag.offsetY ?? 20;\n Object.assign(this.feedbackElement.style, {\n left: (x - offsetX) + 'px',\n top: (y - offsetY) + 'px',\n width: rect.width + 'px',\n height: rect.height + 'px'\n });\n },\n\n /**\n * Make an element draggable programmatically\n * @param {HTMLElement|string} element - Element or selector\n * @param {Object} options - Configuration options\n */\n makeDraggable: function (element, options = {}) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n\n if (el && !this.instances.has(el)) {\n // Add classes and attributes\n el.classList.add('vd-draggable');\n el.setAttribute('draggable', 'true');\n\n // Set options\n if (options.data) {\n el.dataset.draggable = options.data;\n }\n\n // Initialize\n this.initDraggable(el);\n }\n },\n\n /**\n * Remove draggable functionality from an element\n * @param {HTMLElement|string} element - Element or selector\n */\n removeDraggable: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n\n if (el && this.instances.has(el)) {\n // Clean up\n const instance = this.instances.get(el);\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n\n // Remove classes and attributes\n el.classList.remove('vd-draggable');\n el.removeAttribute('draggable');\n el.removeAttribute('data-draggable');\n }\n },\n\n /**\n * Destroy a draggable instance and clean up event listeners\n * @param {HTMLElement} element - Draggable element\n */\n destroy: function (element) {\n this.removeDraggable(element);\n },\n\n /**\n * Destroy all draggable instances\n */\n destroyAll: function () {\n const instances = Array.from(this.instances.keys());\n instances.forEach(element => this.destroy(element));\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('draggable', Draggable);\n }\n\n // Expose globally\n window.VanduoDraggable = Draggable;\n})();\n", "/**\n * Vanduo Framework \u2013 LazyLoad Component\n * v1.2.1\n *\n * Provides two levels of API:\n *\n * LOW-LEVEL (generic IntersectionObserver wrapper)\n * VanduoLazyLoad.observe(element, callback, options?)\n * VanduoLazyLoad.unobserve(element)\n * VanduoLazyLoad.unobserveAll()\n *\n * HIGH-LEVEL (HTML-section fetcher, mirrors vanduo-docs SPA behaviour)\n * VanduoLazyLoad.loadSection(url, containerEl, options?)\n * options: { placeholder, threshold, rootMargin, onLoaded, onError }\n * placeholder: 'skeleton' | 'spinner' | HTMLString (default: 'skeleton')\n *\n * ATTRIBUTE-DRIVEN (zero-JS usage, wired by init())\n *
    \u2026
    \n *\n * EVENTS dispatched on the host element:\n * lazysection:loading \u2014 fetch started\n * lazysection:loaded \u2014 content injected\n * lazysection:error \u2014 fetch failed\n */\n\n(function () {\n 'use strict';\n\n /* \u2500\u2500 Private state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /** @type {Map} */\n const _observerMap = new Map();\n\n /* \u2500\u2500 Security helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Returns true only if `url` resolves to the same origin as the page\n * (relative paths and same-origin absolute URLs are allowed).\n * Cross-origin URLs are rejected to prevent SSRF-style fetch abuse.\n * @param {string} url\n * @returns {boolean}\n */\n function _isSafeUrl(url) {\n try {\n // Relative URLs (no origin) are always safe\n const resolved = new URL(url, window.location.href);\n return resolved.origin === window.location.origin;\n } catch (_) {\n return false;\n }\n }\n\n /**\n * Safely inject fetched HTML into a container by parsing it with\n * DOMParser (avoids script execution) and replacing children via\n * standard DOM APIs instead of raw innerHTML assignment.\n * @param {Element} containerEl\n * @param {string} html\n */\n function _safeInjectHtml(containerEl, html) {\n const parser = new DOMParser();\n const doc = parser.parseFromString(html.trim(), 'text/html'); // CodeQL [js/xss-through-dom] Intentional: parsed nodes are sanitized below before adoption into live DOM\n\n const DANGEROUS_TAGS = ['SCRIPT', 'IFRAME', 'OBJECT', 'EMBED', 'FORM', 'BASE', 'LINK', 'META', 'STYLE'];\n for (const tag of DANGEROUS_TAGS) {\n const els = doc.querySelectorAll(tag);\n for (let i = els.length - 1; i >= 0; i--) {\n els[i].parentNode.removeChild(els[i]);\n }\n }\n\n // Strip dangerous attributes (e.g. onerror, javascript: urls) from all elements\n function _sanitizeNode(node) {\n if (node.nodeType === Node.ELEMENT_NODE) {\n const attrs = node.attributes;\n for (let i = attrs.length - 1; i >= 0; i--) {\n const attrName = attrs[i].name.toLowerCase();\n const attrValue = attrs[i].value.toLowerCase();\n const trimmedValue = attrValue.trim();\n if (\n attrName.startsWith('on') ||\n trimmedValue.startsWith('javascript:') ||\n trimmedValue.startsWith('data:') ||\n trimmedValue.startsWith('vbscript:')\n ) {\n node.removeAttribute(attrs[i].name);\n }\n }\n const children = node.childNodes;\n for (let i = 0; i < children.length; i++) {\n _sanitizeNode(children[i]);\n }\n }\n }\n _sanitizeNode(doc.body);\n\n // Collect all top-level body children\n const nodes = Array.from(doc.body.childNodes);\n // Clear container and append parsed nodes\n while (containerEl.firstChild) {\n containerEl.removeChild(containerEl.firstChild);\n }\n nodes.forEach(function (node) {\n containerEl.appendChild(document.adoptNode(node));\n });\n }\n\n /* \u2500\u2500 Placeholder HTML \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n function _skeletonHtml() {\n return '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + 'Loading\u2026
    ';\n }\n\n function _spinnerHtml() {\n return '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + 'Loading\u2026
    ';\n }\n\n function _resolvePlaceholder(placeholder) {\n if (!placeholder || placeholder === 'skeleton') return _skeletonHtml();\n if (placeholder === 'spinner') return _spinnerHtml();\n // Caller-supplied HTML string\n return placeholder;\n }\n\n /* \u2500\u2500 Dispatch helper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n function _dispatch(el, eventName, detail) {\n el.dispatchEvent(new CustomEvent(eventName, { bubbles: true, detail: detail || {} }));\n }\n\n /* \u2500\u2500 VanduoLazyLoad \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const VanduoLazyLoad = {\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * LOW-LEVEL API\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Observe an element. `callback` is invoked once when the element\n * enters the viewport, then the element is automatically unobserved.\n *\n * @param {Element} element\n * @param {function(Element): void} callback\n * @param {{ threshold?: number, rootMargin?: string }} [options]\n */\n observe: function (element, callback, options) {\n if (!(element instanceof Element)) {\n console.warn('[VanduoLazyLoad] observe() requires a DOM Element.');\n return;\n }\n if (typeof callback !== 'function') {\n console.warn('[VanduoLazyLoad] observe() requires a callback function.');\n return;\n }\n // Already observed \u2014 ignore\n if (_observerMap.has(element)) return;\n\n const threshold = (options && options.threshold != null) ? options.threshold : 0;\n const rootMargin = (options && options.rootMargin) ? options.rootMargin : '0px';\n\n const observer = new IntersectionObserver(function (entries, obs) {\n entries.forEach(function (entry) {\n if (entry.isIntersecting) {\n obs.unobserve(entry.target);\n _observerMap.delete(entry.target);\n try {\n callback(entry.target);\n } catch (e) {\n console.error('[VanduoLazyLoad] Callback threw:', e);\n }\n }\n });\n }, { threshold: threshold, rootMargin: rootMargin });\n\n _observerMap.set(element, observer);\n observer.observe(element);\n },\n\n /**\n * Stop observing an element that was previously passed to observe().\n * @param {Element} element\n */\n unobserve: function (element) {\n const observer = _observerMap.get(element);\n if (observer) {\n observer.unobserve(element);\n _observerMap.delete(element);\n }\n },\n\n /**\n * Stop observing ALL currently observed elements.\n */\n unobserveAll: function () {\n _observerMap.forEach(function (observer, element) {\n observer.unobserve(element);\n });\n _observerMap.clear();\n },\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * HIGH-LEVEL API\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Fetch an HTML partial and inject it into `containerEl` when the\n * container enters the viewport. A placeholder is shown immediately.\n *\n * @param {string} url URL of the HTML partial to fetch\n * @param {Element} containerEl Target element whose content will be replaced\n * @param {{\n * placeholder?: 'skeleton'|'spinner'|string,\n * threshold?: number,\n * rootMargin?: string,\n * onLoaded?: function(Element): void,\n * onError?: function(Error): void\n * }} [options]\n */\n loadSection: function (url, containerEl, options) {\n if (typeof url !== 'string' || !url) {\n console.warn('[VanduoLazyLoad] loadSection() requires a non-empty URL string.');\n return;\n }\n if (!(containerEl instanceof Element)) {\n console.warn('[VanduoLazyLoad] loadSection() requires a DOM Element as containerEl.');\n return;\n }\n // Reject cross-origin URLs to prevent SSRF-style fetch abuse\n if (!_isSafeUrl(url)) {\n console.error('[VanduoLazyLoad] loadSection() blocked cross-origin URL:', url);\n return;\n }\n\n const opts = options || {};\n // Placeholders are known-safe static HTML strings built internally\n const placeholderHtml = _resolvePlaceholder(opts.placeholder);\n\n // Use _safeInjectHtml instead of innerHTML to prevent XSS from caller-supplied placeholders\n _safeInjectHtml(containerEl, placeholderHtml);\n _dispatch(containerEl, 'lazysection:loading', { url: url });\n\n // Fetch when visible\n this.observe(containerEl, function () {\n const controller = new window.AbortController();\n const timeoutId = setTimeout(function () { controller.abort(); }, 10000);\n\n window.fetch(url, { signal: controller.signal })\n .then(function (res) {\n clearTimeout(timeoutId);\n if (!res.ok) throw new Error('HTTP ' + res.status);\n return res.text();\n })\n .then(function (html) {\n // Use DOMParser to parse fetched content safely, avoiding\n // raw innerHTML assignment of externally-sourced strings\n _safeInjectHtml(containerEl, html);\n _dispatch(containerEl, 'lazysection:loaded', { url: url });\n // Re-init Vanduo components inside the new content\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.init();\n }\n if (typeof opts.onLoaded === 'function') {\n opts.onLoaded(containerEl);\n }\n })\n .catch(function (err) {\n // Build error node via DOM APIs \u2014 no dynamic HTML strings\n const alertEl = document.createElement('div');\n alertEl.className = 'vd-alert vd-alert-error';\n alertEl.setAttribute('role', 'alert');\n const msgEl = document.createElement('span');\n msgEl.textContent = 'Failed to load content. ';\n const detailEl = document.createElement('small');\n detailEl.style.opacity = '0.7';\n detailEl.textContent = err.message;\n alertEl.appendChild(msgEl);\n alertEl.appendChild(detailEl);\n while (containerEl.firstChild) {\n containerEl.removeChild(containerEl.firstChild);\n }\n containerEl.appendChild(alertEl);\n _dispatch(containerEl, 'lazysection:error', { url: url, error: err });\n console.error('[VanduoLazyLoad] loadSection failed:', err);\n if (typeof opts.onError === 'function') {\n opts.onError(err);\n }\n });\n }, { threshold: opts.threshold, rootMargin: opts.rootMargin });\n },\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * ATTRIBUTE-DRIVEN INIT\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Scan the DOM for [data-vd-lazy] elements and wire them up.\n * Safe to call multiple times \u2014 already-observed elements are skipped.\n */\n init: function () {\n const self = this;\n const elements = document.querySelectorAll('[data-vd-lazy]');\n elements.forEach(function (el) {\n // Skip already-observed or already-loaded elements\n if (_observerMap.has(el) || el.dataset.vdLazyState === 'loading' || el.dataset.vdLazyState === 'loaded') return;\n\n const url = el.getAttribute('data-vd-lazy');\n if (!url) return;\n\n el.dataset.vdLazyState = 'loading';\n const placeholder = el.getAttribute('data-vd-lazy-placeholder') || 'skeleton';\n\n self.loadSection(url, el, {\n placeholder: placeholder,\n onLoaded: function () {\n el.dataset.vdLazyState = 'loaded';\n },\n onError: function () {\n el.dataset.vdLazyState = 'error';\n }\n });\n });\n }\n };\n\n /* \u2500\u2500 Register with Vanduo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('LazyLoad', VanduoLazyLoad);\n }\n\n /* \u2500\u2500 Global convenience alias \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n window.VanduoLazyLoad = VanduoLazyLoad;\n\n})();\n", "/**\n * Vanduo Framework - Glass Scroll Activation\n * Generic scroll-aware glass activation via IntersectionObserver.\n *\n * Usage:\n * Add `data-glass-scroll` to any element carrying a `.vd-glass*` class.\n * By default the previous sibling is used as the sentinel.\n * Point to a custom sentinel via `data-glass-sentinel=\"\"`.\n *\n * Behaviour:\n * - Adds `.is-glass-active` when the sentinel leaves the viewport (scrolled past).\n * - Removes `.is-glass-active` when the sentinel re-enters the viewport.\n */\n(function () {\n 'use strict';\n\n const GlassScroll = {\n /** @type {Map} */\n observers: new Map(),\n\n init: function () {\n document.querySelectorAll('[data-glass-scroll]').forEach(el => {\n if (this.observers.has(el)) return;\n this.initElement(el);\n });\n },\n\n /**\n * Wire up a single scroll-activated glass element.\n * @param {HTMLElement} el\n */\n initElement: function (el) {\n const sentinelSelector = el.dataset.glassSentinel;\n let sentinel;\n\n if (sentinelSelector) {\n sentinel = document.querySelector(sentinelSelector);\n }\n\n if (!sentinel) {\n // Fall back to the previous sibling element\n sentinel = el.previousElementSibling;\n }\n\n if (!sentinel) {\n // No sentinel available \u2014 activate immediately so glass is always shown\n el.classList.add('is-glass-active');\n return;\n }\n\n const observer = new IntersectionObserver(\n (entries) => {\n entries.forEach(entry => {\n // Active when sentinel is NOT intersecting (scrolled past it)\n el.classList.toggle('is-glass-active', !entry.isIntersecting);\n });\n },\n { threshold: 0, rootMargin: '0px' }\n );\n\n observer.observe(sentinel);\n this.observers.set(el, observer);\n },\n\n /**\n * Disconnect and remove a single element's observer.\n * @param {HTMLElement} el\n */\n destroy: function (el) {\n const observer = this.observers.get(el);\n if (observer) {\n observer.disconnect();\n this.observers.delete(el);\n }\n },\n\n destroyAll: function () {\n this.observers.forEach((observer, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('glassScroll', GlassScroll);\n }\n\n window.VanduoGlassScroll = GlassScroll;\n})();\n", "/**\n * Vanduo Framework - Water Morph Effect Component\n * Liquid wave content-swap animation on click\n *\n * Usage:\n * Add .vd-morph or [data-vd-morph] to any element.\n * Provide .vd-morph-content.vd-morph-current and .vd-morph-content.vd-morph-next children.\n * Wave and shine layers are auto-created if absent.\n *\n * JS API:\n * VanduoMorph.morph(el) \u2014 trigger morph programmatically (from center)\n * VanduoMorph.destroy(el) \u2014 tear down listeners for one element\n * VanduoMorph.destroyAll() \u2014 tear down all instances\n */\n\n(function () {\n 'use strict';\n\n const MORPH_DURATION_MS = 750;\n\n const Morph = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-morph, [data-vd-morph]');\n elements.forEach(function (el) {\n if (Morph.instances.has(el)) return;\n if (el.getAttribute('data-vd-morph') === 'manual') return;\n Morph.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n Morph._ensureLayers(el);\n\n const cleanup = [];\n let morphing = false;\n\n const handleClick = function (e) {\n if (morphing) return;\n Morph._runMorph(el, e, function () { morphing = false; });\n morphing = true;\n };\n\n el.addEventListener('click', handleClick);\n cleanup.push(function () { el.removeEventListener('click', handleClick); });\n\n this.instances.set(el, { cleanup: cleanup });\n },\n\n morph: function (el) {\n if (!el) return;\n if (!this.instances.has(el)) this.initInstance(el);\n this._runMorph(el, null, null);\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(function (fn) { fn(); });\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) { Morph.destroy(el); });\n },\n\n /* \u2500\u2500 Internal helpers \u2500\u2500 */\n\n _ensureLayers: function (el) {\n if (!el.querySelector('.vd-morph-wave')) {\n const wave = document.createElement('span');\n wave.className = 'vd-morph-wave';\n wave.setAttribute('aria-hidden', 'true');\n el.insertBefore(wave, el.firstChild);\n }\n if (!el.querySelector('.vd-morph-shine')) {\n const shine = document.createElement('span');\n shine.className = 'vd-morph-shine';\n shine.setAttribute('aria-hidden', 'true');\n const waveEl = el.querySelector('.vd-morph-wave');\n if (waveEl && waveEl.nextSibling) {\n el.insertBefore(shine, waveEl.nextSibling);\n } else {\n el.insertBefore(shine, el.firstChild);\n }\n }\n },\n\n _runMorph: function (el, pointerEvent, onComplete) {\n const wave = el.querySelector('.vd-morph-wave');\n if (wave) {\n const rect = el.getBoundingClientRect();\n const cx = rect.left + rect.width / 2;\n const cy = rect.top + rect.height / 2;\n const px = pointerEvent ? (pointerEvent.clientX || cx) : cx;\n const py = pointerEvent ? (pointerEvent.clientY || cy) : cy;\n wave.style.left = (px - rect.left) + 'px';\n wave.style.top = (py - rect.top) + 'px';\n }\n\n el.classList.add('is-morphing');\n\n let duration = MORPH_DURATION_MS;\n const custom = getComputedStyle(el).getPropertyValue('--morph-duration');\n if (custom) {\n const parsed = parseFloat(custom);\n if (!isNaN(parsed)) duration = parsed * (custom.indexOf('ms') !== -1 ? 1 : 1000);\n }\n\n setTimeout(function () {\n el.classList.remove('is-morphing');\n\n const current = el.querySelector('.vd-morph-current');\n const next = el.querySelector('.vd-morph-next');\n if (current && next) {\n current.classList.remove('vd-morph-current');\n current.classList.add('vd-morph-next');\n next.classList.remove('vd-morph-next');\n next.classList.add('vd-morph-current');\n }\n\n if (typeof onComplete === 'function') onComplete();\n }, duration);\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('morph', Morph);\n }\n\n window.VanduoMorph = Morph;\n\n})();\n", "/**\n * Vanduo Framework \u2014 Expanding flex cards\n * Click / Enter / Space / Arrow keys to change the active card.\n *\n * Usage:\n *
    \n * Use data-vd-expanding-cards=\"manual\" to skip auto-init.\n */\n\n(function () {\n 'use strict';\n\n const ExpandingCards = {\n instances: new Map(),\n\n init: function () {\n document.querySelectorAll('.vd-expanding-cards').forEach(function (el) {\n if (el.getAttribute('data-vd-expanding-cards') === 'manual') return;\n if (ExpandingCards.instances.has(el)) return;\n ExpandingCards.initContainer(el);\n });\n },\n\n initContainer: function (container) {\n const cleanup = [];\n\n const getCards = function () {\n return Array.prototype.slice.call(container.querySelectorAll('.vd-expanding-card'));\n };\n\n const setActive = function (card) {\n const cards = getCards();\n if (!card || cards.indexOf(card) === -1) return;\n cards.forEach(function (c) {\n c.classList.toggle('is-active', c === card);\n });\n card.focus({ preventScroll: true });\n };\n\n const onClick = function (e) {\n const t = e.target;\n const card = t.closest ? t.closest('.vd-expanding-card') : null;\n if (!card || !container.contains(card)) return;\n setActive(card);\n };\n\n const onKeydown = function (e) {\n if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'Home' && e.key !== 'End') {\n return;\n }\n const cards = getCards().filter(function (c) {\n return c.offsetParent !== null || c.getClientRects().length > 0;\n });\n if (!cards.length) return;\n const activeEl = document.activeElement;\n let idx = cards.indexOf(activeEl);\n if (idx < 0) {\n idx = cards.findIndex(function (c) {\n return c.classList.contains('is-active');\n });\n }\n if (idx < 0) idx = 0;\n\n if (e.key === 'ArrowLeft') {\n e.preventDefault();\n setActive(cards[Math.max(0, idx - 1)]);\n } else if (e.key === 'ArrowRight') {\n e.preventDefault();\n setActive(cards[Math.min(cards.length - 1, idx + 1)]);\n } else if (e.key === 'Home') {\n e.preventDefault();\n setActive(cards[0]);\n } else if (e.key === 'End') {\n e.preventDefault();\n setActive(cards[cards.length - 1]);\n }\n };\n\n container.addEventListener('click', onClick);\n cleanup.push(function () {\n container.removeEventListener('click', onClick);\n });\n\n container.addEventListener('keydown', onKeydown);\n cleanup.push(function () {\n container.removeEventListener('keydown', onKeydown);\n });\n\n getCards().forEach(function (card) {\n if (!card.hasAttribute('tabindex')) {\n card.setAttribute('tabindex', '0');\n }\n card.setAttribute('role', 'button');\n if (!card.hasAttribute('aria-pressed')) {\n card.setAttribute('aria-pressed', card.classList.contains('is-active') ? 'true' : 'false');\n }\n });\n\n const syncAria = function () {\n getCards().forEach(function (card) {\n card.setAttribute('aria-pressed', card.classList.contains('is-active') ? 'true' : 'false');\n });\n };\n\n const mo = new MutationObserver(syncAria);\n mo.observe(container, { attributes: true, subtree: true, attributeFilter: ['class'] });\n cleanup.push(function () {\n mo.disconnect();\n });\n syncAria();\n\n ExpandingCards.instances.set(container, { cleanup: cleanup });\n },\n\n destroy: function (container) {\n const inst = this.instances.get(container);\n if (!inst) return;\n inst.cleanup.forEach(function (fn) {\n fn();\n });\n this.instances.delete(container);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) {\n ExpandingCards.destroy(el);\n });\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('expandingCards', ExpandingCards);\n }\n\n window.VanduoExpandingCards = ExpandingCards;\n})();\n", "/**\n * Vanduo Framework \u2014 Timeline animated reveal\n * Opt-in: add `.vd-timeline-animated` to a `.vd-timeline` container.\n * Uses IntersectionObserver to add `.is-revealed` per item with staggered delays.\n * Optional: `.vd-timeline-playback` with `[data-vd-timeline-prev|next|play|pause]` for stepped control.\n */\n\n(function () {\n 'use strict';\n\n const STAGGER_MS = 140;\n const MAX_STAGGER_INDEX = 7;\n const PLAY_INTERVAL_MS = 800;\n\n function countRevealedPrefix(items) {\n let count = 0;\n for (let i = 0; i < items.length; i++) {\n if (!items[i].classList.contains('is-revealed')) break;\n count++;\n }\n return count;\n }\n\n function findPlaybackControls(container) {\n return container.parentElement || document.body;\n }\n\n function initPlayback(container, items, cleanup) {\n items.forEach(function (item) {\n item.classList.remove('is-revealed');\n });\n\n const scope = findPlaybackControls(container);\n const prevBtn = scope.querySelector('[data-vd-timeline-prev]');\n const nextBtn = scope.querySelector('[data-vd-timeline-next]');\n const playBtn = scope.querySelector('[data-vd-timeline-play]');\n const pauseBtn = scope.querySelector('[data-vd-timeline-pause]');\n\n let playTimer = null;\n\n function updateNavButtons() {\n const k = countRevealedPrefix(items);\n const n = items.length;\n if (prevBtn) {\n const atStart = k === 0;\n prevBtn.disabled = atStart;\n prevBtn.setAttribute('aria-disabled', atStart ? 'true' : 'false');\n }\n if (nextBtn) {\n const atEnd = k >= n;\n nextBtn.disabled = atEnd;\n nextBtn.setAttribute('aria-disabled', atEnd ? 'true' : 'false');\n }\n if (playBtn) {\n playBtn.setAttribute('aria-pressed', playTimer ? 'true' : 'false');\n }\n if (pauseBtn) {\n pauseBtn.disabled = !playTimer;\n }\n }\n\n function stepNext() {\n const k = countRevealedPrefix(items);\n if (k < items.length) {\n items[k].classList.add('is-revealed');\n }\n updateNavButtons();\n }\n\n function stepPrev() {\n const k = countRevealedPrefix(items);\n if (k > 0) {\n items[k - 1].classList.remove('is-revealed');\n }\n updateNavButtons();\n }\n\n function play() {\n if (playTimer) return;\n playTimer = setInterval(function () {\n if (countRevealedPrefix(items) >= items.length) {\n pause();\n return;\n }\n stepNext();\n }, PLAY_INTERVAL_MS);\n updateNavButtons();\n }\n\n function pause() {\n if (playTimer) {\n clearInterval(playTimer);\n playTimer = null;\n }\n updateNavButtons();\n }\n\n function addClick(el, fn) {\n if (!el) return;\n const handler = function (e) {\n e.preventDefault();\n fn();\n };\n el.addEventListener('click', handler);\n cleanup.push(function () {\n el.removeEventListener('click', handler);\n });\n }\n\n addClick(prevBtn, stepPrev);\n addClick(nextBtn, stepNext);\n addClick(playBtn, play);\n addClick(pauseBtn, pause);\n\n cleanup.push(function () {\n pause();\n });\n\n updateNavButtons();\n\n return {\n stepNext: stepNext,\n stepPrev: stepPrev,\n play: play,\n pause: pause\n };\n }\n\n const Timeline = {\n instances: new Map(),\n\n init: function () {\n document.querySelectorAll('.vd-timeline.vd-timeline-animated').forEach(function (el) {\n if (Timeline.instances.has(el)) return;\n Timeline.initInstance(el);\n });\n },\n\n reinit: function () {\n Timeline.destroyAll();\n Timeline.init();\n },\n\n initInstance: function (container) {\n const cleanup = [];\n const items = Array.prototype.filter.call(container.children, function (child) {\n return child.classList && child.classList.contains('vd-timeline-item');\n });\n\n items.forEach(function (item, i) {\n const idx = Math.min(i, MAX_STAGGER_INDEX);\n item.style.setProperty('--vd-timeline-reveal-delay', (idx * STAGGER_MS) + 'ms');\n });\n\n const reducedMotion = typeof window.matchMedia === 'function'\n && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n if (reducedMotion) {\n items.forEach(function (item) {\n item.classList.add('is-revealed');\n });\n Timeline.instances.set(container, { cleanup: cleanup });\n return;\n }\n\n const playback = container.classList && container.classList.contains('vd-timeline-playback');\n\n if (playback) {\n const playbackApi = initPlayback(container, items, cleanup);\n Timeline.instances.set(container, { cleanup: cleanup, playback: playbackApi });\n return;\n }\n\n if (typeof IntersectionObserver === 'undefined') {\n items.forEach(function (item) {\n item.classList.add('is-revealed');\n });\n Timeline.instances.set(container, { cleanup: cleanup });\n return;\n }\n\n const observer = new IntersectionObserver(function (entries) {\n entries.forEach(function (entry) {\n if (!entry.isIntersecting) return;\n entry.target.classList.add('is-revealed');\n observer.unobserve(entry.target);\n });\n }, {\n root: null,\n rootMargin: '0px 0px -10% 0px',\n threshold: 0.15\n });\n\n items.forEach(function (item) {\n observer.observe(item);\n });\n\n cleanup.push(function () {\n observer.disconnect();\n });\n\n Timeline.instances.set(container, { cleanup: cleanup });\n },\n\n destroy: function (container) {\n const inst = this.instances.get(container);\n if (!inst) return;\n inst.cleanup.forEach(function (fn) {\n fn();\n });\n this.instances.delete(container);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) {\n Timeline.destroy(el);\n });\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('timeline', Timeline);\n }\n\n window.VanduoTimeline = Timeline;\n})();\n", "/**\n * Vanduo Framework - Flow (Carousel/Slider) Component\n * Touch-enabled carousel with slide/fade transitions, autoplay, indicators\n */\n\n(function () {\n 'use strict';\n\n const Flow = {\n instances: new Map(),\n\n init: function () {\n const carousels = document.querySelectorAll('.vd-flow, .vd-carousel');\n carousels.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const track = el.querySelector('.vd-flow-track');\n if (!track) return;\n\n const slides = Array.from(track.querySelectorAll('.vd-flow-slide'));\n if (slides.length === 0) return;\n\n const isFade = el.classList.contains('vd-flow-fade');\n const autoplay = el.hasAttribute('data-vd-autoplay');\n const interval = parseInt(el.getAttribute('data-vd-interval'), 10) || 5000;\n const loop = el.getAttribute('data-vd-loop') !== 'false';\n\n const state = {\n current: 0,\n total: slides.length,\n autoplayTimer: null,\n isFade: isFade,\n loop: loop,\n isDragging: false,\n startX: 0,\n currentX: 0,\n threshold: 50\n };\n\n const cleanup = [];\n\n // Set initial active slide\n slides.forEach((slide, i) => {\n slide.setAttribute('role', 'group');\n slide.setAttribute('aria-roledescription', 'slide');\n slide.setAttribute('aria-label', 'Slide ' + (i + 1) + ' of ' + slides.length);\n if (i === 0) slide.classList.add('is-active');\n });\n\n el.setAttribute('role', 'region');\n el.setAttribute('aria-roledescription', 'carousel');\n if (!el.getAttribute('aria-label')) {\n el.setAttribute('aria-label', 'Carousel');\n }\n\n // Live region for announcements\n const liveRegion = document.createElement('div');\n liveRegion.setAttribute('aria-live', 'polite');\n liveRegion.setAttribute('aria-atomic', 'true');\n liveRegion.className = 'sr-only';\n liveRegion.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);';\n el.appendChild(liveRegion);\n\n const goTo = (index, announce) => {\n if (announce === undefined) announce = true;\n let target = index;\n if (state.loop) {\n target = ((index % state.total) + state.total) % state.total;\n } else {\n target = Math.max(0, Math.min(index, state.total - 1));\n }\n\n const prev = state.current;\n state.current = target;\n\n if (state.isFade) {\n slides.forEach((s, i) => {\n s.classList.toggle('is-active', i === target);\n });\n } else {\n track.style.transform = 'translateX(-' + (target * 100) + '%)';\n }\n\n // Update indicators\n const indicators = el.querySelectorAll('.vd-flow-indicator');\n indicators.forEach((ind, i) => {\n ind.classList.toggle('is-active', i === target);\n ind.setAttribute('aria-selected', i === target ? 'true' : 'false');\n });\n\n // Update slide ARIA\n slides.forEach((s, i) => {\n s.setAttribute('aria-hidden', i !== target ? 'true' : 'false');\n });\n\n if (announce) {\n liveRegion.textContent = 'Slide ' + (target + 1) + ' of ' + state.total;\n }\n\n el.dispatchEvent(new CustomEvent('flow:change', {\n detail: { current: target, previous: prev, total: state.total }\n }));\n };\n\n const next = () => goTo(state.current + 1);\n const prev = () => goTo(state.current - 1);\n\n // Controls\n const prevBtn = el.querySelector('.vd-flow-prev');\n const nextBtn = el.querySelector('.vd-flow-next');\n\n if (prevBtn) {\n const h = () => prev();\n prevBtn.addEventListener('click', h);\n cleanup.push(() => prevBtn.removeEventListener('click', h));\n }\n if (nextBtn) {\n const h = () => next();\n nextBtn.addEventListener('click', h);\n cleanup.push(() => nextBtn.removeEventListener('click', h));\n }\n\n // Indicators\n const indicators = el.querySelectorAll('.vd-flow-indicator');\n indicators.forEach((ind, i) => {\n ind.setAttribute('role', 'tab');\n ind.setAttribute('aria-selected', i === 0 ? 'true' : 'false');\n ind.setAttribute('aria-label', 'Go to slide ' + (i + 1));\n const h = () => goTo(i);\n ind.addEventListener('click', h);\n cleanup.push(() => ind.removeEventListener('click', h));\n });\n\n // Keyboard navigation\n const keyHandler = (e) => {\n if (e.key === 'ArrowLeft') { prev(); e.preventDefault(); }\n if (e.key === 'ArrowRight') { next(); e.preventDefault(); }\n };\n el.setAttribute('tabindex', '0');\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n\n // Touch / pointer support\n const pointerDown = (e) => {\n state.isDragging = true;\n state.startX = e.clientX || (e.touches && e.touches[0].clientX) || 0;\n state.currentX = state.startX;\n el.classList.add('is-dragging');\n };\n\n const pointerMove = (e) => {\n if (!state.isDragging) return;\n state.currentX = e.clientX || (e.touches && e.touches[0].clientX) || 0;\n };\n\n const pointerUp = () => {\n if (!state.isDragging) return;\n state.isDragging = false;\n el.classList.remove('is-dragging');\n const diff = state.startX - state.currentX;\n if (Math.abs(diff) > state.threshold) {\n if (diff > 0) next();\n else prev();\n }\n };\n\n el.addEventListener('mousedown', pointerDown);\n el.addEventListener('mousemove', pointerMove);\n el.addEventListener('mouseup', pointerUp);\n el.addEventListener('mouseleave', pointerUp);\n el.addEventListener('touchstart', pointerDown, { passive: true });\n el.addEventListener('touchmove', pointerMove, { passive: true });\n el.addEventListener('touchend', pointerUp);\n\n cleanup.push(\n () => el.removeEventListener('mousedown', pointerDown),\n () => el.removeEventListener('mousemove', pointerMove),\n () => el.removeEventListener('mouseup', pointerUp),\n () => el.removeEventListener('mouseleave', pointerUp),\n () => el.removeEventListener('touchstart', pointerDown),\n () => el.removeEventListener('touchmove', pointerMove),\n () => el.removeEventListener('touchend', pointerUp)\n );\n\n // Autoplay\n const startAutoplay = () => {\n stopAutoplay();\n state.autoplayTimer = setInterval(next, interval);\n };\n\n const stopAutoplay = () => {\n if (state.autoplayTimer) {\n clearInterval(state.autoplayTimer);\n state.autoplayTimer = null;\n }\n };\n\n if (autoplay) {\n startAutoplay();\n const pauseHandler = () => stopAutoplay();\n const resumeHandler = () => startAutoplay();\n el.addEventListener('mouseenter', pauseHandler);\n el.addEventListener('mouseleave', resumeHandler);\n el.addEventListener('focusin', pauseHandler);\n el.addEventListener('focusout', resumeHandler);\n cleanup.push(\n () => el.removeEventListener('mouseenter', pauseHandler),\n () => el.removeEventListener('mouseleave', resumeHandler),\n () => el.removeEventListener('focusin', pauseHandler),\n () => el.removeEventListener('focusout', resumeHandler),\n () => stopAutoplay()\n );\n }\n\n // Initial ARIA state\n goTo(0, false);\n\n this.instances.set(el, {\n cleanup: cleanup,\n goTo: goTo,\n next: next,\n prev: prev,\n getState: () => ({ ...state })\n });\n },\n\n goTo: function (el, index) {\n const instance = this.instances.get(el);\n if (instance) instance.goTo(index);\n },\n\n next: function (el) {\n const instance = this.instances.get(el);\n if (instance) instance.next();\n },\n\n prev: function (el) {\n const instance = this.instances.get(el);\n if (instance) instance.prev();\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('flow', Flow);\n }\n\n window.VanduoFlow = Flow;\n\n})();\n", "/**\n * Vanduo Framework - Bubble (Popover) Component\n * Click-triggered rich HTML popover, reuses tooltip positioning concepts\n */\n\n(function () {\n 'use strict';\n\n const Bubble = {\n instances: new Map(),\n _globalCleanups: [],\n\n init: function () {\n const triggers = document.querySelectorAll('[data-vd-bubble], [data-vd-popover]');\n triggers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n\n if (this._globalCleanups.length === 0) {\n const outsideClick = (e) => {\n this.instances.forEach((inst, trigger) => {\n if (!inst.popover.contains(e.target) && !trigger.contains(e.target)) {\n this.hide(trigger);\n }\n });\n };\n const escHandler = (e) => {\n if (e.key === 'Escape') {\n this.instances.forEach((_, trigger) => this.hide(trigger));\n }\n };\n document.addEventListener('click', outsideClick, true);\n document.addEventListener('keydown', escHandler);\n this._globalCleanups.push(\n () => document.removeEventListener('click', outsideClick, true),\n () => document.removeEventListener('keydown', escHandler)\n );\n }\n },\n\n initInstance: function (trigger) {\n const cleanup = [];\n const placement = trigger.getAttribute('data-vd-bubble-placement') ||\n trigger.getAttribute('data-vd-popover-placement') || 'bottom';\n\n // Build popover element\n const popover = document.createElement('div');\n popover.className = 'vd-bubble-content';\n popover.setAttribute('role', 'dialog');\n popover.setAttribute('aria-modal', 'false');\n popover.setAttribute('data-placement', placement);\n\n const title = trigger.getAttribute('data-vd-bubble-title') ||\n trigger.getAttribute('data-vd-popover-title');\n const content = trigger.getAttribute('data-vd-bubble') ||\n trigger.getAttribute('data-vd-popover') || '';\n const htmlContent = trigger.getAttribute('data-vd-bubble-html') ||\n trigger.getAttribute('data-vd-popover-html');\n const allowSvg = trigger.hasAttribute('data-vd-bubble-allow-svg') ||\n trigger.hasAttribute('data-vd-popover-allow-svg');\n\n if (title) {\n const header = document.createElement('div');\n header.className = 'vd-bubble-header';\n const titleSpan = document.createElement('span');\n titleSpan.textContent = title;\n const closeBtn = document.createElement('button');\n closeBtn.className = 'vd-bubble-close';\n closeBtn.setAttribute('aria-label', 'Close');\n closeBtn.innerHTML = '×';\n header.appendChild(titleSpan);\n header.appendChild(closeBtn);\n popover.appendChild(header);\n\n const closeHandler = (e) => { e.stopPropagation(); this.hide(trigger); };\n closeBtn.addEventListener('click', closeHandler);\n cleanup.push(() => closeBtn.removeEventListener('click', closeHandler));\n }\n\n const body = document.createElement('div');\n body.className = 'vd-bubble-body';\n if (htmlContent) {\n if (typeof sanitizeHtml === 'function') {\n body.innerHTML = sanitizeHtml(htmlContent, { allowSvg });\n } else {\n body.textContent = htmlContent;\n }\n } else {\n body.textContent = content;\n }\n popover.appendChild(body);\n\n document.body.appendChild(popover);\n\n // ARIA on trigger\n const popId = 'vd-bubble-' + Math.random().toString(36).slice(2, 9);\n popover.id = popId;\n trigger.setAttribute('aria-haspopup', 'dialog');\n trigger.setAttribute('aria-expanded', 'false');\n trigger.setAttribute('aria-controls', popId);\n\n // Toggle on click\n const toggleHandler = (e) => {\n e.stopPropagation();\n if (popover.classList.contains('is-visible')) {\n this.hide(trigger);\n } else {\n this.hideAll();\n this.show(trigger);\n }\n };\n trigger.addEventListener('click', toggleHandler);\n cleanup.push(() => trigger.removeEventListener('click', toggleHandler));\n\n this.instances.set(trigger, { popover, cleanup, placement });\n },\n\n position: function (trigger, popover, placement) {\n const rect = trigger.getBoundingClientRect();\n const popRect = popover.getBoundingClientRect();\n const gap = 10;\n let top, left;\n\n switch (placement) {\n case 'top':\n top = rect.top - popRect.height - gap + window.scrollY;\n left = rect.left + (rect.width - popRect.width) / 2 + window.scrollX;\n break;\n case 'left':\n top = rect.top + (rect.height - popRect.height) / 2 + window.scrollY;\n left = rect.left - popRect.width - gap + window.scrollX;\n break;\n case 'right':\n top = rect.top + (rect.height - popRect.height) / 2 + window.scrollY;\n left = rect.right + gap + window.scrollX;\n break;\n default: // bottom\n top = rect.bottom + gap + window.scrollY;\n left = rect.left + (rect.width - popRect.width) / 2 + window.scrollX;\n }\n\n // Clamp to viewport\n left = Math.max(8, Math.min(left, window.innerWidth - popRect.width - 8));\n top = Math.max(8, top);\n\n popover.style.top = top + 'px';\n popover.style.left = left + 'px';\n },\n\n show: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n const { popover, placement } = instance;\n\n popover.style.display = 'block';\n popover.classList.add('is-visible');\n trigger.setAttribute('aria-expanded', 'true');\n\n requestAnimationFrame(() => {\n this.position(trigger, popover, placement);\n });\n\n trigger.dispatchEvent(new CustomEvent('bubble:show', {\n bubbles: true,\n detail: { trigger: trigger, placement: placement }\n }));\n },\n\n hide: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n instance.popover.classList.remove('is-visible');\n trigger.setAttribute('aria-expanded', 'false');\n trigger.dispatchEvent(new CustomEvent('bubble:hide', {\n bubbles: true,\n detail: { trigger: trigger }\n }));\n },\n\n hideAll: function () {\n this.instances.forEach((_, trigger) => this.hide(trigger));\n },\n\n destroy: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n if (instance.popover.parentNode) {\n instance.popover.parentNode.removeChild(instance.popover);\n }\n trigger.removeAttribute('aria-haspopup');\n trigger.removeAttribute('aria-expanded');\n trigger.removeAttribute('aria-controls');\n this.instances.delete(trigger);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, trigger) => this.destroy(trigger));\n this._globalCleanups.forEach(fn => fn());\n this._globalCleanups = [];\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('bubble', Bubble);\n }\n\n window.VanduoBubble = Bubble;\n\n})();\n", "/**\n * Vanduo Framework - Waypoint (Scrollspy) Component\n * Highlights navigation links based on scroll position using IntersectionObserver\n */\n\n(function () {\n 'use strict';\n\n const Waypoint = {\n instances: new Map(),\n\n init: function () {\n const navs = document.querySelectorAll('[data-vd-waypoint-nav], [data-vd-scrollspy-nav]');\n navs.forEach(nav => {\n if (this.instances.has(nav)) return;\n this.initInstance(nav);\n });\n },\n\n initInstance: function (nav) {\n const links = Array.from(nav.querySelectorAll('a[href^=\"#\"]'));\n if (links.length === 0) return;\n\n const cleanup = [];\n const offset = parseInt(nav.getAttribute('data-vd-waypoint-offset') || '80', 10);\n const sections = [];\n\n links.forEach(link => {\n const id = link.getAttribute('href').slice(1);\n const section = document.getElementById(id);\n if (section) {\n section.setAttribute('data-vd-waypoint-section', '');\n sections.push({ id, link, section });\n }\n });\n\n if (sections.length === 0) return;\n\n const activeSections = new Set();\n\n const setActive = (id) => {\n links.forEach(l => l.classList.remove('is-active'));\n const target = links.find(l => l.getAttribute('href') === '#' + id);\n if (target) {\n target.classList.add('is-active');\n nav.dispatchEvent(new CustomEvent('waypoint:change', {\n detail: { activeId: id, link: target }\n }));\n }\n };\n\n const rootMargin = '-' + offset + 'px 0px -40% 0px';\n\n const observer = new IntersectionObserver((entries) => {\n entries.forEach(entry => {\n if (entry.isIntersecting) {\n activeSections.add(entry.target.id);\n } else {\n activeSections.delete(entry.target.id);\n }\n });\n\n // Pick the topmost visible section\n for (let i = 0; i < sections.length; i++) {\n if (activeSections.has(sections[i].id)) {\n setActive(sections[i].id);\n return;\n }\n }\n }, {\n rootMargin: rootMargin,\n threshold: 0\n });\n\n sections.forEach(s => observer.observe(s.section));\n\n // Smooth scroll on click\n links.forEach(link => {\n const clickHandler = (e) => {\n e.preventDefault();\n const id = link.getAttribute('href').slice(1);\n const section = document.getElementById(id);\n if (section) {\n section.scrollIntoView({ behavior: 'smooth' });\n setActive(id);\n }\n };\n link.addEventListener('click', clickHandler);\n cleanup.push(() => link.removeEventListener('click', clickHandler));\n });\n\n cleanup.push(() => observer.disconnect());\n\n this.instances.set(nav, { observer, cleanup, sections, setActive });\n },\n\n refresh: function (nav) {\n this.destroy(nav);\n this.initInstance(nav);\n },\n\n destroy: function (nav) {\n const instance = this.instances.get(nav);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(nav);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, nav) => this.destroy(nav));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('waypoint', Waypoint);\n }\n\n window.VanduoWaypoint = Waypoint;\n\n})();\n", "/**\n * Vanduo Framework - Ripple (Waves Effect) Component\n * Adds expanding circle animation on click at pointer position\n */\n\n(function () {\n 'use strict';\n\n const Ripple = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-ripple, [data-vd-ripple]');\n elements.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n\n const createWave = (e) => {\n const rect = el.getBoundingClientRect();\n const size = Math.max(rect.width, rect.height);\n const x = (e.clientX || (e.touches && e.touches[0].clientX) || rect.left + rect.width / 2) - rect.left - size / 2;\n const y = (e.clientY || (e.touches && e.touches[0].clientY) || rect.top + rect.height / 2) - rect.top - size / 2;\n\n const wave = document.createElement('span');\n wave.className = 'vd-ripple-wave';\n wave.style.width = size + 'px';\n wave.style.height = size + 'px';\n wave.style.left = x + 'px';\n wave.style.top = y + 'px';\n\n el.appendChild(wave);\n\n wave.addEventListener('animationend', () => {\n if (wave.parentNode) wave.parentNode.removeChild(wave);\n });\n };\n\n el.addEventListener('mousedown', createWave);\n el.addEventListener('touchstart', createWave, { passive: true });\n\n cleanup.push(\n () => el.removeEventListener('mousedown', createWave),\n () => el.removeEventListener('touchstart', createWave)\n );\n\n this.instances.set(el, { cleanup });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n // Remove any lingering wave elements\n el.querySelectorAll('.vd-ripple-wave').forEach(w => w.remove());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('ripple', Ripple);\n }\n\n window.VanduoRipple = Ripple;\n\n})();\n", "/**\n * Vanduo Framework - Affix (Sticky) Component\n * Uses IntersectionObserver to toggle .is-stuck within the nearest scrollable parent\n */\n\n(function () {\n 'use strict';\n\n function isScrollable(element) {\n if (!element || element === document.body) return false;\n\n const style = window.getComputedStyle(element);\n const overflowY = style.overflowY;\n const overflowX = style.overflowX;\n const canScrollY = /(auto|scroll|overlay)/.test(overflowY) && element.scrollHeight > element.clientHeight;\n const canScrollX = /(auto|scroll|overlay)/.test(overflowX) && element.scrollWidth > element.clientWidth;\n\n return canScrollY || canScrollX;\n }\n\n function getScrollParent(element) {\n let parent = element.parentElement;\n\n while (parent && parent !== document.body && parent !== document.documentElement) {\n if (isScrollable(parent)) return parent;\n parent = parent.parentElement;\n }\n\n return null;\n }\n\n const Affix = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-affix, .vd-sticky, [data-vd-affix]');\n elements.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const parsedOffset = parseInt(el.getAttribute('data-vd-affix-offset') || '0', 10);\n const offset = Number.isNaN(parsedOffset) ? 0 : parsedOffset;\n const scrollParent = getScrollParent(el);\n let isStuck = false;\n\n const sentinel = document.createElement('div');\n sentinel.style.cssText = 'display:block;height:1px;margin-bottom:-1px;visibility:hidden;pointer-events:none;';\n el.parentNode.insertBefore(sentinel, el);\n\n el.style.setProperty('--affix-top-offset', offset + 'px');\n\n function stick() {\n if (isStuck) return;\n isStuck = true;\n el.classList.add('is-stuck');\n el.dispatchEvent(new CustomEvent('affix:stuck', {\n bubbles: true,\n detail: {\n offset: offset,\n root: scrollParent || window\n }\n }));\n }\n\n function unstick() {\n if (!isStuck) return;\n isStuck = false;\n el.classList.remove('is-stuck');\n el.dispatchEvent(new CustomEvent('affix:unstuck', {\n bubbles: true,\n detail: {\n offset: offset,\n root: scrollParent || window\n }\n }));\n }\n\n const observer = new IntersectionObserver(function (entries) {\n entries.forEach(entry => {\n if (!entry.isIntersecting) {\n stick();\n } else {\n unstick();\n }\n });\n }, {\n root: scrollParent,\n rootMargin: '-' + offset + 'px 0px 0px 0px',\n threshold: 0\n });\n\n observer.observe(sentinel);\n\n cleanup.push(\n () => observer.disconnect(),\n () => { if (sentinel.parentNode) sentinel.parentNode.removeChild(sentinel); },\n () => {\n el.classList.remove('is-stuck');\n el.style.removeProperty('--affix-top-offset');\n }\n );\n\n this.instances.set(el, { cleanup, observer, sentinel, scrollParent });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n el.classList.remove('is-stuck');\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('affix', Affix);\n }\n\n window.VanduoAffix = Affix;\n\n})();\n", "/**\n * Vanduo Framework - Suggest (Autocomplete) Component\n * Dropdown suggestion list with keyboard navigation, static/async data sources\n */\n\n(function () {\n 'use strict';\n\n /**\n * Escape HTML entities to prevent XSS when inserting into innerHTML\n * @param {string} text\n * @returns {string}\n */\n function _escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Allow same-origin URLs by default, or an explicit allowlist of origins.\n * @param {string} url\n * @param {string[]} allowlist\n * @returns {boolean}\n */\n function _isSafeUrl(url, allowlist) {\n try {\n const resolved = new URL(url, window.location.href);\n if (resolved.origin === window.location.origin) return true;\n return allowlist.includes(resolved.origin);\n } catch (_e) {\n return false;\n }\n }\n\n const Suggest = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-suggest], [data-vd-autocomplete]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const minChars = parseInt(input.getAttribute('data-vd-suggest-min-chars') || '1', 10);\n const url = input.getAttribute('data-vd-suggest-url') || '';\n const allowlistAttr = input.getAttribute('data-vd-suggest-allowlist') || '';\n const allowlist = allowlistAttr\n .split(',')\n .map(value => value.trim())\n .filter(Boolean);\n const staticData = input.getAttribute('data-vd-suggest') || input.getAttribute('data-vd-autocomplete') || '';\n let items = [];\n\n try { items = JSON.parse(staticData); } catch (_e) {\n items = staticData.split(',').map(s => s.trim()).filter(Boolean);\n }\n\n // Wrap input if not already wrapped\n let wrapper = input.closest('.vd-suggest-wrapper, .vd-autocomplete-wrapper');\n if (!wrapper) {\n wrapper = document.createElement('div');\n wrapper.className = 'vd-suggest-wrapper';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n }\n\n // Create dropdown list\n const list = document.createElement('ul');\n list.className = 'vd-suggest-list';\n list.setAttribute('role', 'listbox');\n const listId = 'vd-suggest-' + Math.random().toString(36).slice(2, 9);\n list.id = listId;\n wrapper.appendChild(list);\n\n // ARIA\n input.setAttribute('role', 'combobox');\n input.setAttribute('aria-autocomplete', 'list');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('aria-controls', listId);\n input.setAttribute('autocomplete', 'off');\n\n let highlighted = -1;\n let currentItems = [];\n let debounceTimer = null;\n\n const renderItems = (filtered, query) => {\n list.innerHTML = '';\n currentItems = filtered;\n highlighted = -1;\n\n if (filtered.length === 0) {\n const empty = document.createElement('li');\n empty.className = 'vd-suggest-empty';\n empty.textContent = 'No results';\n list.appendChild(empty);\n return;\n }\n\n filtered.forEach((item, i) => {\n const li = document.createElement('li');\n li.className = 'vd-suggest-item';\n li.setAttribute('role', 'option');\n li.id = listId + '-item-' + i;\n\n const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);\n if (query) {\n // Escape HTML first to prevent XSS, then highlight matches in the safe string\n const escaped = _escapeHtml(text);\n const re = new RegExp('(' + query.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi');\n li.innerHTML = escaped.replace(re, '$1');\n } else {\n li.textContent = text;\n }\n\n li.addEventListener('click', () => selectItem(i));\n list.appendChild(li);\n });\n };\n\n const open = () => {\n list.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n };\n\n const close = () => {\n list.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n highlighted = -1;\n input.removeAttribute('aria-activedescendant');\n };\n\n const selectItem = (index) => {\n const item = currentItems[index];\n const value = typeof item === 'object' ? (item.value || item.label || String(item)) : String(item);\n input.value = value;\n close();\n input.dispatchEvent(new CustomEvent('suggest:select', {\n detail: { value, item, index },\n bubbles: true\n }));\n };\n\n const highlight = (index) => {\n const listItems = list.querySelectorAll('.vd-suggest-item');\n listItems.forEach(li => li.classList.remove('is-highlighted'));\n if (index >= 0 && index < listItems.length) {\n highlighted = index;\n listItems[index].classList.add('is-highlighted');\n input.setAttribute('aria-activedescendant', listItems[index].id);\n listItems[index].scrollIntoView({ block: 'nearest' });\n }\n };\n\n const doSearch = async (query) => {\n if (query.length < minChars) { close(); return; }\n\n let filtered;\n if (url) {\n try {\n if (!_isSafeUrl(url, allowlist)) {\n console.warn('[VanduoSuggest] Blocked non-allowlisted URL:', url);\n filtered = [];\n } else {\n const separator = url.includes('?') ? '&' : '?';\n const res = await window.fetch(url + separator + 'q=' + encodeURIComponent(query));\n filtered = await res.json();\n }\n } catch (_e) {\n filtered = [];\n }\n } else {\n const lower = query.toLowerCase();\n filtered = items.filter(item => {\n const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);\n return text.toLowerCase().includes(lower);\n });\n }\n\n renderItems(filtered, query);\n if (filtered.length > 0) open();\n else open(); // show \"no results\"\n };\n\n const inputHandler = () => {\n clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => doSearch(input.value), 200);\n };\n\n const keyHandler = (e) => {\n if (!list.classList.contains('is-open')) {\n if (e.key === 'ArrowDown') { doSearch(input.value); e.preventDefault(); }\n return;\n }\n\n const total = currentItems.length;\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n highlight(highlighted < total - 1 ? highlighted + 1 : 0);\n break;\n case 'ArrowUp':\n e.preventDefault();\n highlight(highlighted > 0 ? highlighted - 1 : total - 1);\n break;\n case 'Enter':\n e.preventDefault();\n if (highlighted >= 0) selectItem(highlighted);\n break;\n case 'Escape':\n close();\n break;\n }\n };\n\n const blurHandler = () => {\n setTimeout(close, 200);\n };\n\n input.addEventListener('input', inputHandler);\n input.addEventListener('keydown', keyHandler);\n input.addEventListener('blur', blurHandler);\n input.addEventListener('focus', () => { if (input.value.length >= minChars) doSearch(input.value); });\n\n cleanup.push(\n () => input.removeEventListener('input', inputHandler),\n () => input.removeEventListener('keydown', keyHandler),\n () => input.removeEventListener('blur', blurHandler),\n () => clearTimeout(debounceTimer),\n () => { if (list.parentNode) list.parentNode.removeChild(list); }\n );\n\n this.instances.set(input, { cleanup, list, close });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('suggest', Suggest);\n }\n\n window.VanduoSuggest = Suggest;\n\n})();\n", "/**\n * Vanduo Framework - Validate (Form Validation) Component\n * Declarative validation via data attributes with real-time and on-submit modes\n */\n\n(function () {\n 'use strict';\n\n const Validate = {\n instances: new Map(),\n\n rules: {\n required: (value) => value.trim().length > 0,\n email: (value) => /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(value),\n url: (value) => { try { new URL(value); return true; } catch (_e) { return false; } },\n number: (value) => !isNaN(parseFloat(value)) && isFinite(value),\n min: (value, param) => value.length >= parseInt(param, 10),\n max: (value, param) => value.length <= parseInt(param, 10),\n minVal: (value, param) => parseFloat(value) >= parseFloat(param),\n maxVal: (value, param) => parseFloat(value) <= parseFloat(param),\n pattern: (value, param) => {\n try {\n // Cap regex length to prevent ReDoS from excessively complex patterns\n if (param.length > 100) return false;\n return new RegExp(param).test(value);\n } catch (_e) { return false; }\n },\n match: (value, param) => {\n try {\n const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(param) : param;\n const other = document.querySelector('[name=\"' + escaped + '\"]');\n return other ? value === other.value : false;\n } catch (_e) {\n return false;\n }\n }\n },\n\n messages: {\n required: 'This field is required',\n email: 'Please enter a valid email address',\n url: 'Please enter a valid URL',\n number: 'Please enter a valid number',\n min: 'Minimum {0} characters required',\n max: 'Maximum {0} characters allowed',\n minVal: 'Value must be at least {0}',\n maxVal: 'Value must be at most {0}',\n pattern: 'Invalid format',\n match: 'Fields do not match'\n },\n\n init: function () {\n const forms = document.querySelectorAll('[data-vd-validate], .vd-validate');\n forms.forEach(form => {\n if (this.instances.has(form)) return;\n this.initInstance(form);\n });\n },\n\n initInstance: function (form) {\n const cleanup = [];\n const mode = form.getAttribute('data-vd-validate-mode') || 'blur'; // blur | input | submit\n const fields = form.querySelectorAll('[data-vd-rules]');\n\n const validateField = (field) => {\n const rulesStr = field.getAttribute('data-vd-rules') || '';\n const rules = rulesStr.split('|').map(r => r.trim()).filter(Boolean);\n const value = field.value;\n const errors = [];\n\n for (const rule of rules) {\n const [name, ...params] = rule.split(':');\n const param = params.join(':');\n const validator = this.rules[name];\n\n if (validator && !validator(value, param)) {\n const customMsg = field.getAttribute('data-vd-msg-' + name);\n let msg = customMsg || this.messages[name] || 'Invalid';\n if (param) msg = msg.replace('{0}', param);\n errors.push(msg);\n break; // one error at a time\n }\n }\n\n this.setFieldState(field, errors);\n return errors.length === 0;\n };\n\n const validateAll = () => {\n let valid = true;\n fields.forEach(field => {\n if (!validateField(field)) valid = false;\n });\n return valid;\n };\n\n // Per-field listeners\n fields.forEach(field => {\n if (mode === 'input' || mode === 'blur') {\n const eventType = mode === 'input' ? 'input' : 'blur';\n const handler = () => validateField(field);\n field.addEventListener(eventType, handler);\n cleanup.push(() => field.removeEventListener(eventType, handler));\n\n if (mode === 'blur') {\n const inputClear = () => {\n if (field.classList.contains('is-invalid') || field.classList.contains('is-valid')) {\n validateField(field);\n }\n };\n field.addEventListener('input', inputClear);\n cleanup.push(() => field.removeEventListener('input', inputClear));\n }\n }\n });\n\n // Form submit\n const submitHandler = (e) => {\n const valid = validateAll();\n if (!valid) {\n e.preventDefault();\n e.stopPropagation();\n // Focus first invalid field\n const firstInvalid = form.querySelector('.is-invalid');\n if (firstInvalid) firstInvalid.focus();\n }\n form.dispatchEvent(new CustomEvent('validate:submit', {\n detail: { valid },\n bubbles: true\n }));\n };\n\n form.addEventListener('submit', submitHandler);\n cleanup.push(() => form.removeEventListener('submit', submitHandler));\n\n this.instances.set(form, { cleanup, validateAll, validateField });\n },\n\n setFieldState: function (field, errors) {\n const wrapper = field.closest('.vd-form-group') || field.parentElement;\n let errorEl = wrapper.querySelector('.vd-validate-error');\n\n field.classList.remove('is-valid', 'is-invalid');\n\n if (errors.length > 0) {\n field.classList.add('is-invalid');\n field.setAttribute('aria-invalid', 'true');\n if (!errorEl) {\n errorEl = document.createElement('div');\n errorEl.className = 'vd-validate-error';\n errorEl.id = 'vd-err-' + Math.random().toString(36).slice(2, 9);\n errorEl.setAttribute('role', 'alert');\n wrapper.appendChild(errorEl);\n }\n errorEl.textContent = errors[0];\n errorEl.style.display = '';\n field.setAttribute('aria-describedby', errorEl.id);\n } else if (field.value.trim()) {\n field.classList.add('is-valid');\n field.removeAttribute('aria-invalid');\n if (errorEl) errorEl.style.display = 'none';\n } else {\n field.removeAttribute('aria-invalid');\n if (errorEl) errorEl.style.display = 'none';\n }\n },\n\n validateForm: function (form) {\n const instance = this.instances.get(form);\n return instance ? instance.validateAll() : false;\n },\n\n addRule: function (name, validator, message) {\n this.rules[name] = validator;\n if (message) this.messages[name] = message;\n },\n\n destroy: function (form) {\n const instance = this.instances.get(form);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(form);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, form) => this.destroy(form));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('validate', Validate);\n }\n\n window.VanduoValidate = Validate;\n\n})();\n", "/**\n * Vanduo Framework - Datepicker Component\n * Calendar popup attached to input field with month/year navigation\n */\n\n(function () {\n 'use strict';\n\n const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];\n const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];\n\n function escapeRegexChar(c) {\n return c.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n }\n\n function buildParseFormat(format) {\n let regex = '^';\n const order = [];\n let i = 0;\n while (i < format.length) {\n const slice = format.slice(i);\n if (slice.toLowerCase().startsWith('yyyy')) {\n regex += '(\\\\d{4})';\n order.push('y');\n i += 4;\n } else if (slice.toLowerCase().startsWith('mm')) {\n regex += '(\\\\d{2})';\n order.push('m');\n i += 2;\n } else if (slice.toLowerCase().startsWith('dd')) {\n regex += '(\\\\d{2})';\n order.push('d');\n i += 2;\n } else {\n regex += escapeRegexChar(format[i]);\n i++;\n }\n }\n regex += '$';\n return { regex: new RegExp(regex), order };\n }\n\n function parseDateFromFormat(value, format) {\n if (!value || !format) return null;\n const { regex, order } = buildParseFormat(format);\n const m = value.trim().match(regex);\n if (!m) return null;\n let y;\n let mo;\n let d;\n let ci = 1;\n for (let k = 0; k < order.length; k++) {\n const part = order[k];\n const v = parseInt(m[ci++], 10);\n if (Number.isNaN(v)) return null;\n if (part === 'y') y = v;\n else if (part === 'm') mo = v - 1;\n else if (part === 'd') d = v;\n }\n if (y === undefined || mo === undefined || d === undefined) return null;\n const dt = new Date(y, mo, d);\n if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== d) return null;\n return dt;\n }\n\n function formatDate(d, format) {\n const yyyy = String(d.getFullYear());\n const mm = String(d.getMonth() + 1).padStart(2, '0');\n const dd = String(d.getDate()).padStart(2, '0');\n let out = '';\n let i = 0;\n while (i < format.length) {\n const slice = format.slice(i);\n if (slice.toLowerCase().startsWith('yyyy')) {\n out += yyyy;\n i += 4;\n } else if (slice.toLowerCase().startsWith('mm')) {\n out += mm;\n i += 2;\n } else if (slice.toLowerCase().startsWith('dd')) {\n out += dd;\n i += 2;\n } else {\n out += format[i];\n i++;\n }\n }\n return out;\n }\n\n function dateKey(d) {\n return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');\n }\n\n function addDays(d, n) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n x.setDate(x.getDate() + n);\n return x;\n }\n\n function addMonthsClamped(d, n) {\n return new Date(d.getFullYear(), d.getMonth() + n, d.getDate());\n }\n\n function parseYmdLocal(ymd) {\n if (!ymd || typeof ymd !== 'string') return null;\n const m = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(ymd.trim());\n if (!m) return null;\n const y = +m[1];\n const mo = +m[2] - 1;\n const day = +m[3];\n const dt = new Date(y, mo, day);\n if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== day) return null;\n return dt;\n }\n\n function startOfWeekSunday(d) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const day = x.getDay();\n x.setDate(x.getDate() - day);\n return x;\n }\n\n function endOfWeekSunday(d) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const day = x.getDay();\n x.setDate(x.getDate() + (6 - day));\n return x;\n }\n\n const Datepicker = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-datepicker]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const format = input.getAttribute('data-vd-datepicker-format') || 'YYYY-MM-DD';\n const minStr = input.getAttribute('data-vd-datepicker-min');\n const maxStr = input.getAttribute('data-vd-datepicker-max');\n const minDate = minStr ? parseYmdLocal(minStr) : null;\n const maxDate = maxStr ? parseYmdLocal(maxStr) : null;\n\n const today = new Date();\n let viewYear = today.getFullYear();\n let viewMonth = today.getMonth();\n let selectedDate = null;\n let viewMode = 'days'; // days | months | years\n let focusedDate = null;\n /** Prevents focus() after close from immediately re-opening the popup */\n let skipNextFocusOpen = false;\n\n const isDisabled = (d) => {\n if (minDate) {\n const t = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n if (t < minDate.getTime()) return true;\n }\n if (maxDate) {\n const t = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n if (t > maxDate.getTime()) return true;\n }\n return false;\n };\n\n const ensureMonthInRange = (y, m) => {\n if (!minDate && !maxDate) return { y: y, m: m };\n const first = new Date(y, m, 1);\n const last = new Date(y, m + 1, 0);\n if (minDate && last.getTime() < minDate.getTime()) {\n return { y: minDate.getFullYear(), m: minDate.getMonth() };\n }\n if (maxDate && first.getTime() > maxDate.getTime()) {\n return { y: maxDate.getFullYear(), m: maxDate.getMonth() };\n }\n return { y: y, m: m };\n };\n\n const firstSelectableInMonth = (y, m) => {\n const last = new Date(y, m + 1, 0).getDate();\n for (let day = 1; day <= last; day++) {\n const dt = new Date(y, m, day);\n if (!isDisabled(dt)) return dt;\n }\n return new Date(y, m, 1);\n };\n\n if (input.value) {\n const trimmed = input.value.trim();\n let parsed = parseDateFromFormat(trimmed, format);\n if (!parsed) {\n const fallback = new Date(trimmed);\n if (!isNaN(fallback.getTime())) parsed = fallback;\n }\n if (parsed) {\n selectedDate = parsed;\n viewYear = parsed.getFullYear();\n viewMonth = parsed.getMonth();\n }\n }\n\n const clampedInit = ensureMonthInRange(viewYear, viewMonth);\n viewYear = clampedInit.y;\n viewMonth = clampedInit.m;\n\n // Create popup\n const popup = document.createElement('div');\n popup.className = 'vd-datepicker-popup';\n popup.setAttribute('role', 'dialog');\n popup.setAttribute('aria-label', 'Choose date');\n popup.tabIndex = -1;\n\n const wrapper = document.createElement('div');\n wrapper.className = 'vd-suggest-wrapper';\n wrapper.style.position = 'relative';\n wrapper.style.display = 'inline-block';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n wrapper.appendChild(popup);\n\n const isSameDay = (a, b) => a && b &&\n a.getFullYear() === b.getFullYear() &&\n a.getMonth() === b.getMonth() &&\n a.getDate() === b.getDate();\n\n const selectDate = (date) => {\n selectedDate = date;\n viewYear = date.getFullYear();\n viewMonth = date.getMonth();\n input.value = formatDate(date, format);\n skipNextFocusOpen = true;\n close();\n input.dispatchEvent(new CustomEvent('datepicker:select', {\n detail: { date: date, formatted: input.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n input.focus();\n };\n\n const focusFocusedDay = () => {\n if (viewMode !== 'days' || !focusedDate) return;\n const key = dateKey(focusedDate);\n const btn = popup.querySelector('[data-vd-date=\"' + key + '\"]');\n if (btn && !btn.classList.contains('is-outside') && btn.getAttribute('aria-disabled') !== 'true') {\n btn.focus();\n }\n };\n\n const skipDisabled = (d, stepDir, maxSteps) => {\n let x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const step = stepDir > 0 ? 1 : -1;\n for (let i = 0; i < maxSteps; i++) {\n if (!isDisabled(x)) return x;\n x = addDays(x, step);\n }\n return d;\n };\n\n const createDayBtn = (day, outside, date) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-day';\n btn.textContent = day;\n btn.setAttribute('role', 'gridcell');\n\n if (outside) {\n btn.classList.add('is-outside');\n btn.tabIndex = -1;\n btn.setAttribute('aria-disabled', 'true');\n return btn;\n }\n\n btn.setAttribute('data-vd-date', dateKey(date));\n\n if (date && isSameDay(date, today)) btn.classList.add('is-today');\n if (date && isSameDay(date, selectedDate)) btn.classList.add('is-selected');\n if (date && isDisabled(date)) {\n btn.classList.add('is-disabled');\n btn.setAttribute('aria-disabled', 'true');\n btn.tabIndex = -1;\n return btn;\n }\n\n if (date) {\n const isFocused = focusedDate && isSameDay(date, focusedDate);\n btn.tabIndex = isFocused ? 0 : -1;\n\n btn.addEventListener('click', () => {\n selectedDate = date;\n viewYear = date.getFullYear();\n viewMonth = date.getMonth();\n focusedDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());\n input.value = formatDate(date, format);\n skipNextFocusOpen = true;\n close();\n input.dispatchEvent(new CustomEvent('datepicker:select', {\n detail: { date: date, formatted: input.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n input.focus();\n });\n }\n\n return btn;\n };\n\n const render = () => {\n popup.innerHTML = '';\n\n // Header\n const header = document.createElement('div');\n header.className = 'vd-datepicker-header';\n\n const prevBtn = document.createElement('button');\n prevBtn.type = 'button';\n prevBtn.className = 'vd-datepicker-prev';\n prevBtn.innerHTML = '‹';\n prevBtn.setAttribute('aria-label', 'Previous');\n\n const nextBtn = document.createElement('button');\n nextBtn.type = 'button';\n nextBtn.className = 'vd-datepicker-next';\n nextBtn.innerHTML = '›';\n nextBtn.setAttribute('aria-label', 'Next');\n\n const title = document.createElement('span');\n title.className = 'vd-datepicker-title';\n\n if (viewMode === 'days') {\n title.textContent = MONTHS[viewMonth] + ' ' + viewYear;\n title.addEventListener('click', () => { viewMode = 'months'; render(); });\n prevBtn.addEventListener('click', () => { viewMonth--; if (viewMonth < 0) { viewMonth = 11; viewYear--; } render(); });\n nextBtn.addEventListener('click', () => { viewMonth++; if (viewMonth > 11) { viewMonth = 0; viewYear++; } render(); });\n } else if (viewMode === 'months') {\n title.textContent = String(viewYear);\n title.addEventListener('click', () => { viewMode = 'years'; render(); });\n prevBtn.addEventListener('click', () => { viewYear--; render(); });\n nextBtn.addEventListener('click', () => { viewYear++; render(); });\n } else {\n const decadeStart = Math.floor(viewYear / 10) * 10;\n title.textContent = decadeStart + ' - ' + (decadeStart + 9);\n prevBtn.addEventListener('click', () => { viewYear -= 10; render(); });\n nextBtn.addEventListener('click', () => { viewYear += 10; render(); });\n }\n\n header.appendChild(prevBtn);\n header.appendChild(title);\n header.appendChild(nextBtn);\n popup.appendChild(header);\n\n if (viewMode === 'days') {\n const gridWrap = document.createElement('div');\n gridWrap.className = 'vd-datepicker-grid';\n gridWrap.setAttribute('role', 'grid');\n gridWrap.setAttribute('aria-label', 'Calendar');\n\n const weekdays = document.createElement('div');\n weekdays.className = 'vd-datepicker-weekdays';\n weekdays.setAttribute('role', 'row');\n DAYS.forEach(function (d) {\n const span = document.createElement('span');\n span.setAttribute('role', 'columnheader');\n span.setAttribute('aria-label', d);\n span.textContent = d;\n weekdays.appendChild(span);\n });\n gridWrap.appendChild(weekdays);\n\n const firstDay = new Date(viewYear, viewMonth, 1).getDay();\n const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();\n const daysInPrev = new Date(viewYear, viewMonth, 0).getDate();\n\n const cells = [];\n\n for (let i = firstDay - 1; i >= 0; i--) {\n const dayNum = daysInPrev - i;\n const prevMonth = viewMonth === 0 ? 11 : viewMonth - 1;\n const prevYear = viewMonth === 0 ? viewYear - 1 : viewYear;\n const date = new Date(prevYear, prevMonth, dayNum);\n cells.push({ day: dayNum, outside: true, date: date });\n }\n\n for (let d = 1; d <= daysInMonth; d++) {\n const date = new Date(viewYear, viewMonth, d);\n cells.push({ day: d, outside: false, date: date });\n }\n\n const totalCells = firstDay + daysInMonth;\n const remaining = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7);\n for (let i = 1; i <= remaining; i++) {\n const date = new Date(viewYear, viewMonth + 1, i);\n cells.push({ day: i, outside: true, date: date });\n }\n\n for (let r = 0; r < cells.length; r += 7) {\n const row = document.createElement('div');\n row.className = 'vd-datepicker-row';\n row.setAttribute('role', 'row');\n for (let c = 0; c < 7; c++) {\n const cell = cells[r + c];\n row.appendChild(createDayBtn(cell.day, cell.outside, cell.date));\n }\n gridWrap.appendChild(row);\n }\n\n popup.appendChild(gridWrap);\n } else if (viewMode === 'months') {\n const grid = document.createElement('div');\n grid.className = 'vd-datepicker-months';\n MONTHS.forEach((name, i) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-month-btn';\n btn.textContent = name.slice(0, 3);\n if (selectedDate && selectedDate.getFullYear() === viewYear && selectedDate.getMonth() === i) {\n btn.classList.add('is-selected');\n }\n btn.addEventListener('click', () => { viewMonth = i; viewMode = 'days'; render(); });\n grid.appendChild(btn);\n });\n popup.appendChild(grid);\n } else {\n const grid = document.createElement('div');\n grid.className = 'vd-datepicker-years';\n const decadeStart = Math.floor(viewYear / 10) * 10;\n for (let y = decadeStart - 1; y <= decadeStart + 10; y++) {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-year-btn';\n btn.textContent = y;\n if (selectedDate && selectedDate.getFullYear() === y) btn.classList.add('is-selected');\n if (y < decadeStart || y > decadeStart + 9) btn.style.opacity = '0.4';\n btn.addEventListener('click', () => { viewYear = y; viewMode = 'months'; render(); });\n grid.appendChild(btn);\n }\n popup.appendChild(grid);\n }\n };\n\n const handleGridKeydown = (e) => {\n if (!popup.classList.contains('is-open') || viewMode !== 'days') return;\n const grid = popup.querySelector('.vd-datepicker-grid');\n if (!grid || !grid.contains(e.target)) return;\n\n const key = e.key;\n if (key !== 'ArrowLeft' && key !== 'ArrowRight' && key !== 'ArrowUp' && key !== 'ArrowDown' &&\n key !== 'Home' && key !== 'End' && key !== 'PageUp' && key !== 'PageDown' &&\n key !== 'Enter' && key !== ' ' && key !== 'Escape') {\n return;\n }\n\n if (key === 'Escape') {\n e.preventDefault();\n e.stopPropagation();\n skipNextFocusOpen = true;\n close();\n input.focus();\n return;\n }\n\n if (!focusedDate) {\n focusedDate = firstSelectableInMonth(viewYear, viewMonth);\n }\n\n if (key === 'Enter' || key === ' ') {\n e.preventDefault();\n if (focusedDate && !isDisabled(focusedDate)) {\n selectDate(new Date(focusedDate.getFullYear(), focusedDate.getMonth(), focusedDate.getDate()));\n }\n return;\n }\n\n e.preventDefault();\n\n let next = new Date(focusedDate.getFullYear(), focusedDate.getMonth(), focusedDate.getDate());\n let skipDir = 1;\n\n if (key === 'ArrowLeft') {\n next = addDays(next, -1);\n skipDir = -1;\n } else if (key === 'ArrowRight') {\n next = addDays(next, 1);\n skipDir = 1;\n } else if (key === 'ArrowUp') {\n next = addDays(next, -7);\n skipDir = -1;\n } else if (key === 'ArrowDown') {\n next = addDays(next, 7);\n skipDir = 1;\n } else if (key === 'Home') {\n next = startOfWeekSunday(next);\n skipDir = 1;\n } else if (key === 'End') {\n next = endOfWeekSunday(next);\n skipDir = -1;\n } else if (key === 'PageUp') {\n next = addMonthsClamped(next, -1);\n skipDir = -1;\n } else if (key === 'PageDown') {\n next = addMonthsClamped(next, 1);\n skipDir = 1;\n }\n\n next = skipDisabled(next, skipDir, 400);\n\n if (next.getMonth() !== viewMonth || next.getFullYear() !== viewYear) {\n viewYear = next.getFullYear();\n viewMonth = next.getMonth();\n const cl = ensureMonthInRange(viewYear, viewMonth);\n viewYear = cl.y;\n viewMonth = cl.m;\n }\n\n focusedDate = next;\n render();\n requestAnimationFrame(focusFocusedDay);\n };\n\n const open = () => {\n viewMode = 'days';\n if (selectedDate) {\n viewYear = selectedDate.getFullYear();\n viewMonth = selectedDate.getMonth();\n }\n const cl = ensureMonthInRange(viewYear, viewMonth);\n viewYear = cl.y;\n viewMonth = cl.m;\n\n if (selectedDate) {\n focusedDate = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate());\n } else {\n focusedDate = firstSelectableInMonth(viewYear, viewMonth);\n }\n\n render();\n popup.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n requestAnimationFrame(focusFocusedDay);\n };\n\n const close = () => {\n popup.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n viewMode = 'days';\n };\n\n const focusHandler = () => {\n if (skipNextFocusOpen) {\n skipNextFocusOpen = false;\n return;\n }\n open();\n };\n const outsideHandler = (e) => {\n if (!wrapper.contains(e.target)) close();\n };\n const escHandler = (e) => {\n if (e.key === 'Escape' && popup.classList.contains('is-open')) {\n skipNextFocusOpen = true;\n close();\n input.focus();\n }\n };\n\n input.addEventListener('focus', focusHandler);\n document.addEventListener('click', outsideHandler, true);\n document.addEventListener('keydown', escHandler);\n popup.addEventListener('keydown', handleGridKeydown);\n\n input.setAttribute('aria-haspopup', 'dialog');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('autocomplete', 'off');\n\n cleanup.push(\n () => input.removeEventListener('focus', focusHandler),\n () => document.removeEventListener('click', outsideHandler, true),\n () => document.removeEventListener('keydown', escHandler),\n () => popup.removeEventListener('keydown', handleGridKeydown)\n );\n\n this.instances.set(input, { cleanup: cleanup, open: open, close: close, popup: popup });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('datepicker', Datepicker);\n }\n\n window.VanduoDatepicker = Datepicker;\n\n})();\n", "/**\n * Vanduo Framework - Timepicker Component\n * Dropdown time selection with 12h/24h format and configurable step intervals\n */\n\n(function () {\n 'use strict';\n\n const Timepicker = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-timepicker]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const is24h = input.getAttribute('data-vd-timepicker-format') === '24h';\n const step = parseInt(input.getAttribute('data-vd-timepicker-step') || '30', 10);\n\n // Create wrapper\n let wrapper = input.closest('.vd-suggest-wrapper');\n if (!wrapper) {\n wrapper = document.createElement('div');\n wrapper.style.position = 'relative';\n wrapper.style.display = 'inline-block';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n }\n\n // Create popup\n const popup = document.createElement('div');\n popup.className = 'vd-timepicker-popup';\n popup.setAttribute('role', 'listbox');\n wrapper.appendChild(popup);\n\n // Generate time slots\n const times = [];\n for (let h = 0; h < 24; h++) {\n for (let m = 0; m < 60; m += step) {\n const hh24 = String(h).padStart(2, '0');\n const mm = String(m).padStart(2, '0');\n\n if (is24h) {\n times.push({ display: hh24 + ':' + mm, value: hh24 + ':' + mm });\n } else {\n const period = h < 12 ? 'AM' : 'PM';\n const h12 = h === 0 ? 12 : (h > 12 ? h - 12 : h);\n const display = h12 + ':' + mm + ' ' + period;\n times.push({ display, value: hh24 + ':' + mm });\n }\n }\n }\n\n const render = () => {\n popup.innerHTML = '';\n times.forEach(t => {\n const item = document.createElement('div');\n item.className = 'vd-timepicker-item';\n item.setAttribute('role', 'option');\n item.textContent = t.display;\n\n if (input.value === t.value || input.value === t.display) {\n item.classList.add('is-selected');\n }\n\n item.addEventListener('click', () => {\n input.value = t.display;\n popup.querySelectorAll('.vd-timepicker-item').forEach(i => i.classList.remove('is-selected'));\n item.classList.add('is-selected');\n close();\n input.dispatchEvent(new CustomEvent('timepicker:select', {\n detail: { display: t.display, value: t.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n });\n\n popup.appendChild(item);\n });\n };\n\n const open = () => {\n render();\n popup.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n // Scroll to selected\n const selected = popup.querySelector('.is-selected');\n if (selected) selected.scrollIntoView({ block: 'center' });\n };\n\n const close = () => {\n popup.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n };\n\n const focusHandler = () => open();\n const outsideHandler = (e) => {\n if (!wrapper.contains(e.target)) close();\n };\n const escHandler = (e) => { if (e.key === 'Escape') close(); };\n\n input.addEventListener('focus', focusHandler);\n document.addEventListener('click', outsideHandler, true);\n document.addEventListener('keydown', escHandler);\n input.setAttribute('aria-haspopup', 'listbox');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('autocomplete', 'off');\n input.readOnly = true;\n\n cleanup.push(\n () => input.removeEventListener('focus', focusHandler),\n () => document.removeEventListener('click', outsideHandler, true),\n () => document.removeEventListener('keydown', escHandler)\n );\n\n this.instances.set(input, { cleanup, open, close });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('timepicker', Timepicker);\n }\n\n window.VanduoTimepicker = Timepicker;\n\n})();\n", "/**\n * Vanduo Framework - Stepper Component\n * Multi-step progress indicator with state management\n */\n\n(function () {\n 'use strict';\n\n const Stepper = {\n instances: new Map(),\n\n init: function () {\n const steppers = document.querySelectorAll('.vd-stepper');\n steppers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const items = Array.from(el.querySelectorAll('.vd-stepper-item'));\n const isClickable = el.classList.contains('vd-stepper-clickable');\n let currentIndex = items.findIndex(i => i.classList.contains('is-active'));\n if (currentIndex === -1) currentIndex = 0;\n\n const setStep = (index) => {\n if (index < 0 || index >= items.length) return;\n const prev = currentIndex;\n currentIndex = index;\n\n items.forEach((item, i) => {\n item.classList.remove('is-active', 'is-completed');\n if (i < index) item.classList.add('is-completed');\n else if (i === index) item.classList.add('is-active');\n });\n\n el.dispatchEvent(new CustomEvent('stepper:change', {\n detail: { current: index, previous: prev, total: items.length },\n bubbles: true\n }));\n };\n\n if (isClickable) {\n items.forEach((item, i) => {\n const handler = () => setStep(i);\n item.addEventListener('click', handler);\n cleanup.push(() => item.removeEventListener('click', handler));\n });\n }\n\n // Initialize current state\n setStep(currentIndex);\n\n this.instances.set(el, {\n cleanup,\n setStep,\n next: () => setStep(currentIndex + 1),\n prev: () => setStep(currentIndex - 1),\n getCurrent: () => currentIndex\n });\n },\n\n setStep: function (el, index) {\n const inst = this.instances.get(el);\n if (inst) inst.setStep(index);\n },\n\n next: function (el) {\n const inst = this.instances.get(el);\n if (inst) inst.next();\n },\n\n prev: function (el) {\n const inst = this.instances.get(el);\n if (inst) inst.prev();\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('stepper', Stepper);\n }\n\n window.VanduoStepper = Stepper;\n\n})();\n", "/**\n * Vanduo Framework - Rating Component\n * Star-based rating input with hover preview and read-only mode\n */\n\n(function () {\n 'use strict';\n\n const Rating = {\n instances: new Map(),\n\n init: function () {\n const ratings = document.querySelectorAll('[data-vd-rating]');\n ratings.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const max = parseInt(el.getAttribute('data-vd-rating-max') || '5', 10);\n const initialValue = parseFloat(el.getAttribute('data-vd-rating-value') || '0');\n const readonly = el.classList.contains('vd-rating-readonly') || el.hasAttribute('data-vd-rating-readonly');\n let currentValue = initialValue;\n\n el.classList.add('vd-rating');\n el.setAttribute('role', 'radiogroup');\n el.setAttribute('aria-label', el.getAttribute('aria-label') || 'Rating');\n\n // Clear existing stars\n el.innerHTML = '';\n\n // Create stars\n const stars = [];\n for (let i = 1; i <= max; i++) {\n const star = document.createElement('button');\n star.type = 'button';\n star.className = 'vd-rating-star';\n star.setAttribute('role', 'radio');\n star.setAttribute('aria-label', i + ' star' + (i > 1 ? 's' : ''));\n star.setAttribute('aria-checked', i <= currentValue ? 'true' : 'false');\n if (readonly) star.tabIndex = -1;\n stars.push(star);\n el.appendChild(star);\n }\n\n // Value display\n const valueDisplay = document.createElement('span');\n valueDisplay.className = 'vd-rating-value';\n valueDisplay.textContent = currentValue > 0 ? currentValue.toString() : '';\n el.appendChild(valueDisplay);\n\n const updateStars = (value) => {\n stars.forEach((star, i) => {\n star.classList.remove('is-active', 'is-half');\n const starNum = i + 1;\n if (starNum <= Math.floor(value)) {\n star.classList.add('is-active');\n } else if (starNum - 0.5 <= value) {\n star.classList.add('is-half');\n }\n star.setAttribute('aria-checked', starNum <= value ? 'true' : 'false');\n });\n valueDisplay.textContent = value > 0 ? value.toString() : '';\n };\n\n updateStars(currentValue);\n\n if (!readonly) {\n stars.forEach((star, i) => {\n const enterHandler = () => {\n stars.forEach((s, j) => {\n s.classList.toggle('is-hovered', j <= i);\n });\n };\n const leaveHandler = () => {\n stars.forEach(s => s.classList.remove('is-hovered'));\n };\n const clickHandler = () => {\n currentValue = i + 1;\n el.setAttribute('data-vd-rating-value', currentValue);\n updateStars(currentValue);\n el.dispatchEvent(new CustomEvent('rating:change', {\n detail: { value: currentValue, max },\n bubbles: true\n }));\n };\n\n star.addEventListener('mouseenter', enterHandler);\n star.addEventListener('mouseleave', leaveHandler);\n star.addEventListener('click', clickHandler);\n\n cleanup.push(\n () => star.removeEventListener('mouseenter', enterHandler),\n () => star.removeEventListener('mouseleave', leaveHandler),\n () => star.removeEventListener('click', clickHandler)\n );\n });\n\n // Keyboard\n const keyHandler = (e) => {\n if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {\n e.preventDefault();\n if (currentValue < max) {\n currentValue++;\n updateStars(currentValue);\n stars[currentValue - 1].focus();\n el.dispatchEvent(new CustomEvent('rating:change', { detail: { value: currentValue, max }, bubbles: true }));\n }\n } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {\n e.preventDefault();\n if (currentValue > 1) {\n currentValue--;\n updateStars(currentValue);\n stars[currentValue - 1].focus();\n el.dispatchEvent(new CustomEvent('rating:change', { detail: { value: currentValue, max }, bubbles: true }));\n }\n }\n };\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n }\n\n this.instances.set(el, {\n cleanup,\n getValue: () => currentValue,\n setValue: (v) => { currentValue = v; updateStars(v); }\n });\n },\n\n getValue: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getValue() : 0;\n },\n\n setValue: function (el, value) {\n const inst = this.instances.get(el);\n if (inst) inst.setValue(value);\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('rating', Rating);\n }\n\n window.VanduoRating = Rating;\n\n})();\n", "/**\n * Vanduo Framework - Transfer (Dual-list) Component\n * Source-to-target list transfer with search, select-all, and move actions\n */\n\n(function () {\n 'use strict';\n\n const Transfer = {\n instances: new Map(),\n\n init: function () {\n const transfers = document.querySelectorAll('[data-vd-transfer]');\n transfers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n el.classList.add('vd-transfer');\n\n let sourceData, targetData;\n try {\n const raw = JSON.parse(el.getAttribute('data-vd-transfer') || '[]');\n sourceData = raw.map((item, i) => ({\n id: item.id || 'item-' + i,\n label: item.label || item.text || String(item),\n selected: false\n }));\n } catch (_e) {\n sourceData = [];\n }\n targetData = [];\n\n const sourceSelected = new Set();\n const targetSelected = new Set();\n\n const render = () => {\n el.innerHTML = '';\n\n // Source panel\n const sourcePanel = createPanel('Source', sourceData, sourceSelected, 'source');\n // Actions\n const actions = document.createElement('div');\n actions.className = 'vd-transfer-actions';\n\n const moveRightBtn = document.createElement('button');\n moveRightBtn.type = 'button';\n moveRightBtn.className = 'vd-transfer-btn';\n moveRightBtn.innerHTML = '›';\n moveRightBtn.setAttribute('aria-label', 'Move to target');\n moveRightBtn.disabled = sourceSelected.size === 0;\n moveRightBtn.addEventListener('click', () => moveRight());\n\n const moveLeftBtn = document.createElement('button');\n moveLeftBtn.type = 'button';\n moveLeftBtn.className = 'vd-transfer-btn';\n moveLeftBtn.innerHTML = '‹';\n moveLeftBtn.setAttribute('aria-label', 'Move to source');\n moveLeftBtn.disabled = targetSelected.size === 0;\n moveLeftBtn.addEventListener('click', () => moveLeft());\n\n actions.appendChild(moveRightBtn);\n actions.appendChild(moveLeftBtn);\n\n // Target panel\n const targetPanel = createPanel('Target', targetData, targetSelected, 'target');\n\n el.appendChild(sourcePanel);\n el.appendChild(actions);\n el.appendChild(targetPanel);\n };\n\n const createPanel = (title, data, selected, _side) => {\n const panel = document.createElement('div');\n panel.className = 'vd-transfer-panel';\n\n const header = document.createElement('div');\n header.className = 'vd-transfer-header';\n const titleSpan = document.createElement('span');\n titleSpan.textContent = title;\n const count = document.createElement('span');\n count.className = 'vd-transfer-count';\n count.textContent = selected.size + '/' + data.length;\n header.appendChild(titleSpan);\n header.appendChild(count);\n panel.appendChild(header);\n\n // Search\n const searchDiv = document.createElement('div');\n searchDiv.className = 'vd-transfer-search';\n const searchInput = document.createElement('input');\n searchInput.type = 'text';\n searchInput.placeholder = 'Search...';\n searchInput.setAttribute('aria-label', 'Search ' + title.toLowerCase());\n searchDiv.appendChild(searchInput);\n panel.appendChild(searchDiv);\n\n // List\n const list = document.createElement('ul');\n list.className = 'vd-transfer-list';\n list.setAttribute('role', 'listbox');\n\n const renderList = (filter) => {\n list.innerHTML = '';\n const filtered = filter ? data.filter(d => {\n const label = (d.label || d.text || String(d)).toLowerCase();\n return label.includes(filter.toLowerCase());\n }) : data;\n filtered.forEach(item => {\n const li = document.createElement('li');\n li.className = 'vd-transfer-item';\n li.setAttribute('role', 'option');\n if (selected.has(item.id)) li.classList.add('is-selected');\n\n const checkbox = document.createElement('input');\n checkbox.type = 'checkbox';\n checkbox.checked = selected.has(item.id);\n checkbox.setAttribute('aria-label', item.label);\n\n const label = document.createElement('span');\n label.textContent = item.label;\n\n li.addEventListener('click', () => {\n if (selected.has(item.id)) selected.delete(item.id);\n else selected.add(item.id);\n render();\n });\n\n li.appendChild(checkbox);\n li.appendChild(label);\n list.appendChild(li);\n });\n };\n\n searchInput.addEventListener('input', () => renderList(searchInput.value));\n renderList('');\n\n panel.appendChild(list);\n return panel;\n };\n\n const moveRight = () => {\n const toMove = sourceData.filter(d => sourceSelected.has(d.id));\n sourceData = sourceData.filter(d => !sourceSelected.has(d.id));\n targetData = targetData.concat(toMove);\n sourceSelected.clear();\n render();\n fireChange();\n };\n\n const moveLeft = () => {\n const toMove = targetData.filter(d => targetSelected.has(d.id));\n targetData = targetData.filter(d => !targetSelected.has(d.id));\n sourceData = sourceData.concat(toMove);\n targetSelected.clear();\n render();\n fireChange();\n };\n\n const fireChange = () => {\n el.dispatchEvent(new CustomEvent('transfer:change', {\n detail: {\n source: sourceData.map(d => d.id),\n target: targetData.map(d => d.id)\n },\n bubbles: true\n }));\n };\n\n render();\n\n this.instances.set(el, {\n cleanup,\n getTarget: () => targetData.map(d => d.id),\n getSource: () => sourceData.map(d => d.id)\n });\n },\n\n getSelected: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getTarget() : [];\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n el.innerHTML = '';\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('transfer', Transfer);\n }\n\n window.VanduoTransfer = Transfer;\n\n})();\n", "/**\n * Vanduo Framework - Tree View Component\n * Hierarchical collapsible tree with checkbox selection and keyboard navigation\n */\n\n(function () {\n 'use strict';\n\n const Tree = {\n instances: new Map(),\n\n init: function () {\n const trees = document.querySelectorAll('[data-vd-tree]');\n trees.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const cascade = el.getAttribute('data-vd-tree-cascade') !== 'false';\n\n let data;\n try { data = JSON.parse(el.getAttribute('data-vd-tree') || '[]'); } catch (_e) { data = []; }\n\n el.classList.add('vd-tree');\n el.setAttribute('role', 'tree');\n\n const render = (items, parent) => {\n parent.innerHTML = '';\n items.forEach(item => {\n const node = document.createElement('li');\n node.className = 'vd-tree-node';\n node.setAttribute('role', 'treeitem');\n node.setAttribute('aria-expanded', item.open ? 'true' : 'false');\n if (item.open) node.classList.add('is-open');\n\n const content = document.createElement('div');\n content.className = 'vd-tree-node-content';\n\n // Toggle\n if (item.children && item.children.length > 0) {\n const toggle = document.createElement('button');\n toggle.type = 'button';\n toggle.className = 'vd-tree-toggle';\n toggle.setAttribute('aria-label', 'Toggle');\n toggle.addEventListener('click', (e) => {\n e.stopPropagation();\n item.open = !item.open;\n node.classList.toggle('is-open');\n node.setAttribute('aria-expanded', item.open ? 'true' : 'false');\n });\n content.appendChild(toggle);\n } else {\n const ph = document.createElement('span');\n ph.className = 'vd-tree-toggle-placeholder';\n content.appendChild(ph);\n }\n\n // Checkbox\n if (el.hasAttribute('data-vd-tree-checkbox')) {\n const cb = document.createElement('input');\n cb.type = 'checkbox';\n cb.className = 'vd-tree-checkbox';\n cb.checked = !!item.checked;\n cb.setAttribute('aria-label', item.label);\n cb.addEventListener('change', (e) => {\n e.stopPropagation();\n item.checked = cb.checked;\n if (cascade && item.children) {\n setChildChecked(item.children, cb.checked);\n render(data, el);\n }\n el.dispatchEvent(new CustomEvent('tree:check', {\n detail: { id: item.id, checked: cb.checked, label: item.label },\n bubbles: true\n }));\n });\n content.appendChild(cb);\n }\n\n // Icon\n if (item.icon) {\n const icon = document.createElement('span');\n icon.className = 'vd-tree-icon ' + item.icon;\n content.appendChild(icon);\n }\n\n // Label\n const label = document.createElement('span');\n label.className = 'vd-tree-label';\n label.textContent = item.label || '';\n content.appendChild(label);\n\n node.appendChild(content);\n\n // Children\n if (item.children && item.children.length > 0) {\n const childList = document.createElement('ul');\n childList.className = 'vd-tree-children';\n childList.setAttribute('role', 'group');\n render(item.children, childList);\n node.appendChild(childList);\n }\n\n parent.appendChild(node);\n });\n };\n\n const setChildChecked = (items, checked) => {\n items.forEach(item => {\n item.checked = checked;\n if (item.children) setChildChecked(item.children, checked);\n });\n };\n\n // Keyboard\n const keyHandler = (e) => {\n const focused = document.activeElement;\n if (!el.contains(focused)) return;\n\n const nodes = Array.from(el.querySelectorAll('.vd-tree-node-content'));\n const idx = nodes.indexOf(focused.closest('.vd-tree-node-content'));\n if (idx === -1) return;\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n if (idx < nodes.length - 1) {\n const next = nodes[idx + 1].querySelector('.vd-tree-toggle, .vd-tree-label');\n if (next) next.focus();\n }\n break;\n case 'ArrowUp':\n e.preventDefault();\n if (idx > 0) {\n const prev = nodes[idx - 1].querySelector('.vd-tree-toggle, .vd-tree-label');\n if (prev) prev.focus();\n }\n break;\n }\n };\n\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n\n render(data, el);\n\n this.instances.set(el, {\n cleanup,\n getData: () => data,\n getChecked: () => {\n const checked = [];\n const collect = (items) => {\n items.forEach(i => {\n if (i.checked) checked.push(i.id || i.label);\n if (i.children) collect(i.children);\n });\n };\n collect(data);\n return checked;\n }\n });\n },\n\n getChecked: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getChecked() : [];\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n el.innerHTML = '';\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('tree', Tree);\n }\n\n window.VanduoTree = Tree;\n\n})();\n", "/**\n * Vanduo Framework - Spotlight (Feature Discovery) Component\n * Guided tour with overlay highlight and step-through tooltip\n */\n\n(function () {\n 'use strict';\n\n const Spotlight = {\n _active: false,\n _steps: [],\n _currentStep: 0,\n _elements: {},\n _cleanup: [],\n _boundTriggers: new WeakMap(),\n _triggerElement: null,\n\n init: function () {\n const triggers = document.querySelectorAll('[data-vd-spotlight]');\n\n triggers.forEach(trigger => {\n if (this._boundTriggers.has(trigger)) return;\n\n const clickHandler = (event) => {\n event.preventDefault();\n\n const steps = this._parseSteps(trigger.getAttribute('data-vd-spotlight'));\n if (steps.length === 0) return;\n\n this.start(steps, { trigger });\n };\n\n trigger.addEventListener('click', clickHandler);\n this._boundTriggers.set(trigger, clickHandler);\n });\n },\n\n _parseSteps: function (raw) {\n if (typeof raw !== 'string' || raw.trim() === '') return [];\n\n try {\n const parsed = JSON.parse(raw);\n return this._normalizeSteps(parsed);\n } catch (error) {\n console.error('VanduoSpotlight: invalid data-vd-spotlight payload.', error);\n return [];\n }\n },\n\n _normalizeStep: function (step) {\n if (!step || typeof step !== 'object') return null;\n\n const target = step.target;\n const hasSelectorTarget = typeof target === 'string' && target.trim() !== '';\n const hasElementTarget = typeof Element !== 'undefined' && target instanceof Element;\n\n if (!hasSelectorTarget && !hasElementTarget) return null;\n\n const title = typeof step.title === 'string' ? step.title : '';\n const description = typeof step.description === 'string'\n ? step.description\n : (typeof step.content === 'string' ? step.content : '');\n\n return {\n target,\n title,\n description\n };\n },\n\n _normalizeSteps: function (steps) {\n if (!Array.isArray(steps)) return [];\n\n return steps\n .map(step => this._normalizeStep(step))\n .filter(Boolean);\n },\n\n start: function (steps, options) {\n if (this._active) this.stop();\n\n const normalizedSteps = this._normalizeSteps(steps);\n if (normalizedSteps.length === 0) return;\n\n const startOptions = options || {};\n\n this._steps = normalizedSteps;\n this._currentStep = 0;\n this._active = true;\n this._triggerElement = startOptions.trigger || (document.activeElement instanceof HTMLElement ? document.activeElement : null);\n\n // Create overlay\n const overlay = document.createElement('div');\n overlay.className = 'vd-spotlight-overlay';\n overlay.setAttribute('aria-hidden', 'true');\n document.body.appendChild(overlay);\n\n // Create tooltip\n const tooltip = document.createElement('div');\n tooltip.className = 'vd-spotlight-tooltip';\n tooltip.setAttribute('role', 'dialog');\n tooltip.setAttribute('aria-modal', 'true');\n tooltip.tabIndex = -1;\n document.body.appendChild(tooltip);\n\n this._elements = { overlay, tooltip };\n\n // ESC to close\n const escHandler = (e) => { if (e.key === 'Escape') this.stop(); };\n document.addEventListener('keydown', escHandler);\n this._cleanup.push(() => document.removeEventListener('keydown', escHandler));\n\n // Overlay click to close\n overlay.addEventListener('click', () => this.stop());\n\n this._showStep(this._currentStep);\n },\n\n _showStep: function (index) {\n const step = this._steps[index];\n if (!step) return;\n\n const target = typeof step.target === 'string' ? document.querySelector(step.target) : step.target;\n const { tooltip } = this._elements;\n\n // Remove previous highlight\n document.querySelectorAll('.vd-spotlight-target').forEach(el => {\n el.classList.remove('vd-spotlight-target');\n });\n\n // Highlight target\n if (target) {\n target.classList.add('vd-spotlight-target');\n target.scrollIntoView({ behavior: 'smooth', block: 'center' });\n }\n\n // Build tooltip content\n const total = this._steps.length;\n tooltip.innerHTML = '';\n tooltip.removeAttribute('aria-labelledby');\n tooltip.removeAttribute('aria-describedby');\n\n if (step.title) {\n const title = document.createElement('h4');\n title.className = 'vd-spotlight-title';\n title.id = 'vd-spotlight-title-' + index + '-' + Date.now();\n title.textContent = step.title;\n tooltip.appendChild(title);\n tooltip.setAttribute('aria-labelledby', title.id);\n }\n\n if (step.description) {\n const desc = document.createElement('p');\n desc.className = 'vd-spotlight-description';\n desc.id = 'vd-spotlight-description-' + index + '-' + Date.now();\n desc.textContent = step.description;\n tooltip.appendChild(desc);\n tooltip.setAttribute('aria-describedby', desc.id);\n }\n\n // Footer\n const footer = document.createElement('div');\n footer.className = 'vd-spotlight-footer';\n footer.setAttribute('aria-label', 'Step ' + (index + 1) + ' of ' + total);\n\n const counter = document.createElement('span');\n counter.className = 'vd-spotlight-counter';\n counter.textContent = (index + 1) + ' / ' + total;\n\n const actions = document.createElement('div');\n actions.className = 'vd-spotlight-actions';\n\n if (index > 0) {\n const prevBtn = document.createElement('button');\n prevBtn.type = 'button';\n prevBtn.className = 'vd-spotlight-btn';\n prevBtn.textContent = 'Back';\n prevBtn.addEventListener('click', () => this.prev());\n actions.appendChild(prevBtn);\n }\n\n const skipBtn = document.createElement('button');\n skipBtn.type = 'button';\n skipBtn.className = 'vd-spotlight-btn';\n skipBtn.textContent = 'Skip';\n skipBtn.addEventListener('click', () => this.stop());\n actions.appendChild(skipBtn);\n\n if (index < total - 1) {\n const nextBtn = document.createElement('button');\n nextBtn.type = 'button';\n nextBtn.className = 'vd-spotlight-btn vd-spotlight-btn-primary';\n nextBtn.textContent = 'Next';\n nextBtn.addEventListener('click', () => this.next());\n actions.appendChild(nextBtn);\n } else {\n const doneBtn = document.createElement('button');\n doneBtn.type = 'button';\n doneBtn.className = 'vd-spotlight-btn vd-spotlight-btn-primary';\n doneBtn.textContent = 'Done';\n doneBtn.addEventListener('click', () => this.stop());\n actions.appendChild(doneBtn);\n }\n\n footer.appendChild(counter);\n footer.appendChild(actions);\n tooltip.appendChild(footer);\n\n // Position tooltip near target\n if (target) {\n requestAnimationFrame(() => {\n const rect = target.getBoundingClientRect();\n const tRect = tooltip.getBoundingClientRect();\n let top = rect.bottom + 12 + window.scrollY;\n let left = rect.left + (rect.width - tRect.width) / 2 + window.scrollX;\n\n // Keep in viewport\n left = Math.max(8, Math.min(left, window.innerWidth - tRect.width - 8));\n if (top + tRect.height > window.innerHeight + window.scrollY) {\n top = rect.top - tRect.height - 12 + window.scrollY;\n }\n\n tooltip.style.top = top + 'px';\n tooltip.style.left = left + 'px';\n });\n }\n\n document.dispatchEvent(new CustomEvent('spotlight:step', {\n detail: { index, step: index, total, data: step }\n }));\n },\n\n next: function () {\n if (this._currentStep < this._steps.length - 1) {\n this._currentStep++;\n this._showStep(this._currentStep);\n }\n },\n\n prev: function () {\n if (this._currentStep > 0) {\n this._currentStep--;\n this._showStep(this._currentStep);\n }\n },\n\n stop: function () {\n if (!this._active) return;\n\n const total = this._steps.length;\n const detail = {\n completedSteps: total === 0 ? 0 : Math.min(this._currentStep + 1, total),\n total,\n completed: total > 0 && this._currentStep >= total - 1\n };\n\n this._active = false;\n\n document.querySelectorAll('.vd-spotlight-target').forEach(el => {\n el.classList.remove('vd-spotlight-target');\n });\n\n if (this._elements.overlay && this._elements.overlay.parentNode) {\n this._elements.overlay.parentNode.removeChild(this._elements.overlay);\n }\n if (this._elements.tooltip && this._elements.tooltip.parentNode) {\n this._elements.tooltip.parentNode.removeChild(this._elements.tooltip);\n }\n\n this._cleanup.forEach(fn => fn());\n this._cleanup = [];\n this._elements = {};\n this._steps = [];\n this._currentStep = 0;\n\n if (this._triggerElement && this._triggerElement.isConnected && typeof this._triggerElement.focus === 'function') {\n this._triggerElement.focus();\n }\n this._triggerElement = null;\n\n document.dispatchEvent(new CustomEvent('spotlight:end', { detail }));\n },\n\n destroyAll: function () {\n this.stop();\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('spotlight', Spotlight);\n }\n\n window.VanduoSpotlight = Spotlight;\n\n})();\n", "/**\n * Vanduo Framework - Music Player Component\n * HTML5 Audio-based music player with transport controls, volume,\n * and optional shuffle, seek bar, and playlist features.\n *\n * Options (passed to MusicPlayer.init or data-music-player-options):\n * tracks {Array} - [{name, url}] \u2014 required\n * volume {number} - Initial volume 0\u20131 (default 0.5)\n * shuffle {boolean} - Shuffle on init (default false)\n * showProgress {boolean} - Show seek/progress bar (default false)\n * showPlaylist {boolean} - Show expandable playlist panel (default false)\n * autoAdvance {boolean} - Auto-play next track on end (default true)\n * glass {boolean} - Frosted-glass surface styling (default false)\n * detachable {boolean} - Show detach/attach; float above page when detached (default false)\n * floatingPosition {string|null} - After detach: 'bottom-left' or 'bottom-right' (default null = bottom-right)\n * draggable {boolean} - When detached+detachable, drag by handle (default false)\n * minimizable {boolean} - Minimize/expand control (default false)\n * startMinimized {boolean} - On first detach, start minimized (default false)\n * persistPosition {boolean} - Save floating x/y in localStorage (default false)\n * persistKey {string} - Storage key for persist (default from element id)\n *\n * Custom events (all bubble, dispatched on container):\n * musicplayer:play \u2014 playback started\n * musicplayer:pause \u2014 playback paused\n * musicplayer:trackchange \u2014 detail: { index, name, url }\n * musicplayer:volumechange \u2014 detail: { volume }\n * musicplayer:ended \u2014 last track ended (autoAdvance=false only)\n * musicplayer:detach \u2014 after floating player is created\n * musicplayer:attach \u2014 after returned to document flow\n * musicplayer:minimize \u2014 collapsed to minimal controls\n * musicplayer:expand \u2014 full controls visible again\n *\n * Programmatic API:\n * MusicPlayer.init(container?, options?)\n * MusicPlayer.play(container)\n * MusicPlayer.pause(container)\n * MusicPlayer.toggle(container)\n * MusicPlayer.next(container)\n * MusicPlayer.previous(container)\n * MusicPlayer.setVolume(container, value)\n * MusicPlayer.setTrack(container, index)\n * MusicPlayer.shuffle(container)\n * MusicPlayer.detach(container, position?)\n * MusicPlayer.attach(container)\n * MusicPlayer.minimize(container)\n * MusicPlayer.expand(container)\n * MusicPlayer.toggleMinimize(container)\n * MusicPlayer.setPosition(container, 'bottom-left'|'bottom-right'|{x,y})\n * MusicPlayer.getState(container)\n * MusicPlayer.destroy(container)\n * MusicPlayer.destroyAll()\n */\n\n(function () {\n 'use strict';\n\n /* \u2500\u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Fisher-Yates shuffle (returns new array).\n * @param {Array} arr\n * @returns {Array}\n */\n function shuffleArray(arr) {\n const shuffled = arr.slice();\n for (let i = shuffled.length - 1; i > 0; i--) {\n const j = Math.floor(Math.random() * (i + 1));\n const tmp = shuffled[i];\n shuffled[i] = shuffled[j];\n shuffled[j] = tmp;\n }\n return shuffled;\n }\n\n /**\n * Format seconds as m:ss.\n * @param {number} seconds\n * @returns {string}\n */\n function formatTime(seconds) {\n if (!isFinite(seconds) || seconds < 0) return '0:00';\n const m = Math.floor(seconds / 60);\n const s = Math.floor(seconds % 60);\n return m + ':' + (s < 10 ? '0' : '') + s;\n }\n\n /**\n * Set CSS background-size on a range input to visually fill the track.\n * @param {HTMLInputElement} input\n */\n /**\n * @param {string|undefined} id\n * @returns {string}\n */\n function persistStorageKey(id) {\n return 'vanduo:music-player:' + (id && id.trim() ? id.trim() : 'default') + ':pos';\n }\n\n function updateRangeFill(input) {\n const min = parseFloat(input.min) || 0;\n const max = parseFloat(input.max) || 1;\n const val = parseFloat(input.value) || 0;\n const pct = ((val - min) / (max - min)) * 100;\n input.style.setProperty('--fill', pct + '%');\n // Fallback inline gradient for browsers without ::-moz-range-progress\n input.style.backgroundImage =\n 'linear-gradient(to right, var(--music-player-track-fill, currentColor) 0%, ' +\n 'var(--music-player-track-fill, currentColor) ' + pct + '%, ' +\n 'var(--music-player-track-bg, #ccc) ' + pct + '%, ' +\n 'var(--music-player-track-bg, #ccc) 100%)';\n }\n\n /* \u2500\u2500\u2500 Phosphor icon helper (matches framework icon style) \u2500 */\n\n /**\n * Return an element (Phosphor icon).\n * @param {string} name\n * @returns {HTMLElement}\n */\n function icon(name) {\n const el = document.createElement('i');\n el.className = 'ph ph-' + name;\n el.setAttribute('aria-hidden', 'true');\n return el;\n }\n\n /* \u2500\u2500\u2500 Component \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const MusicPlayer = {\n /** @type {Map} */\n instances: new Map(),\n\n /**\n * Default options.\n */\n defaults: {\n tracks: [],\n volume: 0.5,\n shuffle: false,\n showProgress: false,\n showPlaylist: false,\n autoAdvance: true,\n glass: false,\n detachable: false,\n /** @type {null|string} */\n floatingPosition: null,\n draggable: false,\n minimizable: false,\n startMinimized: false,\n persistPosition: false,\n persistKey: '',\n },\n\n /**\n * Auto-initialize all .vd-music-player / [data-music-player] elements.\n * Options can be provided via data-music-player-options (JSON string).\n */\n init: function () {\n document.querySelectorAll('.vd-music-player, [data-music-player]').forEach((el) => {\n if (this.instances.has(el)) return;\n\n let opts = {};\n const attr = el.getAttribute('data-music-player-options');\n if (attr) {\n try { opts = JSON.parse(attr); } catch (_) { /* ignore malformed JSON */ }\n }\n this.initPlayer(el, opts);\n });\n },\n\n /**\n * Initialize a single player element.\n * @param {HTMLElement} container\n * @param {Object} [options]\n */\n initPlayer: function (container, options) {\n const opts = Object.assign({}, this.defaults, options || {});\n\n // Validate and normalise tracks\n const rawTracks = Array.isArray(opts.tracks) ? opts.tracks : [];\n const tracks = rawTracks.filter((t) => t && typeof t.url === 'string' && t.url.trim());\n\n // Build shuffled working copy without mutating opts\n const trackList = opts.shuffle ? shuffleArray(tracks) : tracks.slice();\n\n /* \u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const state = {\n tracks: trackList,\n originalTracks: tracks.slice(),\n currentIndex: 0,\n isPlaying: false,\n volume: Math.max(0, Math.min(1, opts.volume)),\n shuffle: opts.shuffle,\n showProgress: opts.showProgress,\n showPlaylist: opts.showPlaylist,\n autoAdvance: opts.autoAdvance,\n audio: null,\n glass: Boolean(opts.glass),\n detachable: Boolean(opts.detachable),\n floatingPosition: opts.floatingPosition || 'bottom-right',\n draggable: Boolean(opts.draggable) && Boolean(opts.detachable),\n minimizable: Boolean(opts.minimizable),\n startMinimized: Boolean(opts.startMinimized),\n persistPosition: Boolean(opts.persistPosition),\n persistKey: typeof opts.persistKey === 'string' ? opts.persistKey : '',\n isDetached: false,\n isMinimized: false,\n _startMinimizeApplied: false,\n };\n\n /* \u2500\u2500 Audio element \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const audio = new Audio();\n audio.volume = state.volume;\n audio.preload = 'metadata';\n state.audio = audio;\n\n /* \u2500\u2500 Build DOM \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n this._buildDOM(container, state);\n\n // Grab references after DOM build\n const refs = {\n btnPlay: container.querySelector('.vd-music-player-btn-play'),\n btnPrev: container.querySelector('.vd-music-player-btn-prev'),\n btnNext: container.querySelector('.vd-music-player-btn-next'),\n btnShuffle: container.querySelector('.vd-music-player-btn-shuffle'),\n btnPlaylist: container.querySelector('.vd-music-player-btn-playlist'),\n btnDetach: container.querySelector('.vd-music-player-btn-detach'),\n btnAttach: container.querySelector('.vd-music-player-btn-attach'),\n btnMinimize: container.querySelector('.vd-music-player-btn-minimize'),\n dragHandle: container.querySelector('.vd-music-player-drag-handle'),\n trackName: container.querySelector('.vd-music-player-track-name'),\n volumeSlider: container.querySelector('.vd-music-player-volume-slider'),\n volumeIcon: container.querySelector('.vd-music-player-volume-icon'),\n progressBar: container.querySelector('.vd-music-player-progress-bar'),\n timeElapsed: container.querySelector('.vd-music-player-time-elapsed'),\n timeDuration: container.querySelector('.vd-music-player-time-duration'),\n playlistPanel: container.querySelector('.vd-music-player-playlist'),\n };\n\n /* \u2500\u2500 Internal render helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const renderPlayIcon = () => {\n const btn = refs.btnPlay;\n if (!btn) return;\n btn.innerHTML = '';\n btn.appendChild(icon(state.isPlaying ? 'pause' : 'play'));\n btn.setAttribute('aria-label', state.isPlaying ? 'Pause' : 'Play');\n btn.classList.toggle('is-active', state.isPlaying);\n };\n\n const renderTrackName = () => {\n const el = refs.trackName;\n if (!el) return;\n const track = state.tracks[state.currentIndex];\n if (track) {\n el.textContent = track.name || 'Unknown Track';\n el.classList.remove('is-idle');\n } else {\n el.textContent = 'No tracks loaded';\n el.classList.add('is-idle');\n }\n };\n\n const renderVolumeIcon = () => {\n const el = refs.volumeIcon;\n if (!el) return;\n el.innerHTML = '';\n const v = state.volume;\n const name = v === 0 ? 'speaker-none' : v < 0.5 ? 'speaker-low' : 'speaker-high';\n el.appendChild(icon(name));\n };\n\n const renderShuffleBtn = () => {\n const btn = refs.btnShuffle;\n if (!btn) return;\n btn.classList.toggle('is-active', state.shuffle);\n btn.setAttribute('aria-pressed', state.shuffle ? 'true' : 'false');\n };\n\n const renderPlaylistItems = () => {\n const panel = refs.playlistPanel;\n if (!panel) return;\n panel.innerHTML = '';\n state.tracks.forEach((track, i) => {\n const item = document.createElement('button');\n item.className =\n 'vd-music-player-playlist-item' + (i === state.currentIndex ? ' is-active' : '');\n item.type = 'button';\n item.setAttribute('data-index', String(i));\n item.setAttribute('aria-current', i === state.currentIndex ? 'true' : 'false');\n\n const num = document.createElement('span');\n num.className = 'vd-music-player-playlist-num';\n num.textContent = String(i + 1);\n\n const name = document.createElement('span');\n name.className = 'vd-music-player-playlist-name';\n name.textContent = track.name || 'Track ' + (i + 1);\n\n item.appendChild(num);\n item.appendChild(name);\n panel.appendChild(item);\n });\n };\n\n const renderProgress = () => {\n const bar = refs.progressBar;\n if (!bar || !audio.duration) return;\n const pct = (audio.currentTime / audio.duration) * 100;\n bar.value = String(pct);\n updateRangeFill(bar);\n if (refs.timeElapsed) refs.timeElapsed.textContent = formatTime(audio.currentTime);\n if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);\n };\n\n /* \u2500\u2500 Load track \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const loadTrack = (index, autoPlay) => {\n const track = state.tracks[index];\n if (!track) return;\n state.currentIndex = index;\n audio.src = track.url;\n renderTrackName();\n renderPlaylistItems();\n\n // Reset progress\n if (refs.progressBar) {\n refs.progressBar.value = '0';\n updateRangeFill(refs.progressBar);\n }\n if (refs.timeElapsed) refs.timeElapsed.textContent = '0:00';\n if (refs.timeDuration) refs.timeDuration.textContent = '0:00';\n\n container.dispatchEvent(\n new CustomEvent('musicplayer:trackchange', {\n bubbles: true,\n detail: { index, name: track.name, url: track.url },\n })\n );\n\n if (autoPlay) {\n audio.play().catch(() => { /* browser may block autoplay */ });\n }\n };\n\n /* \u2500\u2500 Audio event listeners \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const cleanupFunctions = [];\n\n const onPlay = () => {\n state.isPlaying = true;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:play', { bubbles: true }));\n };\n\n const onPause = () => {\n state.isPlaying = false;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:pause', { bubbles: true }));\n };\n\n const onEnded = () => {\n if (state.autoAdvance && state.tracks.length > 1) {\n const next = (state.currentIndex + 1) % state.tracks.length;\n loadTrack(next, true);\n } else {\n state.isPlaying = false;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:ended', { bubbles: true }));\n }\n };\n\n const onTimeUpdate = () => {\n if (state.showProgress) renderProgress();\n };\n\n const onLoadedMetadata = () => {\n if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);\n if (refs.progressBar) {\n refs.progressBar.max = '100';\n updateRangeFill(refs.progressBar);\n }\n };\n\n audio.addEventListener('play', onPlay);\n audio.addEventListener('pause', onPause);\n audio.addEventListener('ended', onEnded);\n audio.addEventListener('timeupdate', onTimeUpdate);\n audio.addEventListener('loadedmetadata', onLoadedMetadata);\n cleanupFunctions.push(() => {\n audio.removeEventListener('play', onPlay);\n audio.removeEventListener('pause', onPause);\n audio.removeEventListener('ended', onEnded);\n audio.removeEventListener('timeupdate', onTimeUpdate);\n audio.removeEventListener('loadedmetadata', onLoadedMetadata);\n audio.pause();\n audio.src = '';\n });\n\n /* \u2500\u2500 Control button listeners \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n if (refs.btnPlay) {\n const handler = () => {\n if (!audio.src && state.tracks.length) loadTrack(state.currentIndex, false);\n if (state.isPlaying) {\n audio.pause();\n } else {\n audio.play().catch(() => {});\n }\n };\n refs.btnPlay.addEventListener('click', handler);\n cleanupFunctions.push(() => refs.btnPlay.removeEventListener('click', handler));\n\n // Keyboard: Space / Enter (already native for `;let s="";for(let[a,r]of Object.entries(this.NEUTRAL_COLORS))s+=``;let o="";this.RADIUS_OPTIONS.forEach(a=>{o+=``});let i="";for(let[a,r]of Object.entries(this.FONT_OPTIONS))i+=``;return` +`).length,o=document.createElement("div");o.className="vd-code-snippet-line-numbers",o.setAttribute("aria-hidden","true");for(let a=1;a<=s;a++){let r=document.createElement("span");r.textContent=a,o.appendChild(r)}let i=document.createElement("div");i.className="vd-code-snippet-code",i.appendChild(t.cloneNode(!0)),t.parentNode.removeChild(t),e.appendChild(o),e.appendChild(i)},expand:function(e){if(typeof e=="string"&&(e=document.querySelector(e)),!e)return;e.dataset.expanded="true";let t=e.querySelector(".vd-code-snippet-toggle"),n=e.querySelector(".vd-code-snippet-content");t&&t.setAttribute("aria-expanded","true"),n&&(n.dataset.visible="true")},collapse:function(e){if(typeof e=="string"&&(e=document.querySelector(e)),!e)return;e.dataset.expanded="false";let t=e.querySelector(".vd-code-snippet-toggle"),n=e.querySelector(".vd-code-snippet-content");t&&t.setAttribute("aria-expanded","false"),n&&(n.dataset.visible="false")},showLang:function(e,t){if(typeof e=="string"&&(e=document.querySelector(e)),!e)return;let n=e.querySelector(`.vd-code-snippet-tab[data-lang="${t}"]`),s=e.querySelectorAll(".vd-code-snippet-tab"),o=e.querySelectorAll(".vd-code-snippet-pane");n&&this.switchTab(e,n,s,o)},destroy:function(e){typeof e=="string"&&(e=document.querySelector(e)),e&&(e._codeSnippetCleanup&&(e._codeSnippetCleanup.forEach(t=>t()),delete e._codeSnippetCleanup),delete e.dataset.initialized)},destroyAll:function(){document.querySelectorAll('.vd-code-snippet[data-initialized="true"]').forEach(t=>this.destroy(t))}};typeof window.Vanduo<"u"&&window.Vanduo.register("codeSnippet",m),window.CodeSnippet=m})();(function(){"use strict";let m={instances:new Map,init:function(){document.querySelectorAll(".vd-collapsible, .accordion").forEach(t=>{this.instances.has(t)||this.initCollapsible(t)})},initCollapsible:function(e){let t=e.classList.contains("accordion"),n=e.querySelectorAll(".vd-collapsible-item, .accordion-item"),s=[];n.forEach(o=>{let i=o.querySelector(".vd-collapsible-header, .accordion-header"),a=o.querySelector(".vd-collapsible-body, .accordion-body"),r=o.querySelector(".vd-collapsible-trigger, .accordion-trigger")||i;if(!i||!a)return;o.classList.contains("is-open")?this.openItem(o,a,!1):this.closeItem(o,a,!1);let c=l=>{l.preventDefault(),this.toggleItem(o,a,e,t)};r.addEventListener("click",c),s.push(()=>r.removeEventListener("click",c))}),this.instances.set(e,{cleanup:s})},toggleItem:function(e,t,n,s){e.classList.contains("is-open")?this.closeItem(e,t):(s&&n.querySelectorAll(".vd-collapsible-item.is-open, .accordion-item.is-open").forEach(a=>{if(a!==e){let r=a.querySelector(".vd-collapsible-body, .accordion-body");this.closeItem(a,r)}}),this.openItem(e,t))},openItem:function(e,t,n=!0){n||(t.style.transition="none"),e.classList.add("is-open"),e.setAttribute("aria-expanded","true");let s=t.scrollHeight;t.style.maxHeight=`${s}px`,n||setTimeout(()=>{t.style.transition=""},0),e.dispatchEvent(new CustomEvent("collapsible:open",{bubbles:!0}))},closeItem:function(e,t,n=!0){n||(t.style.transition="none"),e.classList.remove("is-open"),e.setAttribute("aria-expanded","false"),t.style.maxHeight="0",n||setTimeout(()=>{t.style.transition=""},0),e.dispatchEvent(new CustomEvent("collapsible:close",{bubbles:!0}))},open:function(e){let t=typeof e=="string"?document.querySelector(e):e;if(t){let n=t.querySelector(".vd-collapsible-body, .accordion-body");n&&this.openItem(t,n)}},close:function(e){let t=typeof e=="string"?document.querySelector(e):e;if(t){let n=t.querySelector(".vd-collapsible-body, .accordion-body");n&&this.closeItem(t,n)}},toggle:function(e){let t=typeof e=="string"?document.querySelector(e):e;if(t){let n=t.querySelector(".vd-collapsible-body, .accordion-body"),s=t.closest(".vd-collapsible, .accordion"),o=s&&s.classList.contains("accordion");n&&this.toggleItem(t,n,s,o)}},destroy:function(e){let t=this.instances.get(e);t&&(t.cleanup.forEach(n=>n()),this.instances.delete(e))},destroyAll:function(){this.instances.forEach((e,t)=>{this.destroy(t)})}};typeof window.Vanduo<"u"&&window.Vanduo.register("collapsible",m),window.VanduoCollapsible=m})();(function(){"use strict";let m={instances:new Map,init:function(){document.querySelectorAll(".vd-dropdown").forEach(t=>{this.instances.has(t)||this.initDropdown(t)})},initDropdown:function(e){let t=e.querySelector(".vd-dropdown-toggle"),n=e.querySelector(".vd-dropdown-menu");if(!t||!n)return;let s=[];t.setAttribute("aria-haspopup","true"),t.setAttribute("aria-expanded","false"),n.setAttribute("role","menu"),n.setAttribute("aria-hidden","true");let o=c=>{c.preventDefault(),c.stopPropagation(),this.toggleDropdown(e,t,n)};t.addEventListener("click",o),s.push(()=>t.removeEventListener("click",o));let i=c=>{!e.contains(c.target)&&n.classList.contains("is-open")&&this.closeDropdown(e,t,n)};document.addEventListener("click",i),s.push(()=>document.removeEventListener("click",i));let a=c=>{this.handleKeydown(c,e,t,n)};t.addEventListener("keydown",a),s.push(()=>t.removeEventListener("keydown",a)),n.querySelectorAll(".vd-dropdown-item:not(.disabled):not(.is-disabled)").forEach(c=>{let l=h=>{h.preventDefault(),this.selectItem(c,e,t,n)};c.addEventListener("click",l),s.push(()=>c.removeEventListener("click",l));let f=h=>{(h.key==="Enter"||h.key===" ")&&(h.preventDefault(),this.selectItem(c,e,t,n))};c.addEventListener("keydown",f),s.push(()=>c.removeEventListener("keydown",f))}),this.instances.set(e,{toggle:t,menu:n,cleanup:s,typeaheadBuffer:"",typeaheadTimer:null})},toggleDropdown:function(e,t,n){n.classList.contains("is-open")?this.closeDropdown(e,t,n):this.openDropdown(e,t,n)},openDropdown:function(e,t,n){document.querySelectorAll(".vd-dropdown-menu.is-open").forEach(i=>{if(i!==n){let a=i.closest(".vd-dropdown"),r=a.querySelector(".vd-dropdown-toggle");this.closeDropdown(a,r,i)}}),e.classList.add("is-open"),n.classList.add("is-open"),t.setAttribute("aria-expanded","true"),n.setAttribute("aria-hidden","false"),this.positionMenu(e,n);let o=n.querySelector(".vd-dropdown-item:not(.disabled):not(.is-disabled)");o&&setTimeout(()=>o.focus(),0)},closeDropdown:function(e,t,n){e.classList.remove("is-open"),n.classList.remove("is-open"),t.setAttribute("aria-expanded","false"),n.setAttribute("aria-hidden","true"),t.focus()},positionMenu:function(e,t){let n=e.getBoundingClientRect(),s=t.getBoundingClientRect(),o=window.innerWidth,i=window.innerHeight,a=8;if(t.classList.remove("vd-dropdown-menu-end","vd-dropdown-menu-start","vd-dropdown-menu-top"),e.classList.contains("vd-dropdown-dropup")){t.classList.add("vd-dropdown-menu-top");return}e.classList.contains("vd-dropdown-dropright")||e.classList.contains("vd-dropdown-dropleft")||(n.left+s.width>o-a?t.classList.add("vd-dropdown-menu-end"):t.classList.add("vd-dropdown-menu-start"),n.bottom+s.height>i-a&&n.top-s.height>a&&t.classList.add("vd-dropdown-menu-top"))},handleKeydown:function(e,t,n,s){let o=s.classList.contains("is-open"),i=Array.from(s.querySelectorAll(".vd-dropdown-item:not(.disabled):not(.is-disabled)")),a=i.findIndex(r=>r===document.activeElement);switch(e.key){case"Enter":case" ":case"ArrowDown":if(e.preventDefault(),!o)this.openDropdown(t,n,s);else if(e.key==="ArrowDown"){let r=a0?a-1:i.length-1;i[r].focus()}break;case"Escape":o&&(e.preventDefault(),this.closeDropdown(t,n,s));break;case"Home":o&&(e.preventDefault(),i[0].focus());break;case"End":o&&(e.preventDefault(),i[i.length-1].focus());break;default:if(o&&e.key.length===1&&!e.ctrlKey&&!e.metaKey&&!e.altKey){let r=this.instances.get(t);if(!r)break;clearTimeout(r.typeaheadTimer),r.typeaheadBuffer+=e.key.toLowerCase();let c=i.find(l=>l.textContent.trim().toLowerCase().startsWith(r.typeaheadBuffer));c&&c.focus(),r.typeaheadTimer=setTimeout(()=>{r.typeaheadBuffer=""},500)}break}},selectItem:function(e,t,n,s){s.querySelectorAll(".vd-dropdown-item").forEach(o=>{o.classList.remove("active","is-active")}),e.classList.add("active","is-active"),(n.tagName==="BUTTON"||n.classList.contains("btn"))&&(n.textContent=e.textContent.trim()),this.closeDropdown(t,n,s),e.dispatchEvent(new CustomEvent("dropdown:select",{bubbles:!0,detail:{item:e,value:e.dataset.value||e.textContent}}))},open:function(e){let t=typeof e=="string"?document.querySelector(e):e;if(t){let n=t.querySelector(".vd-dropdown-toggle"),s=t.querySelector(".vd-dropdown-menu");n&&s&&this.openDropdown(t,n,s)}},close:function(e){let t=typeof e=="string"?document.querySelector(e):e;if(t){let n=t.querySelector(".vd-dropdown-toggle"),s=t.querySelector(".vd-dropdown-menu");n&&s&&this.closeDropdown(t,n,s)}},destroy:function(e){let t=this.instances.get(e);t&&(t.cleanup.forEach(n=>n()),this.instances.delete(e))},destroyAll:function(){this.instances.forEach((e,t)=>{this.destroy(t)})}};typeof window.Vanduo<"u"&&window.Vanduo.register("dropdown",m),window.VanduoDropdown=m})();(function(){"use strict";let m={STORAGE_KEY:"vanduo-font-preference",isInitialized:!1,fonts:{system:{name:"System Default",family:null},"jetbrains-mono":{name:"JetBrains Mono",family:"'JetBrains Mono', monospace"},ubuntu:{name:"Ubuntu",family:"'Ubuntu', sans-serif",category:"sans-serif",description:"Friendly, humanist sans-serif"},"open-sans":{name:"Open Sans",family:"'Open Sans', sans-serif",category:"sans-serif",description:"Neutral, highly readable"},lato:{name:"Lato",family:"'Lato', sans-serif",category:"sans-serif",description:"Friendly, rounded sans-serif"}},init:function(){if(this.state={preference:this.getPreference()},this.fonts[this.state.preference]||(this.state.preference="ubuntu",this.setStorageValue(this.STORAGE_KEY,this.state.preference)),this.isInitialized){this.applyFont(),this.renderUI(),this.updateUI();return}this.isInitialized=!0,this.applyFont(),this.renderUI(),console.log("Vanduo Font Switcher initialized")},getPreference:function(){return this.getStorageValue(this.STORAGE_KEY,"ubuntu")},setPreference:function(e){if(!this.fonts[e]){console.warn("Unknown font:",e);return}this.state.preference=e,this.setStorageValue(this.STORAGE_KEY,e),this.applyFont(),this.updateUI();let t=new CustomEvent("font:change",{bubbles:!0,detail:{font:e,fontData:this.fonts[e]}});document.dispatchEvent(t)},applyFont:function(){let e=this.state.preference;e==="system"?document.documentElement.removeAttribute("data-font"):document.documentElement.setAttribute("data-font",e)},renderUI:function(){document.querySelectorAll('[data-toggle="font"]').forEach(t=>{if(t.getAttribute("data-font-initialized")==="true"){t.tagName==="SELECT"&&(t.value=this.state.preference);return}if(t.tagName==="SELECT"){t.value=this.state.preference;let n=s=>{this.setPreference(s.target.value)};t.addEventListener("change",n),t._fontToggleHandler=n}else{let n=()=>{let s=Object.keys(this.fonts),i=(s.indexOf(this.state.preference)+1)%s.length;this.setPreference(s[i])};t.addEventListener("click",n),t._fontToggleHandler=n}t.setAttribute("data-font-initialized","true")})},updateUI:function(){document.querySelectorAll('[data-toggle="font"]').forEach(t=>{if(t.tagName==="SELECT")t.value=this.state.preference;else{let n=t.querySelector(".font-current-label");n&&(n.textContent=this.fonts[this.state.preference].name)}})},getCurrentFont:function(){return this.state.preference},getFontData:function(e){return this.fonts[e]||null},destroyAll:function(){document.querySelectorAll('[data-toggle="font"][data-font-initialized="true"]').forEach(t=>{if(t._fontToggleHandler){let n=t.tagName==="SELECT"?"change":"click";t.removeEventListener(n,t._fontToggleHandler),delete t._fontToggleHandler}t.removeAttribute("data-font-initialized")}),this.isInitialized=!1},getStorageValue:function(e,t){if(typeof window.safeStorageGet=="function")return window.safeStorageGet(e,t);try{let n=localStorage.getItem(e);return n!==null?n:t}catch{return t}},setStorageValue:function(e,t){if(typeof window.safeStorageSet=="function")return window.safeStorageSet(e,t);try{return localStorage.setItem(e,t),!0}catch{return!1}}};window.Vanduo&&window.Vanduo.register("fontSwitcher",m),window.FontSwitcher=m})();(function(){"use strict";let m=(function(){try{return CSS.supports("selector(:has(*))")}catch{return!1}})(),e={instances:new Map,init:function(){document.querySelectorAll("[data-layout-mode]").forEach(function(n){this.instances.has(n)||this.initContainer(n)}.bind(this)),this.initToggleButtons()},initContainer:function(t){let n=t.getAttribute("data-layout-mode")||"standard",s=[];this.applyMode(t,n),t.setAttribute("role","region"),t.setAttribute("aria-label","Grid layout: "+n+" mode"),this.instances.set(t,{cleanup:s,mode:n})},initToggleButtons:function(){document.querySelectorAll("[data-grid-toggle]").forEach(function(n){if(n.getAttribute("data-grid-initialized")==="true")return;let s=function(o){o.preventDefault();let i=n.getAttribute("data-grid-toggle"),a;i?a=document.querySelector(i):a=n.closest("[data-layout-mode]"),a&&this.toggle(a)}.bind(this);n.addEventListener("click",s),n.setAttribute("data-grid-initialized","true"),n.setAttribute("aria-pressed","false"),n._gridCleanup=function(){n.removeEventListener("click",s),n.removeAttribute("data-grid-initialized"),n.removeAttribute("aria-pressed")}}.bind(this))},applyFibFallback:function(t){if(m)return;t.querySelectorAll(".vd-row, .row").forEach(function(s){let i=s.querySelectorAll(':scope > [class*="vd-col-"], :scope > [class*="col-"]').length;i===1?s.style.gridTemplateColumns="1fr":i===2?s.style.gridTemplateColumns="1fr 1.618fr":i===3?s.style.gridTemplateColumns="2fr 3fr 5fr":i===4?s.style.gridTemplateColumns="1fr 2fr 3fr 5fr":s.style.gridTemplateColumns="repeat("+i+", 1fr)"})},removeFibFallback:function(t){t.querySelectorAll(".vd-row, .row").forEach(function(s){s.style.gridTemplateColumns=""})},applyMode:function(t,n){t.classList.remove("vd-grid-standard","vd-grid-fibonacci"),n==="fibonacci"?(t.classList.add("vd-grid-fibonacci"),this.applyFibFallback(t)):(t.classList.add("vd-grid-standard"),this.removeFibFallback(t)),t.setAttribute("data-layout-mode",n),t.setAttribute("aria-label","Grid layout: "+n+" mode"),document.querySelectorAll("[data-grid-toggle]").forEach(function(a){let r=a.getAttribute("data-grid-toggle");if(r&&t.matches(r)){let c=n==="fibonacci";c?a.classList.add("is-active"):a.classList.remove("is-active"),a.setAttribute("aria-pressed",c?"true":"false")}});let o=this.instances.get(t);o&&(o.mode=n);let i;try{i=new CustomEvent("grid:modechange",{bubbles:!0,detail:{container:t,mode:n}})}catch{i=document.createEvent("CustomEvent"),i.initCustomEvent("grid:modechange",!0,!0,{container:t,mode:n})}t.dispatchEvent(i)},toggle:function(t){if(typeof t=="string"&&(t=document.querySelector(t)),!t)return;let s=(t.getAttribute("data-layout-mode")||"standard")==="fibonacci"?"standard":"fibonacci";this.applyMode(t,s)},setMode:function(t,n){typeof t=="string"&&(t=document.querySelector(t)),t&&(n!=="fibonacci"&&n!=="standard"||this.applyMode(t,n))},getMode:function(t){return typeof t=="string"&&(t=document.querySelector(t)),t?t.getAttribute("data-layout-mode")||"standard":null},destroy:function(t){let n=this.instances.get(t);n&&(n.cleanup.forEach(function(s){s()}),t.classList.remove("vd-grid-standard","vd-grid-fibonacci"),t.removeAttribute("aria-label"),this.removeFibFallback(t),this.instances.delete(t))},destroyAll:function(){this.instances.forEach(function(n,s){this.destroy(s)}.bind(this)),document.querySelectorAll('[data-grid-initialized="true"]').forEach(function(n){n._gridCleanup&&(n._gridCleanup(),delete n._gridCleanup)})}};typeof window.Vanduo<"u"&&window.Vanduo.register("gridLayout",e),window.VanduoGridLayout=e})();(function(){"use strict";let m={backdrop:null,container:null,img:null,closeBtn:null,caption:null,currentTrigger:null,scrollThreshold:50,initialScrollY:0,isOpen:!1,_cleanupFunctions:[],init:function(){this.createBackdrop(),this.bindTriggers()},createBackdrop:function(){if(this.backdrop||document.querySelector(".vd-image-box-backdrop")){this.backdrop||(this.backdrop=document.querySelector(".vd-image-box-backdrop"),this.container=this.backdrop.querySelector(".vd-image-box-container"),this.img=this.backdrop.querySelector(".vd-image-box-img"),this.closeBtn=this.backdrop.querySelector(".vd-image-box-close"),this.caption=this.backdrop.querySelector(".vd-image-box-caption"),this.bindBackdropEvents());return}this.backdrop=document.createElement("div"),this.backdrop.className="vd-image-box-backdrop",this.backdrop.setAttribute("role","dialog"),this.backdrop.setAttribute("aria-modal","true"),this.backdrop.setAttribute("aria-label","Image viewer"),this.backdrop.setAttribute("tabindex","-1"),this.container=document.createElement("div"),this.container.className="vd-image-box-container",this.img=document.createElement("img"),this.img.className="vd-image-box-img",this.img.alt="",this.closeBtn=document.createElement("button"),this.closeBtn.className="vd-image-box-close",this.closeBtn.setAttribute("aria-label","Close image viewer"),this.closeBtn.innerHTML="×",this.caption=document.createElement("div"),this.caption.className="vd-image-box-caption",this.container.appendChild(this.img),this.backdrop.appendChild(this.closeBtn),this.backdrop.appendChild(this.container),this.backdrop.appendChild(this.caption),document.body.appendChild(this.backdrop),this.bindBackdropEvents()},bindBackdropEvents:function(){let e=this,t=function(a){(a.target===e.backdrop||a.target===e.container)&&e.close()};this.backdrop.addEventListener("click",t),this._cleanupFunctions.push(()=>this.backdrop.removeEventListener("click",t));let n=function(){e.close()};this.img.addEventListener("click",n),this._cleanupFunctions.push(()=>this.img.removeEventListener("click",n));let s=function(){e.close()};this.closeBtn.addEventListener("click",s),this._cleanupFunctions.push(()=>this.closeBtn.removeEventListener("click",s));let o=function(a){a.key==="Escape"&&e.isOpen&&e.close()};document.addEventListener("keydown",o),this._cleanupFunctions.push(()=>document.removeEventListener("keydown",o));let i=function(){if(!e.isOpen)return;let a=window.scrollY;Math.abs(a-e.initialScrollY)>e.scrollThreshold&&e.close()};window.addEventListener("scroll",i,{passive:!0}),this._cleanupFunctions.push(()=>window.removeEventListener("scroll",i))},bindTriggers:function(){let e=this;document.querySelectorAll("[data-image-box]").forEach(function(n){if(n.dataset.imageBoxInitialized)return;if(n.dataset.imageBoxInitialized="true",n.classList.add("vd-image-box-trigger"),n.tagName==="IMG"){n.complete&&n.naturalWidth===0&&n.classList.add("is-broken");let o=function(){n.classList.add("is-broken")};n.addEventListener("error",o);let i=function(){n.classList.remove("is-broken")};n.addEventListener("load",i)}let s=function(o){o.preventDefault(),e.open(n)};if(n.addEventListener("click",s),n._imageBoxCleanup=()=>n.removeEventListener("click",s),n.tagName!=="BUTTON"&&n.tagName!=="A"){n.setAttribute("role","button"),n.setAttribute("tabindex","0"),n.setAttribute("aria-label","View enlarged image");let o=function(a){(a.key==="Enter"||a.key===" ")&&(a.preventDefault(),e.open(n))};n.addEventListener("keydown",o);let i=n._imageBoxCleanup;n._imageBoxCleanup=()=>{i(),n.removeEventListener("keydown",o)}}})},open:function(e){if(this.isOpen)return;this.currentTrigger=e,this.isOpen=!0,this.initialScrollY=window.scrollY;let t=e.dataset.imageBoxFullSrc||e.dataset.imageBoxSrc||e.src||e.href;if(!t){console.warn("[Vanduo ImageBox] No image source found for trigger:",e);return}let n=e.dataset.imageBoxCaption||e.alt||"";this.img.src=t,this.img.alt=e.alt||"",n?(this.caption.textContent=n,this.caption.style.display="block"):this.caption.style.display="none";let s=window.innerWidth-document.documentElement.clientWidth;document.body.style.setProperty("--scrollbar-width",`${s}px`),document.body.classList.add("body-image-box-open"),this.backdrop.classList.add("is-visible"),this.backdrop.focus(),e.dispatchEvent(new CustomEvent("imageBox:open",{bubbles:!0,detail:{src:t}})),this.img.complete||(this.img.style.opacity="0",this._imgLoadHandler=()=>{this.img.style.opacity=""},this.img.addEventListener("load",this._imgLoadHandler,{once:!0}))},close:function(){this.isOpen&&(this.isOpen=!1,this.backdrop.classList.remove("is-visible"),document.body.classList.remove("body-image-box-open"),document.body.style.removeProperty("--scrollbar-width"),this.currentTrigger&&(this.currentTrigger.focus(),this.currentTrigger.dispatchEvent(new CustomEvent("imageBox:close",{bubbles:!0})),this.currentTrigger=null),setTimeout(()=>{this.isOpen||(this._imgLoadHandler&&(this.img.removeEventListener("load",this._imgLoadHandler),this._imgLoadHandler=null),this.img.src="",this.img.alt="")},300))},reinit:function(){this.bindTriggers()},destroy:function(){this.isOpen&&this.close(),this.backdrop&&this.backdrop.parentNode&&this.backdrop.parentNode.removeChild(this.backdrop),this._cleanupFunctions.forEach(t=>t()),this._cleanupFunctions=[],document.querySelectorAll("[data-image-box-initialized]").forEach(t=>{t.classList.remove("vd-image-box-trigger"),t._imageBoxCleanup&&(t._imageBoxCleanup(),delete t._imageBoxCleanup),delete t.dataset.imageBoxInitialized}),this.backdrop=null,this.container=null,this.img=null,this.closeBtn=null,this.caption=null,this.currentTrigger=null,this.isOpen=!1},destroyAll:function(){this.destroy()}};typeof window.Vanduo<"u"&&window.Vanduo.register("imageBox",m),window.VanduoImageBox=m})();(function(){"use strict";let m={modals:new Map,openModals:[],zIndexCounter:1050,_triggerCleanups:[],_sharedEscHandler:null,getPortalState:function(e){return e._vdPortalState||(e._vdPortalState={originalParent:null,originalNextSibling:null,placeholder:null}),e._vdPortalState},portalToBody:function(e){if(!e||e.parentNode===document.body)return;let t=this.getPortalState(e);t.originalParent=e.parentNode,t.originalNextSibling=e.nextSibling,t.placeholder||(t.placeholder=document.createComment("vd-modal-placeholder")),t.originalParent.insertBefore(t.placeholder,e),document.body.appendChild(e),e.dataset.vdPortaled="true"},restoreFromPortal:function(e){if(!e)return;let t=this.getPortalState(e);if(!t.placeholder){delete e.dataset.vdPortaled;return}t.placeholder.parentNode?(t.placeholder.parentNode.insertBefore(e,t.placeholder),t.placeholder.parentNode.removeChild(t.placeholder)):t.originalParent&&t.originalParent.isConnected&&(t.originalNextSibling&&t.originalNextSibling.parentNode===t.originalParent?t.originalParent.insertBefore(e,t.originalNextSibling):t.originalParent.appendChild(e)),t.originalParent=null,t.originalNextSibling=null,t.placeholder=null,delete e.dataset.vdPortaled},init:function(){document.querySelectorAll(".vd-modal").forEach(n=>{this.modals.has(n)||this.initModal(n)}),document.querySelectorAll("[data-modal]").forEach(n=>{if(n.dataset.modalTriggerInitialized)return;n.dataset.modalTriggerInitialized="true";let s=o=>{o.preventDefault();let i=n.dataset.modal,a=document.querySelector(i);a&&this.open(a)};n.addEventListener("click",s),this._triggerCleanups.push(()=>n.removeEventListener("click",s))})},initModal:function(e){let t=this.createBackdrop(e),n=e.querySelectorAll('.vd-modal-close, [data-dismiss="modal"]'),s=e.querySelector(".vd-modal-dialog");if(!s)return;let o=[];e.setAttribute("role","dialog"),e.setAttribute("aria-modal","true"),e.setAttribute("aria-hidden","true"),e.id||(e.id="modal-"+Math.random().toString(36).substr(2,9));let i=e.querySelector(".vd-modal-title");i&&!i.id&&(i.id=e.id+"-title",e.setAttribute("aria-labelledby",i.id)),n.forEach(r=>{let c=()=>{this.close(e)};r.addEventListener("click",c),o.push(()=>r.removeEventListener("click",c))});let a=r=>{r.target===t&&e.dataset.backdrop!=="static"&&this.close(e)};t.addEventListener("click",a),o.push(()=>t.removeEventListener("click",a)),this._sharedEscHandler||(this._sharedEscHandler=r=>{if(r.key==="Escape"&&this.openModals.length>0){let c=this.openModals[this.openModals.length-1];c.dataset.keyboard!=="false"&&this.close(c)}},document.addEventListener("keydown",this._sharedEscHandler)),this.modals.set(e,{backdrop:t,dialog:s,trapHandler:null,cleanup:o})},createBackdrop:function(e){let t=e.querySelector(".vd-modal-backdrop");return t||(t=document.createElement("div"),t.className="vd-modal-backdrop",document.body.appendChild(t)),t},open:function(e){let t=typeof e=="string"?document.querySelector(e):e;if(!t){console.warn("[Vanduo Modals] Modal element not found:",e);return}if(!this.modals.has(t)){console.warn("[Vanduo Modals] Modal not initialized:",t);return}let n=this.modals.get(t),{backdrop:s,dialog:o}=n;if(this.portalToBody(t),this.zIndexCounter+=10,t.style.zIndex=this.zIndexCounter,s.style.zIndex=this.zIndexCounter-1,this.openModals.push(t),s.classList.add("is-visible"),t.classList.add("is-open"),t.setAttribute("aria-hidden","false"),this.openModals.length===1){document.body.classList.add("body-modal-open");let a=window.innerWidth-document.documentElement.clientWidth;a>0&&(document.body.style.paddingRight=`${a}px`)}let i=this.trapFocus(t);n.trapHandler=i,setTimeout(()=>{let a=this.getFocusableElements(t)[0];a&&a.focus()},100),t.dispatchEvent(new CustomEvent("modal:open",{bubbles:!0}))},close:function(e){let t=typeof e=="string"?document.querySelector(e):e;if(!t){console.warn("[Vanduo Modals] Modal element not found:",e);return}if(!this.modals.has(t)){console.warn("[Vanduo Modals] Modal not initialized:",t);return}let n=this.modals.get(t),{backdrop:s,trapHandler:o}=n;o&&(t.removeEventListener("keydown",o),n.trapHandler=null);let i=this.openModals.indexOf(t);if(i>-1&&this.openModals.splice(i,1),t.classList.remove("is-open"),t.setAttribute("aria-hidden","true"),this.openModals.length===0)s.classList.remove("is-visible"),document.body.classList.remove("body-modal-open"),document.body.style.paddingRight="",this.zIndexCounter=1050;else{let r=this.openModals[this.openModals.length-1];this.modals.get(r).backdrop.classList.add("is-visible")}let a=document.querySelector(`[data-modal="#${t.id}"]`);a&&a.focus(),t.dispatchEvent(new CustomEvent("modal:close",{bubbles:!0})),this.restoreFromPortal(t)},trapFocus:function(e){let t=this,n=function(s){if(s.key!=="Tab")return;let o=t.getFocusableElements(e),i=o[0],a=o[o.length-1];s.shiftKey?document.activeElement===i&&(s.preventDefault(),a.focus()):document.activeElement===a&&(s.preventDefault(),i.focus())};return e.addEventListener("keydown",n),n},getFocusableElements:function(e){return Array.from(e.querySelectorAll('a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter(n=>!n.hasAttribute("disabled")&&n.offsetWidth>0&&n.offsetHeight>0)},toggle:function(e){let t=typeof e=="string"?document.querySelector(e):e;t&&(t.classList.contains("is-open")?this.close(t):this.open(t))},destroy:function(e){let t=this.modals.get(e);if(t){if(e.classList.contains("is-open")){let n=this.openModals.indexOf(e);n>-1&&this.openModals.splice(n,1),t.backdrop.classList.remove("is-visible"),e.classList.remove("is-open"),e.setAttribute("aria-hidden","true"),this.openModals.length===0&&(document.body.classList.remove("body-modal-open"),document.body.style.paddingRight="",this.zIndexCounter=1050)}this.restoreFromPortal(e),t.cleanup&&t.cleanup.forEach(n=>n()),t.backdrop&&t.backdrop.parentNode&&t.backdrop.parentNode.removeChild(t.backdrop),this.modals.delete(e)}},destroyAll:function(){this.modals.forEach((e,t)=>{this.destroy(t)}),this._triggerCleanups.forEach(e=>e()),this._triggerCleanups=[],this._sharedEscHandler&&(document.removeEventListener("keydown",this._sharedEscHandler),this._sharedEscHandler=null)}};typeof window.Vanduo<"u"&&window.Vanduo.register("modals",m),window.VanduoModals=m})();(function(){"use strict";let m={instances:new Map,getBreakpoint:function(){let t=getComputedStyle(document.documentElement).getPropertyValue("--breakpoint-lg").trim(),n=parseInt(t,10);return isNaN(n)?992:n},init:function(){document.querySelectorAll(".vd-navbar").forEach(t=>{this.instances.has(t)||this.initNavbar(t)})},initScrollWatcher:function(e){let t=e.classList.contains("vd-navbar-glass"),n=e.classList.contains("vd-navbar-transparent");if(!t&&!n)return null;let s=()=>{let i=parseInt(e.dataset.scrollThreshold,10);return isNaN(i)?e.offsetHeight||60:i},o=()=>{let i=window.scrollY>s();e.classList.toggle("vd-navbar-scrolled",i)};return o(),window.addEventListener("scroll",o,{passive:!0}),()=>window.removeEventListener("scroll",o)},initNavbar:function(e){let t=e.querySelector(".vd-navbar-toggle, .vd-navbar-burger"),n=e.querySelector(".vd-navbar-menu"),s=e.querySelector(".vd-navbar-overlay")||this.createOverlay(e),o=[],i=this.initScrollWatcher(e);if(i&&o.push(i),!t||!n){o.length&&this.instances.set(e,{toggle:null,menu:null,overlay:null,cleanup:o});return}let a=u=>{u.preventDefault(),u.stopPropagation(),this.toggleMenu(e,t,n,s)};if(t.addEventListener("click",a),o.push(()=>t.removeEventListener("click",a)),s){let u=()=>{this.closeMenu(e,t,n,s)};s.addEventListener("click",u),o.push(()=>s.removeEventListener("click",u))}let r=u=>{u.key==="Escape"&&n.classList.contains("is-open")&&this.closeMenu(e,t,n,s)};document.addEventListener("keydown",r),o.push(()=>document.removeEventListener("keydown",r));let c,l=()=>{clearTimeout(c),c=setTimeout(()=>{let u=this.getBreakpoint();window.innerWidth>=u&&n.classList.contains("is-open")&&this.closeMenu(e,t,n,s)},250)};window.addEventListener("resize",l),o.push(()=>{clearTimeout(c),window.removeEventListener("resize",l)});let f=u=>{n.classList.contains("is-open")&&!e.contains(u.target)&&!n.contains(u.target)&&this.closeMenu(e,t,n,s)};document.addEventListener("click",f),o.push(()=>document.removeEventListener("click",f)),n.querySelectorAll(".vd-navbar-dropdown > .vd-nav-link, .vd-navbar-dropdown > .nav-link").forEach(u=>{let d=p=>{let b=this.getBreakpoint();if(window.innerWidthu.removeEventListener("click",d))}),this.instances.set(e,{toggle:t,menu:n,overlay:s,cleanup:o})},destroy:function(e){let t=this.instances.get(e);t&&(t.cleanup.forEach(n=>n()),t.overlay&&t.overlay.parentNode&&t.overlay.parentNode.removeChild(t.overlay),this.instances.delete(e))},destroyAll:function(){this.instances.forEach((e,t)=>{this.destroy(t)})},toggleMenu:function(e,t,n,s){n.classList.contains("is-open")?this.closeMenu(e,t,n,s):this.openMenu(e,t,n,s)},openMenu:function(e,t,n,s){n.classList.add("is-open"),t.classList.add("is-active"),s&&s.classList.add("is-active"),document.body.classList.add("body-navbar-open"),t.setAttribute("aria-expanded","true"),n.setAttribute("aria-hidden","false")},closeMenu:function(e,t,n,s){n.classList.remove("is-open"),t.classList.remove("is-active"),s&&s.classList.remove("is-active"),document.body.classList.remove("body-navbar-open"),n.querySelectorAll(".vd-navbar-dropdown-menu.is-open").forEach(i=>{i.classList.remove("is-open")}),t.setAttribute("aria-expanded","false"),n.setAttribute("aria-hidden","true")},createOverlay:function(e){let t=document.createElement("div");return t.className="vd-navbar-overlay",document.body.appendChild(t),t}};typeof window.Vanduo<"u"&&window.Vanduo.register("navbar",m),window.VanduoNavbar=m})();(function(){"use strict";let m={instances:new Map,init:function(){document.querySelectorAll(".vd-pagination[data-pagination]").forEach(t=>{this.instances.has(t)||this.initPagination(t)})},initPagination:function(e){let t=parseInt(e.dataset.totalPages)||1,n=parseInt(e.dataset.currentPage)||1,s=parseInt(e.dataset.maxVisible)||7;this.render(e,{totalPages:t,currentPage:n,maxVisible:s});let o=i=>{let a=i.target.closest(".vd-pagination-link");if(!a||a.closest(".vd-pagination-item.disabled")||a.closest(".vd-pagination-item.active"))return;i.preventDefault();let r=a.closest(".vd-pagination-item"),c=r.dataset.page;c?this.goToPage(e,parseInt(c)):r.classList.contains("pagination-prev")?this.prevPage(e):r.classList.contains("pagination-next")&&this.nextPage(e)};e.addEventListener("click",o),this.instances.set(e,{cleanup:[()=>e.removeEventListener("click",o)]})},render:function(e,t){let{totalPages:n,currentPage:s,maxVisible:o}=t;if(n<=1){e.innerHTML="";return}let i="";i+=`
  • `,i+='Previous',i+="
  • ";let a=this.calculatePages(s,n,o),r=0;a.forEach(c=>{if(c==="ellipsis")i+='
  • \u2026
  • ';else{c!==r+1&&r>0&&(i+='
  • \u2026
  • ');let l=Number(c);i+=`
  • `,i+=`${l}`,i+="
  • ",r=c}}),i+=`
  • `,i+='Next',i+="
  • ",e.innerHTML=i,e.dataset.currentPage=s},calculatePages:function(e,t,n){let s=[],o=Math.floor(n/2);if(t<=n)for(let i=1;i<=t;i++)s.push(i);else{s.push(1);let i=Math.max(2,e-o),a=Math.min(t-1,e+o);e<=o+1&&(a=Math.min(t-1,n-1)),e>=t-o&&(i=Math.max(2,t-n+2)),i>2&&s.push("ellipsis");for(let r=i;r<=a;r++)s.push(r);a1&&s.push(t)}return s},goToPage:function(e,t){let n=parseInt(e.dataset.totalPages)||1,s=parseInt(e.dataset.maxVisible)||7;t<1||t>n||(this.render(e,{totalPages:n,currentPage:t,maxVisible:s}),e.dispatchEvent(new CustomEvent("pagination:change",{bubbles:!0,detail:{page:t,totalPages:n}})))},prevPage:function(e){let t=parseInt(e.dataset.currentPage)||1;t>1&&this.goToPage(e,t-1)},nextPage:function(e){let t=parseInt(e.dataset.currentPage)||1,n=parseInt(e.dataset.totalPages)||1;tn()),this.instances.delete(e))},destroyAll:function(){this.instances.forEach((e,t)=>{this.destroy(t)})}};typeof window.Vanduo<"u"&&window.Vanduo.register("pagination",m),window.VanduoPagination=m})();(function(){"use strict";let m={parallaxElements:new Map,ticking:!1,isMobile:window.innerWidth<768,reducedMotion:window.matchMedia("(prefers-reduced-motion: reduce)").matches,isInitialized:!1,_onScroll:null,_onResize:null,init:function(){if(this.isInitialized){this.refresh();return}if(this.isInitialized=!0,this.reducedMotion)return;document.querySelectorAll(".vd-parallax").forEach(t=>{t.dataset.parallaxInitialized||this.initParallax(t)}),this.handleScroll(),this._onScroll=()=>{this.handleScroll()},window.addEventListener("scroll",this._onScroll,{passive:!0}),this._onResize=()=>{this.isMobile=window.innerWidth<768,this.updateAll()},window.addEventListener("resize",this._onResize)},initParallax:function(e){e.dataset.parallaxInitialized="true";let t=e.classList.contains("parallax-disable-mobile");if(t&&this.isMobile)return;let n=e.querySelectorAll(".vd-parallax-layer, .vd-parallax-bg"),s=this.getSpeed(e),o=e.classList.contains("parallax-horizontal")?"horizontal":"vertical";this.parallaxElements.set(e,{layers:Array.from(n),speed:s,direction:o,disableMobile:t}),this.updateParallax(e)},getSpeed:function(e){return e.classList.contains("parallax-slow")?.5:e.classList.contains("parallax-fast")?1.5:1},handleScroll:function(){this.ticking||(window.requestAnimationFrame(()=>{this.updateAll(),this.ticking=!1}),this.ticking=!0)},updateAll:function(){this.parallaxElements.forEach((e,t)=>{e.disableMobile&&this.isMobile||this.updateParallax(t)})},updateParallax:function(e){let t=this.parallaxElements.get(e);if(!t)return;let n=e.getBoundingClientRect(),s=window.innerHeight,o=n.top,i=n.height,r=(Math.max(0,Math.min(1,(s-o)/(s+i)))-.5)*t.speed*100;t.layers.forEach((c,l)=>{let f=c.dataset.parallaxSpeed?parseFloat(c.dataset.parallaxSpeed):1,h=r*f;t.direction==="horizontal"?c.style.transform=`translateX(${h}px)`:c.style.transform=`translateY(${h}px)`})},destroy:function(e){let t=typeof e=="string"?document.querySelector(e):e;t&&this.parallaxElements.has(t)&&(this.parallaxElements.get(t).layers.forEach(s=>{s.style.transform=""}),this.parallaxElements.delete(t))},refresh:function(){this.updateAll()},destroyAll:function(){this.parallaxElements.forEach((e,t)=>{this.destroy(t)}),this.parallaxElements.clear(),this._onScroll&&(window.removeEventListener("scroll",this._onScroll),this._onScroll=null),this._onResize&&(window.removeEventListener("resize",this._onResize),this._onResize=null),this.isInitialized=!1}};typeof window.Vanduo<"u"&&window.Vanduo.register("parallax",m),window.VanduoParallax=m})();(function(){"use strict";let m={init:function(){document.querySelectorAll(".vd-progress-bar[data-progress], .progress-bar[data-progress]").forEach(t=>{t.dataset.progressInitialized||this.initProgressBar(t)})},initProgressBar:function(e){e.dataset.progressInitialized="true";let t=parseInt(e.dataset.progress)||0;this.setProgress(e,t,!1)},setProgress:function(e,t,n=!0){let s=typeof e=="string"?document.querySelector(e):e;if(!s)return;t=Math.max(0,Math.min(100,t)),n?s.style.transition="width var(--transition-duration-slow) var(--transition-ease)":(s.style.transition="none",setTimeout(()=>{s.style.transition=""},0)),s.style.width=t+"%",s.setAttribute("aria-valuenow",t),s.setAttribute("aria-valuemin",0),s.setAttribute("aria-valuemax",100);let o=s.querySelector(".vd-progress-text, .progress-text");o&&(o.textContent=t+"%"),s.dispatchEvent(new CustomEvent("progress:update",{bubbles:!0,detail:{value:t,max:100}})),t>=100&&s.dispatchEvent(new CustomEvent("progress:complete",{bubbles:!0,detail:{value:t,max:100}}))},animateProgress:function(e,t,n=1e3){let s=typeof e=="string"?document.querySelector(e):e;if(!s)return;let o=parseInt(s.style.width)||0,i=t-o,a=performance.now(),r=c=>{let l=c-a,f=Math.min(l/n,1),h=1-Math.pow(1-f,3),u=o+i*h;this.setProgress(s,u,!1),f<1&&requestAnimationFrame(r)};requestAnimationFrame(r)},show:function(e){let t=typeof e=="string"?document.querySelector(e):e;t&&(t.style.display="inline-block",t.setAttribute("aria-hidden","false"))},hide:function(e){let t=typeof e=="string"?document.querySelector(e):e;t&&(t.style.display="none",t.setAttribute("aria-hidden","true"))},toggle:function(e){let t=typeof e=="string"?document.querySelector(e):e;t&&(t.style.display==="none"||t.getAttribute("aria-hidden")==="true"?this.show(t):this.hide(t))},destroyAll:function(){document.querySelectorAll('.vd-progress-bar[data-progress-initialized="true"], .progress-bar[data-progress-initialized="true"]').forEach(t=>{delete t.dataset.progressInitialized})}};typeof window.Vanduo<"u"&&window.Vanduo.register("preloader",m),window.VanduoPreloader=m})();(function(){"use strict";let m={instances:new Map,init:function(){document.querySelectorAll("select.vd-custom-select-input, select[data-custom-select]").forEach(t=>{this.instances.has(t)||this.initSelect(t)})},initSelect:function(e){if(e.closest(".vd-custom-select-wrapper"))return;let t=[],n=document.createElement("div");n.className="custom-select-wrapper",e.parentNode.insertBefore(n,e),n.appendChild(e);let s=document.createElement("button");s.type="button",s.className="custom-select-button",s.setAttribute("aria-haspopup","listbox"),s.setAttribute("aria-expanded","false"),s.setAttribute("aria-labelledby",e.id||this.generateId(e));let o=document.createElement("div");if(o.className="custom-select-dropdown",o.setAttribute("role","listbox"),e.dataset.searchable==="true"){let l=document.createElement("div");l.className="custom-select-search";let f=document.createElement("input");f.type="text",f.className="input input-sm",f.placeholder="Search...",f.setAttribute("aria-label","Search options"),l.appendChild(f),o.appendChild(l);let h=d=>{this.filterOptions(o,d.target.value)},u=typeof debounce=="function"?debounce(h,150):h;f.addEventListener("input",u),t.push(()=>f.removeEventListener("input",u))}this.buildOptions(e,o,s),n.appendChild(s),n.appendChild(o),this.updateButtonText(e,s);let i=l=>{l.preventDefault(),l.stopPropagation(),this.toggleDropdown(s,o)};s.addEventListener("click",i),t.push(()=>s.removeEventListener("click",i));let a=l=>{!n.contains(l.target)&&o.classList.contains("is-open")&&this.closeDropdown(s,o)};document.addEventListener("click",a),t.push(()=>document.removeEventListener("click",a));let r=l=>{this.handleKeydown(l,e,s,o)};s.addEventListener("keydown",r),t.push(()=>s.removeEventListener("keydown",r));let c=()=>{this.updateButtonText(e,s),this.updateSelectedOptions(e,o)};e.addEventListener("change",c),t.push(()=>e.removeEventListener("change",c)),this.instances.set(e,{wrapper:n,button:s,dropdown:o,cleanup:t,typeaheadBuffer:"",typeaheadTimer:null})},buildOptions:function(e,t,n){let s=e.querySelectorAll("option"),o=document.createDocumentFragment();s.forEach((i,a)=>{if(i.parentElement.tagName==="OPTGROUP"){let c=i.parentElement;if(!t.querySelector(`[data-group="${c.label}"]`)){let l=document.createElement("div");l.className="custom-select-option-group",l.textContent=c.label,l.dataset.group=c.label,o.appendChild(l)}}if(i.value===""&&!i.textContent.trim())return;let r=document.createElement("div");r.className="custom-select-option",r.textContent=i.textContent,r.setAttribute("role","option"),r.setAttribute("data-value",i.value),r.setAttribute("data-index",a),i.selected&&(r.classList.add("is-selected"),r.setAttribute("aria-selected","true")),i.disabled&&(r.classList.add("is-disabled"),r.setAttribute("aria-disabled","true")),r.addEventListener("click",c=>{i.disabled||this.selectOption(e,i,r,n,t)}),o.appendChild(r)}),t.appendChild(o)},selectOption:function(e,t,n,s,o){e.multiple?(t.selected=!t.selected,n.classList.toggle("is-selected"),n.setAttribute("aria-selected",t.selected)):(e.value=t.value,e.dispatchEvent(new Event("change",{bubbles:!0})),this.closeDropdown(s,o)),this.updateButtonText(e,s)},updateButtonText:function(e,t){if(e.multiple){let n=Array.from(e.selectedOptions);n.length===0?t.textContent=e.dataset.placeholder||"Select options...":n.length===1?t.textContent=n[0].textContent:t.textContent=`${n.length} selected`}else{let n=e.options[e.selectedIndex];t.textContent=n?n.textContent:e.dataset.placeholder||"Select..."}},updateSelectedOptions:function(e,t){let n=t.querySelectorAll(".custom-select-option"),s=Array.from(e.selectedOptions).map(o=>o.value);n.forEach(o=>{let i=o.dataset.value;s.includes(i)?(o.classList.add("is-selected"),o.setAttribute("aria-selected","true")):(o.classList.remove("is-selected"),o.setAttribute("aria-selected","false"))})},toggleDropdown:function(e,t){t.classList.contains("is-open")?this.closeDropdown(e,t):this.openDropdown(e,t)},openDropdown:function(e,t){t.classList.add("is-open"),e.setAttribute("aria-expanded","true");let n=t.querySelector(".custom-select-option:not(.is-disabled)");n&&n.focus()},closeDropdown:function(e,t){t.classList.remove("is-open"),e.setAttribute("aria-expanded","false")},handleKeydown:function(e,t,n,s){let o=s.classList.contains("is-open"),i=Array.from(s.querySelectorAll(".custom-select-option:not(.is-disabled)")),a=i.findIndex(r=>r===document.activeElement);switch(e.key){case"Enter":case" ":if(e.preventDefault(),o&&a>=0){let r=i[a],c=t.options[parseInt(r.dataset.index)];this.selectOption(t,c,r,n,s)}else this.openDropdown(n,s);break;case"Escape":o&&(e.preventDefault(),this.closeDropdown(n,s),n.focus());break;case"ArrowDown":if(e.preventDefault(),!o)this.openDropdown(n,s);else{let r=a0?a-1:i.length-1;i[r].focus()}break;case"Home":o&&(e.preventDefault(),i[0].focus());break;case"End":o&&(e.preventDefault(),i[i.length-1].focus());break;default:if(o&&e.key.length===1&&!e.ctrlKey&&!e.metaKey&&!e.altKey){let r=this.instances.get(t);if(!r)break;clearTimeout(r.typeaheadTimer),r.typeaheadBuffer+=e.key.toLowerCase();let c=i.find(l=>l.textContent.trim().toLowerCase().startsWith(r.typeaheadBuffer));c&&c.focus(),r.typeaheadTimer=setTimeout(()=>{r.typeaheadBuffer=""},500)}break}},filterOptions:function(e,t){let n=e.querySelectorAll(".vd-custom-select-option"),s=t.toLowerCase();n.forEach(o=>{o.textContent.toLowerCase().includes(s)?o.style.display="block":o.style.display="none"})},generateId:function(e){if(e.id)return e.id;let t="select-"+Math.random().toString(36).substr(2,9);return e.id=t,t},destroy:function(e){let t=this.instances.get(e);t&&(t.cleanup.forEach(n=>n()),t.wrapper&&t.wrapper.parentNode&&(t.wrapper.parentNode.insertBefore(e,t.wrapper),t.wrapper.parentNode.removeChild(t.wrapper)),this.instances.delete(e))},destroyAll:function(){this.instances.forEach((e,t)=>{this.destroy(t)})}};typeof window.Vanduo<"u"&&window.Vanduo.register("select",m)})();(function(){"use strict";let m={sidenavs:new Map,breakpoint:992,restoreDelayMs:450,_globalCleanups:[],isFixedVariant:function(e){return e.classList.contains("vd-sidenav-fixed")||e.classList.contains("sidenav-fixed")},isPushVariant:function(e){return e.classList.contains("vd-sidenav-push")||e.classList.contains("sidenav-push")},isRightVariant:function(e){return e.classList.contains("vd-sidenav-right")||e.classList.contains("sidenav-right")},getPortalState:function(e){return e._vdPortalState||(e._vdPortalState={originalParent:null,originalNextSibling:null,placeholder:null,restoreTimer:null,restoreHandler:null}),e._vdPortalState},cancelScheduledRestore:function(e){let t=this.getPortalState(e);t.restoreHandler&&(e.removeEventListener("transitionend",t.restoreHandler),t.restoreHandler=null),t.restoreTimer&&(window.clearTimeout(t.restoreTimer),t.restoreTimer=null)},portalToBody:function(e){if(!e)return;if(e.parentNode===document.body){this.cancelScheduledRestore(e);return}let t=this.getPortalState(e);this.cancelScheduledRestore(e),t.originalParent=e.parentNode,t.originalNextSibling=e.nextSibling,t.placeholder||(t.placeholder=document.createComment("vd-sidenav-placeholder")),t.originalParent.insertBefore(t.placeholder,e),document.body.appendChild(e),e.dataset.vdPortaled="true"},restoreFromPortal:function(e){if(!e)return;let t=this.getPortalState(e);if(this.cancelScheduledRestore(e),!t.placeholder){delete e.dataset.vdPortaled;return}t.placeholder.parentNode?(t.placeholder.parentNode.insertBefore(e,t.placeholder),t.placeholder.parentNode.removeChild(t.placeholder)):t.originalParent&&t.originalParent.isConnected&&(t.originalNextSibling&&t.originalNextSibling.parentNode===t.originalParent?t.originalParent.insertBefore(e,t.originalNextSibling):t.originalParent.appendChild(e)),t.originalParent=null,t.originalNextSibling=null,t.placeholder=null,delete e.dataset.vdPortaled},scheduleRestoreFromPortal:function(e){if(!e||e.parentNode!==document.body)return;let t=this.getPortalState(e);this.cancelScheduledRestore(e);let n=()=>{this.restoreFromPortal(e)},s=o=>{o.target!==e||o.propertyName!=="transform"||n()};t.restoreHandler=s,e.addEventListener("transitionend",s),t.restoreTimer=window.setTimeout(()=>{n()},this.restoreDelayMs)},init:function(){document.querySelectorAll(".vd-sidenav, .vd-offcanvas").forEach(s=>{this.sidenavs.has(s)||this.initSidenav(s)}),document.querySelectorAll("[data-sidenav-toggle]").forEach(s=>{if(s.dataset.sidenavToggleInitialized)return;s.dataset.sidenavToggleInitialized="true";let o=i=>{i.preventDefault();let a=s.dataset.sidenavToggle,r=document.querySelector(a);r&&this.toggle(r)};s.addEventListener("click",o),this._globalCleanups.push(()=>s.removeEventListener("click",o))}),this.handleResize();let n=()=>{this.handleResize()};window.addEventListener("resize",n),this._globalCleanups.push(()=>window.removeEventListener("resize",n))},initSidenav:function(e){let t=e.getAttribute("data-vd-position");if(t){let r=e.classList.contains("vd-offcanvas")?"vd-offcanvas":"vd-sidenav";e.classList.add(r+"-"+t)}let n=this.createOverlay(e),s=e.querySelector(".vd-sidenav-close, .vd-offcanvas-close"),o=[];if(e.setAttribute("role","navigation"),e.setAttribute("aria-hidden","true"),s){let r=()=>{this.close(e)};s.addEventListener("click",r),o.push(()=>s.removeEventListener("click",r))}let i=()=>{e.dataset.backdrop!=="static"&&this.close(e)};n.addEventListener("click",i),o.push(()=>n.removeEventListener("click",i));let a=r=>{r.key==="Escape"&&e.classList.contains("is-open")&&e.dataset.keyboard!=="false"&&this.close(e)};document.addEventListener("keydown",a),o.push(()=>document.removeEventListener("keydown",a)),this.sidenavs.set(e,{overlay:n,cleanup:o})},createOverlay:function(e){let t=e.querySelector(".vd-sidenav-overlay");return t||(t=document.createElement("div"),t.className="vd-sidenav-overlay",document.body.appendChild(t)),t},open:function(e){let t=typeof e=="string"?document.querySelector(e):e;if(!t||!this.sidenavs.has(t))return;let{overlay:n}=this.sidenavs.get(t);this.portalToBody(t),this.isFixedVariant(t)||n.classList.add("is-visible"),t.classList.add("is-open"),t.setAttribute("aria-hidden","false"),document.body.classList.add("body-sidenav-open"),this.isPushVariant(t)&&this.handlePushVariant(t,!0),t.dispatchEvent(new CustomEvent("sidenav:open",{bubbles:!0}))},close:function(e){let t=typeof e=="string"?document.querySelector(e):e;if(!t||!this.sidenavs.has(t))return;let{overlay:n}=this.sidenavs.get(t);n.classList.remove("is-visible"),t.classList.remove("is-open"),t.setAttribute("aria-hidden","true"),document.body.classList.remove("body-sidenav-open"),this.isPushVariant(t)&&this.handlePushVariant(t,!1),t.dispatchEvent(new CustomEvent("sidenav:close",{bubbles:!0})),this.scheduleRestoreFromPortal(t)},toggle:function(e){let t=typeof e=="string"?document.querySelector(e):e;t&&(t.classList.contains("is-open")?this.close(t):this.open(t))},handlePushVariant:function(e,t){let n=document.querySelector('main, .main-content, .content, [role="main"]')||document.body;t?window.innerWidth>=this.breakpoint&&(this.isRightVariant(e)?n.style.marginRight=e.offsetWidth+"px":n.style.marginLeft=e.offsetWidth+"px"):(n.style.marginLeft="",n.style.marginRight="")},handleResize:function(){this.sidenavs.forEach(({overlay:e},t)=>{window.innerWidth>=this.breakpoint?this.isFixedVariant(t)&&!t.classList.contains("is-open")&&(t.classList.add("is-open"),t.setAttribute("aria-hidden","false"),e.classList.remove("is-visible")):this.isFixedVariant(t)&&t.classList.contains("is-open")&&this.close(t)})},destroy:function(e){let t=this.sidenavs.get(e);t&&(e.classList.contains("is-open")&&(t.overlay.classList.remove("is-visible"),e.classList.remove("is-open"),e.setAttribute("aria-hidden","true"),document.body.classList.remove("body-sidenav-open")),this.restoreFromPortal(e),t.cleanup.forEach(n=>n()),t.overlay&&t.overlay.parentNode&&t.overlay.parentNode.removeChild(t.overlay),this.sidenavs.delete(e))},destroyAll:function(){this.sidenavs.forEach((e,t)=>{this.destroy(t)}),this._globalCleanups.forEach(e=>e()),this._globalCleanups=[]}};typeof window.Vanduo<"u"&&window.Vanduo.register("sidenav",m),window.VanduoSidenav=m})();(function(){"use strict";let m={instances:new Map,init:function(){document.querySelectorAll(".vd-tabs, [data-tabs]").forEach(t=>{this.instances.has(t)||this.initTabs(t)})},initTabs:function(e){let t=e.querySelector('.vd-tab-list, [role="tablist"]'),n=e.querySelectorAll(".vd-tab-link, [data-tab]"),s=e.querySelectorAll(".vd-tab-pane, [data-tab-pane]");if(!t||n.length===0)return;let o=[];t.setAttribute("role","tablist"),n.forEach((a,r)=>{let c=this.getTabId(a,r),l=this.findPane(e,c,s);a.setAttribute("role","tab"),a.setAttribute("aria-selected",a.classList.contains("is-active")?"true":"false"),a.setAttribute("tabindex",a.classList.contains("is-active")?"0":"-1"),a.id||(a.id=`tab-btn-${c}`),l&&(l.setAttribute("role","tabpanel"),l.setAttribute("aria-labelledby",a.id),l.id||(l.id=`tab-pane-${c}`),a.setAttribute("aria-controls",l.id));let f=u=>{u.preventDefault(),!a.classList.contains("disabled")&&!a.disabled&&this.activateTab(e,a,n,s)};a.addEventListener("click",f),o.push(()=>a.removeEventListener("click",f));let h=u=>{this.handleKeydown(u,e,a,n,s)};a.addEventListener("keydown",h),o.push(()=>a.removeEventListener("keydown",h))}),!e.querySelector(".vd-tab-link.is-active, [data-tab].is-active")&&n.length>0&&this.activateTab(e,n[0],n,s),this.instances.set(e,{cleanup:o})},getTabId:function(e,t){return e.dataset.tabTarget||e.dataset.tab||e.getAttribute("href")?.replace("#","")||(typeof t=="number"?`tab-${t}`:e.id)},findPane:function(e,t,n){let s=e.querySelector(`[data-tab-pane="${t}"]`);return s||(s=e.querySelector(`#${t}`)),s||e.querySelectorAll(".vd-tab-link, [data-tab]").forEach((i,a)=>{this.getTabId(i,a)===t&&n[a]&&(s=n[a])}),s},activateTab:function(e,t,n,s){let o=Array.from(n).indexOf(t),i=this.getTabId(t,o);n.forEach(c=>{c.classList.remove("is-active"),c.setAttribute("aria-selected","false"),c.setAttribute("tabindex","-1"),c.parentElement&&c.parentElement.classList.contains("tab-item")&&c.parentElement.classList.remove("is-active")}),s.forEach(c=>{c.classList.remove("is-active")}),t.classList.add("is-active"),t.setAttribute("aria-selected","true"),t.setAttribute("tabindex","0"),t.parentElement&&t.parentElement.classList.contains("tab-item")&&t.parentElement.classList.add("is-active");let a=this.findPane(e,i,s);a&&a.classList.add("is-active");let r=new CustomEvent("tab:change",{bubbles:!0,detail:{tab:t,pane:a,tabId:i}});e.dispatchEvent(r)},handleKeydown:function(e,t,n,s,o){let i=t.classList.contains("vd-tabs-vertical")||t.classList.contains("tabs-vertical"),a=Array.from(s).filter(l=>!l.classList.contains("disabled")&&!l.disabled),r=a.indexOf(n),c=r;switch(e.key){case"ArrowLeft":i||(e.preventDefault(),c=r>0?r-1:a.length-1);break;case"ArrowRight":i||(e.preventDefault(),c=r0?r-1:a.length-1);break;case"ArrowDown":i&&(e.preventDefault(),c=rn()),this.instances.delete(e))},destroyAll:function(){this.instances.forEach((e,t)=>{this.destroy(t)})}};typeof window.Vanduo<"u"&&window.Vanduo.register("tabs",m)})();(function(){"use strict";let m={STORAGE_KEYS:{PRIMARY:"vanduo-primary-color",NEUTRAL:"vanduo-neutral-color",RADIUS:"vanduo-radius",FONT:"vanduo-font-preference",THEME:"vanduo-theme-preference"},DEFAULTS:{PRIMARY_LIGHT:"black",PRIMARY_DARK:"amber",NEUTRAL:"charcoal",RADIUS:"0.5",FONT:"ubuntu",THEME:"system"},PRIMARY_COLORS:{black:{name:"Black",color:"#000000"},red:{name:"Red",color:"#fa5252"},orange:{name:"Orange",color:"#fd7e14"},amber:{name:"Amber",color:"#f59f00"},yellow:{name:"Yellow",color:"#fcc419"},lime:{name:"Lime",color:"#82c91e"},green:{name:"Green",color:"#40c057"},emerald:{name:"Emerald",color:"#20c997"},teal:{name:"Teal",color:"#12b886"},cyan:{name:"Cyan",color:"#22b8cf"},sky:{name:"Sky",color:"#3bc9db"},blue:{name:"Blue",color:"#228be6"},indigo:{name:"Indigo",color:"#4c6ef5"},violet:{name:"Violet",color:"#7950f2"},purple:{name:"Purple",color:"#be4bdb"},fuchsia:{name:"Fuchsia",color:"#f06595"},pink:{name:"Pink",color:"#e64980"},rose:{name:"Rose",color:"#ff8787"}},NEUTRAL_COLORS:{charcoal:{name:"Charcoal",color:"#2d333b"},slate:{name:"Slate",color:"#64748b"},gray:{name:"Gray",color:"#6b7280"},zinc:{name:"Zinc",color:"#71717a"},neutral:{name:"Neutral",color:"#737373"},stone:{name:"Stone",color:"#78716c"}},RADIUS_OPTIONS:["0","0.125","0.25","0.375","0.5"],FONT_OPTIONS:{"jetbrains-mono":{name:"JetBrains Mono",family:"'JetBrains Mono', monospace"},system:{name:"System Default",family:null},ubuntu:{name:"Ubuntu",family:"'Ubuntu', sans-serif"},lato:{name:"Lato",family:"'Lato', sans-serif"},"open-sans":{name:"Open Sans",family:"'Open Sans', sans-serif"}},THEME_MODES:["system","dark","light"],state:{primary:null,neutral:null,radius:null,font:null,theme:null,isOpen:!1},isInitialized:!1,_cleanup:[],elements:{customizer:null,trigger:null,triggers:[],panel:null,overlay:null},init:function(){if(this.isInitialized){this.bindExistingElements(),this.bindTriggerEvents(),this.bindPanelEvents(),this.updateUI();return}this.isInitialized=!0,this._cleanup=[],this.loadPreferences(),this.applyAllPreferences(),this.bindExistingElements(),this.bindEvents(),console.log("Vanduo Theme Customizer initialized")},addListener:function(e,t,n,s){e&&(e.addEventListener(t,n,s),this._cleanup.push(()=>e.removeEventListener(t,n,s)))},getDefaultPrimary:function(e){return e==="system"?window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?this.DEFAULTS.PRIMARY_DARK:this.DEFAULTS.PRIMARY_LIGHT:e==="dark"?this.DEFAULTS.PRIMARY_DARK:this.DEFAULTS.PRIMARY_LIGHT},loadPreferences:function(){this.state.theme=this.getStorageValue(this.STORAGE_KEYS.THEME,this.DEFAULTS.THEME),this.state.primary=this.getStorageValue(this.STORAGE_KEYS.PRIMARY,this.getDefaultPrimary(this.state.theme)),this._normalizeDefaultPrimaryIfStaleWithStoredTheme(),this.state.neutral=this.getStorageValue(this.STORAGE_KEYS.NEUTRAL,this.DEFAULTS.NEUTRAL),this.state.radius=this.getStorageValue(this.STORAGE_KEYS.RADIUS,this.DEFAULTS.RADIUS),this.state.font=this.getStorageValue(this.STORAGE_KEYS.FONT,this.DEFAULTS.FONT)},savePreference:function(e,t){this.setStorageValue(e,t)},applyAllPreferences:function(){this.applyPrimary(this.state.primary),this.applyNeutral(this.state.neutral),this.applyRadius(this.state.radius),this.applyFont(this.state.font),this.applyTheme(this.state.theme)},applyPrimary:function(e){this.PRIMARY_COLORS[e]||(e=this.getDefaultPrimary(this.state.theme)),this.state.primary=e,document.documentElement.setAttribute("data-primary",e),this.savePreference(this.STORAGE_KEYS.PRIMARY,e),this.dispatchEvent("primary-change",{color:e})},applyNeutral:function(e){this.NEUTRAL_COLORS[e]||(e=this.DEFAULTS.NEUTRAL),this.state.neutral=e,document.documentElement.setAttribute("data-neutral",e),this.savePreference(this.STORAGE_KEYS.NEUTRAL,e),this.dispatchEvent("neutral-change",{neutral:e})},applyRadius:function(e){this.RADIUS_OPTIONS.includes(e)||(e=this.DEFAULTS.RADIUS),this.state.radius=e,document.documentElement.setAttribute("data-radius",e),document.documentElement.style.setProperty("--radius-scale",e),this.savePreference(this.STORAGE_KEYS.RADIUS,e),this.dispatchEvent("radius-change",{radius:e})},applyFont:function(e){this.FONT_OPTIONS[e]||(e=this.DEFAULTS.FONT),this.state.font=e,e==="system"?document.documentElement.removeAttribute("data-font"):document.documentElement.setAttribute("data-font",e),this.savePreference(this.STORAGE_KEYS.FONT,e),window.FontSwitcher&&window.FontSwitcher.setPreference&&(window.FontSwitcher.state.preference=e,window.FontSwitcher.applyFont()),this.dispatchEvent("font-change",{font:e})},applyTheme:function(e){if(this.THEME_MODES.includes(e)||(e=this.DEFAULTS.THEME),this._isApplying=!0,this.isUsingDefaultPrimary()){let t=this.getDefaultPrimary(e);this.state.primary!==t&&this.applyPrimary(t)}if(this.state.theme=e,e==="system"?document.documentElement.removeAttribute("data-theme"):document.documentElement.setAttribute("data-theme",e),this.savePreference(this.STORAGE_KEYS.THEME,e),window.Vanduo&&window.Vanduo.components.themeSwitcher){let t=window.Vanduo.components.themeSwitcher;t.state&&t.state.preference!==e&&(t.state.preference=e,typeof t.setStorageValue=="function"&&t.setStorageValue(t.STORAGE_KEY,e),typeof t.updateUI=="function"&&t.updateUI())}this._isApplying=!1,this.dispatchEvent("mode-change",{mode:e})},dispatchEvent:function(e,t){let n=new CustomEvent("theme:"+e,{bubbles:!0,detail:t});document.dispatchEvent(n);let s=new CustomEvent("theme:change",{bubbles:!0,detail:{type:e,value:t[Object.keys(t)[0]],state:{...this.state}}});document.dispatchEvent(s)},bindExistingElements:function(){this.elements.customizer=document.querySelector(".vd-theme-customizer"),this.elements.triggers=Array.from(document.querySelectorAll("[data-theme-customizer-trigger]")),!this.elements.trigger&&this.elements.triggers.length&&(this.elements.trigger=this.elements.triggers[0]),this.elements.customizer?(this.elements.trigger=this.elements.customizer.querySelector(".vd-theme-customizer-trigger")||this.elements.trigger,this.elements.panel=this.elements.customizer.querySelector(".vd-theme-customizer-panel"),this.elements.overlay=this.elements.customizer.querySelector(".vd-theme-customizer-overlay")):this.elements.triggers.length&&this.createDynamicPanel(),this.updateUI()},createDynamicPanel:function(){if(!this.elements.triggers.length)return;this.elements.trigger=this.elements.triggers[0];let e=document.createElement("div");e.className="vd-theme-customizer-overlay";let t=document.createElement("div");t.className="vd-theme-customizer-panel",t.innerHTML=this.getPanelHTML(),document.body.appendChild(e),document.body.appendChild(t),this.elements.panel=t,this.elements.overlay=e,this.elements.customizer={contains:n=>t.contains(n)||this.elements.triggers.some(s=>s.contains(n))},this.positionPanel(),this.bindPanelEvents(),this.addListener(window,"resize",()=>this.positionPanel())},positionPanel:function(){if(!this.elements.panel||!this.elements.trigger)return;let e=this.elements.activeTrigger||this.elements.trigger;if(window.innerWidth<768)this.elements.panel.style.top="",this.elements.panel.style.right="",this.elements.panel.style.left="",this.elements.panel.style.height="",this.elements.panel.style.maxHeight="";else{let n=e.getBoundingClientRect(),s=320,o=n.bottom+8,i=window.innerWidth,a=i-n.right;i-a-s<8&&(a=i-s-8),this.elements.panel.style.top=o+"px",this.elements.panel.style.right=a+"px",this.elements.panel.style.left="",this.elements.panel.style.height="auto",this.elements.panel.style.maxHeight="calc(100vh - "+o+"px)"}},bindPanelEvents:function(){if(!this.elements.panel||this.elements.panel.getAttribute("data-customizer-initialized")==="true")return;this.elements.panel.setAttribute("data-customizer-initialized","true"),this.elements.panel.querySelectorAll("[data-color]").forEach(s=>{this.addListener(s,"click",()=>{this.applyPrimary(s.dataset.color),this.updateUI()})}),this.elements.panel.querySelectorAll("[data-neutral]").forEach(s=>{this.addListener(s,"click",()=>{this.applyNeutral(s.dataset.neutral),this.updateUI()})}),this.elements.panel.querySelectorAll("[data-radius]").forEach(s=>{this.addListener(s,"click",()=>{this.applyRadius(s.dataset.radius),this.updateUI()})});let e=this.elements.panel.querySelector("[data-customizer-font]");e&&this.addListener(e,"change",s=>{this.applyFont(s.target.value),this.updateUI()});let t=this.elements.panel.querySelector(".customizer-reset");t&&this.addListener(t,"click",()=>{this.reset()});let n=this.elements.panel.querySelector(".customizer-mobile-close");n&&this.addListener(n,"click",()=>{this.close()}),this.elements.overlay&&this.addListener(this.elements.overlay,"click",()=>{this.close()})},getPanelHTML:function(){let e=typeof escapeHtml=="function"?escapeHtml:function(a){let r=document.createElement("div");return r.textContent=String(a??""),r.innerHTML},t=function(a){let r=String(a??"").trim();return/^(#[0-9a-fA-F]{3,8}|rgb[a]?\([^)]{1,60}\)|hsl[a]?\([^)]{1,60}\)|var\(--[a-zA-Z0-9_-]{1,40}\))$/.test(r)?r:"#000000"},n="";for(let[a,r]of Object.entries(this.PRIMARY_COLORS))n+=``;let s="";for(let[a,r]of Object.entries(this.NEUTRAL_COLORS))s+=``;let o="";this.RADIUS_OPTIONS.forEach(a=>{o+=``});let i="";for(let[a,r]of Object.entries(this.FONT_OPTIONS))i+=``;return`

    Customize Theme

    `;\n }\n\n // Generate neutral color swatches\n let neutralSwatches = '';\n for (const [key, value] of Object.entries(this.NEUTRAL_COLORS)) {\n neutralSwatches += ``;\n }\n\n // Generate radius buttons\n let radiusButtons = '';\n this.RADIUS_OPTIONS.forEach(r => {\n radiusButtons += ``;\n });\n\n // Generate font options\n let fontOptions = '';\n for (const [key, value] of Object.entries(this.FONT_OPTIONS)) {\n fontOptions += ``;\n }\n\n return `\n
    \n

    Customize Theme

    \n \n
    \n
    \n\n
    \n \n
    \n ${primarySwatches}\n
    \n
    \n
    \n \n
    \n ${neutralSwatches}\n
    \n
    \n
    \n \n
    \n ${radiusButtons}\n
    \n
    \n
    \n \n \n
    \n
    \n
    \n \n
    \n `;\n },\n\n /**\n * Bind event listeners\n */\n /**\n * Check whether the current primary color is one of the auto-defaults\n * (i.e. the user hasn't explicitly picked a non-default color).\n */\n isUsingDefaultPrimary: function () {\n return this.state.primary === this.DEFAULTS.PRIMARY_LIGHT ||\n this.state.primary === this.DEFAULTS.PRIMARY_DARK;\n },\n\n /**\n * When primary is still one of the auto-default palette keys (black/amber) but\n * localStorage was written under a different theme (or OS changed in system mode),\n * align in-memory state before applyAllPreferences runs \u2014 avoids amber+light / black+dark drift.\n */\n _normalizeDefaultPrimaryIfStaleWithStoredTheme: function () {\n if (!this.isUsingDefaultPrimary()) {\n return;\n }\n const expected = this.getDefaultPrimary(this.state.theme);\n if (this.state.primary !== expected) {\n this.state.primary = expected;\n }\n },\n\n bindEvents: function () {\n this.bindTriggerEvents();\n\n this.bindPanelEvents();\n\n // Listen for OS dark/light changes so we can swap the default primary\n if (window.matchMedia) {\n const mq = window.matchMedia('(prefers-color-scheme: dark)');\n const handler = () => {\n if (this.state.theme === 'system' && this.isUsingDefaultPrimary()) {\n const newDefault = this.getDefaultPrimary('system');\n if (newDefault !== this.state.primary) {\n this.applyPrimary(newDefault);\n this.updateUI();\n }\n }\n };\n mq.addEventListener('change', handler);\n this._cleanup.push(() => mq.removeEventListener('change', handler));\n }\n\n // Close on outside click\n this.addListener(document, 'click', (e) => {\n if (this.state.isOpen && this.elements.customizer && !this.elements.customizer.contains(e.target)) {\n this.close();\n }\n });\n\n // Close on Escape key\n this.addListener(document, 'keydown', (e) => {\n if (e.key === 'Escape' && this.state.isOpen) {\n this.close();\n }\n });\n },\n\n bindTriggerEvents: function () {\n this.elements.triggers.forEach((trigger) => {\n if (trigger.getAttribute('data-customizer-trigger-initialized') === 'true') {\n return;\n }\n this.addListener(trigger, 'click', (e) => {\n e.preventDefault();\n e.stopPropagation();\n this.elements.activeTrigger = trigger;\n this.elements.trigger = trigger;\n this.toggle();\n });\n trigger.setAttribute('data-customizer-trigger-initialized', 'true');\n });\n },\n\n /**\n * Toggle panel open/close\n */\n toggle: function () {\n if (this.state.isOpen) {\n this.close();\n } else {\n this.open();\n }\n },\n\n /**\n * Open the panel\n */\n open: function () {\n this.state.isOpen = true;\n\n // Ensure panel is positioned correctly before opening\n this.positionPanel();\n\n if (this.elements.panel) {\n this.elements.panel.classList.add('is-open');\n }\n this.elements.triggers.forEach((trigger) => trigger.setAttribute('aria-expanded', 'false'));\n if (this.elements.trigger) this.elements.trigger.setAttribute('aria-expanded', 'true');\n if (this.elements.overlay) {\n this.elements.overlay.classList.add('is-active');\n }\n\n this.dispatchEvent('panel-open', { isOpen: true });\n },\n\n /**\n * Close the panel\n */\n close: function () {\n this.state.isOpen = false;\n\n if (this.elements.panel) {\n this.elements.panel.classList.remove('is-open');\n }\n this.elements.triggers.forEach((trigger) => trigger.setAttribute('aria-expanded', 'false'));\n if (this.elements.overlay) {\n this.elements.overlay.classList.remove('is-active');\n }\n\n this.dispatchEvent('panel-close', { isOpen: false });\n },\n\n /**\n * Update UI to reflect current state\n */\n updateUI: function () {\n if (!this.elements.panel) return;\n\n // Update primary color swatches\n this.elements.panel.querySelectorAll('[data-color]').forEach(swatch => {\n swatch.classList.toggle('is-active', swatch.dataset.color === this.state.primary);\n });\n\n // Update neutral color swatches\n this.elements.panel.querySelectorAll('[data-neutral]').forEach(swatch => {\n swatch.classList.toggle('is-active', swatch.dataset.neutral === this.state.neutral);\n });\n\n // Update radius buttons\n this.elements.panel.querySelectorAll('[data-radius]').forEach(btn => {\n btn.classList.toggle('is-active', btn.dataset.radius === this.state.radius);\n });\n\n // Update font selector\n const fontSelect = this.elements.panel.querySelector('[data-customizer-font]');\n if (fontSelect) {\n fontSelect.value = this.state.font;\n }\n\n },\n\n /**\n * Reset all preferences to defaults\n */\n reset: function () {\n this.applyTheme(this.DEFAULTS.THEME);\n this.applyPrimary(this.getDefaultPrimary(this.DEFAULTS.THEME));\n this.applyNeutral(this.DEFAULTS.NEUTRAL);\n this.applyRadius(this.DEFAULTS.RADIUS);\n this.applyFont(this.DEFAULTS.FONT);\n this.updateUI();\n\n this.dispatchEvent('reset', { state: { ...this.state } });\n },\n\n /**\n * Get current state\n */\n getState: function () {\n return { ...this.state };\n },\n\n /**\n * Programmatically set preferences\n */\n setPreferences: function (prefs) {\n if (prefs.primary) this.applyPrimary(prefs.primary);\n if (prefs.neutral) this.applyNeutral(prefs.neutral);\n if (prefs.radius) this.applyRadius(prefs.radius);\n if (prefs.font) this.applyFont(prefs.font);\n if (prefs.theme) this.applyTheme(prefs.theme);\n this.updateUI();\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 destroyAll: function () {\n this._cleanup.forEach(fn => fn());\n this._cleanup = [];\n\n if (this.elements.panel) {\n this.elements.panel.removeAttribute('data-customizer-initialized');\n }\n\n this.close();\n this.isInitialized = false;\n }\n };\n\n // Register component\n if (window.Vanduo) {\n window.Vanduo.register('themeCustomizer', ThemeCustomizer);\n }\n\n // Expose globally for convenience\n window.ThemeCustomizer = ThemeCustomizer;\n})();\n", "/**\n * Vanduo Framework - Theme Switcher\n * Handles light/dark/system theme toggling and persistence\n */\n\n(function () {\n 'use strict';\n\n const ThemeSwitcher = {\n isInitialized: false,\n _mediaQuery: null,\n _onMediaChange: null,\n\n init: function () {\n this.STORAGE_KEY = 'vanduo-theme-preference';\n this.state = {\n preference: this.getPreference() // 'light', 'dark', or 'system'\n };\n\n if (this.isInitialized) {\n this.applyTheme();\n this.renderUI();\n this.updateUI();\n return;\n }\n\n this.isInitialized = true;\n\n this.applyTheme();\n this.listenForSystemChanges();\n this.renderUI();\n\n console.log('Vanduo Theme Switcher initialized');\n },\n\n getPreference: function () {\n return this.getStorageValue(this.STORAGE_KEY, 'system');\n },\n\n setPreference: function (pref) {\n this.state.preference = pref;\n this.setStorageValue(this.STORAGE_KEY, pref);\n this.applyTheme();\n \n // Coordinate with ThemeCustomizer for primary color swap if available\n // Check _isApplying flag to prevent circular updates\n if (window.ThemeCustomizer && window.ThemeCustomizer.applyTheme && !window.ThemeCustomizer._isApplying) {\n window.ThemeCustomizer.applyTheme(pref);\n }\n \n this.updateUI();\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 applyTheme: function () {\n const pref = this.state.preference;\n\n if (pref === 'system') {\n // When in system mode, we remove the data attribute to let the media query take over\n // or we can explicitly set it. Explicitly setting it ensures consistency if we rely on [data-theme]\n // But if we rely on @media in CSS, we might want to remove attributes.\n // However, our CSS strategy uses :root:not([data-theme=\"light\"]) inside media query for system dark fallback\n // which is a bit complex.\n\n // Simpler approach: \n // If preference is system, REMOVE data-theme attribute. Let CSS media queries handle it.\n document.documentElement.removeAttribute('data-theme');\n } else {\n document.documentElement.setAttribute('data-theme', pref);\n }\n },\n\n listenForSystemChanges: function () {\n if (this._mediaQuery && this._onMediaChange) {\n return;\n }\n\n this._mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n this._onMediaChange = _e => {\n if (this.state.preference === 'system') {\n // Re-apply (effectively just to ensure consistency, though removing attribute usually suffices)\n this.applyTheme();\n // Keep default primary (black/amber) aligned when OS scheme changes while in system mode\n if (window.ThemeCustomizer && typeof window.ThemeCustomizer.applyTheme === 'function' && !window.ThemeCustomizer._isApplying) {\n window.ThemeCustomizer.applyTheme('system');\n }\n }\n };\n this._mediaQuery.addEventListener('change', this._onMediaChange);\n },\n\n // Helper to facilitate UI creation if needed, though often UI is in HTML\n renderUI: function () {\n // Look for any uninitialized theme toggles\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"]');\n toggles.forEach(toggle => {\n if (toggle.getAttribute('data-theme-initialized') === 'true') {\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n }\n return;\n }\n\n // Simplified UI Binding - assumes a select or a button cycle\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n const onChange = (e) => {\n this.setPreference(e.target.value);\n };\n toggle.addEventListener('change', onChange);\n toggle._themeToggleHandler = onChange;\n } else {\n // Button implementation (cycle)\n const onClick = () => {\n const modes = ['system', 'light', 'dark'];\n const nextIndex = (modes.indexOf(this.state.preference) + 1) % modes.length;\n this.setPreference(modes[nextIndex]);\n };\n toggle.addEventListener('click', onClick);\n toggle._themeToggleHandler = onClick;\n }\n // Mark as initialized\n toggle.setAttribute('data-theme-initialized', 'true');\n });\n },\n\n updateUI: function () {\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"]');\n toggles.forEach(toggle => {\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n } else {\n // Update button text/icon if needed\n // e.g. toggle.textContent = this.state.preference;\n // For now, assume the user handles visual state or generic text\n\n // If there is an icon or text span inside, update it\n const span = toggle.querySelector('.theme-current-label');\n if (span) {\n span.textContent = this.state.preference.charAt(0).toUpperCase() + this.state.preference.slice(1);\n }\n }\n });\n },\n\n destroyAll: function () {\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"][data-theme-initialized=\"true\"]');\n toggles.forEach(toggle => {\n if (toggle._themeToggleHandler) {\n const eventName = toggle.tagName === 'SELECT' ? 'change' : 'click';\n toggle.removeEventListener(eventName, toggle._themeToggleHandler);\n delete toggle._themeToggleHandler;\n }\n toggle.removeAttribute('data-theme-initialized');\n });\n\n if (this._mediaQuery && this._onMediaChange) {\n this._mediaQuery.removeEventListener('change', this._onMediaChange);\n }\n\n this._mediaQuery = null;\n this._onMediaChange = null;\n this.isInitialized = false;\n }\n };\n\n // Register component\n if (window.Vanduo) {\n window.Vanduo.register('themeSwitcher', ThemeSwitcher);\n }\n})();\n", "/**\n * Vanduo Framework - Toast Component\n * Popup notifications for user feedback\n */\n\n(function() {\n 'use strict';\n\n /**\n * Toast Component\n */\n const Toast = {\n // Default options\n defaults: {\n position: 'top-right',\n duration: 5000,\n dismissible: true,\n showProgress: true,\n pauseOnHover: true\n },\n\n // Container cache\n containers: {},\n\n /**\n * Get or create a toast container for a position\n * @param {string} position - Container position\n * @returns {HTMLElement} Toast container element\n */\n getContainer: function(position) {\n if (this.containers[position]) {\n return this.containers[position];\n }\n\n const container = document.createElement('div');\n container.className = `vd-toast-container vd-toast-container-${position}`;\n container.setAttribute('role', 'status');\n container.setAttribute('aria-live', 'polite');\n container.setAttribute('aria-atomic', 'false');\n document.body.appendChild(container);\n this.containers[position] = container;\n\n return container;\n },\n\n /**\n * Show a toast notification\n * @param {Object|string} options - Toast options or message string\n * @param {string} [type] - Toast type (success, error, warning, info)\n * @param {number} [duration] - Auto-dismiss duration in ms\n * @returns {HTMLElement} Toast element\n */\n show: function(options, type, duration) {\n // Support simple API:\n // - Toast.show('Message', 'success', 3000)\n // - Toast.show('Message', { type: 'success', duration: 3000 })\n if (typeof options === 'string') {\n if (type && typeof type === 'object') {\n options = Object.assign({}, type, {\n message: options\n });\n } else {\n options = {\n message: options,\n type: type,\n duration: duration\n };\n }\n }\n\n const config = Object.assign({}, this.defaults, options);\n const container = this.getContainer(config.position);\n\n // Create toast element\n const toast = document.createElement('div');\n toast.className = 'vd-toast';\n\n if (config.type) {\n toast.classList.add(`vd-toast-${config.type}`);\n }\n\n if (config.solid) {\n toast.classList.add('vd-toast-solid');\n }\n\n if (config.showProgress && config.duration > 0) {\n toast.classList.add('vd-toast-with-progress');\n }\n\n // Build toast content\n let html = '';\n\n // Icon (sanitize custom icons, default icons are trusted SVG)\n if (config.icon) {\n const allowSvg = config.iconAllowSvg === true;\n const safeIcon = typeof sanitizeHtml === 'function'\n ? sanitizeHtml(config.icon, { allowSvg })\n : escapeHtml(config.icon);\n html += `${safeIcon}`;\n } else if (config.type) {\n html += `${this.getDefaultIcon(config.type)}`;\n }\n\n // Local escape helper \u2014 guarantees HTML-safe output even if the\n // global escapeHtml utility is not loaded in the current bundle.\n const _esc = typeof escapeHtml === 'function'\n ? escapeHtml\n : function (s) {\n const d = document.createElement('div');\n d.appendChild(document.createTextNode(s));\n return d.innerHTML;\n };\n\n // Content (escape text to prevent injection)\n html += '
    ';\n if (config.title) {\n html += `
    ${_esc(String(config.title))}
    `;\n }\n if (config.message) {\n html += `
    ${_esc(String(config.message))}
    `;\n }\n html += '
    ';\n\n // Close button\n if (config.dismissible) {\n html += '';\n }\n\n // Progress bar\n if (config.showProgress && config.duration > 0) {\n const safeDuration = parseInt(config.duration, 10) || 0;\n html += `
    `;\n }\n\n toast.innerHTML = html;\n\n // Add to container\n container.appendChild(toast);\n\n toast._toastCleanup = [];\n\n // Set up close button handler\n if (config.dismissible) {\n const closeBtn = toast.querySelector('.vd-toast-close');\n const onClose = () => {\n this.dismiss(toast);\n };\n closeBtn.addEventListener('click', onClose);\n toast._toastCleanup.push(() => closeBtn.removeEventListener('click', onClose));\n }\n\n // Pause on hover\n let timeoutId = null;\n let remainingTime = config.duration;\n let startTime = null;\n\n const startTimer = () => {\n if (config.duration > 0) {\n startTime = Date.now();\n timeoutId = setTimeout(() => {\n this.dismiss(toast);\n }, remainingTime);\n toast._toastTimeoutId = timeoutId;\n\n // Resume progress animation\n const progress = toast.querySelector('.vd-toast-progress');\n if (progress) {\n progress.style.animationPlayState = 'running';\n }\n }\n };\n\n const pauseTimer = () => {\n if (timeoutId) {\n clearTimeout(timeoutId);\n timeoutId = null;\n toast._toastTimeoutId = null;\n remainingTime -= Date.now() - startTime;\n\n // Pause progress animation\n const progress = toast.querySelector('.vd-toast-progress');\n if (progress) {\n progress.style.animationPlayState = 'paused';\n }\n }\n };\n\n if (config.pauseOnHover) {\n toast.addEventListener('mouseenter', pauseTimer);\n toast.addEventListener('mouseleave', startTimer);\n toast._toastCleanup.push(\n () => toast.removeEventListener('mouseenter', pauseTimer),\n () => toast.removeEventListener('mouseleave', startTimer)\n );\n }\n\n // Trigger enter animation\n requestAnimationFrame(() => {\n toast.classList.add('is-visible');\n startTimer();\n });\n\n // Store config on element for later access\n toast._toastConfig = config;\n\n // Dispatch show event\n const showEvent = new CustomEvent('toast:show', {\n bubbles: true,\n detail: { toast, config }\n });\n toast.dispatchEvent(showEvent);\n\n return toast;\n },\n\n /**\n * Dismiss a toast\n * @param {HTMLElement} toast - Toast element to dismiss\n */\n dismiss: function(toast) {\n if (!toast || toast.classList.contains('is-exiting')) return;\n\n if (toast._toastTimeoutId) {\n clearTimeout(toast._toastTimeoutId);\n toast._toastTimeoutId = null;\n }\n\n toast.classList.remove('is-visible');\n toast.classList.add('is-exiting');\n\n // Dispatch dismiss event\n const dismissEvent = new CustomEvent('toast:dismiss', {\n bubbles: true,\n detail: { toast }\n });\n toast.dispatchEvent(dismissEvent);\n\n // Remove after animation\n const handleTransitionEnd = () => {\n toast.removeEventListener('transitionend', handleTransitionEnd);\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n };\n\n toast.addEventListener('transitionend', handleTransitionEnd);\n\n // Fallback removal if transition doesn't fire\n setTimeout(() => {\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n }, 400);\n },\n\n /**\n * Destroy all toasts and containers\n */\n destroyAll: function() {\n Object.keys(this.containers).forEach(position => {\n const container = this.containers[position];\n if (!container) return;\n\n const toasts = container.querySelectorAll('.vd-toast');\n toasts.forEach(toast => {\n if (toast._toastTimeoutId) {\n clearTimeout(toast._toastTimeoutId);\n }\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n });\n\n if (container.parentElement) {\n container.parentElement.removeChild(container);\n }\n });\n\n this.containers = {};\n },\n\n /**\n * Dismiss all toasts\n * @param {string} [position] - Optional position to clear (clears all if not specified)\n */\n dismissAll: function(position) {\n if (position && this.containers[position]) {\n const toasts = this.containers[position].querySelectorAll('.vd-toast');\n toasts.forEach(toast => this.dismiss(toast));\n } else {\n Object.values(this.containers).forEach(container => {\n const toasts = container.querySelectorAll('.vd-toast');\n toasts.forEach(toast => this.dismiss(toast));\n });\n }\n },\n\n /**\n * Get default icon SVG for a type\n * @param {string} type - Toast type\n * @returns {string} SVG icon markup\n */\n getDefaultIcon: function(type) {\n const icons = {\n success: '',\n error: '',\n warning: '',\n info: ''\n };\n\n return icons[type] || '';\n },\n\n /**\n * Convenience methods for common toast types\n */\n success: function(message, options) {\n return this.show(Object.assign({ message, type: 'success' }, options));\n },\n\n error: function(message, options) {\n return this.show(Object.assign({ message, type: 'error' }, options));\n },\n\n warning: function(message, options) {\n return this.show(Object.assign({ message, type: 'warning' }, options));\n },\n\n info: function(message, options) {\n return this.show(Object.assign({ message, type: 'info' }, options));\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('toast', Toast);\n }\n\n // Also expose globally for convenience\n window.Toast = Toast;\n\n})();\n", "/**\n * Vanduo Framework - Tooltips Component\n * JavaScript functionality for tooltips\n */\n\n(function () {\n 'use strict';\n\n /**\n * Tooltips Component\n */\n const Tooltips = {\n tooltips: new Map(),\n delayTimers: new Map(),\n\n /**\n * Sanitize HTML \u2014 delegates to shared sanitizeHtml from helpers.js\n * @param {string} input\n * @param {{ allowSvg?: boolean }} [options]\n * @returns {string} sanitized HTML\n */\n sanitizeHtml: function (input, options) {\n if (typeof sanitizeHtml === 'function') {\n return sanitizeHtml(input, options);\n }\n // Fallback: content originates from developer-authored data attributes,\n // so returning it as-is is no worse than any other inline HTML.\n return input || '';\n },\n\n /**\n * Initialize tooltips\n */\n init: function () {\n const elements = document.querySelectorAll('[data-tooltip], [data-tooltip-html]');\n\n elements.forEach(element => {\n if (this.tooltips.has(element)) {\n return;\n }\n this.initTooltip(element);\n });\n },\n\n /**\n * Initialize a single tooltip\n * @param {HTMLElement} element - Element with tooltip\n */\n initTooltip: function (element) {\n const tooltip = this.createTooltip(element);\n const cleanupFunctions = [];\n\n // Show on hover/focus\n const enterHandler = () => { this.showTooltip(element, tooltip); };\n const leaveHandler = () => { this.hideTooltip(element, tooltip); };\n const focusHandler = () => { this.showTooltip(element, tooltip); };\n const blurHandler = () => { this.hideTooltip(element, tooltip); };\n\n element.addEventListener('mouseenter', enterHandler);\n element.addEventListener('mouseleave', leaveHandler);\n element.addEventListener('focus', focusHandler);\n element.addEventListener('blur', blurHandler);\n\n cleanupFunctions.push(\n () => element.removeEventListener('mouseenter', enterHandler),\n () => element.removeEventListener('mouseleave', leaveHandler),\n () => element.removeEventListener('focus', focusHandler),\n () => element.removeEventListener('blur', blurHandler)\n );\n\n this.tooltips.set(element, { tooltip, cleanup: cleanupFunctions });\n },\n\n /**\n * Create tooltip element\n * @param {HTMLElement} element - Target element\n * @returns {HTMLElement} Tooltip element\n */\n createTooltip: function (element) {\n const tooltip = document.createElement('div');\n tooltip.className = 'vd-tooltip';\n tooltip.setAttribute('role', 'tooltip');\n tooltip.setAttribute('aria-hidden', 'true');\n\n // Generate unique ID and link via aria-describedby\n const tooltipId = 'tooltip-' + Math.random().toString(36).substr(2, 9);\n tooltip.id = tooltipId;\n element.setAttribute('aria-describedby', tooltipId);\n\n // Get content\n const htmlContent = element.dataset.tooltipHtml;\n const textContent = element.dataset.tooltip;\n\n if (htmlContent) {\n const allowSvg = element.hasAttribute('data-tooltip-allow-svg');\n tooltip.innerHTML = this.sanitizeHtml(htmlContent, { allowSvg });\n tooltip.classList.add('vd-tooltip-html');\n } else if (textContent) {\n tooltip.textContent = textContent;\n }\n\n // Get placement\n const placement = element.dataset.tooltipPlacement || element.dataset.placement || 'top';\n tooltip.setAttribute('data-placement', placement);\n tooltip.classList.add(`vd-tooltip-${placement}`);\n\n // Get variant\n if (element.dataset.tooltipVariant) {\n tooltip.classList.add(`vd-tooltip-${element.dataset.tooltipVariant}`);\n }\n\n // Get size\n if (element.dataset.tooltipSize) {\n tooltip.classList.add(`vd-tooltip-${element.dataset.tooltipSize}`);\n }\n\n // Get delay\n const delay = parseInt(element.dataset.tooltipDelay) || 0;\n tooltip.dataset.delay = delay;\n\n document.body.appendChild(tooltip);\n\n return tooltip;\n },\n\n /**\n * Show tooltip\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n showTooltip: function (element, tooltip) {\n const delay = parseInt(tooltip.dataset.delay) || 0;\n\n if (delay > 0) {\n const timer = setTimeout(() => {\n this.positionTooltip(element, tooltip);\n tooltip.classList.add('is-visible');\n tooltip.setAttribute('aria-hidden', 'false');\n }, delay);\n this.delayTimers.set(element, timer);\n } else {\n this.positionTooltip(element, tooltip);\n tooltip.classList.add('is-visible');\n tooltip.setAttribute('aria-hidden', 'false');\n }\n },\n\n /**\n * Hide tooltip\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n hideTooltip: function (element, tooltip) {\n // Clear delay timer if exists\n const timer = this.delayTimers.get(element);\n if (timer) {\n clearTimeout(timer);\n this.delayTimers.delete(element);\n }\n\n tooltip.classList.remove('is-visible');\n tooltip.setAttribute('aria-hidden', 'true');\n },\n\n /**\n * Position tooltip relative to element\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n positionTooltip: function (element, tooltip) {\n const placement = tooltip.dataset.placement || 'top';\n const rect = element.getBoundingClientRect();\n const tooltipRect = tooltip.getBoundingClientRect();\n const scrollTop = window.pageYOffset || document.documentElement.scrollTop;\n const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;\n\n let top = 0;\n let left = 0;\n\n switch (placement) {\n case 'top':\n top = rect.top + scrollTop - tooltipRect.height - 8;\n left = rect.left + scrollLeft + (rect.width / 2) - (tooltipRect.width / 2);\n break;\n case 'bottom':\n top = rect.bottom + scrollTop + 8;\n left = rect.left + scrollLeft + (rect.width / 2) - (tooltipRect.width / 2);\n break;\n case 'left':\n top = rect.top + scrollTop + (rect.height / 2) - (tooltipRect.height / 2);\n left = rect.left + scrollLeft - tooltipRect.width - 8;\n break;\n case 'right':\n top = rect.top + scrollTop + (rect.height / 2) - (tooltipRect.height / 2);\n left = rect.right + scrollLeft + 8;\n break;\n }\n\n // Prevent overflow\n const viewportWidth = window.innerWidth;\n const viewportHeight = window.innerHeight;\n const padding = 8;\n\n if (left < padding) {\n left = padding;\n } else if (left + tooltipRect.width > viewportWidth - padding) {\n left = viewportWidth - tooltipRect.width - padding;\n }\n\n if (top < scrollTop + padding) {\n top = scrollTop + padding;\n } else if (top + tooltipRect.height > scrollTop + viewportHeight - padding) {\n top = scrollTop + viewportHeight - tooltipRect.height - padding;\n }\n\n // Use single style assignment with transform for better performance\n tooltip.style.cssText = `position: absolute; top: 0; left: 0; transform: translate(${left}px, ${top}px);`;\n },\n\n /**\n * Show tooltip programmatically\n * @param {HTMLElement|string} element - Target element or selector\n */\n show: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n this.showTooltip(el, tooltip);\n }\n },\n\n /**\n * Hide tooltip programmatically\n * @param {HTMLElement|string} element - Target element or selector\n */\n hide: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n this.hideTooltip(el, tooltip);\n }\n },\n\n /**\n * Update tooltip content\n * @param {HTMLElement|string} element - Target element or selector\n * @param {string} content - New content\n * @param {boolean} isHtml - Whether content is HTML\n */\n update: function (element, content, isHtml = false) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n if (isHtml) {\n const allowSvg = el.hasAttribute('data-tooltip-allow-svg');\n tooltip.innerHTML = this.sanitizeHtml(content, { allowSvg });\n tooltip.classList.add('vd-tooltip-html');\n } else {\n tooltip.textContent = content;\n tooltip.classList.remove('vd-tooltip-html');\n }\n }\n },\n\n /**\n * Destroy a tooltip instance and clean up\n * @param {HTMLElement} element - Element with tooltip\n */\n destroy: function (element) {\n const data = this.tooltips.get(element);\n if (!data) return;\n\n // Clear any pending timer\n const timer = this.delayTimers.get(element);\n if (timer) {\n clearTimeout(timer);\n this.delayTimers.delete(element);\n }\n\n data.cleanup.forEach(fn => fn());\n\n // Remove tooltip element from DOM\n if (data.tooltip && data.tooltip.parentNode) {\n data.tooltip.parentNode.removeChild(data.tooltip);\n }\n\n this.tooltips.delete(element);\n },\n\n /**\n * Destroy all tooltip instances\n */\n destroyAll: function () {\n this.tooltips.forEach((data, element) => {\n this.destroy(element);\n });\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('tooltips', Tooltips);\n }\n\n // Expose globally\n window.VanduoTooltips = Tooltips;\n\n})();\n", "/**\n * Vanduo Framework - Search Component\n * Client-side search functionality for content pages\n * \n * @example Basic usage (initialize with defaults)\n * // HTML:\n * //
    \n * // \n * //
    \n * //
    \n * \n * @example Custom configuration\n * var search = Search.create({\n * containerSelector: '.my-search',\n * contentSelector: 'article[id]',\n * titleSelector: 'h2, h3',\n * maxResults: 5,\n * onSelect: function(result) {\n * console.log('Selected:', result.title);\n * }\n * });\n * \n * @example With custom data source\n * var search = Search.create({\n * containerSelector: '.my-search',\n * data: [\n * { id: 'item1', title: 'First Item', content: 'Description...', category: 'Category A' },\n * { id: 'item2', title: 'Second Item', content: 'Description...', category: 'Category B' }\n * ]\n * });\n */\n\n(function() {\n 'use strict';\n\n /**\n * Default configuration\n */\n const DEFAULTS = {\n // Behavior\n minQueryLength: 2,\n maxResults: 10,\n debounceMs: 150,\n highlightTag: 'mark',\n keyboardShortcut: true, // Enable Cmd/Ctrl+K shortcut\n \n // Selectors (for DOM-based indexing)\n containerSelector: '.vd-doc-search',\n inputSelector: '.vd-doc-search-input',\n resultsSelector: '.vd-doc-search-results',\n contentSelector: '.doc-content section[id]',\n titleSelector: '.demo-title, h2, h3',\n navSelector: '.doc-nav-link',\n sectionSelector: '.doc-nav-section',\n \n // Content extraction\n excludeFromContent: 'pre, code, script, style',\n maxContentLength: 500,\n \n // Custom data source (alternative to DOM indexing)\n data: null,\n \n // Category icons mapping\n categoryIcons: {\n 'getting-started': 'ph-rocket-launch',\n 'core': 'ph-cube',\n 'components': 'ph-puzzle-piece',\n 'interactive': 'ph-cursor-click',\n 'data-display': 'ph-table',\n 'feedback': 'ph-bell',\n 'meta': 'ph-info',\n 'default': 'ph-file-text'\n },\n \n // Callbacks\n onSelect: null, // function(result) - called when result is selected\n onSearch: null, // function(query, results) - called after search\n onOpen: null, // function() - called when results open\n onClose: null, // function() - called when results close\n \n // Text customization\n emptyTitle: 'No results found',\n emptyText: 'Try different keywords or check spelling',\n placeholder: 'Search...'\n };\n\n /**\n * Search Component Factory\n * Creates a new search instance with the given configuration\n * \n * @param {Object} options - Configuration options\n * @returns {Object} Search instance\n */\n function createSearch(options) {\n const config = Object.assign({}, DEFAULTS, options || {});\n \n // Instance state\n const state = {\n initialized: false,\n index: [],\n results: [],\n activeIndex: -1,\n isOpen: false,\n query: '',\n container: null,\n input: null,\n resultsContainer: null,\n debounceTimer: null,\n boundHandlers: {}\n };\n\n function safeInvokeCallback(name, fn, ...args) {\n try {\n fn(...args);\n } catch (error) {\n console.warn('[Vanduo Search] Callback error in \"' + name + '\":', error);\n }\n }\n\n function setResultsHtml(html) {\n if (!state.resultsContainer) return;\n try {\n state.resultsContainer.innerHTML = html;\n } catch (error) {\n console.warn('[Vanduo Search] Failed to render results:', error);\n }\n }\n\n /**\n * Initialize the search component\n * Idempotent \u2014 safe to call more than once on the same instance.\n * Returns the instance on success, null if required DOM elements are missing.\n */\n function init() {\n if (state.initialized) {\n return instance;\n }\n\n state.container = document.querySelector(config.containerSelector);\n if (!state.container) {\n state.initialized = false;\n return null;\n }\n\n state.input = state.container.querySelector(config.inputSelector);\n state.resultsContainer = state.container.querySelector(config.resultsSelector);\n\n if (!state.input || !state.resultsContainer) {\n state.initialized = false;\n return null;\n }\n\n // Set placeholder if configured\n if (config.placeholder) {\n state.input.setAttribute('placeholder', config.placeholder);\n }\n\n // Build search index\n buildIndex();\n\n // Bind events\n bindEvents();\n\n // Set up ARIA attributes\n setupAria();\n\n state.initialized = true;\n return instance;\n }\n\n /**\n * Build search index from DOM or custom data\n */\n function buildIndex() {\n state.index = [];\n \n // Use custom data if provided\n if (config.data && Array.isArray(config.data)) {\n config.data.forEach(function(item) {\n state.index.push({\n id: item.id || slugify(item.title),\n title: item.title || '',\n category: item.category || '',\n categorySlug: slugify(item.category || ''),\n content: item.content || '',\n keywords: item.keywords || extractKeywordsFromText(item.title + ' ' + item.content),\n url: item.url || '#' + (item.id || slugify(item.title)),\n icon: item.icon || ''\n });\n });\n return;\n }\n\n // Build from DOM\n const sections = document.querySelectorAll(config.contentSelector);\n const categoryMap = buildCategoryMap();\n\n sections.forEach(function(section) {\n const id = section.id;\n if (!id) return;\n\n const titleEl = section.querySelector(config.titleSelector);\n const title = titleEl ? titleEl.textContent.replace(/v[\\d.]+/g, '').trim() : id;\n const category = categoryMap[id] || 'Documentation';\n const content = extractContent(section);\n const keywords = extractKeywords(section, title);\n const iconEl = titleEl ? titleEl.querySelector('i.ph') : null;\n let icon = '';\n if (iconEl && iconEl.classList) {\n for (let ci = 0; ci < iconEl.classList.length; ci++) {\n if (iconEl.classList[ci].indexOf('ph-') === 0) {\n icon = iconEl.classList[ci];\n break;\n }\n }\n }\n\n state.index.push({\n id: id,\n title: title,\n category: category,\n categorySlug: slugify(category),\n content: content,\n keywords: keywords,\n url: '#' + id,\n icon: icon\n });\n });\n }\n\n /**\n * Build a map of section IDs to their categories\n */\n function buildCategoryMap() {\n const map = {};\n let currentCategory = 'Documentation';\n const navItems = document.querySelectorAll(config.navSelector + ', ' + config.sectionSelector);\n\n navItems.forEach(function(item) {\n if (item.classList.contains('doc-nav-section')) {\n currentCategory = item.textContent.trim();\n } else {\n const href = item.getAttribute('href');\n if (href && href.startsWith('#')) {\n const id = href.substring(1);\n map[id] = currentCategory;\n }\n }\n });\n\n return map;\n }\n\n /**\n * Extract searchable content from a section\n */\n function extractContent(section) {\n const clone = section.cloneNode(true);\n \n const toRemove = clone.querySelectorAll(config.excludeFromContent);\n toRemove.forEach(function(el) {\n el.remove();\n });\n\n let text = clone.textContent || '';\n text = text.replace(/\\s+/g, ' ').trim();\n \n return text.substring(0, config.maxContentLength);\n }\n\n /**\n * Extract keywords from a section\n */\n function extractKeywords(section, title) {\n const keywords = [];\n \n // Add title words\n title.toLowerCase().split(/\\s+/).forEach(function(word) {\n if (word.length > 2) {\n keywords.push(word);\n }\n });\n\n // Add class names from code examples\n const codeBlocks = section.querySelectorAll('code');\n codeBlocks.forEach(function(code) {\n const text = code.textContent || '';\n const classMatches = text.match(/\\.([\\w-]+)/g);\n if (classMatches) {\n classMatches.forEach(function(match) {\n keywords.push(match.substring(1).toLowerCase());\n });\n }\n });\n\n // Add data attributes\n const dataAttrs = section.querySelectorAll('[data-tooltip], [data-modal]');\n dataAttrs.forEach(function(el) {\n const attrs = el.getAttributeNames().filter(function(name) {\n return name.startsWith('data-');\n });\n attrs.forEach(function(attr) {\n keywords.push(attr.replace('data-', ''));\n });\n });\n\n return Array.from(new Set(keywords));\n }\n\n /**\n * Extract keywords from text string\n */\n function extractKeywordsFromText(text) {\n const words = text.toLowerCase().split(/\\s+/);\n return words.filter(function(word) {\n return word.length > 2;\n });\n }\n\n /**\n * Convert string to slug\n */\n function slugify(str) {\n return str.toLowerCase().replace(/\\s+/g, '-').replace(/[^a-z0-9-]/g, '');\n }\n\n /**\n * Bind event listeners\n */\n function bindEvents() {\n // Store bound handlers for cleanup\n state.boundHandlers.handleInput = function(e) {\n handleInput(e);\n };\n state.boundHandlers.handleFocus = function() {\n if (state.query.length >= config.minQueryLength) {\n open();\n }\n };\n state.boundHandlers.handleKeydown = function(e) {\n handleKeydown(e);\n };\n state.boundHandlers.handleOutsideClick = function(e) {\n if (!state.container.contains(e.target)) {\n close();\n }\n };\n state.boundHandlers.handleGlobalKeydown = function(e) {\n if (config.keyboardShortcut && (e.metaKey || e.ctrlKey) && e.key === 'k') {\n e.preventDefault();\n state.input.focus();\n state.input.select();\n }\n };\n state.boundHandlers.handleResultClick = function(e) {\n const result = e.target.closest('.vd-doc-search-result');\n if (result) {\n const index = parseInt(result.dataset.index, 10);\n select(index);\n }\n };\n\n // Input events\n state.input.addEventListener('input', state.boundHandlers.handleInput);\n state.input.addEventListener('focus', state.boundHandlers.handleFocus);\n state.input.addEventListener('keydown', state.boundHandlers.handleKeydown);\n\n // Close on outside click\n document.addEventListener('click', state.boundHandlers.handleOutsideClick);\n\n // Global keyboard shortcut\n document.addEventListener('keydown', state.boundHandlers.handleGlobalKeydown);\n\n // Result click handling\n state.resultsContainer.addEventListener('click', state.boundHandlers.handleResultClick);\n }\n\n /**\n * Unbind event listeners\n */\n function unbindEvents() {\n if (state.input) {\n state.input.removeEventListener('input', state.boundHandlers.handleInput);\n state.input.removeEventListener('focus', state.boundHandlers.handleFocus);\n state.input.removeEventListener('keydown', state.boundHandlers.handleKeydown);\n }\n document.removeEventListener('click', state.boundHandlers.handleOutsideClick);\n document.removeEventListener('keydown', state.boundHandlers.handleGlobalKeydown);\n if (state.resultsContainer) {\n state.resultsContainer.removeEventListener('click', state.boundHandlers.handleResultClick);\n }\n }\n\n /**\n * Set up ARIA attributes\n */\n function setupAria() {\n const resultsId = state.resultsContainer.id || 'search-results-' + Math.random().toString(36).substr(2, 9);\n state.resultsContainer.id = resultsId;\n \n state.input.setAttribute('role', 'combobox');\n state.input.setAttribute('aria-autocomplete', 'list');\n state.input.setAttribute('aria-controls', resultsId);\n state.input.setAttribute('aria-expanded', 'false');\n \n state.resultsContainer.setAttribute('role', 'listbox');\n state.resultsContainer.setAttribute('aria-label', 'Search results');\n }\n\n /**\n * Handle input changes\n */\n function handleInput(e) {\n const query = e.target.value.trim();\n\n if (state.debounceTimer) {\n clearTimeout(state.debounceTimer);\n }\n\n state.debounceTimer = setTimeout(function() {\n state.query = query;\n\n if (query.length < config.minQueryLength) {\n close();\n return;\n }\n\n state.results = search(query);\n state.activeIndex = -1;\n render();\n open();\n\n // Callback\n if (typeof config.onSearch === 'function') {\n safeInvokeCallback('onSearch', config.onSearch, query, state.results);\n }\n }, config.debounceMs);\n }\n\n /**\n * Handle keyboard navigation\n */\n function handleKeydown(e) {\n if (!state.isOpen) {\n if (e.key === 'ArrowDown' && state.query.length >= config.minQueryLength) {\n e.preventDefault();\n state.results = search(state.query);\n render();\n open();\n }\n return;\n }\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n navigate(1);\n break;\n\n case 'ArrowUp':\n e.preventDefault();\n navigate(-1);\n break;\n\n case 'Enter':\n e.preventDefault();\n if (state.activeIndex >= 0) {\n select(state.activeIndex);\n } else if (state.results.length > 0) {\n select(0);\n }\n break;\n\n case 'Escape':\n e.preventDefault();\n close();\n break;\n\n case 'Tab':\n close();\n break;\n }\n }\n\n /**\n * Perform search\n */\n function search(query) {\n const terms = query.toLowerCase().split(/\\s+/).filter(function(t) {\n return t.length > 0;\n });\n const scored = [];\n\n state.index.forEach(function(entry) {\n let score = 0;\n const titleLower = entry.title.toLowerCase();\n const categoryLower = entry.category.toLowerCase();\n const contentLower = entry.content.toLowerCase();\n\n terms.forEach(function(term) {\n // Title match - highest priority\n if (titleLower.includes(term)) {\n score += 100;\n if (titleLower === term) {\n score += 50;\n } else if (titleLower.startsWith(term)) {\n score += 25;\n }\n }\n\n // Category match\n if (categoryLower.includes(term)) {\n score += 50;\n }\n\n // Keyword match\n const keywordMatch = entry.keywords.some(function(k) {\n return k.includes(term);\n });\n if (keywordMatch) {\n score += 30;\n }\n\n // Content match\n if (contentLower.includes(term)) {\n score += 10;\n }\n });\n\n if (score > 0) {\n scored.push({\n id: entry.id,\n title: entry.title,\n category: entry.category,\n categorySlug: entry.categorySlug,\n content: entry.content,\n url: entry.url,\n icon: entry.icon,\n score: score\n });\n }\n });\n\n scored.sort(function(a, b) {\n return b.score - a.score;\n });\n\n return scored.slice(0, config.maxResults);\n }\n\n /**\n * Render search results\n */\n function render() {\n if (state.results.length === 0) {\n setResultsHtml(renderEmpty());\n return;\n }\n\n let html = '
      ';\n\n state.results.forEach(function(result, index) {\n const isActive = index === state.activeIndex;\n const icon = result.icon || getCategoryIcon(result.categorySlug);\n const excerpt = getExcerpt(result.content, state.query);\n\n html += '
    • ' +\n '
      ' +\n '' +\n '
      ' +\n '
      ' +\n '
      ' + highlight(result.title, state.query) + '
      ' +\n '
      ' + escapeHtml(result.category) + '
      ' +\n '
      ' + highlight(excerpt, state.query) + '
      ' +\n '
      ' +\n '
    • ';\n });\n\n html += '
    ';\n html += renderFooter();\n\n setResultsHtml(html);\n }\n\n /**\n * Render empty state\n */\n function renderEmpty() {\n return '
    ' +\n '
    ' +\n '
    ' + escapeHtml(config.emptyTitle) + '
    ' +\n '
    ' + escapeHtml(config.emptyText) + '
    ' +\n '
    ';\n }\n\n /**\n * Render footer with keyboard hints\n */\n function renderFooter() {\n return '
    ' +\n '\u2191\u2193 to navigate' +\n '\u21B5 to select' +\n 'esc to close' +\n '
    ';\n }\n\n /**\n * Get icon for category\n */\n function getCategoryIcon(categorySlug) {\n return config.categoryIcons[categorySlug] || config.categoryIcons['default'] || 'ph-file-text';\n }\n\n /**\n * Get excerpt from content\n */\n function getExcerpt(content, query) {\n const terms = query.toLowerCase().split(/\\s+/);\n const contentLower = content.toLowerCase();\n const excerptLength = 100;\n\n let matchPos = -1;\n for (let i = 0; i < terms.length; i++) {\n const pos = contentLower.indexOf(terms[i]);\n if (pos !== -1 && (matchPos === -1 || pos < matchPos)) {\n matchPos = pos;\n }\n }\n\n if (matchPos === -1) {\n return content.substring(0, excerptLength) + '...';\n }\n\n const start = Math.max(0, matchPos - 30);\n const end = Math.min(content.length, matchPos + excerptLength);\n let excerpt = content.substring(start, end);\n\n if (start > 0) {\n excerpt = '...' + excerpt;\n }\n if (end < content.length) {\n excerpt = excerpt + '...';\n }\n\n return excerpt;\n }\n\n /**\n * Highlight matched terms in text\n */\n function highlight(text, query) {\n if (!query) return escapeHtml(text);\n\n const terms = query.toLowerCase().split(/\\s+/).filter(function(t) {\n return t.length > 0;\n });\n let escaped = escapeHtml(text);\n\n terms.forEach(function(term) {\n // Skip overly long terms to prevent ReDoS\n if (term.length > 50) return;\n const regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi');\n escaped = escaped.replace(regex, '<' + config.highlightTag + '>$1');\n });\n\n return escaped;\n }\n\n /**\n * Escape HTML entities\n */\n function escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Navigate results with keyboard\n */\n function navigate(direction) {\n let newIndex = state.activeIndex + direction;\n\n if (newIndex < 0) {\n newIndex = state.results.length - 1;\n } else if (newIndex >= state.results.length) {\n newIndex = 0;\n }\n\n setActiveIndex(newIndex);\n }\n\n /**\n * Set active result index\n */\n function setActiveIndex(index) {\n const prevActive = state.resultsContainer.querySelector('.vd-doc-search-result.is-active');\n if (prevActive) {\n prevActive.classList.remove('is-active');\n prevActive.setAttribute('aria-selected', 'false');\n }\n\n state.activeIndex = index;\n\n const newActive = state.resultsContainer.querySelector('[data-index=\"' + index + '\"]');\n if (newActive) {\n newActive.classList.add('is-active');\n newActive.setAttribute('aria-selected', 'true');\n state.input.setAttribute('aria-activedescendant', 'vd-doc-search-result-' + index);\n newActive.scrollIntoView({ block: 'nearest' });\n }\n }\n\n /**\n * Select a result\n */\n function select(index) {\n const result = state.results[index];\n if (!result) return;\n\n // Close search\n close();\n state.input.value = '';\n state.query = '';\n\n // Custom callback\n if (typeof config.onSelect === 'function') {\n safeInvokeCallback('onSelect', config.onSelect, result);\n return;\n }\n\n // Default behavior: navigate to section\n const section = document.querySelector(result.url);\n if (section) {\n section.scrollIntoView({ behavior: 'smooth', block: 'start' });\n window.history.pushState(null, '', result.url);\n updateSidebarActive(result.id);\n }\n }\n\n /**\n * Update sidebar navigation active state\n */\n function updateSidebarActive(sectionId) {\n const navLinks = document.querySelectorAll(config.navSelector);\n navLinks.forEach(function(link) {\n link.classList.remove('active');\n if (link.getAttribute('href') === '#' + sectionId) {\n link.classList.add('active');\n }\n });\n }\n\n /**\n * Open results dropdown\n */\n function open() {\n if (state.isOpen) return;\n\n state.isOpen = true;\n state.resultsContainer.classList.add('is-open');\n state.input.setAttribute('aria-expanded', 'true');\n\n if (typeof config.onOpen === 'function') {\n safeInvokeCallback('onOpen', config.onOpen);\n }\n }\n\n /**\n * Close results dropdown\n */\n function close() {\n if (!state.isOpen) return;\n\n state.isOpen = false;\n state.activeIndex = -1;\n state.resultsContainer.classList.remove('is-open');\n state.input.setAttribute('aria-expanded', 'false');\n state.input.removeAttribute('aria-activedescendant');\n\n if (typeof config.onClose === 'function') {\n safeInvokeCallback('onClose', config.onClose);\n }\n }\n\n /**\n * Destroy the component\n */\n function destroy() {\n unbindEvents();\n \n state.initialized = false;\n state.index = [];\n state.results = [];\n state.isOpen = false;\n state.query = '';\n\n if (state.debounceTimer) {\n clearTimeout(state.debounceTimer);\n }\n\n if (state.resultsContainer) {\n setResultsHtml('');\n }\n }\n\n /**\n * Rebuild the search index\n */\n function rebuild() {\n buildIndex();\n }\n\n /**\n * Update configuration\n */\n function setConfig(newConfig) {\n Object.assign(config, newConfig);\n }\n\n /**\n * Get current configuration\n */\n function getConfig() {\n return Object.assign({}, config);\n }\n\n /**\n * Get search index\n */\n function getIndex() {\n return state.index.slice();\n }\n\n // Public instance API\n const instance = {\n init: init,\n destroy: destroy,\n rebuild: rebuild,\n search: search,\n open: open,\n close: close,\n setConfig: setConfig,\n getConfig: getConfig,\n getIndex: getIndex\n };\n\n return instance;\n }\n\n /**\n * Search Component (singleton for backward compatibility)\n */\n const Search = {\n // Factory method \u2014 creates and auto-initializes a new independent instance.\n // Always returns the instance so callers retain a reference even if the\n // DOM container is not yet available (they can retry init() later).\n create: function(options) {\n const instance = createSearch(options);\n if (instance) {\n instance.init();\n }\n return instance || null;\n },\n \n // Default instance\n _instance: null,\n \n // Configuration (for default instance)\n config: Object.assign({}, DEFAULTS),\n\n /**\n * Initialize the default search instance\n */\n init: function(options) {\n if (this._instance) {\n this._instance.destroy();\n }\n \n if (options) {\n Object.assign(this.config, options);\n }\n \n this._instance = createSearch(this.config);\n return this._instance ? this._instance.init() : null;\n },\n\n /**\n * Destroy the default instance\n */\n destroy: function() {\n if (this._instance) {\n this._instance.destroy();\n this._instance = null;\n }\n },\n\n destroyAll: function() {\n this.destroy();\n },\n\n /**\n * Rebuild the default instance index\n */\n rebuild: function() {\n if (this._instance) {\n this._instance.rebuild();\n }\n },\n\n /**\n * Search using the default instance\n */\n search: function(query) {\n return this._instance ? this._instance.search(query) : [];\n },\n\n /**\n * Open the default instance\n */\n open: function() {\n if (this._instance) {\n this._instance.open();\n }\n },\n\n /**\n * Close the default instance\n */\n close: function() {\n if (this._instance) {\n this._instance.close();\n }\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('docSearch', Search);\n }\n\n // Expose globally (both names for compatibility)\n window.Search = Search;\n window.DocSearch = Search; // Backward compatibility\n window.VanduoDocSearch = Search; // New name compatibility\n\n})();\n", "/**\n * Vanduo Framework - Draggable Component\n * JavaScript functionality for draggable elements and drop zones\n */\n\n(function () {\n 'use strict';\n\n /**\n * Draggable Component\n */\n const Draggable = {\n // Store initialized draggables and their cleanup functions\n instances: new Map(),\n // Store current drag state\n currentDrag: null,\n // Store touch state\n touchState: null,\n // Feedback element\n feedbackElement: null,\n // Shared selector used by init and touch reorder\n containerSelector: '.vd-draggable-container, .vd-draggable-container-vertical',\n\n /**\n * Initialize draggable components\n */\n init: function () {\n const draggables = document.querySelectorAll('.vd-draggable, [data-draggable]');\n\n draggables.forEach(element => {\n if (this.instances.has(element)) {\n return;\n }\n this.initDraggable(element);\n });\n\n const containers = document.querySelectorAll(this.containerSelector);\n containers.forEach(container => {\n if (!this.instances.has(container)) {\n this.initContainer(container);\n }\n });\n\n const dropZones = document.querySelectorAll('.vd-drop-zone');\n dropZones.forEach(zone => {\n if (!this.instances.has(zone)) {\n this.initDropZone(zone);\n }\n });\n\n this.createFeedbackElement();\n },\n\n /**\n * Initialize a single draggable element\n * @param {HTMLElement} element - Draggable element\n */\n initDraggable: function (element) {\n const cleanupFunctions = [];\n\n // Make element draggable if not already\n if (!element.hasAttribute('draggable')) {\n element.setAttribute('draggable', 'true');\n }\n\n // Accessibility: add ARIA attributes\n if (!element.hasAttribute('tabindex')) {\n element.setAttribute('tabindex', '0');\n }\n element.setAttribute('role', 'option');\n element.setAttribute('aria-roledescription', 'draggable item');\n element.setAttribute('aria-grabbed', 'false');\n\n // Handle drag start\n const dragStartHandler = (e) => {\n this.handleDragStart(e, element);\n };\n element.addEventListener('dragstart', dragStartHandler);\n cleanupFunctions.push(() => element.removeEventListener('dragstart', dragStartHandler));\n\n // Handle drag\n const dragHandler = (e) => {\n this.handleDrag(e, element);\n };\n element.addEventListener('drag', dragHandler);\n cleanupFunctions.push(() => element.removeEventListener('drag', dragHandler));\n\n // Handle drag end\n const dragEndHandler = (e) => {\n this.handleDragEnd(e, element);\n };\n element.addEventListener('dragend', dragEndHandler);\n cleanupFunctions.push(() => element.removeEventListener('dragend', dragEndHandler));\n\n // Handle touch start (for mobile)\n const touchStartHandler = (e) => {\n this.handleTouchStart(e, element);\n };\n element.addEventListener('touchstart', touchStartHandler);\n cleanupFunctions.push(() => element.removeEventListener('touchstart', touchStartHandler));\n\n // Handle touch move (for mobile)\n // { passive: false } is required so that e.preventDefault() works\n // once the drag threshold is reached \u2014 without it, modern browsers\n // treat the listener as passive and silently ignore preventDefault().\n const touchMoveHandler = (e) => {\n this.handleTouchMove(e, element);\n };\n element.addEventListener('touchmove', touchMoveHandler, { passive: false });\n cleanupFunctions.push(() => element.removeEventListener('touchmove', touchMoveHandler));\n\n // Handle touch end (for mobile)\n const touchEndHandler = (e) => {\n this.handleTouchEnd(e, element);\n };\n element.addEventListener('touchend', touchEndHandler, { passive: false });\n cleanupFunctions.push(() => element.removeEventListener('touchend', touchEndHandler));\n\n // Handle touch cancel (for mobile)\n const touchCancelHandler = (e) => {\n this.handleTouchEnd(e, element);\n };\n element.addEventListener('touchcancel', touchCancelHandler);\n cleanupFunctions.push(() => element.removeEventListener('touchcancel', touchCancelHandler));\n\n // Keyboard navigation\n const keydownHandler = (e) => {\n this.handleKeydown(e, element);\n };\n element.addEventListener('keydown', keydownHandler);\n cleanupFunctions.push(() => element.removeEventListener('keydown', keydownHandler));\n\n this.instances.set(element, { cleanup: cleanupFunctions });\n },\n\n /**\n * Initialize a draggable container\n * @param {HTMLElement} container - Draggable container\n */\n initContainer: function (container) {\n // Accessibility: add ARIA role to container\n container.setAttribute('role', 'listbox');\n container.setAttribute('aria-label', container.getAttribute('aria-label') || 'Draggable items');\n\n const items = container.querySelectorAll('.vd-draggable-item');\n items.forEach(item => {\n if (!this.instances.has(item)) {\n this.initDraggable(item);\n }\n });\n\n const cleanupFunctions = [];\n\n // Handle drag enter\n const dragEnterHandler = (e) => {\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n };\n\n // Handle drag over for auto-sorting\n const dragOverHandler = (e) => {\n e.preventDefault(); // Necessary to allow drop\n e.dataTransfer.dropEffect = 'move';\n\n if (!this.currentDrag) return;\n const draggingEl = this.currentDrag.element;\n\n // Only reorder if dragging an item that belongs to this container (or if we support cross-container drag, but keep simple)\n if (!container.contains(draggingEl)) return;\n\n // Prevent jumps when the dragging finalizes with 0,0 coordinates near the end\n if (e.clientX === 0 && e.clientY === 0) return;\n\n this.handleReorder(container, draggingEl, e.clientX, e.clientY);\n };\n\n // Handle drop\n const dropHandler = (e) => {\n e.preventDefault(); // crucial to prevent the browser's default handling and snapping back\n };\n\n container.addEventListener('dragenter', dragEnterHandler);\n container.addEventListener('dragover', dragOverHandler);\n container.addEventListener('drop', dropHandler);\n\n cleanupFunctions.push(() => {\n container.removeEventListener('dragenter', dragEnterHandler);\n container.removeEventListener('dragover', dragOverHandler);\n container.removeEventListener('drop', dropHandler);\n });\n\n this.instances.set(container, { cleanup: cleanupFunctions });\n },\n\n /**\n * Initialize a drop zone\n * @param {HTMLElement} zone - Drop zone element\n */\n initDropZone: function (zone) {\n const cleanupFunctions = [];\n\n // Accessibility: add ARIA role to drop zone\n zone.setAttribute('role', 'region');\n zone.setAttribute('aria-dropeffect', 'move');\n if (!zone.hasAttribute('aria-label')) {\n zone.setAttribute('aria-label', 'Drop zone');\n }\n\n // Handle drag over\n const dragOverHandler = (e) => {\n e.preventDefault();\n this.handleDragOver(e, zone);\n };\n zone.addEventListener('dragover', dragOverHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragover', dragOverHandler));\n\n // Handle drag enter\n const dragEnterHandler = (e) => {\n e.preventDefault();\n this.handleDragEnter(e, zone);\n };\n zone.addEventListener('dragenter', dragEnterHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragenter', dragEnterHandler));\n\n // Handle drag leave\n const dragLeaveHandler = (e) => {\n this.handleDragLeave(e, zone);\n };\n zone.addEventListener('dragleave', dragLeaveHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragleave', dragLeaveHandler));\n\n // Handle drop\n const dropHandler = (e) => {\n e.preventDefault();\n this.handleDrop(e, zone);\n };\n zone.addEventListener('drop', dropHandler);\n cleanupFunctions.push(() => zone.removeEventListener('drop', dropHandler));\n\n this.instances.set(zone, { cleanup: cleanupFunctions });\n },\n\n /**\n * Create feedback element for drag operations\n */\n createFeedbackElement: function () {\n if (!this.feedbackElement) {\n // Reuse existing element if present\n const existing = document.querySelector('.vd-drag-feedback');\n if (existing) {\n this.feedbackElement = existing;\n return;\n }\n\n this.feedbackElement = document.createElement('div');\n this.feedbackElement.className = 'vd-drag-feedback hidden';\n this.feedbackElement.setAttribute('role', 'presentation');\n document.body.appendChild(this.feedbackElement);\n }\n },\n\n /**\n * Handle drag start event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDragStart: function (e, element) {\n // Add dragging class\n element.classList.add('is-dragging');\n\n // Accessibility: update aria-grabbed\n element.setAttribute('aria-grabbed', 'true');\n\n // Store drag state\n this.currentDrag = {\n element: element,\n initialPosition: { x: e.clientX, y: e.clientY },\n initialBounds: element.getBoundingClientRect(),\n data: this.getData(element)\n };\n\n // Set drag data\n e.dataTransfer.effectAllowed = 'move';\n e.dataTransfer.setData('text/plain', this.currentDrag.data);\n\n // We no longer suppress the native ghost image or manually update feedback\n // for mouse drags, relying on the browser's native rendering instead.\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:start', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: e.clientX, y: e.clientY }\n }\n }));\n },\n\n /**\n * Handle drag event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDrag: function (e, element) {\n // Guard against null state (race condition on fast interactions)\n if (!this.currentDrag) return;\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:drag', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: e.clientX, y: e.clientY },\n delta: {\n x: e.clientX - this.currentDrag.initialPosition.x,\n y: e.clientY - this.currentDrag.initialPosition.y\n }\n }\n }));\n },\n\n /**\n * Handle drag end event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDragEnd: function (e, element) {\n // Remove dragging class\n element.classList.remove('is-dragging');\n element.classList.add('is-dropped');\n setTimeout(() => element.classList.remove('is-dropped'), 300);\n\n // Accessibility: update aria-grabbed\n element.setAttribute('aria-grabbed', 'false');\n\n // Hide feedback\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n\n // Guard against null state\n const data = this.currentDrag?.data || this.getData(element);\n const initialPos = this.currentDrag?.initialPosition || { x: 0, y: 0 };\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:end', {\n bubbles: true,\n detail: {\n element: element,\n data: data,\n position: { x: e.clientX, y: e.clientY },\n delta: {\n x: e.clientX - initialPos.x,\n y: e.clientY - initialPos.y\n }\n }\n }));\n\n // Reset drag state\n this.currentDrag = null;\n },\n\n /**\n * Handle touch start event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchStart: function (e, element) {\n // Don't prevent default here \u2014 it blocks scrolling.\n // We only prevent default in touchmove once drag threshold is reached.\n const touch = e.touches[0];\n const rect = element.getBoundingClientRect();\n this.touchState = {\n element: element,\n startX: touch.clientX,\n startY: touch.clientY,\n lastX: touch.clientX,\n lastY: touch.clientY,\n // Keep preview anchored to the original grab point.\n offsetX: touch.clientX - rect.left,\n offsetY: touch.clientY - rect.top,\n startTime: Date.now(),\n isDragging: false\n };\n },\n\n /**\n * Handle touch move event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchMove: function (e, element) {\n if (!this.touchState) return;\n\n const touch = e.touches[0];\n this.touchState.lastX = touch.clientX;\n this.touchState.lastY = touch.clientY;\n const deltaX = touch.clientX - this.touchState.startX;\n const deltaY = touch.clientY - this.touchState.startY;\n\n // Only start dragging if moved a minimum distance\n if (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10) {\n // Now we know it's a drag, not a scroll \u2014 prevent default\n if (e.cancelable) e.preventDefault();\n\n if (!this.touchState.isDragging) {\n this.touchState.isDragging = true;\n element.classList.add('is-dragging');\n element.setAttribute('aria-grabbed', 'true');\n\n // Store drag state\n this.currentDrag = {\n element: element,\n initialPosition: { x: this.touchState.startX, y: this.touchState.startY },\n initialBounds: element.getBoundingClientRect(),\n data: this.getData(element),\n // Preserve where inside the element the drag started for accurate ghost positioning.\n offsetX: this.touchState.offsetX,\n offsetY: this.touchState.offsetY\n };\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:start', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: touch.clientX, y: touch.clientY }\n }\n }));\n }\n\n // Update feedback\n this.updateFeedback(touch.clientX, touch.clientY);\n\n // Dispatch event\n if (this.currentDrag) {\n element.dispatchEvent(new CustomEvent('draggable:drag', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: touch.clientX, y: touch.clientY },\n delta: { x: deltaX, y: deltaY }\n }\n }));\n\n this.updateTouchDropZone(touch.clientX, touch.clientY);\n\n // Reorder for touch\n const container = element.closest(this.containerSelector);\n if (container && container.contains(element)) {\n this.handleReorder(container, element, touch.clientX, touch.clientY);\n }\n }\n }\n },\n\n /**\n * Handle touch end event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchEnd: function (e, element) {\n if (this.touchState && this.touchState.isDragging) {\n if (e.cancelable) e.preventDefault();\n const endTouch = e.changedTouches?.[0];\n const endPosition = {\n x: endTouch?.clientX ?? this.touchState.lastX ?? this.touchState.startX,\n y: endTouch?.clientY ?? this.touchState.lastY ?? this.touchState.startY\n };\n\n const dropZone = this.resolveDropZoneAtPoint(endPosition.x, endPosition.y) || this.touchState.overZone;\n if (dropZone) {\n this.dispatchDrop(dropZone, endPosition);\n } else if (this.touchState.overZone) {\n this.touchState.overZone.classList.remove('is-drag-over');\n }\n\n element.classList.remove('is-dragging');\n element.classList.add('is-dropped');\n element.setAttribute('aria-grabbed', 'false');\n setTimeout(() => element.classList.remove('is-dropped'), 300);\n\n // Hide feedback\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n\n // Dispatch event\n const data = this.currentDrag?.data || this.getData(element);\n const startX = this.touchState?.startX || 0;\n const startY = this.touchState?.startY || 0;\n\n element.dispatchEvent(new CustomEvent('draggable:end', {\n bubbles: true,\n detail: {\n element: element,\n data: data,\n position: endPosition,\n delta: {\n x: endPosition.x - startX,\n y: endPosition.y - startY\n }\n }\n }));\n }\n\n // Reset states\n this.touchState = null;\n this.currentDrag = null;\n },\n\n /**\n * Handle drag over event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} _zone - Drop zone element\n */\n handleDragOver: function (e, _zone) {\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n },\n\n /**\n * Handle drag enter event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDragEnter: function (e, zone) {\n e.preventDefault();\n zone.classList.add('is-drag-over');\n },\n\n /**\n * Handle drag leave event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDragLeave: function (e, zone) {\n zone.classList.remove('is-drag-over');\n },\n\n /**\n * Handle drop event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDrop: function (e, zone) {\n e.preventDefault();\n this.dispatchDrop(zone, { x: e.clientX, y: e.clientY });\n },\n\n /**\n * Resolve a drop zone from viewport coordinates\n * @param {number} x\n * @param {number} y\n * @returns {HTMLElement|null}\n */\n resolveDropZoneAtPoint: function (x, y) {\n if (!Number.isFinite(x) || !Number.isFinite(y)) return null;\n\n // Prefer the full stacking list so overlays/top elements don't hide real drop targets.\n if (typeof document.elementsFromPoint === 'function') {\n const stacked = document.elementsFromPoint(x, y);\n for (const element of stacked) {\n const zone = element.closest('.vd-drop-zone');\n if (zone) return zone;\n }\n }\n\n const target = document.elementFromPoint(x, y);\n const targetZone = target ? target.closest('.vd-drop-zone') : null;\n if (targetZone) return targetZone;\n\n // Last-resort fallback for mobile emulation edge cases.\n const zones = document.querySelectorAll('.vd-drop-zone');\n for (const zone of zones) {\n const rect = zone.getBoundingClientRect();\n if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {\n return zone;\n }\n }\n\n return null;\n },\n\n /**\n * Track and update active drop-zone hover state on touch devices\n * @param {number} x\n * @param {number} y\n */\n updateTouchDropZone: function (x, y) {\n if (!this.touchState) return;\n\n const nextZone = this.resolveDropZoneAtPoint(x, y);\n const prevZone = this.touchState.overZone || null;\n\n if (prevZone && prevZone !== nextZone) {\n prevZone.classList.remove('is-drag-over');\n }\n\n if (nextZone && nextZone !== prevZone) {\n nextZone.classList.add('is-drag-over');\n }\n\n this.touchState.overZone = nextZone || null;\n },\n\n /**\n * Dispatch a normalized drop event for mouse and touch flows\n * @param {HTMLElement} zone\n * @param {{x:number, y:number}} position\n */\n dispatchDrop: function (zone, position) {\n zone.classList.remove('is-drag-over');\n zone.dispatchEvent(new CustomEvent('draggable:drop', {\n bubbles: true,\n detail: {\n zone: zone,\n element: this.currentDrag?.element,\n data: this.currentDrag?.data,\n position: position\n }\n }));\n },\n\n /**\n * Reorder elements in container based on cursor position\n * @param {HTMLElement} container \n * @param {HTMLElement} element \n * @param {number} clientX \n * @param {number} clientY \n */\n handleReorder: function (container, element, clientX, clientY) {\n const isVertical = container.classList.contains('vd-draggable-container-vertical');\n const siblings = [...container.querySelectorAll('.vd-draggable-item:not(.is-dragging), .vd-draggable:not(.is-dragging)')];\n\n const nextSibling = siblings.reduce((closest, child) => {\n const box = child.getBoundingClientRect();\n const offset = isVertical\n ? clientY - box.top - box.height / 2\n : clientX - box.left - box.width / 2;\n\n if (offset < 0 && offset > closest.offset) {\n return { offset: offset, element: child };\n } else {\n return closest;\n }\n }, { offset: Number.NEGATIVE_INFINITY }).element;\n\n if (nextSibling == null) {\n container.appendChild(element);\n } else {\n container.insertBefore(element, nextSibling);\n }\n },\n\n /**\n * Handle keyboard events\n * @param {KeyboardEvent} e - Keyboard event\n * @param {HTMLElement} element - Draggable element\n */\n handleKeydown: function (e, element) {\n switch (e.key) {\n case 'Enter':\n case ' ':\n e.preventDefault();\n // Trigger click or custom action\n element.click();\n break;\n case 'Escape':\n // Cancel drag if in progress\n if (element.classList.contains('is-dragging')) {\n element.classList.remove('is-dragging');\n element.setAttribute('aria-grabbed', 'false');\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n this.currentDrag = null;\n }\n break;\n case 'ArrowUp':\n case 'ArrowLeft': {\n e.preventDefault();\n const prev = element.previousElementSibling;\n if (prev && (prev.classList.contains('vd-draggable') || prev.classList.contains('vd-draggable-item'))) {\n element.parentNode.insertBefore(element, prev);\n element.focus();\n element.dispatchEvent(new CustomEvent('draggable:reorder', {\n bubbles: true,\n detail: { element: element, direction: 'up' }\n }));\n }\n break;\n }\n case 'ArrowDown':\n case 'ArrowRight': {\n e.preventDefault();\n const next = element.nextElementSibling;\n if (next && (next.classList.contains('vd-draggable') || next.classList.contains('vd-draggable-item'))) {\n element.parentNode.insertBefore(next, element);\n element.focus();\n element.dispatchEvent(new CustomEvent('draggable:reorder', {\n bubbles: true,\n detail: { element: element, direction: 'down' }\n }));\n }\n break;\n }\n }\n },\n\n /**\n * Get data from draggable element\n * @param {HTMLElement} element - Draggable element\n * @returns {string} Data associated with the element\n */\n getData: function (element) {\n return element.dataset.draggable || element.textContent.trim();\n },\n\n /**\n * Update drag feedback element\n * @param {number} x - Current X coordinate\n * @param {number} y - Current Y coordinate\n */\n updateFeedback: function (x, y) {\n if (!this.currentDrag) return;\n\n // Show feedback\n this.feedbackElement.classList.remove('hidden');\n\n // Update feedback content\n const rect = this.currentDrag.initialBounds;\n this.feedbackElement.innerHTML = '';\n const clone = this.currentDrag.element.cloneNode(true);\n this.feedbackElement.appendChild(clone);\n\n // Set styles\n const offsetX = this.currentDrag.offsetX ?? 20;\n const offsetY = this.currentDrag.offsetY ?? 20;\n Object.assign(this.feedbackElement.style, {\n left: (x - offsetX) + 'px',\n top: (y - offsetY) + 'px',\n width: rect.width + 'px',\n height: rect.height + 'px'\n });\n },\n\n /**\n * Make an element draggable programmatically\n * @param {HTMLElement|string} element - Element or selector\n * @param {Object} options - Configuration options\n */\n makeDraggable: function (element, options = {}) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n\n if (el && !this.instances.has(el)) {\n // Add classes and attributes\n el.classList.add('vd-draggable');\n el.setAttribute('draggable', 'true');\n\n // Set options\n if (options.data) {\n el.dataset.draggable = options.data;\n }\n\n // Initialize\n this.initDraggable(el);\n }\n },\n\n /**\n * Remove draggable functionality from an element\n * @param {HTMLElement|string} element - Element or selector\n */\n removeDraggable: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n\n if (el && this.instances.has(el)) {\n // Clean up\n const instance = this.instances.get(el);\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n\n // Remove classes and attributes\n el.classList.remove('vd-draggable');\n el.removeAttribute('draggable');\n el.removeAttribute('data-draggable');\n }\n },\n\n /**\n * Destroy a draggable instance and clean up event listeners\n * @param {HTMLElement} element - Draggable element\n */\n destroy: function (element) {\n this.removeDraggable(element);\n },\n\n /**\n * Destroy all draggable instances\n */\n destroyAll: function () {\n const instances = Array.from(this.instances.keys());\n instances.forEach(element => this.destroy(element));\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('draggable', Draggable);\n }\n\n // Expose globally\n window.VanduoDraggable = Draggable;\n})();\n", "/**\n * Vanduo Framework \u2013 LazyLoad Component\n * v1.2.1\n *\n * Provides two levels of API:\n *\n * LOW-LEVEL (generic IntersectionObserver wrapper)\n * VanduoLazyLoad.observe(element, callback, options?)\n * VanduoLazyLoad.unobserve(element)\n * VanduoLazyLoad.unobserveAll()\n *\n * HIGH-LEVEL (HTML-section fetcher, mirrors vanduo-docs SPA behaviour)\n * VanduoLazyLoad.loadSection(url, containerEl, options?)\n * options: { placeholder, threshold, rootMargin, onLoaded, onError }\n * placeholder: 'skeleton' | 'spinner' | HTMLString (default: 'skeleton')\n *\n * ATTRIBUTE-DRIVEN (zero-JS usage, wired by init())\n *
    \u2026
    \n *\n * EVENTS dispatched on the host element:\n * lazysection:loading \u2014 fetch started\n * lazysection:loaded \u2014 content injected\n * lazysection:error \u2014 fetch failed\n */\n\n(function () {\n 'use strict';\n\n /* \u2500\u2500 Private state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /** @type {Map} */\n const _observerMap = new Map();\n\n /* \u2500\u2500 Security helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Returns true only if `url` resolves to the same origin as the page\n * (relative paths and same-origin absolute URLs are allowed).\n * Cross-origin URLs are rejected to prevent SSRF-style fetch abuse.\n * @param {string} url\n * @returns {boolean}\n */\n function _isSafeUrl(url) {\n try {\n // Relative URLs (no origin) are always safe\n const resolved = new URL(url, window.location.href);\n return resolved.origin === window.location.origin;\n } catch (_) {\n return false;\n }\n }\n\n /**\n * Safely inject fetched HTML into a container by parsing it with\n * DOMParser (avoids script execution) and replacing children via\n * standard DOM APIs instead of raw innerHTML assignment.\n * @param {Element} containerEl\n * @param {string} html\n */\n function _safeInjectHtml(containerEl, html) {\n const parser = new DOMParser();\n const doc = parser.parseFromString(html.trim(), 'text/html'); // CodeQL [js/xss-through-dom] Intentional: parsed nodes are sanitized below before adoption into live DOM\n\n const DANGEROUS_TAGS = ['SCRIPT', 'IFRAME', 'OBJECT', 'EMBED', 'FORM', 'BASE', 'LINK', 'META', 'STYLE'];\n for (const tag of DANGEROUS_TAGS) {\n const els = doc.querySelectorAll(tag);\n for (let i = els.length - 1; i >= 0; i--) {\n els[i].parentNode.removeChild(els[i]);\n }\n }\n\n // Strip dangerous attributes (e.g. onerror, javascript: urls) from all elements\n function _sanitizeNode(node) {\n if (node.nodeType === Node.ELEMENT_NODE) {\n const attrs = node.attributes;\n for (let i = attrs.length - 1; i >= 0; i--) {\n const attrName = attrs[i].name.toLowerCase();\n const attrValue = attrs[i].value.toLowerCase();\n const trimmedValue = attrValue.trim();\n if (\n attrName.startsWith('on') ||\n trimmedValue.startsWith('javascript:') ||\n trimmedValue.startsWith('data:') ||\n trimmedValue.startsWith('vbscript:')\n ) {\n node.removeAttribute(attrs[i].name);\n }\n }\n const children = node.childNodes;\n for (let i = 0; i < children.length; i++) {\n _sanitizeNode(children[i]);\n }\n }\n }\n _sanitizeNode(doc.body);\n\n // Collect all top-level body children\n const nodes = Array.from(doc.body.childNodes);\n // Clear container and append parsed nodes\n while (containerEl.firstChild) {\n containerEl.removeChild(containerEl.firstChild);\n }\n nodes.forEach(function (node) {\n containerEl.appendChild(document.adoptNode(node));\n });\n }\n\n /* \u2500\u2500 Placeholder HTML \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n function _skeletonHtml() {\n return '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + 'Loading\u2026
    ';\n }\n\n function _spinnerHtml() {\n return '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + 'Loading\u2026
    ';\n }\n\n function _resolvePlaceholder(placeholder) {\n if (!placeholder || placeholder === 'skeleton') return _skeletonHtml();\n if (placeholder === 'spinner') return _spinnerHtml();\n // Caller-supplied HTML string\n return placeholder;\n }\n\n /* \u2500\u2500 Dispatch helper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n function _dispatch(el, eventName, detail) {\n el.dispatchEvent(new CustomEvent(eventName, { bubbles: true, detail: detail || {} }));\n }\n\n /* \u2500\u2500 VanduoLazyLoad \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const VanduoLazyLoad = {\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * LOW-LEVEL API\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Observe an element. `callback` is invoked once when the element\n * enters the viewport, then the element is automatically unobserved.\n *\n * @param {Element} element\n * @param {function(Element): void} callback\n * @param {{ threshold?: number, rootMargin?: string }} [options]\n */\n observe: function (element, callback, options) {\n if (!(element instanceof Element)) {\n console.warn('[VanduoLazyLoad] observe() requires a DOM Element.');\n return;\n }\n if (typeof callback !== 'function') {\n console.warn('[VanduoLazyLoad] observe() requires a callback function.');\n return;\n }\n // Already observed \u2014 ignore\n if (_observerMap.has(element)) return;\n\n const threshold = (options && options.threshold != null) ? options.threshold : 0;\n const rootMargin = (options && options.rootMargin) ? options.rootMargin : '0px';\n\n const observer = new IntersectionObserver(function (entries, obs) {\n entries.forEach(function (entry) {\n if (entry.isIntersecting) {\n obs.unobserve(entry.target);\n _observerMap.delete(entry.target);\n try {\n callback(entry.target);\n } catch (e) {\n console.error('[VanduoLazyLoad] Callback threw:', e);\n }\n }\n });\n }, { threshold: threshold, rootMargin: rootMargin });\n\n _observerMap.set(element, observer);\n observer.observe(element);\n },\n\n /**\n * Stop observing an element that was previously passed to observe().\n * @param {Element} element\n */\n unobserve: function (element) {\n const observer = _observerMap.get(element);\n if (observer) {\n observer.unobserve(element);\n _observerMap.delete(element);\n }\n },\n\n /**\n * Stop observing ALL currently observed elements.\n */\n unobserveAll: function () {\n _observerMap.forEach(function (observer, element) {\n observer.unobserve(element);\n });\n _observerMap.clear();\n },\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * HIGH-LEVEL API\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Fetch an HTML partial and inject it into `containerEl` when the\n * container enters the viewport. A placeholder is shown immediately.\n *\n * @param {string} url URL of the HTML partial to fetch\n * @param {Element} containerEl Target element whose content will be replaced\n * @param {{\n * placeholder?: 'skeleton'|'spinner'|string,\n * threshold?: number,\n * rootMargin?: string,\n * onLoaded?: function(Element): void,\n * onError?: function(Error): void\n * }} [options]\n */\n loadSection: function (url, containerEl, options) {\n if (typeof url !== 'string' || !url) {\n console.warn('[VanduoLazyLoad] loadSection() requires a non-empty URL string.');\n return;\n }\n if (!(containerEl instanceof Element)) {\n console.warn('[VanduoLazyLoad] loadSection() requires a DOM Element as containerEl.');\n return;\n }\n // Reject cross-origin URLs to prevent SSRF-style fetch abuse\n if (!_isSafeUrl(url)) {\n console.error('[VanduoLazyLoad] loadSection() blocked cross-origin URL:', url);\n return;\n }\n\n const opts = options || {};\n // Placeholders are known-safe static HTML strings built internally\n const placeholderHtml = _resolvePlaceholder(opts.placeholder);\n\n // Use _safeInjectHtml instead of innerHTML to prevent XSS from caller-supplied placeholders\n _safeInjectHtml(containerEl, placeholderHtml);\n _dispatch(containerEl, 'lazysection:loading', { url: url });\n\n // Fetch when visible\n this.observe(containerEl, function () {\n const controller = new window.AbortController();\n const timeoutId = setTimeout(function () { controller.abort(); }, 10000);\n\n window.fetch(url, { signal: controller.signal })\n .then(function (res) {\n clearTimeout(timeoutId);\n if (!res.ok) throw new Error('HTTP ' + res.status);\n return res.text();\n })\n .then(function (html) {\n // Use DOMParser to parse fetched content safely, avoiding\n // raw innerHTML assignment of externally-sourced strings\n _safeInjectHtml(containerEl, html);\n _dispatch(containerEl, 'lazysection:loaded', { url: url });\n // Re-init Vanduo components inside the new content\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.init();\n }\n if (typeof opts.onLoaded === 'function') {\n opts.onLoaded(containerEl);\n }\n })\n .catch(function (err) {\n // Build error node via DOM APIs \u2014 no dynamic HTML strings\n const alertEl = document.createElement('div');\n alertEl.className = 'vd-alert vd-alert-error';\n alertEl.setAttribute('role', 'alert');\n const msgEl = document.createElement('span');\n msgEl.textContent = 'Failed to load content. ';\n const detailEl = document.createElement('small');\n detailEl.style.opacity = '0.7';\n detailEl.textContent = err.message;\n alertEl.appendChild(msgEl);\n alertEl.appendChild(detailEl);\n while (containerEl.firstChild) {\n containerEl.removeChild(containerEl.firstChild);\n }\n containerEl.appendChild(alertEl);\n _dispatch(containerEl, 'lazysection:error', { url: url, error: err });\n console.error('[VanduoLazyLoad] loadSection failed:', err);\n if (typeof opts.onError === 'function') {\n opts.onError(err);\n }\n });\n }, { threshold: opts.threshold, rootMargin: opts.rootMargin });\n },\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * ATTRIBUTE-DRIVEN INIT\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Scan the DOM for [data-vd-lazy] elements and wire them up.\n * Safe to call multiple times \u2014 already-observed elements are skipped.\n */\n init: function () {\n const self = this;\n const elements = document.querySelectorAll('[data-vd-lazy]');\n elements.forEach(function (el) {\n // Skip already-observed or already-loaded elements\n if (_observerMap.has(el) || el.dataset.vdLazyState === 'loading' || el.dataset.vdLazyState === 'loaded') return;\n\n const url = el.getAttribute('data-vd-lazy');\n if (!url) return;\n\n el.dataset.vdLazyState = 'loading';\n const placeholder = el.getAttribute('data-vd-lazy-placeholder') || 'skeleton';\n\n self.loadSection(url, el, {\n placeholder: placeholder,\n onLoaded: function () {\n el.dataset.vdLazyState = 'loaded';\n },\n onError: function () {\n el.dataset.vdLazyState = 'error';\n }\n });\n });\n }\n };\n\n /* \u2500\u2500 Register with Vanduo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('LazyLoad', VanduoLazyLoad);\n }\n\n /* \u2500\u2500 Global convenience alias \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n window.VanduoLazyLoad = VanduoLazyLoad;\n\n})();\n", "/**\n * Vanduo Framework - Glass Scroll Activation\n * Generic scroll-aware glass activation via IntersectionObserver.\n *\n * Usage:\n * Add `data-glass-scroll` to any element carrying a `.vd-glass*` class.\n * By default the previous sibling is used as the sentinel.\n * Point to a custom sentinel via `data-glass-sentinel=\"\"`.\n *\n * Behaviour:\n * - Adds `.is-glass-active` when the sentinel leaves the viewport (scrolled past).\n * - Removes `.is-glass-active` when the sentinel re-enters the viewport.\n */\n(function () {\n 'use strict';\n\n const GlassScroll = {\n /** @type {Map} */\n observers: new Map(),\n\n init: function () {\n document.querySelectorAll('[data-glass-scroll]').forEach(el => {\n if (this.observers.has(el)) return;\n this.initElement(el);\n });\n },\n\n /**\n * Wire up a single scroll-activated glass element.\n * @param {HTMLElement} el\n */\n initElement: function (el) {\n const sentinelSelector = el.dataset.glassSentinel;\n let sentinel;\n\n if (sentinelSelector) {\n sentinel = document.querySelector(sentinelSelector);\n }\n\n if (!sentinel) {\n // Fall back to the previous sibling element\n sentinel = el.previousElementSibling;\n }\n\n if (!sentinel) {\n // No sentinel available \u2014 activate immediately so glass is always shown\n el.classList.add('is-glass-active');\n return;\n }\n\n const observer = new IntersectionObserver(\n (entries) => {\n entries.forEach(entry => {\n // Active when sentinel is NOT intersecting (scrolled past it)\n el.classList.toggle('is-glass-active', !entry.isIntersecting);\n });\n },\n { threshold: 0, rootMargin: '0px' }\n );\n\n observer.observe(sentinel);\n this.observers.set(el, observer);\n },\n\n /**\n * Disconnect and remove a single element's observer.\n * @param {HTMLElement} el\n */\n destroy: function (el) {\n const observer = this.observers.get(el);\n if (observer) {\n observer.disconnect();\n this.observers.delete(el);\n }\n },\n\n destroyAll: function () {\n this.observers.forEach((observer, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('glassScroll', GlassScroll);\n }\n\n window.VanduoGlassScroll = GlassScroll;\n})();\n", "/**\n * Vanduo Framework - Water Morph Effect Component\n * Liquid wave content-swap animation on click\n *\n * Usage:\n * Add .vd-morph or [data-vd-morph] to any element.\n * Provide .vd-morph-content.vd-morph-current and .vd-morph-content.vd-morph-next children.\n * Wave and shine layers are auto-created if absent.\n *\n * JS API:\n * VanduoMorph.morph(el) \u2014 trigger morph programmatically (from center)\n * VanduoMorph.destroy(el) \u2014 tear down listeners for one element\n * VanduoMorph.destroyAll() \u2014 tear down all instances\n */\n\n(function () {\n 'use strict';\n\n const MORPH_DURATION_MS = 750;\n\n const Morph = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-morph, [data-vd-morph]');\n elements.forEach(function (el) {\n if (Morph.instances.has(el)) return;\n if (el.getAttribute('data-vd-morph') === 'manual') return;\n Morph.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n Morph._ensureLayers(el);\n\n const cleanup = [];\n let morphing = false;\n\n const handleClick = function (e) {\n if (morphing) return;\n Morph._runMorph(el, e, function () { morphing = false; });\n morphing = true;\n };\n\n el.addEventListener('click', handleClick);\n cleanup.push(function () { el.removeEventListener('click', handleClick); });\n\n this.instances.set(el, { cleanup: cleanup });\n },\n\n morph: function (el) {\n if (!el) return;\n if (!this.instances.has(el)) this.initInstance(el);\n this._runMorph(el, null, null);\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(function (fn) { fn(); });\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) { Morph.destroy(el); });\n },\n\n /* \u2500\u2500 Internal helpers \u2500\u2500 */\n\n _ensureLayers: function (el) {\n if (!el.querySelector('.vd-morph-wave')) {\n const wave = document.createElement('span');\n wave.className = 'vd-morph-wave';\n wave.setAttribute('aria-hidden', 'true');\n el.insertBefore(wave, el.firstChild);\n }\n if (!el.querySelector('.vd-morph-shine')) {\n const shine = document.createElement('span');\n shine.className = 'vd-morph-shine';\n shine.setAttribute('aria-hidden', 'true');\n const waveEl = el.querySelector('.vd-morph-wave');\n if (waveEl && waveEl.nextSibling) {\n el.insertBefore(shine, waveEl.nextSibling);\n } else {\n el.insertBefore(shine, el.firstChild);\n }\n }\n },\n\n _runMorph: function (el, pointerEvent, onComplete) {\n const wave = el.querySelector('.vd-morph-wave');\n if (wave) {\n const rect = el.getBoundingClientRect();\n const cx = rect.left + rect.width / 2;\n const cy = rect.top + rect.height / 2;\n const px = pointerEvent ? (pointerEvent.clientX || cx) : cx;\n const py = pointerEvent ? (pointerEvent.clientY || cy) : cy;\n wave.style.left = (px - rect.left) + 'px';\n wave.style.top = (py - rect.top) + 'px';\n }\n\n el.classList.add('is-morphing');\n\n let duration = MORPH_DURATION_MS;\n const custom = getComputedStyle(el).getPropertyValue('--morph-duration');\n if (custom) {\n const parsed = parseFloat(custom);\n if (!isNaN(parsed)) duration = parsed * (custom.indexOf('ms') !== -1 ? 1 : 1000);\n }\n\n setTimeout(function () {\n el.classList.remove('is-morphing');\n\n const current = el.querySelector('.vd-morph-current');\n const next = el.querySelector('.vd-morph-next');\n if (current && next) {\n current.classList.remove('vd-morph-current');\n current.classList.add('vd-morph-next');\n next.classList.remove('vd-morph-next');\n next.classList.add('vd-morph-current');\n }\n\n if (typeof onComplete === 'function') onComplete();\n }, duration);\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('morph', Morph);\n }\n\n window.VanduoMorph = Morph;\n\n})();\n", "/**\n * Vanduo Framework \u2014 Expanding flex cards\n * Click / Enter / Space / Arrow keys to change the active card.\n *\n * Usage:\n *
    \n * Use data-vd-expanding-cards=\"manual\" to skip auto-init.\n */\n\n(function () {\n 'use strict';\n\n const ExpandingCards = {\n instances: new Map(),\n\n init: function () {\n document.querySelectorAll('.vd-expanding-cards').forEach(function (el) {\n if (el.getAttribute('data-vd-expanding-cards') === 'manual') return;\n if (ExpandingCards.instances.has(el)) return;\n ExpandingCards.initContainer(el);\n });\n },\n\n initContainer: function (container) {\n const cleanup = [];\n\n const getCards = function () {\n return Array.prototype.slice.call(container.querySelectorAll('.vd-expanding-card'));\n };\n\n const setActive = function (card) {\n const cards = getCards();\n if (!card || cards.indexOf(card) === -1) return;\n cards.forEach(function (c) {\n c.classList.toggle('is-active', c === card);\n });\n card.focus({ preventScroll: true });\n };\n\n const onClick = function (e) {\n const t = e.target;\n const card = t.closest ? t.closest('.vd-expanding-card') : null;\n if (!card || !container.contains(card)) return;\n setActive(card);\n };\n\n const onKeydown = function (e) {\n if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'Home' && e.key !== 'End') {\n return;\n }\n const cards = getCards().filter(function (c) {\n return c.offsetParent !== null || c.getClientRects().length > 0;\n });\n if (!cards.length) return;\n const activeEl = document.activeElement;\n let idx = cards.indexOf(activeEl);\n if (idx < 0) {\n idx = cards.findIndex(function (c) {\n return c.classList.contains('is-active');\n });\n }\n if (idx < 0) idx = 0;\n\n if (e.key === 'ArrowLeft') {\n e.preventDefault();\n setActive(cards[Math.max(0, idx - 1)]);\n } else if (e.key === 'ArrowRight') {\n e.preventDefault();\n setActive(cards[Math.min(cards.length - 1, idx + 1)]);\n } else if (e.key === 'Home') {\n e.preventDefault();\n setActive(cards[0]);\n } else if (e.key === 'End') {\n e.preventDefault();\n setActive(cards[cards.length - 1]);\n }\n };\n\n container.addEventListener('click', onClick);\n cleanup.push(function () {\n container.removeEventListener('click', onClick);\n });\n\n container.addEventListener('keydown', onKeydown);\n cleanup.push(function () {\n container.removeEventListener('keydown', onKeydown);\n });\n\n getCards().forEach(function (card) {\n if (!card.hasAttribute('tabindex')) {\n card.setAttribute('tabindex', '0');\n }\n card.setAttribute('role', 'button');\n if (!card.hasAttribute('aria-pressed')) {\n card.setAttribute('aria-pressed', card.classList.contains('is-active') ? 'true' : 'false');\n }\n });\n\n const syncAria = function () {\n getCards().forEach(function (card) {\n card.setAttribute('aria-pressed', card.classList.contains('is-active') ? 'true' : 'false');\n });\n };\n\n const mo = new MutationObserver(syncAria);\n mo.observe(container, { attributes: true, subtree: true, attributeFilter: ['class'] });\n cleanup.push(function () {\n mo.disconnect();\n });\n syncAria();\n\n ExpandingCards.instances.set(container, { cleanup: cleanup });\n },\n\n destroy: function (container) {\n const inst = this.instances.get(container);\n if (!inst) return;\n inst.cleanup.forEach(function (fn) {\n fn();\n });\n this.instances.delete(container);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) {\n ExpandingCards.destroy(el);\n });\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('expandingCards', ExpandingCards);\n }\n\n window.VanduoExpandingCards = ExpandingCards;\n})();\n", "/**\n * Vanduo Framework \u2014 Timeline animated reveal\n * Opt-in: add `.vd-timeline-animated` to a `.vd-timeline` container.\n * Uses IntersectionObserver to add `.is-revealed` per item with staggered delays.\n * Optional: `.vd-timeline-playback` with `[data-vd-timeline-prev|next|play|pause]` for stepped control.\n */\n\n(function () {\n 'use strict';\n\n const STAGGER_MS = 140;\n const MAX_STAGGER_INDEX = 7;\n const PLAY_INTERVAL_MS = 800;\n\n function countRevealedPrefix(items) {\n let count = 0;\n for (let i = 0; i < items.length; i++) {\n if (!items[i].classList.contains('is-revealed')) break;\n count++;\n }\n return count;\n }\n\n function findPlaybackControls(container) {\n return container.parentElement || document.body;\n }\n\n function initPlayback(container, items, cleanup) {\n items.forEach(function (item) {\n item.classList.remove('is-revealed');\n });\n\n const scope = findPlaybackControls(container);\n const prevBtn = scope.querySelector('[data-vd-timeline-prev]');\n const nextBtn = scope.querySelector('[data-vd-timeline-next]');\n const playBtn = scope.querySelector('[data-vd-timeline-play]');\n const pauseBtn = scope.querySelector('[data-vd-timeline-pause]');\n\n let playTimer = null;\n\n function updateNavButtons() {\n const k = countRevealedPrefix(items);\n const n = items.length;\n if (prevBtn) {\n const atStart = k === 0;\n prevBtn.disabled = atStart;\n prevBtn.setAttribute('aria-disabled', atStart ? 'true' : 'false');\n }\n if (nextBtn) {\n const atEnd = k >= n;\n nextBtn.disabled = atEnd;\n nextBtn.setAttribute('aria-disabled', atEnd ? 'true' : 'false');\n }\n if (playBtn) {\n playBtn.setAttribute('aria-pressed', playTimer ? 'true' : 'false');\n }\n if (pauseBtn) {\n pauseBtn.disabled = !playTimer;\n }\n }\n\n function stepNext() {\n const k = countRevealedPrefix(items);\n if (k < items.length) {\n items[k].classList.add('is-revealed');\n }\n updateNavButtons();\n }\n\n function stepPrev() {\n const k = countRevealedPrefix(items);\n if (k > 0) {\n items[k - 1].classList.remove('is-revealed');\n }\n updateNavButtons();\n }\n\n function play() {\n if (playTimer) return;\n playTimer = setInterval(function () {\n if (countRevealedPrefix(items) >= items.length) {\n pause();\n return;\n }\n stepNext();\n }, PLAY_INTERVAL_MS);\n updateNavButtons();\n }\n\n function pause() {\n if (playTimer) {\n clearInterval(playTimer);\n playTimer = null;\n }\n updateNavButtons();\n }\n\n function addClick(el, fn) {\n if (!el) return;\n const handler = function (e) {\n e.preventDefault();\n fn();\n };\n el.addEventListener('click', handler);\n cleanup.push(function () {\n el.removeEventListener('click', handler);\n });\n }\n\n addClick(prevBtn, stepPrev);\n addClick(nextBtn, stepNext);\n addClick(playBtn, play);\n addClick(pauseBtn, pause);\n\n cleanup.push(function () {\n pause();\n });\n\n updateNavButtons();\n\n return {\n stepNext: stepNext,\n stepPrev: stepPrev,\n play: play,\n pause: pause\n };\n }\n\n const Timeline = {\n instances: new Map(),\n\n init: function () {\n document.querySelectorAll('.vd-timeline.vd-timeline-animated').forEach(function (el) {\n if (Timeline.instances.has(el)) return;\n Timeline.initInstance(el);\n });\n },\n\n reinit: function () {\n Timeline.destroyAll();\n Timeline.init();\n },\n\n initInstance: function (container) {\n const cleanup = [];\n const items = Array.prototype.filter.call(container.children, function (child) {\n return child.classList && child.classList.contains('vd-timeline-item');\n });\n\n items.forEach(function (item, i) {\n const idx = Math.min(i, MAX_STAGGER_INDEX);\n item.style.setProperty('--vd-timeline-reveal-delay', (idx * STAGGER_MS) + 'ms');\n });\n\n const reducedMotion = typeof window.matchMedia === 'function'\n && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n if (reducedMotion) {\n items.forEach(function (item) {\n item.classList.add('is-revealed');\n });\n Timeline.instances.set(container, { cleanup: cleanup });\n return;\n }\n\n const playback = container.classList && container.classList.contains('vd-timeline-playback');\n\n if (playback) {\n const playbackApi = initPlayback(container, items, cleanup);\n Timeline.instances.set(container, { cleanup: cleanup, playback: playbackApi });\n return;\n }\n\n if (typeof IntersectionObserver === 'undefined') {\n items.forEach(function (item) {\n item.classList.add('is-revealed');\n });\n Timeline.instances.set(container, { cleanup: cleanup });\n return;\n }\n\n const observer = new IntersectionObserver(function (entries) {\n entries.forEach(function (entry) {\n if (!entry.isIntersecting) return;\n entry.target.classList.add('is-revealed');\n observer.unobserve(entry.target);\n });\n }, {\n root: null,\n rootMargin: '0px 0px -10% 0px',\n threshold: 0.15\n });\n\n items.forEach(function (item) {\n observer.observe(item);\n });\n\n cleanup.push(function () {\n observer.disconnect();\n });\n\n Timeline.instances.set(container, { cleanup: cleanup });\n },\n\n destroy: function (container) {\n const inst = this.instances.get(container);\n if (!inst) return;\n inst.cleanup.forEach(function (fn) {\n fn();\n });\n this.instances.delete(container);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) {\n Timeline.destroy(el);\n });\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('timeline', Timeline);\n }\n\n window.VanduoTimeline = Timeline;\n})();\n", "/**\n * Vanduo Framework - Flow (Carousel/Slider) Component\n * Touch-enabled carousel with slide/fade transitions, autoplay, indicators\n */\n\n(function () {\n 'use strict';\n\n const Flow = {\n instances: new Map(),\n\n init: function () {\n const carousels = document.querySelectorAll('.vd-flow, .vd-carousel');\n carousels.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const track = el.querySelector('.vd-flow-track');\n if (!track) return;\n\n const slides = Array.from(track.querySelectorAll('.vd-flow-slide'));\n if (slides.length === 0) return;\n\n const isFade = el.classList.contains('vd-flow-fade');\n const autoplay = el.hasAttribute('data-vd-autoplay');\n const interval = parseInt(el.getAttribute('data-vd-interval'), 10) || 5000;\n const loop = el.getAttribute('data-vd-loop') !== 'false';\n\n const state = {\n current: 0,\n total: slides.length,\n autoplayTimer: null,\n isFade: isFade,\n loop: loop,\n isDragging: false,\n startX: 0,\n currentX: 0,\n threshold: 50\n };\n\n const cleanup = [];\n\n // Set initial active slide\n slides.forEach((slide, i) => {\n slide.setAttribute('role', 'group');\n slide.setAttribute('aria-roledescription', 'slide');\n slide.setAttribute('aria-label', 'Slide ' + (i + 1) + ' of ' + slides.length);\n if (i === 0) slide.classList.add('is-active');\n });\n\n el.setAttribute('role', 'region');\n el.setAttribute('aria-roledescription', 'carousel');\n if (!el.getAttribute('aria-label')) {\n el.setAttribute('aria-label', 'Carousel');\n }\n\n // Live region for announcements\n const liveRegion = document.createElement('div');\n liveRegion.setAttribute('aria-live', 'polite');\n liveRegion.setAttribute('aria-atomic', 'true');\n liveRegion.className = 'sr-only';\n liveRegion.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);';\n el.appendChild(liveRegion);\n\n const goTo = (index, announce) => {\n if (announce === undefined) announce = true;\n let target = index;\n if (state.loop) {\n target = ((index % state.total) + state.total) % state.total;\n } else {\n target = Math.max(0, Math.min(index, state.total - 1));\n }\n\n const prev = state.current;\n state.current = target;\n\n if (state.isFade) {\n slides.forEach((s, i) => {\n s.classList.toggle('is-active', i === target);\n });\n } else {\n track.style.transform = 'translateX(-' + (target * 100) + '%)';\n }\n\n // Update indicators\n const indicators = el.querySelectorAll('.vd-flow-indicator');\n indicators.forEach((ind, i) => {\n ind.classList.toggle('is-active', i === target);\n ind.setAttribute('aria-selected', i === target ? 'true' : 'false');\n });\n\n // Update slide ARIA\n slides.forEach((s, i) => {\n s.setAttribute('aria-hidden', i !== target ? 'true' : 'false');\n });\n\n if (announce) {\n liveRegion.textContent = 'Slide ' + (target + 1) + ' of ' + state.total;\n }\n\n el.dispatchEvent(new CustomEvent('flow:change', {\n detail: { current: target, previous: prev, total: state.total }\n }));\n };\n\n const next = () => goTo(state.current + 1);\n const prev = () => goTo(state.current - 1);\n\n // Controls\n const prevBtn = el.querySelector('.vd-flow-prev');\n const nextBtn = el.querySelector('.vd-flow-next');\n\n if (prevBtn) {\n const h = () => prev();\n prevBtn.addEventListener('click', h);\n cleanup.push(() => prevBtn.removeEventListener('click', h));\n }\n if (nextBtn) {\n const h = () => next();\n nextBtn.addEventListener('click', h);\n cleanup.push(() => nextBtn.removeEventListener('click', h));\n }\n\n // Indicators\n const indicators = el.querySelectorAll('.vd-flow-indicator');\n indicators.forEach((ind, i) => {\n ind.setAttribute('role', 'tab');\n ind.setAttribute('aria-selected', i === 0 ? 'true' : 'false');\n ind.setAttribute('aria-label', 'Go to slide ' + (i + 1));\n const h = () => goTo(i);\n ind.addEventListener('click', h);\n cleanup.push(() => ind.removeEventListener('click', h));\n });\n\n // Keyboard navigation\n const keyHandler = (e) => {\n if (e.key === 'ArrowLeft') { prev(); e.preventDefault(); }\n if (e.key === 'ArrowRight') { next(); e.preventDefault(); }\n };\n el.setAttribute('tabindex', '0');\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n\n // Touch / pointer support\n const pointerDown = (e) => {\n state.isDragging = true;\n state.startX = e.clientX || (e.touches && e.touches[0].clientX) || 0;\n state.currentX = state.startX;\n el.classList.add('is-dragging');\n };\n\n const pointerMove = (e) => {\n if (!state.isDragging) return;\n state.currentX = e.clientX || (e.touches && e.touches[0].clientX) || 0;\n };\n\n const pointerUp = () => {\n if (!state.isDragging) return;\n state.isDragging = false;\n el.classList.remove('is-dragging');\n const diff = state.startX - state.currentX;\n if (Math.abs(diff) > state.threshold) {\n if (diff > 0) next();\n else prev();\n }\n };\n\n el.addEventListener('mousedown', pointerDown);\n el.addEventListener('mousemove', pointerMove);\n el.addEventListener('mouseup', pointerUp);\n el.addEventListener('mouseleave', pointerUp);\n el.addEventListener('touchstart', pointerDown, { passive: true });\n el.addEventListener('touchmove', pointerMove, { passive: true });\n el.addEventListener('touchend', pointerUp);\n\n cleanup.push(\n () => el.removeEventListener('mousedown', pointerDown),\n () => el.removeEventListener('mousemove', pointerMove),\n () => el.removeEventListener('mouseup', pointerUp),\n () => el.removeEventListener('mouseleave', pointerUp),\n () => el.removeEventListener('touchstart', pointerDown),\n () => el.removeEventListener('touchmove', pointerMove),\n () => el.removeEventListener('touchend', pointerUp)\n );\n\n // Autoplay\n const startAutoplay = () => {\n stopAutoplay();\n state.autoplayTimer = setInterval(next, interval);\n };\n\n const stopAutoplay = () => {\n if (state.autoplayTimer) {\n clearInterval(state.autoplayTimer);\n state.autoplayTimer = null;\n }\n };\n\n if (autoplay) {\n startAutoplay();\n const pauseHandler = () => stopAutoplay();\n const resumeHandler = () => startAutoplay();\n el.addEventListener('mouseenter', pauseHandler);\n el.addEventListener('mouseleave', resumeHandler);\n el.addEventListener('focusin', pauseHandler);\n el.addEventListener('focusout', resumeHandler);\n cleanup.push(\n () => el.removeEventListener('mouseenter', pauseHandler),\n () => el.removeEventListener('mouseleave', resumeHandler),\n () => el.removeEventListener('focusin', pauseHandler),\n () => el.removeEventListener('focusout', resumeHandler),\n () => stopAutoplay()\n );\n }\n\n // Initial ARIA state\n goTo(0, false);\n\n this.instances.set(el, {\n cleanup: cleanup,\n goTo: goTo,\n next: next,\n prev: prev,\n getState: () => ({ ...state })\n });\n },\n\n goTo: function (el, index) {\n const instance = this.instances.get(el);\n if (instance) instance.goTo(index);\n },\n\n next: function (el) {\n const instance = this.instances.get(el);\n if (instance) instance.next();\n },\n\n prev: function (el) {\n const instance = this.instances.get(el);\n if (instance) instance.prev();\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('flow', Flow);\n }\n\n window.VanduoFlow = Flow;\n\n})();\n", "/**\n * Vanduo Framework - Bubble (Popover) Component\n * Click-triggered rich HTML popover, reuses tooltip positioning concepts\n */\n\n(function () {\n 'use strict';\n\n const Bubble = {\n instances: new Map(),\n _globalCleanups: [],\n\n init: function () {\n const triggers = document.querySelectorAll('[data-vd-bubble], [data-vd-popover]');\n triggers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n\n if (this._globalCleanups.length === 0) {\n const outsideClick = (e) => {\n this.instances.forEach((inst, trigger) => {\n if (!inst.popover.contains(e.target) && !trigger.contains(e.target)) {\n this.hide(trigger);\n }\n });\n };\n const escHandler = (e) => {\n if (e.key === 'Escape') {\n this.instances.forEach((_, trigger) => this.hide(trigger));\n }\n };\n document.addEventListener('click', outsideClick, true);\n document.addEventListener('keydown', escHandler);\n this._globalCleanups.push(\n () => document.removeEventListener('click', outsideClick, true),\n () => document.removeEventListener('keydown', escHandler)\n );\n }\n },\n\n initInstance: function (trigger) {\n const cleanup = [];\n const placement = trigger.getAttribute('data-vd-bubble-placement') ||\n trigger.getAttribute('data-vd-popover-placement') || 'bottom';\n\n // Build popover element\n const popover = document.createElement('div');\n popover.className = 'vd-bubble-content';\n popover.setAttribute('role', 'dialog');\n popover.setAttribute('aria-modal', 'false');\n popover.setAttribute('data-placement', placement);\n\n const title = trigger.getAttribute('data-vd-bubble-title') ||\n trigger.getAttribute('data-vd-popover-title');\n const content = trigger.getAttribute('data-vd-bubble') ||\n trigger.getAttribute('data-vd-popover') || '';\n const htmlContent = trigger.getAttribute('data-vd-bubble-html') ||\n trigger.getAttribute('data-vd-popover-html');\n const allowSvg = trigger.hasAttribute('data-vd-bubble-allow-svg') ||\n trigger.hasAttribute('data-vd-popover-allow-svg');\n\n if (title) {\n const header = document.createElement('div');\n header.className = 'vd-bubble-header';\n const titleSpan = document.createElement('span');\n titleSpan.textContent = title;\n const closeBtn = document.createElement('button');\n closeBtn.className = 'vd-bubble-close';\n closeBtn.setAttribute('aria-label', 'Close');\n closeBtn.innerHTML = '×';\n header.appendChild(titleSpan);\n header.appendChild(closeBtn);\n popover.appendChild(header);\n\n const closeHandler = (e) => { e.stopPropagation(); this.hide(trigger); };\n closeBtn.addEventListener('click', closeHandler);\n cleanup.push(() => closeBtn.removeEventListener('click', closeHandler));\n }\n\n const body = document.createElement('div');\n body.className = 'vd-bubble-body';\n if (htmlContent) {\n if (typeof sanitizeHtml === 'function') {\n body.innerHTML = sanitizeHtml(htmlContent, { allowSvg });\n } else {\n body.textContent = htmlContent;\n }\n } else {\n body.textContent = content;\n }\n popover.appendChild(body);\n\n document.body.appendChild(popover);\n\n // ARIA on trigger\n const popId = 'vd-bubble-' + Math.random().toString(36).slice(2, 9);\n popover.id = popId;\n trigger.setAttribute('aria-haspopup', 'dialog');\n trigger.setAttribute('aria-expanded', 'false');\n trigger.setAttribute('aria-controls', popId);\n\n // Toggle on click\n const toggleHandler = (e) => {\n e.stopPropagation();\n if (popover.classList.contains('is-visible')) {\n this.hide(trigger);\n } else {\n this.hideAll();\n this.show(trigger);\n }\n };\n trigger.addEventListener('click', toggleHandler);\n cleanup.push(() => trigger.removeEventListener('click', toggleHandler));\n\n this.instances.set(trigger, { popover, cleanup, placement });\n },\n\n position: function (trigger, popover, placement) {\n const rect = trigger.getBoundingClientRect();\n const popRect = popover.getBoundingClientRect();\n const gap = 10;\n let top, left;\n\n switch (placement) {\n case 'top':\n top = rect.top - popRect.height - gap + window.scrollY;\n left = rect.left + (rect.width - popRect.width) / 2 + window.scrollX;\n break;\n case 'left':\n top = rect.top + (rect.height - popRect.height) / 2 + window.scrollY;\n left = rect.left - popRect.width - gap + window.scrollX;\n break;\n case 'right':\n top = rect.top + (rect.height - popRect.height) / 2 + window.scrollY;\n left = rect.right + gap + window.scrollX;\n break;\n default: // bottom\n top = rect.bottom + gap + window.scrollY;\n left = rect.left + (rect.width - popRect.width) / 2 + window.scrollX;\n }\n\n // Clamp to viewport\n left = Math.max(8, Math.min(left, window.innerWidth - popRect.width - 8));\n top = Math.max(8, top);\n\n popover.style.top = top + 'px';\n popover.style.left = left + 'px';\n },\n\n show: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n const { popover, placement } = instance;\n\n popover.style.display = 'block';\n popover.classList.add('is-visible');\n trigger.setAttribute('aria-expanded', 'true');\n\n requestAnimationFrame(() => {\n this.position(trigger, popover, placement);\n });\n\n trigger.dispatchEvent(new CustomEvent('bubble:show', {\n bubbles: true,\n detail: { trigger: trigger, placement: placement }\n }));\n },\n\n hide: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n instance.popover.classList.remove('is-visible');\n trigger.setAttribute('aria-expanded', 'false');\n trigger.dispatchEvent(new CustomEvent('bubble:hide', {\n bubbles: true,\n detail: { trigger: trigger }\n }));\n },\n\n hideAll: function () {\n this.instances.forEach((_, trigger) => this.hide(trigger));\n },\n\n destroy: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n if (instance.popover.parentNode) {\n instance.popover.parentNode.removeChild(instance.popover);\n }\n trigger.removeAttribute('aria-haspopup');\n trigger.removeAttribute('aria-expanded');\n trigger.removeAttribute('aria-controls');\n this.instances.delete(trigger);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, trigger) => this.destroy(trigger));\n this._globalCleanups.forEach(fn => fn());\n this._globalCleanups = [];\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('bubble', Bubble);\n }\n\n window.VanduoBubble = Bubble;\n\n})();\n", "/**\n * Vanduo Framework - Waypoint (Scrollspy) Component\n * Highlights navigation links based on scroll position using IntersectionObserver\n */\n\n(function () {\n 'use strict';\n\n const Waypoint = {\n instances: new Map(),\n\n init: function () {\n const navs = document.querySelectorAll('[data-vd-waypoint-nav], [data-vd-scrollspy-nav]');\n navs.forEach(nav => {\n if (this.instances.has(nav)) return;\n this.initInstance(nav);\n });\n },\n\n initInstance: function (nav) {\n const links = Array.from(nav.querySelectorAll('a[href^=\"#\"]'));\n if (links.length === 0) return;\n\n const cleanup = [];\n const offset = parseInt(nav.getAttribute('data-vd-waypoint-offset') || '80', 10);\n const sections = [];\n\n links.forEach(link => {\n const id = link.getAttribute('href').slice(1);\n const section = document.getElementById(id);\n if (section) {\n section.setAttribute('data-vd-waypoint-section', '');\n sections.push({ id, link, section });\n }\n });\n\n if (sections.length === 0) return;\n\n const activeSections = new Set();\n\n const setActive = (id) => {\n links.forEach(l => l.classList.remove('is-active'));\n const target = links.find(l => l.getAttribute('href') === '#' + id);\n if (target) {\n target.classList.add('is-active');\n nav.dispatchEvent(new CustomEvent('waypoint:change', {\n detail: { activeId: id, link: target }\n }));\n }\n };\n\n const rootMargin = '-' + offset + 'px 0px -40% 0px';\n\n const observer = new IntersectionObserver((entries) => {\n entries.forEach(entry => {\n if (entry.isIntersecting) {\n activeSections.add(entry.target.id);\n } else {\n activeSections.delete(entry.target.id);\n }\n });\n\n // Pick the topmost visible section\n for (let i = 0; i < sections.length; i++) {\n if (activeSections.has(sections[i].id)) {\n setActive(sections[i].id);\n return;\n }\n }\n }, {\n rootMargin: rootMargin,\n threshold: 0\n });\n\n sections.forEach(s => observer.observe(s.section));\n\n // Smooth scroll on click\n links.forEach(link => {\n const clickHandler = (e) => {\n e.preventDefault();\n const id = link.getAttribute('href').slice(1);\n const section = document.getElementById(id);\n if (section) {\n section.scrollIntoView({ behavior: 'smooth' });\n setActive(id);\n }\n };\n link.addEventListener('click', clickHandler);\n cleanup.push(() => link.removeEventListener('click', clickHandler));\n });\n\n cleanup.push(() => observer.disconnect());\n\n this.instances.set(nav, { observer, cleanup, sections, setActive });\n },\n\n refresh: function (nav) {\n this.destroy(nav);\n this.initInstance(nav);\n },\n\n destroy: function (nav) {\n const instance = this.instances.get(nav);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(nav);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, nav) => this.destroy(nav));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('waypoint', Waypoint);\n }\n\n window.VanduoWaypoint = Waypoint;\n\n})();\n", "/**\n * Vanduo Framework - Ripple (Waves Effect) Component\n * Adds expanding circle animation on click at pointer position\n */\n\n(function () {\n 'use strict';\n\n const Ripple = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-ripple, [data-vd-ripple]');\n elements.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n\n const createWave = (e) => {\n const rect = el.getBoundingClientRect();\n const size = Math.max(rect.width, rect.height);\n const x = (e.clientX || (e.touches && e.touches[0].clientX) || rect.left + rect.width / 2) - rect.left - size / 2;\n const y = (e.clientY || (e.touches && e.touches[0].clientY) || rect.top + rect.height / 2) - rect.top - size / 2;\n\n const wave = document.createElement('span');\n wave.className = 'vd-ripple-wave';\n wave.style.width = size + 'px';\n wave.style.height = size + 'px';\n wave.style.left = x + 'px';\n wave.style.top = y + 'px';\n\n el.appendChild(wave);\n\n wave.addEventListener('animationend', () => {\n if (wave.parentNode) wave.parentNode.removeChild(wave);\n });\n };\n\n el.addEventListener('mousedown', createWave);\n el.addEventListener('touchstart', createWave, { passive: true });\n\n cleanup.push(\n () => el.removeEventListener('mousedown', createWave),\n () => el.removeEventListener('touchstart', createWave)\n );\n\n this.instances.set(el, { cleanup });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n // Remove any lingering wave elements\n el.querySelectorAll('.vd-ripple-wave').forEach(w => w.remove());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('ripple', Ripple);\n }\n\n window.VanduoRipple = Ripple;\n\n})();\n", "/**\n * Vanduo Framework - Affix (Sticky) Component\n * Uses IntersectionObserver to toggle .is-stuck within the nearest scrollable parent\n */\n\n(function () {\n 'use strict';\n\n function isScrollable(element) {\n if (!element || element === document.body) return false;\n\n const style = window.getComputedStyle(element);\n const overflowY = style.overflowY;\n const overflowX = style.overflowX;\n const canScrollY = /(auto|scroll|overlay)/.test(overflowY) && element.scrollHeight > element.clientHeight;\n const canScrollX = /(auto|scroll|overlay)/.test(overflowX) && element.scrollWidth > element.clientWidth;\n\n return canScrollY || canScrollX;\n }\n\n function getScrollParent(element) {\n let parent = element.parentElement;\n\n while (parent && parent !== document.body && parent !== document.documentElement) {\n if (isScrollable(parent)) return parent;\n parent = parent.parentElement;\n }\n\n return null;\n }\n\n const Affix = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-affix, .vd-sticky, [data-vd-affix]');\n elements.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const parsedOffset = parseInt(el.getAttribute('data-vd-affix-offset') || '0', 10);\n const offset = Number.isNaN(parsedOffset) ? 0 : parsedOffset;\n const scrollParent = getScrollParent(el);\n let isStuck = false;\n\n const sentinel = document.createElement('div');\n sentinel.style.cssText = 'display:block;height:1px;margin-bottom:-1px;visibility:hidden;pointer-events:none;';\n el.parentNode.insertBefore(sentinel, el);\n\n el.style.setProperty('--affix-top-offset', offset + 'px');\n\n function stick() {\n if (isStuck) return;\n isStuck = true;\n el.classList.add('is-stuck');\n el.dispatchEvent(new CustomEvent('affix:stuck', {\n bubbles: true,\n detail: {\n offset: offset,\n root: scrollParent || window\n }\n }));\n }\n\n function unstick() {\n if (!isStuck) return;\n isStuck = false;\n el.classList.remove('is-stuck');\n el.dispatchEvent(new CustomEvent('affix:unstuck', {\n bubbles: true,\n detail: {\n offset: offset,\n root: scrollParent || window\n }\n }));\n }\n\n const observer = new IntersectionObserver(function (entries) {\n entries.forEach(entry => {\n if (!entry.isIntersecting) {\n stick();\n } else {\n unstick();\n }\n });\n }, {\n root: scrollParent,\n rootMargin: '-' + offset + 'px 0px 0px 0px',\n threshold: 0\n });\n\n observer.observe(sentinel);\n\n cleanup.push(\n () => observer.disconnect(),\n () => { if (sentinel.parentNode) sentinel.parentNode.removeChild(sentinel); },\n () => {\n el.classList.remove('is-stuck');\n el.style.removeProperty('--affix-top-offset');\n }\n );\n\n this.instances.set(el, { cleanup, observer, sentinel, scrollParent });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n el.classList.remove('is-stuck');\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('affix', Affix);\n }\n\n window.VanduoAffix = Affix;\n\n})();\n", "/**\n * Vanduo Framework - Suggest (Autocomplete) Component\n * Dropdown suggestion list with keyboard navigation, static/async data sources\n */\n\n(function () {\n 'use strict';\n\n /**\n * Escape HTML entities to prevent XSS when inserting into innerHTML\n * @param {string} text\n * @returns {string}\n */\n function _escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Allow same-origin URLs by default, or an explicit allowlist of origins.\n * @param {string} url\n * @param {string[]} allowlist\n * @returns {boolean}\n */\n function _isSafeUrl(url, allowlist) {\n try {\n const resolved = new URL(url, window.location.href);\n if (resolved.origin === window.location.origin) return true;\n return allowlist.includes(resolved.origin);\n } catch (_e) {\n return false;\n }\n }\n\n const Suggest = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-suggest], [data-vd-autocomplete]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const minChars = parseInt(input.getAttribute('data-vd-suggest-min-chars') || '1', 10);\n const url = input.getAttribute('data-vd-suggest-url') || '';\n const allowlistAttr = input.getAttribute('data-vd-suggest-allowlist') || '';\n const allowlist = allowlistAttr\n .split(',')\n .map(value => value.trim())\n .filter(Boolean);\n const staticData = input.getAttribute('data-vd-suggest') || input.getAttribute('data-vd-autocomplete') || '';\n let items = [];\n\n try { items = JSON.parse(staticData); } catch (_e) {\n items = staticData.split(',').map(s => s.trim()).filter(Boolean);\n }\n\n // Wrap input if not already wrapped\n let wrapper = input.closest('.vd-suggest-wrapper, .vd-autocomplete-wrapper');\n if (!wrapper) {\n wrapper = document.createElement('div');\n wrapper.className = 'vd-suggest-wrapper';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n }\n\n // Create dropdown list\n const list = document.createElement('ul');\n list.className = 'vd-suggest-list';\n list.setAttribute('role', 'listbox');\n const listId = 'vd-suggest-' + Math.random().toString(36).slice(2, 9);\n list.id = listId;\n wrapper.appendChild(list);\n\n // ARIA\n input.setAttribute('role', 'combobox');\n input.setAttribute('aria-autocomplete', 'list');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('aria-controls', listId);\n input.setAttribute('autocomplete', 'off');\n\n let highlighted = -1;\n let currentItems = [];\n let debounceTimer = null;\n\n const renderItems = (filtered, query) => {\n list.innerHTML = '';\n currentItems = filtered;\n highlighted = -1;\n\n if (filtered.length === 0) {\n const empty = document.createElement('li');\n empty.className = 'vd-suggest-empty';\n empty.textContent = 'No results';\n list.appendChild(empty);\n return;\n }\n\n filtered.forEach((item, i) => {\n const li = document.createElement('li');\n li.className = 'vd-suggest-item';\n li.setAttribute('role', 'option');\n li.id = listId + '-item-' + i;\n\n const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);\n if (query) {\n // Escape HTML first to prevent XSS, then highlight matches in the safe string\n const escaped = _escapeHtml(text);\n const re = new RegExp('(' + query.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi');\n li.innerHTML = escaped.replace(re, '$1');\n } else {\n li.textContent = text;\n }\n\n li.addEventListener('click', () => selectItem(i));\n list.appendChild(li);\n });\n };\n\n const open = () => {\n list.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n };\n\n const close = () => {\n list.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n highlighted = -1;\n input.removeAttribute('aria-activedescendant');\n };\n\n const selectItem = (index) => {\n const item = currentItems[index];\n const value = typeof item === 'object' ? (item.value || item.label || String(item)) : String(item);\n input.value = value;\n close();\n input.dispatchEvent(new CustomEvent('suggest:select', {\n detail: { value, item, index },\n bubbles: true\n }));\n };\n\n const highlight = (index) => {\n const listItems = list.querySelectorAll('.vd-suggest-item');\n listItems.forEach(li => li.classList.remove('is-highlighted'));\n if (index >= 0 && index < listItems.length) {\n highlighted = index;\n listItems[index].classList.add('is-highlighted');\n input.setAttribute('aria-activedescendant', listItems[index].id);\n listItems[index].scrollIntoView({ block: 'nearest' });\n }\n };\n\n const doSearch = async (query) => {\n if (query.length < minChars) { close(); return; }\n\n let filtered;\n if (url) {\n try {\n if (!_isSafeUrl(url, allowlist)) {\n console.warn('[VanduoSuggest] Blocked non-allowlisted URL:', url);\n filtered = [];\n } else {\n const separator = url.includes('?') ? '&' : '?';\n const res = await window.fetch(url + separator + 'q=' + encodeURIComponent(query));\n filtered = await res.json();\n }\n } catch (_e) {\n filtered = [];\n }\n } else {\n const lower = query.toLowerCase();\n filtered = items.filter(item => {\n const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);\n return text.toLowerCase().includes(lower);\n });\n }\n\n renderItems(filtered, query);\n if (filtered.length > 0) open();\n else open(); // show \"no results\"\n };\n\n const inputHandler = () => {\n clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => doSearch(input.value), 200);\n };\n\n const keyHandler = (e) => {\n if (!list.classList.contains('is-open')) {\n if (e.key === 'ArrowDown') { doSearch(input.value); e.preventDefault(); }\n return;\n }\n\n const total = currentItems.length;\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n highlight(highlighted < total - 1 ? highlighted + 1 : 0);\n break;\n case 'ArrowUp':\n e.preventDefault();\n highlight(highlighted > 0 ? highlighted - 1 : total - 1);\n break;\n case 'Enter':\n e.preventDefault();\n if (highlighted >= 0) selectItem(highlighted);\n break;\n case 'Escape':\n close();\n break;\n }\n };\n\n const blurHandler = () => {\n setTimeout(close, 200);\n };\n\n input.addEventListener('input', inputHandler);\n input.addEventListener('keydown', keyHandler);\n input.addEventListener('blur', blurHandler);\n input.addEventListener('focus', () => { if (input.value.length >= minChars) doSearch(input.value); });\n\n cleanup.push(\n () => input.removeEventListener('input', inputHandler),\n () => input.removeEventListener('keydown', keyHandler),\n () => input.removeEventListener('blur', blurHandler),\n () => clearTimeout(debounceTimer),\n () => { if (list.parentNode) list.parentNode.removeChild(list); }\n );\n\n this.instances.set(input, { cleanup, list, close });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('suggest', Suggest);\n }\n\n window.VanduoSuggest = Suggest;\n\n})();\n", "/**\n * Vanduo Framework - Validate (Form Validation) Component\n * Declarative validation via data attributes with real-time and on-submit modes\n */\n\n(function () {\n 'use strict';\n\n const Validate = {\n instances: new Map(),\n\n rules: {\n required: (value) => value.trim().length > 0,\n email: (value) => /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(value),\n url: (value) => { try { new URL(value); return true; } catch (_e) { return false; } },\n number: (value) => !isNaN(parseFloat(value)) && isFinite(value),\n min: (value, param) => value.length >= parseInt(param, 10),\n max: (value, param) => value.length <= parseInt(param, 10),\n minVal: (value, param) => parseFloat(value) >= parseFloat(param),\n maxVal: (value, param) => parseFloat(value) <= parseFloat(param),\n pattern: (value, param) => {\n try {\n // Cap regex length to prevent ReDoS from excessively complex patterns\n if (param.length > 100) return false;\n return new RegExp(param).test(value);\n } catch (_e) { return false; }\n },\n match: (value, param) => {\n try {\n const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(param) : param;\n const other = document.querySelector('[name=\"' + escaped + '\"]');\n return other ? value === other.value : false;\n } catch (_e) {\n return false;\n }\n }\n },\n\n messages: {\n required: 'This field is required',\n email: 'Please enter a valid email address',\n url: 'Please enter a valid URL',\n number: 'Please enter a valid number',\n min: 'Minimum {0} characters required',\n max: 'Maximum {0} characters allowed',\n minVal: 'Value must be at least {0}',\n maxVal: 'Value must be at most {0}',\n pattern: 'Invalid format',\n match: 'Fields do not match'\n },\n\n init: function () {\n const forms = document.querySelectorAll('[data-vd-validate], .vd-validate');\n forms.forEach(form => {\n if (this.instances.has(form)) return;\n this.initInstance(form);\n });\n },\n\n initInstance: function (form) {\n const cleanup = [];\n const mode = form.getAttribute('data-vd-validate-mode') || 'blur'; // blur | input | submit\n const fields = form.querySelectorAll('[data-vd-rules]');\n\n const validateField = (field) => {\n const rulesStr = field.getAttribute('data-vd-rules') || '';\n const rules = rulesStr.split('|').map(r => r.trim()).filter(Boolean);\n const value = field.value;\n const errors = [];\n\n for (const rule of rules) {\n const [name, ...params] = rule.split(':');\n const param = params.join(':');\n const validator = this.rules[name];\n\n if (validator && !validator(value, param)) {\n const customMsg = field.getAttribute('data-vd-msg-' + name);\n let msg = customMsg || this.messages[name] || 'Invalid';\n if (param) msg = msg.replace('{0}', param);\n errors.push(msg);\n break; // one error at a time\n }\n }\n\n this.setFieldState(field, errors);\n return errors.length === 0;\n };\n\n const validateAll = () => {\n let valid = true;\n fields.forEach(field => {\n if (!validateField(field)) valid = false;\n });\n return valid;\n };\n\n // Per-field listeners\n fields.forEach(field => {\n if (mode === 'input' || mode === 'blur') {\n const eventType = mode === 'input' ? 'input' : 'blur';\n const handler = () => validateField(field);\n field.addEventListener(eventType, handler);\n cleanup.push(() => field.removeEventListener(eventType, handler));\n\n if (mode === 'blur') {\n const inputClear = () => {\n if (field.classList.contains('is-invalid') || field.classList.contains('is-valid')) {\n validateField(field);\n }\n };\n field.addEventListener('input', inputClear);\n cleanup.push(() => field.removeEventListener('input', inputClear));\n }\n }\n });\n\n // Form submit\n const submitHandler = (e) => {\n const valid = validateAll();\n if (!valid) {\n e.preventDefault();\n e.stopPropagation();\n // Focus first invalid field\n const firstInvalid = form.querySelector('.is-invalid');\n if (firstInvalid) firstInvalid.focus();\n }\n form.dispatchEvent(new CustomEvent('validate:submit', {\n detail: { valid },\n bubbles: true\n }));\n };\n\n form.addEventListener('submit', submitHandler);\n cleanup.push(() => form.removeEventListener('submit', submitHandler));\n\n this.instances.set(form, { cleanup, validateAll, validateField });\n },\n\n setFieldState: function (field, errors) {\n const wrapper = field.closest('.vd-form-group') || field.parentElement;\n let errorEl = wrapper.querySelector('.vd-validate-error');\n\n field.classList.remove('is-valid', 'is-invalid');\n\n if (errors.length > 0) {\n field.classList.add('is-invalid');\n field.setAttribute('aria-invalid', 'true');\n if (!errorEl) {\n errorEl = document.createElement('div');\n errorEl.className = 'vd-validate-error';\n errorEl.id = 'vd-err-' + Math.random().toString(36).slice(2, 9);\n errorEl.setAttribute('role', 'alert');\n wrapper.appendChild(errorEl);\n }\n errorEl.textContent = errors[0];\n errorEl.style.display = '';\n field.setAttribute('aria-describedby', errorEl.id);\n } else if (field.value.trim()) {\n field.classList.add('is-valid');\n field.removeAttribute('aria-invalid');\n if (errorEl) errorEl.style.display = 'none';\n } else {\n field.removeAttribute('aria-invalid');\n if (errorEl) errorEl.style.display = 'none';\n }\n },\n\n validateForm: function (form) {\n const instance = this.instances.get(form);\n return instance ? instance.validateAll() : false;\n },\n\n addRule: function (name, validator, message) {\n this.rules[name] = validator;\n if (message) this.messages[name] = message;\n },\n\n destroy: function (form) {\n const instance = this.instances.get(form);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(form);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, form) => this.destroy(form));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('validate', Validate);\n }\n\n window.VanduoValidate = Validate;\n\n})();\n", "/**\n * Vanduo Framework - Datepicker Component\n * Calendar popup attached to input field with month/year navigation\n */\n\n(function () {\n 'use strict';\n\n const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];\n const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];\n\n function escapeRegexChar(c) {\n return c.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n }\n\n function buildParseFormat(format) {\n let regex = '^';\n const order = [];\n let i = 0;\n while (i < format.length) {\n const slice = format.slice(i);\n if (slice.toLowerCase().startsWith('yyyy')) {\n regex += '(\\\\d{4})';\n order.push('y');\n i += 4;\n } else if (slice.toLowerCase().startsWith('mm')) {\n regex += '(\\\\d{2})';\n order.push('m');\n i += 2;\n } else if (slice.toLowerCase().startsWith('dd')) {\n regex += '(\\\\d{2})';\n order.push('d');\n i += 2;\n } else {\n regex += escapeRegexChar(format[i]);\n i++;\n }\n }\n regex += '$';\n return { regex: new RegExp(regex), order };\n }\n\n function parseDateFromFormat(value, format) {\n if (!value || !format) return null;\n const { regex, order } = buildParseFormat(format);\n const m = value.trim().match(regex);\n if (!m) return null;\n let y;\n let mo;\n let d;\n let ci = 1;\n for (let k = 0; k < order.length; k++) {\n const part = order[k];\n const v = parseInt(m[ci++], 10);\n if (Number.isNaN(v)) return null;\n if (part === 'y') y = v;\n else if (part === 'm') mo = v - 1;\n else if (part === 'd') d = v;\n }\n if (y === undefined || mo === undefined || d === undefined) return null;\n const dt = new Date(y, mo, d);\n if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== d) return null;\n return dt;\n }\n\n function formatDate(d, format) {\n const yyyy = String(d.getFullYear());\n const mm = String(d.getMonth() + 1).padStart(2, '0');\n const dd = String(d.getDate()).padStart(2, '0');\n let out = '';\n let i = 0;\n while (i < format.length) {\n const slice = format.slice(i);\n if (slice.toLowerCase().startsWith('yyyy')) {\n out += yyyy;\n i += 4;\n } else if (slice.toLowerCase().startsWith('mm')) {\n out += mm;\n i += 2;\n } else if (slice.toLowerCase().startsWith('dd')) {\n out += dd;\n i += 2;\n } else {\n out += format[i];\n i++;\n }\n }\n return out;\n }\n\n function dateKey(d) {\n return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');\n }\n\n function addDays(d, n) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n x.setDate(x.getDate() + n);\n return x;\n }\n\n function addMonthsClamped(d, n) {\n return new Date(d.getFullYear(), d.getMonth() + n, d.getDate());\n }\n\n function parseYmdLocal(ymd) {\n if (!ymd || typeof ymd !== 'string') return null;\n const m = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(ymd.trim());\n if (!m) return null;\n const y = +m[1];\n const mo = +m[2] - 1;\n const day = +m[3];\n const dt = new Date(y, mo, day);\n if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== day) return null;\n return dt;\n }\n\n function startOfWeekSunday(d) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const day = x.getDay();\n x.setDate(x.getDate() - day);\n return x;\n }\n\n function endOfWeekSunday(d) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const day = x.getDay();\n x.setDate(x.getDate() + (6 - day));\n return x;\n }\n\n const Datepicker = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-datepicker]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const format = input.getAttribute('data-vd-datepicker-format') || 'YYYY-MM-DD';\n const minStr = input.getAttribute('data-vd-datepicker-min');\n const maxStr = input.getAttribute('data-vd-datepicker-max');\n const minDate = minStr ? parseYmdLocal(minStr) : null;\n const maxDate = maxStr ? parseYmdLocal(maxStr) : null;\n\n const today = new Date();\n let viewYear = today.getFullYear();\n let viewMonth = today.getMonth();\n let selectedDate = null;\n let viewMode = 'days'; // days | months | years\n let focusedDate = null;\n /** Prevents focus() after close from immediately re-opening the popup */\n let skipNextFocusOpen = false;\n\n const isDisabled = (d) => {\n if (minDate) {\n const t = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n if (t < minDate.getTime()) return true;\n }\n if (maxDate) {\n const t = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n if (t > maxDate.getTime()) return true;\n }\n return false;\n };\n\n const ensureMonthInRange = (y, m) => {\n if (!minDate && !maxDate) return { y: y, m: m };\n const first = new Date(y, m, 1);\n const last = new Date(y, m + 1, 0);\n if (minDate && last.getTime() < minDate.getTime()) {\n return { y: minDate.getFullYear(), m: minDate.getMonth() };\n }\n if (maxDate && first.getTime() > maxDate.getTime()) {\n return { y: maxDate.getFullYear(), m: maxDate.getMonth() };\n }\n return { y: y, m: m };\n };\n\n const firstSelectableInMonth = (y, m) => {\n const last = new Date(y, m + 1, 0).getDate();\n for (let day = 1; day <= last; day++) {\n const dt = new Date(y, m, day);\n if (!isDisabled(dt)) return dt;\n }\n return new Date(y, m, 1);\n };\n\n if (input.value) {\n const trimmed = input.value.trim();\n let parsed = parseDateFromFormat(trimmed, format);\n if (!parsed) {\n const fallback = new Date(trimmed);\n if (!isNaN(fallback.getTime())) parsed = fallback;\n }\n if (parsed) {\n selectedDate = parsed;\n viewYear = parsed.getFullYear();\n viewMonth = parsed.getMonth();\n }\n }\n\n const clampedInit = ensureMonthInRange(viewYear, viewMonth);\n viewYear = clampedInit.y;\n viewMonth = clampedInit.m;\n\n // Create popup\n const popup = document.createElement('div');\n popup.className = 'vd-datepicker-popup';\n popup.setAttribute('role', 'dialog');\n popup.setAttribute('aria-label', 'Choose date');\n popup.tabIndex = -1;\n\n const wrapper = document.createElement('div');\n wrapper.className = 'vd-suggest-wrapper';\n wrapper.style.position = 'relative';\n wrapper.style.display = 'inline-block';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n wrapper.appendChild(popup);\n\n const isSameDay = (a, b) => a && b &&\n a.getFullYear() === b.getFullYear() &&\n a.getMonth() === b.getMonth() &&\n a.getDate() === b.getDate();\n\n const selectDate = (date) => {\n selectedDate = date;\n viewYear = date.getFullYear();\n viewMonth = date.getMonth();\n input.value = formatDate(date, format);\n skipNextFocusOpen = true;\n close();\n input.dispatchEvent(new CustomEvent('datepicker:select', {\n detail: { date: date, formatted: input.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n input.focus();\n };\n\n const focusFocusedDay = () => {\n if (viewMode !== 'days' || !focusedDate) return;\n const key = dateKey(focusedDate);\n const btn = popup.querySelector('[data-vd-date=\"' + key + '\"]');\n if (btn && !btn.classList.contains('is-outside') && btn.getAttribute('aria-disabled') !== 'true') {\n btn.focus();\n }\n };\n\n const skipDisabled = (d, stepDir, maxSteps) => {\n let x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const step = stepDir > 0 ? 1 : -1;\n for (let i = 0; i < maxSteps; i++) {\n if (!isDisabled(x)) return x;\n x = addDays(x, step);\n }\n return d;\n };\n\n const createDayBtn = (day, outside, date) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-day';\n btn.textContent = day;\n btn.setAttribute('role', 'gridcell');\n\n if (outside) {\n btn.classList.add('is-outside');\n btn.tabIndex = -1;\n btn.setAttribute('aria-disabled', 'true');\n return btn;\n }\n\n btn.setAttribute('data-vd-date', dateKey(date));\n\n if (date && isSameDay(date, today)) btn.classList.add('is-today');\n if (date && isSameDay(date, selectedDate)) btn.classList.add('is-selected');\n if (date && isDisabled(date)) {\n btn.classList.add('is-disabled');\n btn.setAttribute('aria-disabled', 'true');\n btn.tabIndex = -1;\n return btn;\n }\n\n if (date) {\n const isFocused = focusedDate && isSameDay(date, focusedDate);\n btn.tabIndex = isFocused ? 0 : -1;\n\n btn.addEventListener('click', () => {\n selectedDate = date;\n viewYear = date.getFullYear();\n viewMonth = date.getMonth();\n focusedDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());\n input.value = formatDate(date, format);\n skipNextFocusOpen = true;\n close();\n input.dispatchEvent(new CustomEvent('datepicker:select', {\n detail: { date: date, formatted: input.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n input.focus();\n });\n }\n\n return btn;\n };\n\n const render = () => {\n popup.innerHTML = '';\n\n // Header\n const header = document.createElement('div');\n header.className = 'vd-datepicker-header';\n\n const prevBtn = document.createElement('button');\n prevBtn.type = 'button';\n prevBtn.className = 'vd-datepicker-prev';\n prevBtn.innerHTML = '‹';\n prevBtn.setAttribute('aria-label', 'Previous');\n\n const nextBtn = document.createElement('button');\n nextBtn.type = 'button';\n nextBtn.className = 'vd-datepicker-next';\n nextBtn.innerHTML = '›';\n nextBtn.setAttribute('aria-label', 'Next');\n\n const title = document.createElement('span');\n title.className = 'vd-datepicker-title';\n\n if (viewMode === 'days') {\n title.textContent = MONTHS[viewMonth] + ' ' + viewYear;\n title.addEventListener('click', () => { viewMode = 'months'; render(); });\n prevBtn.addEventListener('click', () => { viewMonth--; if (viewMonth < 0) { viewMonth = 11; viewYear--; } render(); });\n nextBtn.addEventListener('click', () => { viewMonth++; if (viewMonth > 11) { viewMonth = 0; viewYear++; } render(); });\n } else if (viewMode === 'months') {\n title.textContent = String(viewYear);\n title.addEventListener('click', () => { viewMode = 'years'; render(); });\n prevBtn.addEventListener('click', () => { viewYear--; render(); });\n nextBtn.addEventListener('click', () => { viewYear++; render(); });\n } else {\n const decadeStart = Math.floor(viewYear / 10) * 10;\n title.textContent = decadeStart + ' - ' + (decadeStart + 9);\n prevBtn.addEventListener('click', () => { viewYear -= 10; render(); });\n nextBtn.addEventListener('click', () => { viewYear += 10; render(); });\n }\n\n header.appendChild(prevBtn);\n header.appendChild(title);\n header.appendChild(nextBtn);\n popup.appendChild(header);\n\n if (viewMode === 'days') {\n const gridWrap = document.createElement('div');\n gridWrap.className = 'vd-datepicker-grid';\n gridWrap.setAttribute('role', 'grid');\n gridWrap.setAttribute('aria-label', 'Calendar');\n\n const weekdays = document.createElement('div');\n weekdays.className = 'vd-datepicker-weekdays';\n weekdays.setAttribute('role', 'row');\n DAYS.forEach(function (d) {\n const span = document.createElement('span');\n span.setAttribute('role', 'columnheader');\n span.setAttribute('aria-label', d);\n span.textContent = d;\n weekdays.appendChild(span);\n });\n gridWrap.appendChild(weekdays);\n\n const firstDay = new Date(viewYear, viewMonth, 1).getDay();\n const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();\n const daysInPrev = new Date(viewYear, viewMonth, 0).getDate();\n\n const cells = [];\n\n for (let i = firstDay - 1; i >= 0; i--) {\n const dayNum = daysInPrev - i;\n const prevMonth = viewMonth === 0 ? 11 : viewMonth - 1;\n const prevYear = viewMonth === 0 ? viewYear - 1 : viewYear;\n const date = new Date(prevYear, prevMonth, dayNum);\n cells.push({ day: dayNum, outside: true, date: date });\n }\n\n for (let d = 1; d <= daysInMonth; d++) {\n const date = new Date(viewYear, viewMonth, d);\n cells.push({ day: d, outside: false, date: date });\n }\n\n const totalCells = firstDay + daysInMonth;\n const remaining = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7);\n for (let i = 1; i <= remaining; i++) {\n const date = new Date(viewYear, viewMonth + 1, i);\n cells.push({ day: i, outside: true, date: date });\n }\n\n for (let r = 0; r < cells.length; r += 7) {\n const row = document.createElement('div');\n row.className = 'vd-datepicker-row';\n row.setAttribute('role', 'row');\n for (let c = 0; c < 7; c++) {\n const cell = cells[r + c];\n row.appendChild(createDayBtn(cell.day, cell.outside, cell.date));\n }\n gridWrap.appendChild(row);\n }\n\n popup.appendChild(gridWrap);\n } else if (viewMode === 'months') {\n const grid = document.createElement('div');\n grid.className = 'vd-datepicker-months';\n MONTHS.forEach((name, i) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-month-btn';\n btn.textContent = name.slice(0, 3);\n if (selectedDate && selectedDate.getFullYear() === viewYear && selectedDate.getMonth() === i) {\n btn.classList.add('is-selected');\n }\n btn.addEventListener('click', () => { viewMonth = i; viewMode = 'days'; render(); });\n grid.appendChild(btn);\n });\n popup.appendChild(grid);\n } else {\n const grid = document.createElement('div');\n grid.className = 'vd-datepicker-years';\n const decadeStart = Math.floor(viewYear / 10) * 10;\n for (let y = decadeStart - 1; y <= decadeStart + 10; y++) {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-year-btn';\n btn.textContent = y;\n if (selectedDate && selectedDate.getFullYear() === y) btn.classList.add('is-selected');\n if (y < decadeStart || y > decadeStart + 9) btn.style.opacity = '0.4';\n btn.addEventListener('click', () => { viewYear = y; viewMode = 'months'; render(); });\n grid.appendChild(btn);\n }\n popup.appendChild(grid);\n }\n };\n\n const handleGridKeydown = (e) => {\n if (!popup.classList.contains('is-open') || viewMode !== 'days') return;\n const grid = popup.querySelector('.vd-datepicker-grid');\n if (!grid || !grid.contains(e.target)) return;\n\n const key = e.key;\n if (key !== 'ArrowLeft' && key !== 'ArrowRight' && key !== 'ArrowUp' && key !== 'ArrowDown' &&\n key !== 'Home' && key !== 'End' && key !== 'PageUp' && key !== 'PageDown' &&\n key !== 'Enter' && key !== ' ' && key !== 'Escape') {\n return;\n }\n\n if (key === 'Escape') {\n e.preventDefault();\n e.stopPropagation();\n skipNextFocusOpen = true;\n close();\n input.focus();\n return;\n }\n\n if (!focusedDate) {\n focusedDate = firstSelectableInMonth(viewYear, viewMonth);\n }\n\n if (key === 'Enter' || key === ' ') {\n e.preventDefault();\n if (focusedDate && !isDisabled(focusedDate)) {\n selectDate(new Date(focusedDate.getFullYear(), focusedDate.getMonth(), focusedDate.getDate()));\n }\n return;\n }\n\n e.preventDefault();\n\n let next = new Date(focusedDate.getFullYear(), focusedDate.getMonth(), focusedDate.getDate());\n let skipDir = 1;\n\n if (key === 'ArrowLeft') {\n next = addDays(next, -1);\n skipDir = -1;\n } else if (key === 'ArrowRight') {\n next = addDays(next, 1);\n skipDir = 1;\n } else if (key === 'ArrowUp') {\n next = addDays(next, -7);\n skipDir = -1;\n } else if (key === 'ArrowDown') {\n next = addDays(next, 7);\n skipDir = 1;\n } else if (key === 'Home') {\n next = startOfWeekSunday(next);\n skipDir = 1;\n } else if (key === 'End') {\n next = endOfWeekSunday(next);\n skipDir = -1;\n } else if (key === 'PageUp') {\n next = addMonthsClamped(next, -1);\n skipDir = -1;\n } else if (key === 'PageDown') {\n next = addMonthsClamped(next, 1);\n skipDir = 1;\n }\n\n next = skipDisabled(next, skipDir, 400);\n\n if (next.getMonth() !== viewMonth || next.getFullYear() !== viewYear) {\n viewYear = next.getFullYear();\n viewMonth = next.getMonth();\n const cl = ensureMonthInRange(viewYear, viewMonth);\n viewYear = cl.y;\n viewMonth = cl.m;\n }\n\n focusedDate = next;\n render();\n requestAnimationFrame(focusFocusedDay);\n };\n\n const open = () => {\n viewMode = 'days';\n if (selectedDate) {\n viewYear = selectedDate.getFullYear();\n viewMonth = selectedDate.getMonth();\n }\n const cl = ensureMonthInRange(viewYear, viewMonth);\n viewYear = cl.y;\n viewMonth = cl.m;\n\n if (selectedDate) {\n focusedDate = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate());\n } else {\n focusedDate = firstSelectableInMonth(viewYear, viewMonth);\n }\n\n render();\n popup.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n requestAnimationFrame(focusFocusedDay);\n };\n\n const close = () => {\n popup.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n viewMode = 'days';\n };\n\n const focusHandler = () => {\n if (skipNextFocusOpen) {\n skipNextFocusOpen = false;\n return;\n }\n open();\n };\n const outsideHandler = (e) => {\n if (!wrapper.contains(e.target)) close();\n };\n const escHandler = (e) => {\n if (e.key === 'Escape' && popup.classList.contains('is-open')) {\n skipNextFocusOpen = true;\n close();\n input.focus();\n }\n };\n\n input.addEventListener('focus', focusHandler);\n document.addEventListener('click', outsideHandler, true);\n document.addEventListener('keydown', escHandler);\n popup.addEventListener('keydown', handleGridKeydown);\n\n input.setAttribute('aria-haspopup', 'dialog');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('autocomplete', 'off');\n\n cleanup.push(\n () => input.removeEventListener('focus', focusHandler),\n () => document.removeEventListener('click', outsideHandler, true),\n () => document.removeEventListener('keydown', escHandler),\n () => popup.removeEventListener('keydown', handleGridKeydown)\n );\n\n this.instances.set(input, { cleanup: cleanup, open: open, close: close, popup: popup });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('datepicker', Datepicker);\n }\n\n window.VanduoDatepicker = Datepicker;\n\n})();\n", "/**\n * Vanduo Framework - Timepicker Component\n * Dropdown time selection with 12h/24h format and configurable step intervals\n */\n\n(function () {\n 'use strict';\n\n const Timepicker = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-timepicker]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const is24h = input.getAttribute('data-vd-timepicker-format') === '24h';\n const step = parseInt(input.getAttribute('data-vd-timepicker-step') || '30', 10);\n\n // Create wrapper\n let wrapper = input.closest('.vd-suggest-wrapper');\n if (!wrapper) {\n wrapper = document.createElement('div');\n wrapper.style.position = 'relative';\n wrapper.style.display = 'inline-block';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n }\n\n // Create popup\n const popup = document.createElement('div');\n popup.className = 'vd-timepicker-popup';\n popup.setAttribute('role', 'listbox');\n wrapper.appendChild(popup);\n\n // Generate time slots\n const times = [];\n for (let h = 0; h < 24; h++) {\n for (let m = 0; m < 60; m += step) {\n const hh24 = String(h).padStart(2, '0');\n const mm = String(m).padStart(2, '0');\n\n if (is24h) {\n times.push({ display: hh24 + ':' + mm, value: hh24 + ':' + mm });\n } else {\n const period = h < 12 ? 'AM' : 'PM';\n const h12 = h === 0 ? 12 : (h > 12 ? h - 12 : h);\n const display = h12 + ':' + mm + ' ' + period;\n times.push({ display, value: hh24 + ':' + mm });\n }\n }\n }\n\n const render = () => {\n popup.innerHTML = '';\n times.forEach(t => {\n const item = document.createElement('div');\n item.className = 'vd-timepicker-item';\n item.setAttribute('role', 'option');\n item.textContent = t.display;\n\n if (input.value === t.value || input.value === t.display) {\n item.classList.add('is-selected');\n }\n\n item.addEventListener('click', () => {\n input.value = t.display;\n popup.querySelectorAll('.vd-timepicker-item').forEach(i => i.classList.remove('is-selected'));\n item.classList.add('is-selected');\n close();\n input.dispatchEvent(new CustomEvent('timepicker:select', {\n detail: { display: t.display, value: t.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n });\n\n popup.appendChild(item);\n });\n };\n\n const open = () => {\n render();\n popup.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n // Scroll to selected\n const selected = popup.querySelector('.is-selected');\n if (selected) selected.scrollIntoView({ block: 'center' });\n };\n\n const close = () => {\n popup.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n };\n\n const focusHandler = () => open();\n const outsideHandler = (e) => {\n if (!wrapper.contains(e.target)) close();\n };\n const escHandler = (e) => { if (e.key === 'Escape') close(); };\n\n input.addEventListener('focus', focusHandler);\n document.addEventListener('click', outsideHandler, true);\n document.addEventListener('keydown', escHandler);\n input.setAttribute('aria-haspopup', 'listbox');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('autocomplete', 'off');\n input.readOnly = true;\n\n cleanup.push(\n () => input.removeEventListener('focus', focusHandler),\n () => document.removeEventListener('click', outsideHandler, true),\n () => document.removeEventListener('keydown', escHandler)\n );\n\n this.instances.set(input, { cleanup, open, close });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('timepicker', Timepicker);\n }\n\n window.VanduoTimepicker = Timepicker;\n\n})();\n", "/**\n * Vanduo Framework - Stepper Component\n * Multi-step progress indicator with state management\n */\n\n(function () {\n 'use strict';\n\n const Stepper = {\n instances: new Map(),\n\n init: function () {\n const steppers = document.querySelectorAll('.vd-stepper');\n steppers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const items = Array.from(el.querySelectorAll('.vd-stepper-item'));\n const isClickable = el.classList.contains('vd-stepper-clickable');\n let currentIndex = items.findIndex(i => i.classList.contains('is-active'));\n if (currentIndex === -1) currentIndex = 0;\n\n const setStep = (index) => {\n if (index < 0 || index >= items.length) return;\n const prev = currentIndex;\n currentIndex = index;\n\n items.forEach((item, i) => {\n item.classList.remove('is-active', 'is-completed');\n if (i < index) item.classList.add('is-completed');\n else if (i === index) item.classList.add('is-active');\n });\n\n el.dispatchEvent(new CustomEvent('stepper:change', {\n detail: { current: index, previous: prev, total: items.length },\n bubbles: true\n }));\n };\n\n if (isClickable) {\n items.forEach((item, i) => {\n const handler = () => setStep(i);\n item.addEventListener('click', handler);\n cleanup.push(() => item.removeEventListener('click', handler));\n });\n }\n\n // Initialize current state\n setStep(currentIndex);\n\n this.instances.set(el, {\n cleanup,\n setStep,\n next: () => setStep(currentIndex + 1),\n prev: () => setStep(currentIndex - 1),\n getCurrent: () => currentIndex\n });\n },\n\n setStep: function (el, index) {\n const inst = this.instances.get(el);\n if (inst) inst.setStep(index);\n },\n\n next: function (el) {\n const inst = this.instances.get(el);\n if (inst) inst.next();\n },\n\n prev: function (el) {\n const inst = this.instances.get(el);\n if (inst) inst.prev();\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('stepper', Stepper);\n }\n\n window.VanduoStepper = Stepper;\n\n})();\n", "/**\n * Vanduo Framework - Rating Component\n * Star-based rating input with hover preview and read-only mode\n */\n\n(function () {\n 'use strict';\n\n const Rating = {\n instances: new Map(),\n\n init: function () {\n const ratings = document.querySelectorAll('[data-vd-rating]');\n ratings.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const max = parseInt(el.getAttribute('data-vd-rating-max') || '5', 10);\n const initialValue = parseFloat(el.getAttribute('data-vd-rating-value') || '0');\n const readonly = el.classList.contains('vd-rating-readonly') || el.hasAttribute('data-vd-rating-readonly');\n let currentValue = initialValue;\n\n el.classList.add('vd-rating');\n el.setAttribute('role', 'radiogroup');\n el.setAttribute('aria-label', el.getAttribute('aria-label') || 'Rating');\n\n // Clear existing stars\n el.innerHTML = '';\n\n // Create stars\n const stars = [];\n for (let i = 1; i <= max; i++) {\n const star = document.createElement('button');\n star.type = 'button';\n star.className = 'vd-rating-star';\n star.setAttribute('role', 'radio');\n star.setAttribute('aria-label', i + ' star' + (i > 1 ? 's' : ''));\n star.setAttribute('aria-checked', i <= currentValue ? 'true' : 'false');\n if (readonly) star.tabIndex = -1;\n stars.push(star);\n el.appendChild(star);\n }\n\n // Value display\n const valueDisplay = document.createElement('span');\n valueDisplay.className = 'vd-rating-value';\n valueDisplay.textContent = currentValue > 0 ? currentValue.toString() : '';\n el.appendChild(valueDisplay);\n\n const updateStars = (value) => {\n stars.forEach((star, i) => {\n star.classList.remove('is-active', 'is-half');\n const starNum = i + 1;\n if (starNum <= Math.floor(value)) {\n star.classList.add('is-active');\n } else if (starNum - 0.5 <= value) {\n star.classList.add('is-half');\n }\n star.setAttribute('aria-checked', starNum <= value ? 'true' : 'false');\n });\n valueDisplay.textContent = value > 0 ? value.toString() : '';\n };\n\n updateStars(currentValue);\n\n if (!readonly) {\n stars.forEach((star, i) => {\n const enterHandler = () => {\n stars.forEach((s, j) => {\n s.classList.toggle('is-hovered', j <= i);\n });\n };\n const leaveHandler = () => {\n stars.forEach(s => s.classList.remove('is-hovered'));\n };\n const clickHandler = () => {\n currentValue = i + 1;\n el.setAttribute('data-vd-rating-value', currentValue);\n updateStars(currentValue);\n el.dispatchEvent(new CustomEvent('rating:change', {\n detail: { value: currentValue, max },\n bubbles: true\n }));\n };\n\n star.addEventListener('mouseenter', enterHandler);\n star.addEventListener('mouseleave', leaveHandler);\n star.addEventListener('click', clickHandler);\n\n cleanup.push(\n () => star.removeEventListener('mouseenter', enterHandler),\n () => star.removeEventListener('mouseleave', leaveHandler),\n () => star.removeEventListener('click', clickHandler)\n );\n });\n\n // Keyboard\n const keyHandler = (e) => {\n if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {\n e.preventDefault();\n if (currentValue < max) {\n currentValue++;\n updateStars(currentValue);\n stars[currentValue - 1].focus();\n el.dispatchEvent(new CustomEvent('rating:change', { detail: { value: currentValue, max }, bubbles: true }));\n }\n } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {\n e.preventDefault();\n if (currentValue > 1) {\n currentValue--;\n updateStars(currentValue);\n stars[currentValue - 1].focus();\n el.dispatchEvent(new CustomEvent('rating:change', { detail: { value: currentValue, max }, bubbles: true }));\n }\n }\n };\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n }\n\n this.instances.set(el, {\n cleanup,\n getValue: () => currentValue,\n setValue: (v) => { currentValue = v; updateStars(v); }\n });\n },\n\n getValue: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getValue() : 0;\n },\n\n setValue: function (el, value) {\n const inst = this.instances.get(el);\n if (inst) inst.setValue(value);\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('rating', Rating);\n }\n\n window.VanduoRating = Rating;\n\n})();\n", "/**\n * Vanduo Framework - Transfer (Dual-list) Component\n * Source-to-target list transfer with search, select-all, and move actions\n */\n\n(function () {\n 'use strict';\n\n const Transfer = {\n instances: new Map(),\n\n init: function () {\n const transfers = document.querySelectorAll('[data-vd-transfer]');\n transfers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n el.classList.add('vd-transfer');\n\n let sourceData, targetData;\n try {\n const raw = JSON.parse(el.getAttribute('data-vd-transfer') || '[]');\n sourceData = raw.map((item, i) => ({\n id: item.id || 'item-' + i,\n label: item.label || item.text || String(item),\n selected: false\n }));\n } catch (_e) {\n sourceData = [];\n }\n targetData = [];\n\n const sourceSelected = new Set();\n const targetSelected = new Set();\n\n const render = () => {\n el.innerHTML = '';\n\n // Source panel\n const sourcePanel = createPanel('Source', sourceData, sourceSelected, 'source');\n // Actions\n const actions = document.createElement('div');\n actions.className = 'vd-transfer-actions';\n\n const moveRightBtn = document.createElement('button');\n moveRightBtn.type = 'button';\n moveRightBtn.className = 'vd-transfer-btn';\n moveRightBtn.innerHTML = '›';\n moveRightBtn.setAttribute('aria-label', 'Move to target');\n moveRightBtn.disabled = sourceSelected.size === 0;\n moveRightBtn.addEventListener('click', () => moveRight());\n\n const moveLeftBtn = document.createElement('button');\n moveLeftBtn.type = 'button';\n moveLeftBtn.className = 'vd-transfer-btn';\n moveLeftBtn.innerHTML = '‹';\n moveLeftBtn.setAttribute('aria-label', 'Move to source');\n moveLeftBtn.disabled = targetSelected.size === 0;\n moveLeftBtn.addEventListener('click', () => moveLeft());\n\n actions.appendChild(moveRightBtn);\n actions.appendChild(moveLeftBtn);\n\n // Target panel\n const targetPanel = createPanel('Target', targetData, targetSelected, 'target');\n\n el.appendChild(sourcePanel);\n el.appendChild(actions);\n el.appendChild(targetPanel);\n };\n\n const createPanel = (title, data, selected, _side) => {\n const panel = document.createElement('div');\n panel.className = 'vd-transfer-panel';\n\n const header = document.createElement('div');\n header.className = 'vd-transfer-header';\n const titleSpan = document.createElement('span');\n titleSpan.textContent = title;\n const count = document.createElement('span');\n count.className = 'vd-transfer-count';\n count.textContent = selected.size + '/' + data.length;\n header.appendChild(titleSpan);\n header.appendChild(count);\n panel.appendChild(header);\n\n // Search\n const searchDiv = document.createElement('div');\n searchDiv.className = 'vd-transfer-search';\n const searchInput = document.createElement('input');\n searchInput.type = 'text';\n searchInput.placeholder = 'Search...';\n searchInput.setAttribute('aria-label', 'Search ' + title.toLowerCase());\n searchDiv.appendChild(searchInput);\n panel.appendChild(searchDiv);\n\n // List\n const list = document.createElement('ul');\n list.className = 'vd-transfer-list';\n list.setAttribute('role', 'listbox');\n\n const renderList = (filter) => {\n list.innerHTML = '';\n const filtered = filter ? data.filter(d => {\n const label = (d.label || d.text || String(d)).toLowerCase();\n return label.includes(filter.toLowerCase());\n }) : data;\n filtered.forEach(item => {\n const li = document.createElement('li');\n li.className = 'vd-transfer-item';\n li.setAttribute('role', 'option');\n if (selected.has(item.id)) li.classList.add('is-selected');\n\n const checkbox = document.createElement('input');\n checkbox.type = 'checkbox';\n checkbox.checked = selected.has(item.id);\n checkbox.setAttribute('aria-label', item.label);\n\n const label = document.createElement('span');\n label.textContent = item.label;\n\n li.addEventListener('click', () => {\n if (selected.has(item.id)) selected.delete(item.id);\n else selected.add(item.id);\n render();\n });\n\n li.appendChild(checkbox);\n li.appendChild(label);\n list.appendChild(li);\n });\n };\n\n searchInput.addEventListener('input', () => renderList(searchInput.value));\n renderList('');\n\n panel.appendChild(list);\n return panel;\n };\n\n const moveRight = () => {\n const toMove = sourceData.filter(d => sourceSelected.has(d.id));\n sourceData = sourceData.filter(d => !sourceSelected.has(d.id));\n targetData = targetData.concat(toMove);\n sourceSelected.clear();\n render();\n fireChange();\n };\n\n const moveLeft = () => {\n const toMove = targetData.filter(d => targetSelected.has(d.id));\n targetData = targetData.filter(d => !targetSelected.has(d.id));\n sourceData = sourceData.concat(toMove);\n targetSelected.clear();\n render();\n fireChange();\n };\n\n const fireChange = () => {\n el.dispatchEvent(new CustomEvent('transfer:change', {\n detail: {\n source: sourceData.map(d => d.id),\n target: targetData.map(d => d.id)\n },\n bubbles: true\n }));\n };\n\n render();\n\n this.instances.set(el, {\n cleanup,\n getTarget: () => targetData.map(d => d.id),\n getSource: () => sourceData.map(d => d.id)\n });\n },\n\n getSelected: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getTarget() : [];\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n el.innerHTML = '';\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('transfer', Transfer);\n }\n\n window.VanduoTransfer = Transfer;\n\n})();\n", "/**\n * Vanduo Framework - Tree View Component\n * Hierarchical collapsible tree with checkbox selection and keyboard navigation\n */\n\n(function () {\n 'use strict';\n\n const Tree = {\n instances: new Map(),\n\n init: function () {\n const trees = document.querySelectorAll('[data-vd-tree]');\n trees.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const cascade = el.getAttribute('data-vd-tree-cascade') !== 'false';\n\n let data;\n try { data = JSON.parse(el.getAttribute('data-vd-tree') || '[]'); } catch (_e) { data = []; }\n\n el.classList.add('vd-tree');\n el.setAttribute('role', 'tree');\n\n const render = (items, parent) => {\n parent.innerHTML = '';\n items.forEach(item => {\n const node = document.createElement('li');\n node.className = 'vd-tree-node';\n node.setAttribute('role', 'treeitem');\n node.setAttribute('aria-expanded', item.open ? 'true' : 'false');\n if (item.open) node.classList.add('is-open');\n\n const content = document.createElement('div');\n content.className = 'vd-tree-node-content';\n\n // Toggle\n if (item.children && item.children.length > 0) {\n const toggle = document.createElement('button');\n toggle.type = 'button';\n toggle.className = 'vd-tree-toggle';\n toggle.setAttribute('aria-label', 'Toggle');\n toggle.addEventListener('click', (e) => {\n e.stopPropagation();\n item.open = !item.open;\n node.classList.toggle('is-open');\n node.setAttribute('aria-expanded', item.open ? 'true' : 'false');\n });\n content.appendChild(toggle);\n } else {\n const ph = document.createElement('span');\n ph.className = 'vd-tree-toggle-placeholder';\n content.appendChild(ph);\n }\n\n // Checkbox\n if (el.hasAttribute('data-vd-tree-checkbox')) {\n const cb = document.createElement('input');\n cb.type = 'checkbox';\n cb.className = 'vd-tree-checkbox';\n cb.checked = !!item.checked;\n cb.setAttribute('aria-label', item.label);\n cb.addEventListener('change', (e) => {\n e.stopPropagation();\n item.checked = cb.checked;\n if (cascade && item.children) {\n setChildChecked(item.children, cb.checked);\n render(data, el);\n }\n el.dispatchEvent(new CustomEvent('tree:check', {\n detail: { id: item.id, checked: cb.checked, label: item.label },\n bubbles: true\n }));\n });\n content.appendChild(cb);\n }\n\n // Icon\n if (item.icon) {\n const icon = document.createElement('span');\n icon.className = 'vd-tree-icon ' + item.icon;\n content.appendChild(icon);\n }\n\n // Label\n const label = document.createElement('span');\n label.className = 'vd-tree-label';\n label.textContent = item.label || '';\n content.appendChild(label);\n\n node.appendChild(content);\n\n // Children\n if (item.children && item.children.length > 0) {\n const childList = document.createElement('ul');\n childList.className = 'vd-tree-children';\n childList.setAttribute('role', 'group');\n render(item.children, childList);\n node.appendChild(childList);\n }\n\n parent.appendChild(node);\n });\n };\n\n const setChildChecked = (items, checked) => {\n items.forEach(item => {\n item.checked = checked;\n if (item.children) setChildChecked(item.children, checked);\n });\n };\n\n // Keyboard\n const keyHandler = (e) => {\n const focused = document.activeElement;\n if (!el.contains(focused)) return;\n\n const nodes = Array.from(el.querySelectorAll('.vd-tree-node-content'));\n const idx = nodes.indexOf(focused.closest('.vd-tree-node-content'));\n if (idx === -1) return;\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n if (idx < nodes.length - 1) {\n const next = nodes[idx + 1].querySelector('.vd-tree-toggle, .vd-tree-label');\n if (next) next.focus();\n }\n break;\n case 'ArrowUp':\n e.preventDefault();\n if (idx > 0) {\n const prev = nodes[idx - 1].querySelector('.vd-tree-toggle, .vd-tree-label');\n if (prev) prev.focus();\n }\n break;\n }\n };\n\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n\n render(data, el);\n\n this.instances.set(el, {\n cleanup,\n getData: () => data,\n getChecked: () => {\n const checked = [];\n const collect = (items) => {\n items.forEach(i => {\n if (i.checked) checked.push(i.id || i.label);\n if (i.children) collect(i.children);\n });\n };\n collect(data);\n return checked;\n }\n });\n },\n\n getChecked: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getChecked() : [];\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n el.innerHTML = '';\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('tree', Tree);\n }\n\n window.VanduoTree = Tree;\n\n})();\n", "/**\n * Vanduo Framework - Spotlight (Feature Discovery) Component\n * Guided tour with overlay highlight and step-through tooltip\n */\n\n(function () {\n 'use strict';\n\n const Spotlight = {\n _active: false,\n _steps: [],\n _currentStep: 0,\n _elements: {},\n _cleanup: [],\n _boundTriggers: new WeakMap(),\n _triggerElement: null,\n\n init: function () {\n const triggers = document.querySelectorAll('[data-vd-spotlight]');\n\n triggers.forEach(trigger => {\n if (this._boundTriggers.has(trigger)) return;\n\n const clickHandler = (event) => {\n event.preventDefault();\n\n const steps = this._parseSteps(trigger.getAttribute('data-vd-spotlight'));\n if (steps.length === 0) return;\n\n this.start(steps, { trigger });\n };\n\n trigger.addEventListener('click', clickHandler);\n this._boundTriggers.set(trigger, clickHandler);\n });\n },\n\n _parseSteps: function (raw) {\n if (typeof raw !== 'string' || raw.trim() === '') return [];\n\n try {\n const parsed = JSON.parse(raw);\n return this._normalizeSteps(parsed);\n } catch (error) {\n console.error('VanduoSpotlight: invalid data-vd-spotlight payload.', error);\n return [];\n }\n },\n\n _normalizeStep: function (step) {\n if (!step || typeof step !== 'object') return null;\n\n const target = step.target;\n const hasSelectorTarget = typeof target === 'string' && target.trim() !== '';\n const hasElementTarget = typeof Element !== 'undefined' && target instanceof Element;\n\n if (!hasSelectorTarget && !hasElementTarget) return null;\n\n const title = typeof step.title === 'string' ? step.title : '';\n const description = typeof step.description === 'string'\n ? step.description\n : (typeof step.content === 'string' ? step.content : '');\n\n return {\n target,\n title,\n description\n };\n },\n\n _normalizeSteps: function (steps) {\n if (!Array.isArray(steps)) return [];\n\n return steps\n .map(step => this._normalizeStep(step))\n .filter(Boolean);\n },\n\n start: function (steps, options) {\n if (this._active) this.stop();\n\n const normalizedSteps = this._normalizeSteps(steps);\n if (normalizedSteps.length === 0) return;\n\n const startOptions = options || {};\n\n this._steps = normalizedSteps;\n this._currentStep = 0;\n this._active = true;\n this._triggerElement = startOptions.trigger || (document.activeElement instanceof HTMLElement ? document.activeElement : null);\n\n // Create overlay\n const overlay = document.createElement('div');\n overlay.className = 'vd-spotlight-overlay';\n overlay.setAttribute('aria-hidden', 'true');\n document.body.appendChild(overlay);\n\n // Create tooltip\n const tooltip = document.createElement('div');\n tooltip.className = 'vd-spotlight-tooltip';\n tooltip.setAttribute('role', 'dialog');\n tooltip.setAttribute('aria-modal', 'true');\n tooltip.tabIndex = -1;\n document.body.appendChild(tooltip);\n\n this._elements = { overlay, tooltip };\n\n // ESC to close\n const escHandler = (e) => { if (e.key === 'Escape') this.stop(); };\n document.addEventListener('keydown', escHandler);\n this._cleanup.push(() => document.removeEventListener('keydown', escHandler));\n\n // Overlay click to close\n overlay.addEventListener('click', () => this.stop());\n\n this._showStep(this._currentStep);\n },\n\n _showStep: function (index) {\n const step = this._steps[index];\n if (!step) return;\n\n const target = typeof step.target === 'string' ? document.querySelector(step.target) : step.target;\n const { tooltip } = this._elements;\n\n // Remove previous highlight\n document.querySelectorAll('.vd-spotlight-target').forEach(el => {\n el.classList.remove('vd-spotlight-target');\n });\n\n // Highlight target\n if (target) {\n target.classList.add('vd-spotlight-target');\n target.scrollIntoView({ behavior: 'smooth', block: 'center' });\n }\n\n // Build tooltip content\n const total = this._steps.length;\n tooltip.innerHTML = '';\n tooltip.removeAttribute('aria-labelledby');\n tooltip.removeAttribute('aria-describedby');\n\n if (step.title) {\n const title = document.createElement('h4');\n title.className = 'vd-spotlight-title';\n title.id = 'vd-spotlight-title-' + index + '-' + Date.now();\n title.textContent = step.title;\n tooltip.appendChild(title);\n tooltip.setAttribute('aria-labelledby', title.id);\n }\n\n if (step.description) {\n const desc = document.createElement('p');\n desc.className = 'vd-spotlight-description';\n desc.id = 'vd-spotlight-description-' + index + '-' + Date.now();\n desc.textContent = step.description;\n tooltip.appendChild(desc);\n tooltip.setAttribute('aria-describedby', desc.id);\n }\n\n // Footer\n const footer = document.createElement('div');\n footer.className = 'vd-spotlight-footer';\n footer.setAttribute('aria-label', 'Step ' + (index + 1) + ' of ' + total);\n\n const counter = document.createElement('span');\n counter.className = 'vd-spotlight-counter';\n counter.textContent = (index + 1) + ' / ' + total;\n\n const actions = document.createElement('div');\n actions.className = 'vd-spotlight-actions';\n\n if (index > 0) {\n const prevBtn = document.createElement('button');\n prevBtn.type = 'button';\n prevBtn.className = 'vd-spotlight-btn';\n prevBtn.textContent = 'Back';\n prevBtn.addEventListener('click', () => this.prev());\n actions.appendChild(prevBtn);\n }\n\n const skipBtn = document.createElement('button');\n skipBtn.type = 'button';\n skipBtn.className = 'vd-spotlight-btn';\n skipBtn.textContent = 'Skip';\n skipBtn.addEventListener('click', () => this.stop());\n actions.appendChild(skipBtn);\n\n if (index < total - 1) {\n const nextBtn = document.createElement('button');\n nextBtn.type = 'button';\n nextBtn.className = 'vd-spotlight-btn vd-spotlight-btn-primary';\n nextBtn.textContent = 'Next';\n nextBtn.addEventListener('click', () => this.next());\n actions.appendChild(nextBtn);\n } else {\n const doneBtn = document.createElement('button');\n doneBtn.type = 'button';\n doneBtn.className = 'vd-spotlight-btn vd-spotlight-btn-primary';\n doneBtn.textContent = 'Done';\n doneBtn.addEventListener('click', () => this.stop());\n actions.appendChild(doneBtn);\n }\n\n footer.appendChild(counter);\n footer.appendChild(actions);\n tooltip.appendChild(footer);\n\n // Position tooltip near target\n if (target) {\n requestAnimationFrame(() => {\n const rect = target.getBoundingClientRect();\n const tRect = tooltip.getBoundingClientRect();\n let top = rect.bottom + 12 + window.scrollY;\n let left = rect.left + (rect.width - tRect.width) / 2 + window.scrollX;\n\n // Keep in viewport\n left = Math.max(8, Math.min(left, window.innerWidth - tRect.width - 8));\n if (top + tRect.height > window.innerHeight + window.scrollY) {\n top = rect.top - tRect.height - 12 + window.scrollY;\n }\n\n tooltip.style.top = top + 'px';\n tooltip.style.left = left + 'px';\n });\n }\n\n document.dispatchEvent(new CustomEvent('spotlight:step', {\n detail: { index, step: index, total, data: step }\n }));\n },\n\n next: function () {\n if (this._currentStep < this._steps.length - 1) {\n this._currentStep++;\n this._showStep(this._currentStep);\n }\n },\n\n prev: function () {\n if (this._currentStep > 0) {\n this._currentStep--;\n this._showStep(this._currentStep);\n }\n },\n\n stop: function () {\n if (!this._active) return;\n\n const total = this._steps.length;\n const detail = {\n completedSteps: total === 0 ? 0 : Math.min(this._currentStep + 1, total),\n total,\n completed: total > 0 && this._currentStep >= total - 1\n };\n\n this._active = false;\n\n document.querySelectorAll('.vd-spotlight-target').forEach(el => {\n el.classList.remove('vd-spotlight-target');\n });\n\n if (this._elements.overlay && this._elements.overlay.parentNode) {\n this._elements.overlay.parentNode.removeChild(this._elements.overlay);\n }\n if (this._elements.tooltip && this._elements.tooltip.parentNode) {\n this._elements.tooltip.parentNode.removeChild(this._elements.tooltip);\n }\n\n this._cleanup.forEach(fn => fn());\n this._cleanup = [];\n this._elements = {};\n this._steps = [];\n this._currentStep = 0;\n\n if (this._triggerElement && this._triggerElement.isConnected && typeof this._triggerElement.focus === 'function') {\n this._triggerElement.focus();\n }\n this._triggerElement = null;\n\n document.dispatchEvent(new CustomEvent('spotlight:end', { detail }));\n },\n\n destroyAll: function () {\n this.stop();\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('spotlight', Spotlight);\n }\n\n window.VanduoSpotlight = Spotlight;\n\n})();\n", "/**\n * Vanduo Framework - Music Player Component\n * HTML5 Audio-based music player with transport controls, volume,\n * and optional shuffle, seek bar, and playlist features.\n *\n * Options (passed to MusicPlayer.init or data-music-player-options):\n * tracks {Array} - [{name, url}] \u2014 required\n * volume {number} - Initial volume 0\u20131 (default 0.5)\n * shuffle {boolean} - Shuffle on init (default false)\n * showProgress {boolean} - Show seek/progress bar (default false)\n * showPlaylist {boolean} - Show expandable playlist panel (default false)\n * autoAdvance {boolean} - Auto-play next track on end (default true)\n * glass {boolean} - Frosted-glass surface styling (default false)\n * detachable {boolean} - Show detach/attach; float above page when detached (default false)\n * floatingPosition {string|null} - After detach: 'bottom-left' or 'bottom-right' (default null = bottom-right)\n * draggable {boolean} - When detached+detachable, drag by handle (default false)\n * minimizable {boolean} - Minimize/expand control (default false)\n * startMinimized {boolean} - On first detach, start minimized (default false)\n * persistPosition {boolean} - Save floating x/y in localStorage (default false)\n * persistKey {string} - Storage key for persist (default from element id)\n *\n * Custom events (all bubble, dispatched on container):\n * musicplayer:play \u2014 playback started\n * musicplayer:pause \u2014 playback paused\n * musicplayer:trackchange \u2014 detail: { index, name, url }\n * musicplayer:volumechange \u2014 detail: { volume }\n * musicplayer:ended \u2014 last track ended (autoAdvance=false only)\n * musicplayer:detach \u2014 after floating player is created\n * musicplayer:attach \u2014 after returned to document flow\n * musicplayer:minimize \u2014 collapsed to minimal controls\n * musicplayer:expand \u2014 full controls visible again\n *\n * Programmatic API:\n * MusicPlayer.init(container?, options?)\n * MusicPlayer.play(container)\n * MusicPlayer.pause(container)\n * MusicPlayer.toggle(container)\n * MusicPlayer.next(container)\n * MusicPlayer.previous(container)\n * MusicPlayer.setVolume(container, value)\n * MusicPlayer.setTrack(container, index)\n * MusicPlayer.shuffle(container)\n * MusicPlayer.detach(container, position?)\n * MusicPlayer.attach(container)\n * MusicPlayer.minimize(container)\n * MusicPlayer.expand(container)\n * MusicPlayer.toggleMinimize(container)\n * MusicPlayer.setPosition(container, 'bottom-left'|'bottom-right'|{x,y})\n * MusicPlayer.getState(container)\n * MusicPlayer.destroy(container)\n * MusicPlayer.destroyAll()\n */\n\n(function () {\n 'use strict';\n\n /* \u2500\u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Fisher-Yates shuffle (returns new array).\n * @param {Array} arr\n * @returns {Array}\n */\n function shuffleArray(arr) {\n const shuffled = arr.slice();\n for (let i = shuffled.length - 1; i > 0; i--) {\n const j = Math.floor(Math.random() * (i + 1));\n const tmp = shuffled[i];\n shuffled[i] = shuffled[j];\n shuffled[j] = tmp;\n }\n return shuffled;\n }\n\n /**\n * Format seconds as m:ss.\n * @param {number} seconds\n * @returns {string}\n */\n function formatTime(seconds) {\n if (!isFinite(seconds) || seconds < 0) return '0:00';\n const m = Math.floor(seconds / 60);\n const s = Math.floor(seconds % 60);\n return m + ':' + (s < 10 ? '0' : '') + s;\n }\n\n /**\n * Set CSS background-size on a range input to visually fill the track.\n * @param {HTMLInputElement} input\n */\n /**\n * @param {string|undefined} id\n * @returns {string}\n */\n function persistStorageKey(id) {\n return 'vanduo:music-player:' + (id && id.trim() ? id.trim() : 'default') + ':pos';\n }\n\n function updateRangeFill(input) {\n const min = parseFloat(input.min) || 0;\n const max = parseFloat(input.max) || 1;\n const val = parseFloat(input.value) || 0;\n const pct = ((val - min) / (max - min)) * 100;\n input.style.setProperty('--fill', pct + '%');\n // Fallback inline gradient for browsers without ::-moz-range-progress\n input.style.backgroundImage =\n 'linear-gradient(to right, var(--music-player-track-fill, currentColor) 0%, ' +\n 'var(--music-player-track-fill, currentColor) ' + pct + '%, ' +\n 'var(--music-player-track-bg, #ccc) ' + pct + '%, ' +\n 'var(--music-player-track-bg, #ccc) 100%)';\n }\n\n /* \u2500\u2500\u2500 Phosphor icon helper (matches framework icon style) \u2500 */\n\n /**\n * Return an element (Phosphor icon).\n * @param {string} name\n * @returns {HTMLElement}\n */\n function icon(name) {\n const el = document.createElement('i');\n el.className = 'ph ph-' + name;\n el.setAttribute('aria-hidden', 'true');\n return el;\n }\n\n /* \u2500\u2500\u2500 Component \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const MusicPlayer = {\n /** @type {Map} */\n instances: new Map(),\n\n /**\n * Default options.\n */\n defaults: {\n tracks: [],\n volume: 0.5,\n shuffle: false,\n showProgress: false,\n showPlaylist: false,\n autoAdvance: true,\n glass: false,\n detachable: false,\n /** @type {null|string} */\n floatingPosition: null,\n draggable: false,\n minimizable: false,\n startMinimized: false,\n persistPosition: false,\n persistKey: '',\n },\n\n /**\n * Auto-initialize all .vd-music-player / [data-music-player] elements.\n * Options can be provided via data-music-player-options (JSON string).\n */\n init: function () {\n document.querySelectorAll('.vd-music-player, [data-music-player]').forEach((el) => {\n if (this.instances.has(el)) return;\n\n let opts = {};\n const attr = el.getAttribute('data-music-player-options');\n if (attr) {\n try { opts = JSON.parse(attr); } catch (_) { /* ignore malformed JSON */ }\n }\n this.initPlayer(el, opts);\n });\n },\n\n /**\n * Initialize a single player element.\n * @param {HTMLElement} container\n * @param {Object} [options]\n */\n initPlayer: function (container, options) {\n const opts = Object.assign({}, this.defaults, options || {});\n\n // Validate and normalise tracks\n const rawTracks = Array.isArray(opts.tracks) ? opts.tracks : [];\n const tracks = rawTracks.filter((t) => t && typeof t.url === 'string' && t.url.trim());\n\n // Build shuffled working copy without mutating opts\n const trackList = opts.shuffle ? shuffleArray(tracks) : tracks.slice();\n\n /* \u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const state = {\n tracks: trackList,\n originalTracks: tracks.slice(),\n currentIndex: 0,\n isPlaying: false,\n volume: Math.max(0, Math.min(1, opts.volume)),\n shuffle: opts.shuffle,\n showProgress: opts.showProgress,\n showPlaylist: opts.showPlaylist,\n autoAdvance: opts.autoAdvance,\n audio: null,\n glass: Boolean(opts.glass),\n detachable: Boolean(opts.detachable),\n floatingPosition: opts.floatingPosition || 'bottom-right',\n draggable: Boolean(opts.draggable) && Boolean(opts.detachable),\n minimizable: Boolean(opts.minimizable),\n startMinimized: Boolean(opts.startMinimized),\n persistPosition: Boolean(opts.persistPosition),\n persistKey: typeof opts.persistKey === 'string' ? opts.persistKey : '',\n isDetached: false,\n isMinimized: false,\n _startMinimizeApplied: false,\n };\n\n /* \u2500\u2500 Audio element \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const audio = new Audio();\n audio.volume = state.volume;\n audio.preload = 'metadata';\n state.audio = audio;\n\n /* \u2500\u2500 Build DOM \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n this._buildDOM(container, state);\n\n // Grab references after DOM build\n const refs = {\n btnPlay: container.querySelector('.vd-music-player-btn-play'),\n btnPrev: container.querySelector('.vd-music-player-btn-prev'),\n btnNext: container.querySelector('.vd-music-player-btn-next'),\n btnShuffle: container.querySelector('.vd-music-player-btn-shuffle'),\n btnPlaylist: container.querySelector('.vd-music-player-btn-playlist'),\n btnDetach: container.querySelector('.vd-music-player-btn-detach'),\n btnAttach: container.querySelector('.vd-music-player-btn-attach'),\n btnMinimize: container.querySelector('.vd-music-player-btn-minimize'),\n dragHandle: container.querySelector('.vd-music-player-drag-handle'),\n trackName: container.querySelector('.vd-music-player-track-name'),\n volumeSlider: container.querySelector('.vd-music-player-volume-slider'),\n volumeIcon: container.querySelector('.vd-music-player-volume-icon'),\n progressBar: container.querySelector('.vd-music-player-progress-bar'),\n timeElapsed: container.querySelector('.vd-music-player-time-elapsed'),\n timeDuration: container.querySelector('.vd-music-player-time-duration'),\n playlistPanel: container.querySelector('.vd-music-player-playlist'),\n };\n\n /* \u2500\u2500 Internal render helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const renderPlayIcon = () => {\n const btn = refs.btnPlay;\n if (!btn) return;\n btn.innerHTML = '';\n btn.appendChild(icon(state.isPlaying ? 'pause' : 'play'));\n btn.setAttribute('aria-label', state.isPlaying ? 'Pause' : 'Play');\n btn.classList.toggle('is-active', state.isPlaying);\n };\n\n const renderTrackName = () => {\n const el = refs.trackName;\n if (!el) return;\n const track = state.tracks[state.currentIndex];\n if (track) {\n el.textContent = track.name || 'Unknown Track';\n el.classList.remove('is-idle');\n } else {\n el.textContent = 'No tracks loaded';\n el.classList.add('is-idle');\n }\n };\n\n const renderVolumeIcon = () => {\n const el = refs.volumeIcon;\n if (!el) return;\n el.innerHTML = '';\n const v = state.volume;\n const name = v === 0 ? 'speaker-none' : v < 0.5 ? 'speaker-low' : 'speaker-high';\n el.appendChild(icon(name));\n };\n\n const renderShuffleBtn = () => {\n const btn = refs.btnShuffle;\n if (!btn) return;\n btn.classList.toggle('is-active', state.shuffle);\n btn.setAttribute('aria-pressed', state.shuffle ? 'true' : 'false');\n };\n\n const renderPlaylistItems = () => {\n const panel = refs.playlistPanel;\n if (!panel) return;\n panel.innerHTML = '';\n state.tracks.forEach((track, i) => {\n const item = document.createElement('button');\n item.className =\n 'vd-music-player-playlist-item' + (i === state.currentIndex ? ' is-active' : '');\n item.type = 'button';\n item.setAttribute('data-index', String(i));\n item.setAttribute('aria-current', i === state.currentIndex ? 'true' : 'false');\n\n const num = document.createElement('span');\n num.className = 'vd-music-player-playlist-num';\n num.textContent = String(i + 1);\n\n const name = document.createElement('span');\n name.className = 'vd-music-player-playlist-name';\n name.textContent = track.name || 'Track ' + (i + 1);\n\n item.appendChild(num);\n item.appendChild(name);\n panel.appendChild(item);\n });\n };\n\n const renderProgress = () => {\n const bar = refs.progressBar;\n if (!bar || !audio.duration) return;\n const pct = (audio.currentTime / audio.duration) * 100;\n bar.value = String(pct);\n updateRangeFill(bar);\n if (refs.timeElapsed) refs.timeElapsed.textContent = formatTime(audio.currentTime);\n if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);\n };\n\n /* \u2500\u2500 Load track \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const loadTrack = (index, autoPlay) => {\n const track = state.tracks[index];\n if (!track) return;\n state.currentIndex = index;\n audio.src = track.url;\n renderTrackName();\n renderPlaylistItems();\n\n // Reset progress\n if (refs.progressBar) {\n refs.progressBar.value = '0';\n updateRangeFill(refs.progressBar);\n }\n if (refs.timeElapsed) refs.timeElapsed.textContent = '0:00';\n if (refs.timeDuration) refs.timeDuration.textContent = '0:00';\n\n container.dispatchEvent(\n new CustomEvent('musicplayer:trackchange', {\n bubbles: true,\n detail: { index, name: track.name, url: track.url },\n })\n );\n\n if (autoPlay) {\n audio.play().catch(() => { /* browser may block autoplay */ });\n }\n };\n\n /* \u2500\u2500 Audio event listeners \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const cleanupFunctions = [];\n\n const onPlay = () => {\n state.isPlaying = true;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:play', { bubbles: true }));\n };\n\n const onPause = () => {\n state.isPlaying = false;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:pause', { bubbles: true }));\n };\n\n const onEnded = () => {\n if (state.autoAdvance && state.tracks.length > 1) {\n const next = (state.currentIndex + 1) % state.tracks.length;\n loadTrack(next, true);\n } else {\n state.isPlaying = false;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:ended', { bubbles: true }));\n }\n };\n\n const onTimeUpdate = () => {\n if (state.showProgress) renderProgress();\n };\n\n const onLoadedMetadata = () => {\n if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);\n if (refs.progressBar) {\n refs.progressBar.max = '100';\n updateRangeFill(refs.progressBar);\n }\n };\n\n audio.addEventListener('play', onPlay);\n audio.addEventListener('pause', onPause);\n audio.addEventListener('ended', onEnded);\n audio.addEventListener('timeupdate', onTimeUpdate);\n audio.addEventListener('loadedmetadata', onLoadedMetadata);\n cleanupFunctions.push(() => {\n audio.removeEventListener('play', onPlay);\n audio.removeEventListener('pause', onPause);\n audio.removeEventListener('ended', onEnded);\n audio.removeEventListener('timeupdate', onTimeUpdate);\n audio.removeEventListener('loadedmetadata', onLoadedMetadata);\n audio.pause();\n audio.src = '';\n });\n\n /* \u2500\u2500 Control button listeners \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n if (refs.btnPlay) {\n const handler = () => {\n if (!audio.src && state.tracks.length) loadTrack(state.currentIndex, false);\n if (state.isPlaying) {\n audio.pause();\n } else {\n audio.play().catch(() => {});\n }\n };\n refs.btnPlay.addEventListener('click', handler);\n cleanupFunctions.push(() => refs.btnPlay.removeEventListener('click', handler));\n\n // Keyboard: Space / Enter (already native for `;\n }\n\n // Generate neutral color swatches\n let neutralSwatches = '';\n for (const [key, value] of Object.entries(this.NEUTRAL_COLORS)) {\n neutralSwatches += ``;\n }\n\n // Generate radius buttons\n let radiusButtons = '';\n this.RADIUS_OPTIONS.forEach(r => {\n radiusButtons += ``;\n });\n\n // Generate font options\n let fontOptions = '';\n for (const [key, value] of Object.entries(this.FONT_OPTIONS)) {\n fontOptions += ``;\n }\n\n return `\n
    \n

    Customize Theme

    \n \n
    \n
    \n\n
    \n \n
    \n ${primarySwatches}\n
    \n
    \n
    \n \n
    \n ${neutralSwatches}\n
    \n
    \n
    \n \n
    \n ${radiusButtons}\n
    \n
    \n
    \n \n \n
    \n
    \n
    \n \n
    \n `;\n },\n\n /**\n * Bind event listeners\n */\n /**\n * Check whether the current primary color is one of the auto-defaults\n * (i.e. the user hasn't explicitly picked a non-default color).\n */\n isUsingDefaultPrimary: function () {\n return this.state.primary === this.DEFAULTS.PRIMARY_LIGHT ||\n this.state.primary === this.DEFAULTS.PRIMARY_DARK;\n },\n\n /**\n * When primary is still one of the auto-default palette keys (black/amber) but\n * localStorage was written under a different theme (or OS changed in system mode),\n * align in-memory state before applyAllPreferences runs \u2014 avoids amber+light / black+dark drift.\n */\n _normalizeDefaultPrimaryIfStaleWithStoredTheme: function () {\n if (!this.isUsingDefaultPrimary()) {\n return;\n }\n const expected = this.getDefaultPrimary(this.state.theme);\n if (this.state.primary !== expected) {\n this.state.primary = expected;\n }\n },\n\n bindEvents: function () {\n this.bindTriggerEvents();\n\n this.bindPanelEvents();\n\n // Listen for OS dark/light changes so we can swap the default primary\n if (window.matchMedia) {\n const mq = window.matchMedia('(prefers-color-scheme: dark)');\n const handler = () => {\n if (this.state.theme === 'system' && this.isUsingDefaultPrimary()) {\n const newDefault = this.getDefaultPrimary('system');\n if (newDefault !== this.state.primary) {\n this.applyPrimary(newDefault);\n this.updateUI();\n }\n }\n };\n mq.addEventListener('change', handler);\n this._cleanup.push(() => mq.removeEventListener('change', handler));\n }\n\n // Close on outside click\n this.addListener(document, 'click', (e) => {\n if (this.state.isOpen && this.elements.customizer && !this.elements.customizer.contains(e.target)) {\n this.close();\n }\n });\n\n // Close on Escape key\n this.addListener(document, 'keydown', (e) => {\n if (e.key === 'Escape' && this.state.isOpen) {\n this.close();\n }\n });\n },\n\n bindTriggerEvents: function () {\n this.elements.triggers.forEach((trigger) => {\n if (trigger.getAttribute('data-customizer-trigger-initialized') === 'true') {\n return;\n }\n this.addListener(trigger, 'click', (e) => {\n e.preventDefault();\n e.stopPropagation();\n this.elements.activeTrigger = trigger;\n this.elements.trigger = trigger;\n this.toggle();\n });\n trigger.setAttribute('data-customizer-trigger-initialized', 'true');\n });\n },\n\n /**\n * Toggle panel open/close\n */\n toggle: function () {\n if (this.state.isOpen) {\n this.close();\n } else {\n this.open();\n }\n },\n\n /**\n * Open the panel\n */\n open: function () {\n this.state.isOpen = true;\n\n // Ensure panel is positioned correctly before opening\n this.positionPanel();\n\n if (this.elements.panel) {\n this.elements.panel.classList.add('is-open');\n }\n this.elements.triggers.forEach((trigger) => trigger.setAttribute('aria-expanded', 'false'));\n if (this.elements.trigger) this.elements.trigger.setAttribute('aria-expanded', 'true');\n if (this.elements.overlay) {\n this.elements.overlay.classList.add('is-active');\n }\n\n this.dispatchEvent('panel-open', { isOpen: true });\n },\n\n /**\n * Close the panel\n */\n close: function () {\n this.state.isOpen = false;\n\n if (this.elements.panel) {\n this.elements.panel.classList.remove('is-open');\n }\n this.elements.triggers.forEach((trigger) => trigger.setAttribute('aria-expanded', 'false'));\n if (this.elements.overlay) {\n this.elements.overlay.classList.remove('is-active');\n }\n\n this.dispatchEvent('panel-close', { isOpen: false });\n },\n\n /**\n * Update UI to reflect current state\n */\n updateUI: function () {\n if (!this.elements.panel) return;\n\n // Update primary color swatches\n this.elements.panel.querySelectorAll('[data-color]').forEach(swatch => {\n swatch.classList.toggle('is-active', swatch.dataset.color === this.state.primary);\n });\n\n // Update neutral color swatches\n this.elements.panel.querySelectorAll('[data-neutral]').forEach(swatch => {\n swatch.classList.toggle('is-active', swatch.dataset.neutral === this.state.neutral);\n });\n\n // Update radius buttons\n this.elements.panel.querySelectorAll('[data-radius]').forEach(btn => {\n btn.classList.toggle('is-active', btn.dataset.radius === this.state.radius);\n });\n\n // Update font selector\n const fontSelect = this.elements.panel.querySelector('[data-customizer-font]');\n if (fontSelect) {\n fontSelect.value = this.state.font;\n }\n\n },\n\n /**\n * Reset all preferences to defaults\n */\n reset: function () {\n this.applyTheme(this.DEFAULTS.THEME);\n this.applyPrimary(this.getDefaultPrimary(this.DEFAULTS.THEME));\n this.applyNeutral(this.DEFAULTS.NEUTRAL);\n this.applyRadius(this.DEFAULTS.RADIUS);\n this.applyFont(this.DEFAULTS.FONT);\n this.updateUI();\n\n this.dispatchEvent('reset', { state: { ...this.state } });\n },\n\n /**\n * Get current state\n */\n getState: function () {\n return { ...this.state };\n },\n\n /**\n * Programmatically set preferences\n */\n setPreferences: function (prefs) {\n if (prefs.primary) this.applyPrimary(prefs.primary);\n if (prefs.neutral) this.applyNeutral(prefs.neutral);\n if (prefs.radius) this.applyRadius(prefs.radius);\n if (prefs.font) this.applyFont(prefs.font);\n if (prefs.theme) this.applyTheme(prefs.theme);\n this.updateUI();\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 destroyAll: function () {\n this._cleanup.forEach(fn => fn());\n this._cleanup = [];\n\n if (this.elements.panel) {\n this.elements.panel.removeAttribute('data-customizer-initialized');\n }\n\n this.close();\n this.isInitialized = false;\n }\n };\n\n // Register component\n if (window.Vanduo) {\n window.Vanduo.register('themeCustomizer', ThemeCustomizer);\n }\n\n // Expose globally for convenience\n window.ThemeCustomizer = ThemeCustomizer;\n})();\n", "/**\n * Vanduo Framework - Theme Switcher\n * Handles light/dark/system theme toggling and persistence\n */\n\n(function () {\n 'use strict';\n\n const ThemeSwitcher = {\n isInitialized: false,\n _mediaQuery: null,\n _onMediaChange: null,\n\n init: function () {\n this.STORAGE_KEY = 'vanduo-theme-preference';\n this.state = {\n preference: this.getPreference() // 'light', 'dark', or 'system'\n };\n\n if (this.isInitialized) {\n this.applyTheme();\n this.renderUI();\n this.updateUI();\n return;\n }\n\n this.isInitialized = true;\n\n this.applyTheme();\n this.listenForSystemChanges();\n this.renderUI();\n\n console.log('Vanduo Theme Switcher initialized');\n },\n\n getPreference: function () {\n return this.getStorageValue(this.STORAGE_KEY, 'system');\n },\n\n setPreference: function (pref) {\n this.state.preference = pref;\n this.setStorageValue(this.STORAGE_KEY, pref);\n this.applyTheme();\n \n // Coordinate with ThemeCustomizer for primary color swap if available\n // Check _isApplying flag to prevent circular updates\n if (window.ThemeCustomizer && window.ThemeCustomizer.applyTheme && !window.ThemeCustomizer._isApplying) {\n window.ThemeCustomizer.applyTheme(pref);\n }\n \n this.updateUI();\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 applyTheme: function () {\n const pref = this.state.preference;\n\n if (pref === 'system') {\n // When in system mode, we remove the data attribute to let the media query take over\n // or we can explicitly set it. Explicitly setting it ensures consistency if we rely on [data-theme]\n // But if we rely on @media in CSS, we might want to remove attributes.\n // However, our CSS strategy uses :root:not([data-theme=\"light\"]) inside media query for system dark fallback\n // which is a bit complex.\n\n // Simpler approach: \n // If preference is system, REMOVE data-theme attribute. Let CSS media queries handle it.\n document.documentElement.removeAttribute('data-theme');\n } else {\n document.documentElement.setAttribute('data-theme', pref);\n }\n },\n\n listenForSystemChanges: function () {\n if (this._mediaQuery && this._onMediaChange) {\n return;\n }\n\n this._mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n this._onMediaChange = _e => {\n if (this.state.preference === 'system') {\n // Re-apply (effectively just to ensure consistency, though removing attribute usually suffices)\n this.applyTheme();\n // Keep default primary (black/amber) aligned when OS scheme changes while in system mode\n if (window.ThemeCustomizer && typeof window.ThemeCustomizer.applyTheme === 'function' && !window.ThemeCustomizer._isApplying) {\n window.ThemeCustomizer.applyTheme('system');\n }\n }\n };\n this._mediaQuery.addEventListener('change', this._onMediaChange);\n },\n\n // Helper to facilitate UI creation if needed, though often UI is in HTML\n renderUI: function () {\n // Look for any uninitialized theme toggles\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"]');\n toggles.forEach(toggle => {\n if (toggle.getAttribute('data-theme-initialized') === 'true') {\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n }\n return;\n }\n\n // Simplified UI Binding - assumes a select or a button cycle\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n const onChange = (e) => {\n this.setPreference(e.target.value);\n };\n toggle.addEventListener('change', onChange);\n toggle._themeToggleHandler = onChange;\n } else {\n // Button implementation (cycle)\n const onClick = () => {\n const modes = ['system', 'light', 'dark'];\n const nextIndex = (modes.indexOf(this.state.preference) + 1) % modes.length;\n this.setPreference(modes[nextIndex]);\n };\n toggle.addEventListener('click', onClick);\n toggle._themeToggleHandler = onClick;\n }\n // Mark as initialized\n toggle.setAttribute('data-theme-initialized', 'true');\n });\n },\n\n updateUI: function () {\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"]');\n toggles.forEach(toggle => {\n if (toggle.tagName === 'SELECT') {\n toggle.value = this.state.preference;\n } else {\n // Update button text/icon if needed\n // e.g. toggle.textContent = this.state.preference;\n // For now, assume the user handles visual state or generic text\n\n // If there is an icon or text span inside, update it\n const span = toggle.querySelector('.theme-current-label');\n if (span) {\n span.textContent = this.state.preference.charAt(0).toUpperCase() + this.state.preference.slice(1);\n }\n }\n });\n },\n\n destroyAll: function () {\n const toggles = document.querySelectorAll('[data-toggle=\"theme\"][data-theme-initialized=\"true\"]');\n toggles.forEach(toggle => {\n if (toggle._themeToggleHandler) {\n const eventName = toggle.tagName === 'SELECT' ? 'change' : 'click';\n toggle.removeEventListener(eventName, toggle._themeToggleHandler);\n delete toggle._themeToggleHandler;\n }\n toggle.removeAttribute('data-theme-initialized');\n });\n\n if (this._mediaQuery && this._onMediaChange) {\n this._mediaQuery.removeEventListener('change', this._onMediaChange);\n }\n\n this._mediaQuery = null;\n this._onMediaChange = null;\n this.isInitialized = false;\n }\n };\n\n // Register component\n if (window.Vanduo) {\n window.Vanduo.register('themeSwitcher', ThemeSwitcher);\n }\n})();\n", "/**\n * Vanduo Framework - Toast Component\n * Popup notifications for user feedback\n */\n\n(function() {\n 'use strict';\n\n /**\n * Toast Component\n */\n const Toast = {\n // Default options\n defaults: {\n position: 'top-right',\n duration: 5000,\n dismissible: true,\n showProgress: true,\n pauseOnHover: true\n },\n\n // Container cache\n containers: {},\n\n /**\n * Get or create a toast container for a position\n * @param {string} position - Container position\n * @returns {HTMLElement} Toast container element\n */\n getContainer: function(position) {\n if (this.containers[position]) {\n return this.containers[position];\n }\n\n const container = document.createElement('div');\n container.className = `vd-toast-container vd-toast-container-${position}`;\n container.setAttribute('role', 'status');\n container.setAttribute('aria-live', 'polite');\n container.setAttribute('aria-atomic', 'false');\n document.body.appendChild(container);\n this.containers[position] = container;\n\n return container;\n },\n\n /**\n * Show a toast notification\n * @param {Object|string} options - Toast options or message string\n * @param {string} [type] - Toast type (success, error, warning, info)\n * @param {number} [duration] - Auto-dismiss duration in ms\n * @returns {HTMLElement} Toast element\n */\n show: function(options, type, duration) {\n // Support simple API:\n // - Toast.show('Message', 'success', 3000)\n // - Toast.show('Message', { type: 'success', duration: 3000 })\n if (typeof options === 'string') {\n if (type && typeof type === 'object') {\n options = Object.assign({}, type, {\n message: options\n });\n } else {\n options = {\n message: options,\n type: type,\n duration: duration\n };\n }\n }\n\n const config = Object.assign({}, this.defaults, options);\n const container = this.getContainer(config.position);\n\n // Create toast element\n const toast = document.createElement('div');\n toast.className = 'vd-toast';\n\n if (config.type) {\n toast.classList.add(`vd-toast-${config.type}`);\n }\n\n if (config.solid) {\n toast.classList.add('vd-toast-solid');\n }\n\n if (config.showProgress && config.duration > 0) {\n toast.classList.add('vd-toast-with-progress');\n }\n\n // Build toast content\n let html = '';\n\n // Icon (sanitize custom icons, default icons are trusted SVG)\n if (config.icon) {\n const allowSvg = config.iconAllowSvg === true;\n const safeIcon = typeof sanitizeHtml === 'function'\n ? sanitizeHtml(config.icon, { allowSvg })\n : escapeHtml(config.icon);\n html += `${safeIcon}`;\n } else if (config.type) {\n html += `${this.getDefaultIcon(config.type)}`;\n }\n\n // Local escape helper \u2014 guarantees HTML-safe output even if the\n // global escapeHtml utility is not loaded in the current bundle.\n const _esc = typeof escapeHtml === 'function'\n ? escapeHtml\n : function (s) {\n const d = document.createElement('div');\n d.appendChild(document.createTextNode(s));\n return d.innerHTML;\n };\n\n // Content (escape text to prevent injection)\n html += '
    ';\n if (config.title) {\n html += `
    ${_esc(String(config.title))}
    `;\n }\n if (config.message) {\n html += `
    ${_esc(String(config.message))}
    `;\n }\n html += '
    ';\n\n // Close button\n if (config.dismissible) {\n html += '';\n }\n\n // Progress bar\n if (config.showProgress && config.duration > 0) {\n const safeDuration = parseInt(config.duration, 10) || 0;\n html += `
    `;\n }\n\n toast.innerHTML = html;\n\n // Add to container\n container.appendChild(toast);\n\n toast._toastCleanup = [];\n\n // Set up close button handler\n if (config.dismissible) {\n const closeBtn = toast.querySelector('.vd-toast-close');\n const onClose = () => {\n this.dismiss(toast);\n };\n closeBtn.addEventListener('click', onClose);\n toast._toastCleanup.push(() => closeBtn.removeEventListener('click', onClose));\n }\n\n // Pause on hover\n let timeoutId = null;\n let remainingTime = config.duration;\n let startTime = null;\n\n const startTimer = () => {\n if (config.duration > 0) {\n startTime = Date.now();\n timeoutId = setTimeout(() => {\n this.dismiss(toast);\n }, remainingTime);\n toast._toastTimeoutId = timeoutId;\n\n // Resume progress animation\n const progress = toast.querySelector('.vd-toast-progress');\n if (progress) {\n progress.style.animationPlayState = 'running';\n }\n }\n };\n\n const pauseTimer = () => {\n if (timeoutId) {\n clearTimeout(timeoutId);\n timeoutId = null;\n toast._toastTimeoutId = null;\n remainingTime -= Date.now() - startTime;\n\n // Pause progress animation\n const progress = toast.querySelector('.vd-toast-progress');\n if (progress) {\n progress.style.animationPlayState = 'paused';\n }\n }\n };\n\n if (config.pauseOnHover) {\n toast.addEventListener('mouseenter', pauseTimer);\n toast.addEventListener('mouseleave', startTimer);\n toast._toastCleanup.push(\n () => toast.removeEventListener('mouseenter', pauseTimer),\n () => toast.removeEventListener('mouseleave', startTimer)\n );\n }\n\n // Trigger enter animation\n requestAnimationFrame(() => {\n toast.classList.add('is-visible');\n startTimer();\n });\n\n // Store config on element for later access\n toast._toastConfig = config;\n\n // Dispatch show event\n const showEvent = new CustomEvent('toast:show', {\n bubbles: true,\n detail: { toast, config }\n });\n toast.dispatchEvent(showEvent);\n\n return toast;\n },\n\n /**\n * Dismiss a toast\n * @param {HTMLElement} toast - Toast element to dismiss\n */\n dismiss: function(toast) {\n if (!toast || toast.classList.contains('is-exiting')) return;\n\n if (toast._toastTimeoutId) {\n clearTimeout(toast._toastTimeoutId);\n toast._toastTimeoutId = null;\n }\n\n toast.classList.remove('is-visible');\n toast.classList.add('is-exiting');\n\n // Dispatch dismiss event\n const dismissEvent = new CustomEvent('toast:dismiss', {\n bubbles: true,\n detail: { toast }\n });\n toast.dispatchEvent(dismissEvent);\n\n // Remove after animation\n const handleTransitionEnd = () => {\n toast.removeEventListener('transitionend', handleTransitionEnd);\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n };\n\n toast.addEventListener('transitionend', handleTransitionEnd);\n\n // Fallback removal if transition doesn't fire\n setTimeout(() => {\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n }, 400);\n },\n\n /**\n * Destroy all toasts and containers\n */\n destroyAll: function() {\n Object.keys(this.containers).forEach(position => {\n const container = this.containers[position];\n if (!container) return;\n\n const toasts = container.querySelectorAll('.vd-toast');\n toasts.forEach(toast => {\n if (toast._toastTimeoutId) {\n clearTimeout(toast._toastTimeoutId);\n }\n if (toast._toastCleanup) {\n toast._toastCleanup.forEach(fn => fn());\n delete toast._toastCleanup;\n }\n if (toast.parentElement) {\n toast.parentElement.removeChild(toast);\n }\n });\n\n if (container.parentElement) {\n container.parentElement.removeChild(container);\n }\n });\n\n this.containers = {};\n },\n\n /**\n * Dismiss all toasts\n * @param {string} [position] - Optional position to clear (clears all if not specified)\n */\n dismissAll: function(position) {\n if (position && this.containers[position]) {\n const toasts = this.containers[position].querySelectorAll('.vd-toast');\n toasts.forEach(toast => this.dismiss(toast));\n } else {\n Object.values(this.containers).forEach(container => {\n const toasts = container.querySelectorAll('.vd-toast');\n toasts.forEach(toast => this.dismiss(toast));\n });\n }\n },\n\n /**\n * Get default icon SVG for a type\n * @param {string} type - Toast type\n * @returns {string} SVG icon markup\n */\n getDefaultIcon: function(type) {\n const icons = {\n success: '',\n error: '',\n warning: '',\n info: ''\n };\n\n return icons[type] || '';\n },\n\n /**\n * Convenience methods for common toast types\n */\n success: function(message, options) {\n return this.show(Object.assign({ message, type: 'success' }, options));\n },\n\n error: function(message, options) {\n return this.show(Object.assign({ message, type: 'error' }, options));\n },\n\n warning: function(message, options) {\n return this.show(Object.assign({ message, type: 'warning' }, options));\n },\n\n info: function(message, options) {\n return this.show(Object.assign({ message, type: 'info' }, options));\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('toast', Toast);\n }\n\n // Also expose globally for convenience\n window.Toast = Toast;\n\n})();\n", "/**\n * Vanduo Framework - Tooltips Component\n * JavaScript functionality for tooltips\n */\n\n(function () {\n 'use strict';\n\n /**\n * Tooltips Component\n */\n const Tooltips = {\n tooltips: new Map(),\n delayTimers: new Map(),\n\n /**\n * Sanitize HTML \u2014 delegates to shared sanitizeHtml from helpers.js\n * @param {string} input\n * @param {{ allowSvg?: boolean }} [options]\n * @returns {string} sanitized HTML\n */\n sanitizeHtml: function (input, options) {\n if (typeof sanitizeHtml === 'function') {\n return sanitizeHtml(input, options);\n }\n // Fallback: content originates from developer-authored data attributes,\n // so returning it as-is is no worse than any other inline HTML.\n return input || '';\n },\n\n /**\n * Initialize tooltips\n */\n init: function () {\n const elements = document.querySelectorAll('[data-tooltip], [data-tooltip-html]');\n\n elements.forEach(element => {\n if (this.tooltips.has(element)) {\n return;\n }\n this.initTooltip(element);\n });\n },\n\n /**\n * Initialize a single tooltip\n * @param {HTMLElement} element - Element with tooltip\n */\n initTooltip: function (element) {\n const tooltip = this.createTooltip(element);\n const cleanupFunctions = [];\n\n // Show on hover/focus\n const enterHandler = () => { this.showTooltip(element, tooltip); };\n const leaveHandler = () => { this.hideTooltip(element, tooltip); };\n const focusHandler = () => { this.showTooltip(element, tooltip); };\n const blurHandler = () => { this.hideTooltip(element, tooltip); };\n\n element.addEventListener('mouseenter', enterHandler);\n element.addEventListener('mouseleave', leaveHandler);\n element.addEventListener('focus', focusHandler);\n element.addEventListener('blur', blurHandler);\n\n cleanupFunctions.push(\n () => element.removeEventListener('mouseenter', enterHandler),\n () => element.removeEventListener('mouseleave', leaveHandler),\n () => element.removeEventListener('focus', focusHandler),\n () => element.removeEventListener('blur', blurHandler)\n );\n\n this.tooltips.set(element, { tooltip, cleanup: cleanupFunctions });\n },\n\n /**\n * Create tooltip element\n * @param {HTMLElement} element - Target element\n * @returns {HTMLElement} Tooltip element\n */\n createTooltip: function (element) {\n const tooltip = document.createElement('div');\n tooltip.className = 'vd-tooltip';\n tooltip.setAttribute('role', 'tooltip');\n tooltip.setAttribute('aria-hidden', 'true');\n\n // Generate unique ID and link via aria-describedby\n const tooltipId = 'tooltip-' + Math.random().toString(36).substr(2, 9);\n tooltip.id = tooltipId;\n element.setAttribute('aria-describedby', tooltipId);\n\n // Get content\n const htmlContent = element.dataset.tooltipHtml;\n const textContent = element.dataset.tooltip;\n\n if (htmlContent) {\n const allowSvg = element.hasAttribute('data-tooltip-allow-svg');\n tooltip.innerHTML = this.sanitizeHtml(htmlContent, { allowSvg });\n tooltip.classList.add('vd-tooltip-html');\n } else if (textContent) {\n tooltip.textContent = textContent;\n }\n\n // Get placement\n const placement = element.dataset.tooltipPlacement || element.dataset.placement || 'top';\n tooltip.setAttribute('data-placement', placement);\n tooltip.classList.add(`vd-tooltip-${placement}`);\n\n // Get variant\n if (element.dataset.tooltipVariant) {\n tooltip.classList.add(`vd-tooltip-${element.dataset.tooltipVariant}`);\n }\n\n // Get size\n if (element.dataset.tooltipSize) {\n tooltip.classList.add(`vd-tooltip-${element.dataset.tooltipSize}`);\n }\n\n // Get delay\n const delay = parseInt(element.dataset.tooltipDelay) || 0;\n tooltip.dataset.delay = delay;\n\n document.body.appendChild(tooltip);\n\n return tooltip;\n },\n\n /**\n * Show tooltip\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n showTooltip: function (element, tooltip) {\n const delay = parseInt(tooltip.dataset.delay) || 0;\n\n if (delay > 0) {\n const timer = setTimeout(() => {\n this.positionTooltip(element, tooltip);\n tooltip.classList.add('is-visible');\n tooltip.setAttribute('aria-hidden', 'false');\n }, delay);\n this.delayTimers.set(element, timer);\n } else {\n this.positionTooltip(element, tooltip);\n tooltip.classList.add('is-visible');\n tooltip.setAttribute('aria-hidden', 'false');\n }\n },\n\n /**\n * Hide tooltip\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n hideTooltip: function (element, tooltip) {\n // Clear delay timer if exists\n const timer = this.delayTimers.get(element);\n if (timer) {\n clearTimeout(timer);\n this.delayTimers.delete(element);\n }\n\n tooltip.classList.remove('is-visible');\n tooltip.setAttribute('aria-hidden', 'true');\n },\n\n /**\n * Position tooltip relative to element\n * @param {HTMLElement} element - Target element\n * @param {HTMLElement} tooltip - Tooltip element\n */\n positionTooltip: function (element, tooltip) {\n const placement = tooltip.dataset.placement || 'top';\n const rect = element.getBoundingClientRect();\n const tooltipRect = tooltip.getBoundingClientRect();\n const scrollTop = window.pageYOffset || document.documentElement.scrollTop;\n const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;\n\n let top = 0;\n let left = 0;\n\n switch (placement) {\n case 'top':\n top = rect.top + scrollTop - tooltipRect.height - 8;\n left = rect.left + scrollLeft + (rect.width / 2) - (tooltipRect.width / 2);\n break;\n case 'bottom':\n top = rect.bottom + scrollTop + 8;\n left = rect.left + scrollLeft + (rect.width / 2) - (tooltipRect.width / 2);\n break;\n case 'left':\n top = rect.top + scrollTop + (rect.height / 2) - (tooltipRect.height / 2);\n left = rect.left + scrollLeft - tooltipRect.width - 8;\n break;\n case 'right':\n top = rect.top + scrollTop + (rect.height / 2) - (tooltipRect.height / 2);\n left = rect.right + scrollLeft + 8;\n break;\n }\n\n // Prevent overflow\n const viewportWidth = window.innerWidth;\n const viewportHeight = window.innerHeight;\n const padding = 8;\n\n if (left < padding) {\n left = padding;\n } else if (left + tooltipRect.width > viewportWidth - padding) {\n left = viewportWidth - tooltipRect.width - padding;\n }\n\n if (top < scrollTop + padding) {\n top = scrollTop + padding;\n } else if (top + tooltipRect.height > scrollTop + viewportHeight - padding) {\n top = scrollTop + viewportHeight - tooltipRect.height - padding;\n }\n\n // Use single style assignment with transform for better performance\n tooltip.style.cssText = `position: absolute; top: 0; left: 0; transform: translate(${left}px, ${top}px);`;\n },\n\n /**\n * Show tooltip programmatically\n * @param {HTMLElement|string} element - Target element or selector\n */\n show: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n this.showTooltip(el, tooltip);\n }\n },\n\n /**\n * Hide tooltip programmatically\n * @param {HTMLElement|string} element - Target element or selector\n */\n hide: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n this.hideTooltip(el, tooltip);\n }\n },\n\n /**\n * Update tooltip content\n * @param {HTMLElement|string} element - Target element or selector\n * @param {string} content - New content\n * @param {boolean} isHtml - Whether content is HTML\n */\n update: function (element, content, isHtml = false) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n if (el && this.tooltips.has(el)) {\n const { tooltip } = this.tooltips.get(el);\n if (isHtml) {\n const allowSvg = el.hasAttribute('data-tooltip-allow-svg');\n tooltip.innerHTML = this.sanitizeHtml(content, { allowSvg });\n tooltip.classList.add('vd-tooltip-html');\n } else {\n tooltip.textContent = content;\n tooltip.classList.remove('vd-tooltip-html');\n }\n }\n },\n\n /**\n * Destroy a tooltip instance and clean up\n * @param {HTMLElement} element - Element with tooltip\n */\n destroy: function (element) {\n const data = this.tooltips.get(element);\n if (!data) return;\n\n // Clear any pending timer\n const timer = this.delayTimers.get(element);\n if (timer) {\n clearTimeout(timer);\n this.delayTimers.delete(element);\n }\n\n data.cleanup.forEach(fn => fn());\n\n // Remove tooltip element from DOM\n if (data.tooltip && data.tooltip.parentNode) {\n data.tooltip.parentNode.removeChild(data.tooltip);\n }\n\n this.tooltips.delete(element);\n },\n\n /**\n * Destroy all tooltip instances\n */\n destroyAll: function () {\n this.tooltips.forEach((data, element) => {\n this.destroy(element);\n });\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('tooltips', Tooltips);\n }\n\n // Expose globally\n window.VanduoTooltips = Tooltips;\n\n})();\n", "/**\n * Vanduo Framework - Search Component\n * Client-side search functionality for content pages\n * \n * @example Basic usage (initialize with defaults)\n * // HTML:\n * //
    \n * // \n * //
    \n * //
    \n * \n * @example Custom configuration\n * var search = Search.create({\n * containerSelector: '.my-search',\n * contentSelector: 'article[id]',\n * titleSelector: 'h2, h3',\n * maxResults: 5,\n * onSelect: function(result) {\n * console.log('Selected:', result.title);\n * }\n * });\n * \n * @example With custom data source\n * var search = Search.create({\n * containerSelector: '.my-search',\n * data: [\n * { id: 'item1', title: 'First Item', content: 'Description...', category: 'Category A' },\n * { id: 'item2', title: 'Second Item', content: 'Description...', category: 'Category B' }\n * ]\n * });\n */\n\n(function() {\n 'use strict';\n\n /**\n * Default configuration\n */\n const DEFAULTS = {\n // Behavior\n minQueryLength: 2,\n maxResults: 10,\n debounceMs: 150,\n highlightTag: 'mark',\n keyboardShortcut: true, // Enable Cmd/Ctrl+K shortcut\n \n // Selectors (for DOM-based indexing)\n containerSelector: '.vd-doc-search',\n inputSelector: '.vd-doc-search-input',\n resultsSelector: '.vd-doc-search-results',\n contentSelector: '.doc-content section[id]',\n titleSelector: '.demo-title, h2, h3',\n navSelector: '.doc-nav-link',\n sectionSelector: '.doc-nav-section',\n \n // Content extraction\n excludeFromContent: 'pre, code, script, style',\n maxContentLength: 500,\n \n // Custom data source (alternative to DOM indexing)\n data: null,\n \n // Category icons mapping\n categoryIcons: {\n 'getting-started': 'ph-rocket-launch',\n 'core': 'ph-cube',\n 'components': 'ph-puzzle-piece',\n 'interactive': 'ph-cursor-click',\n 'data-display': 'ph-table',\n 'feedback': 'ph-bell',\n 'meta': 'ph-info',\n 'default': 'ph-file-text'\n },\n \n // Callbacks\n onSelect: null, // function(result) - called when result is selected\n onSearch: null, // function(query, results) - called after search\n onOpen: null, // function() - called when results open\n onClose: null, // function() - called when results close\n \n // Text customization\n emptyTitle: 'No results found',\n emptyText: 'Try different keywords or check spelling',\n placeholder: 'Search...'\n };\n\n /**\n * Search Component Factory\n * Creates a new search instance with the given configuration\n * \n * @param {Object} options - Configuration options\n * @returns {Object} Search instance\n */\n function createSearch(options) {\n const config = Object.assign({}, DEFAULTS, options || {});\n \n // Instance state\n const state = {\n initialized: false,\n index: [],\n results: [],\n activeIndex: -1,\n isOpen: false,\n query: '',\n container: null,\n input: null,\n resultsContainer: null,\n debounceTimer: null,\n boundHandlers: {}\n };\n\n function safeInvokeCallback(name, fn, ...args) {\n try {\n fn(...args);\n } catch (error) {\n console.warn('[Vanduo Search] Callback error in \"' + name + '\":', error);\n }\n }\n\n function setResultsHtml(html) {\n if (!state.resultsContainer) return;\n try {\n state.resultsContainer.innerHTML = html;\n } catch (error) {\n console.warn('[Vanduo Search] Failed to render results:', error);\n }\n }\n\n /**\n * Initialize the search component\n * Idempotent \u2014 safe to call more than once on the same instance.\n * Returns the instance on success, null if required DOM elements are missing.\n */\n function init() {\n if (state.initialized) {\n return instance;\n }\n\n state.container = document.querySelector(config.containerSelector);\n if (!state.container) {\n state.initialized = false;\n return null;\n }\n\n state.input = state.container.querySelector(config.inputSelector);\n state.resultsContainer = state.container.querySelector(config.resultsSelector);\n\n if (!state.input || !state.resultsContainer) {\n state.initialized = false;\n return null;\n }\n\n // Set placeholder if configured\n if (config.placeholder) {\n state.input.setAttribute('placeholder', config.placeholder);\n }\n\n // Build search index\n buildIndex();\n\n // Bind events\n bindEvents();\n\n // Set up ARIA attributes\n setupAria();\n\n state.initialized = true;\n return instance;\n }\n\n /**\n * Build search index from DOM or custom data\n */\n function buildIndex() {\n state.index = [];\n \n // Use custom data if provided\n if (config.data && Array.isArray(config.data)) {\n config.data.forEach(function(item) {\n state.index.push({\n id: item.id || slugify(item.title),\n title: item.title || '',\n category: item.category || '',\n categorySlug: slugify(item.category || ''),\n content: item.content || '',\n keywords: item.keywords || extractKeywordsFromText(item.title + ' ' + item.content),\n url: item.url || '#' + (item.id || slugify(item.title)),\n icon: item.icon || ''\n });\n });\n return;\n }\n\n // Build from DOM\n const sections = document.querySelectorAll(config.contentSelector);\n const categoryMap = buildCategoryMap();\n\n sections.forEach(function(section) {\n const id = section.id;\n if (!id) return;\n\n const titleEl = section.querySelector(config.titleSelector);\n const title = titleEl ? titleEl.textContent.replace(/v[\\d.]+/g, '').trim() : id;\n const category = categoryMap[id] || 'Documentation';\n const content = extractContent(section);\n const keywords = extractKeywords(section, title);\n const iconEl = titleEl ? titleEl.querySelector('i.ph') : null;\n let icon = '';\n if (iconEl && iconEl.classList) {\n for (let ci = 0; ci < iconEl.classList.length; ci++) {\n if (iconEl.classList[ci].indexOf('ph-') === 0) {\n icon = iconEl.classList[ci];\n break;\n }\n }\n }\n\n state.index.push({\n id: id,\n title: title,\n category: category,\n categorySlug: slugify(category),\n content: content,\n keywords: keywords,\n url: '#' + id,\n icon: icon\n });\n });\n }\n\n /**\n * Build a map of section IDs to their categories\n */\n function buildCategoryMap() {\n const map = {};\n let currentCategory = 'Documentation';\n const navItems = document.querySelectorAll(config.navSelector + ', ' + config.sectionSelector);\n\n navItems.forEach(function(item) {\n if (item.classList.contains('doc-nav-section')) {\n currentCategory = item.textContent.trim();\n } else {\n const href = item.getAttribute('href');\n if (href && href.startsWith('#')) {\n const id = href.substring(1);\n map[id] = currentCategory;\n }\n }\n });\n\n return map;\n }\n\n /**\n * Extract searchable content from a section\n */\n function extractContent(section) {\n const clone = section.cloneNode(true);\n \n const toRemove = clone.querySelectorAll(config.excludeFromContent);\n toRemove.forEach(function(el) {\n el.remove();\n });\n\n let text = clone.textContent || '';\n text = text.replace(/\\s+/g, ' ').trim();\n \n return text.substring(0, config.maxContentLength);\n }\n\n /**\n * Extract keywords from a section\n */\n function extractKeywords(section, title) {\n const keywords = [];\n \n // Add title words\n title.toLowerCase().split(/\\s+/).forEach(function(word) {\n if (word.length > 2) {\n keywords.push(word);\n }\n });\n\n // Add class names from code examples\n const codeBlocks = section.querySelectorAll('code');\n codeBlocks.forEach(function(code) {\n const text = code.textContent || '';\n const classMatches = text.match(/\\.([\\w-]+)/g);\n if (classMatches) {\n classMatches.forEach(function(match) {\n keywords.push(match.substring(1).toLowerCase());\n });\n }\n });\n\n // Add data attributes\n const dataAttrs = section.querySelectorAll('[data-tooltip], [data-modal]');\n dataAttrs.forEach(function(el) {\n const attrs = el.getAttributeNames().filter(function(name) {\n return name.startsWith('data-');\n });\n attrs.forEach(function(attr) {\n keywords.push(attr.replace('data-', ''));\n });\n });\n\n return Array.from(new Set(keywords));\n }\n\n /**\n * Extract keywords from text string\n */\n function extractKeywordsFromText(text) {\n const words = text.toLowerCase().split(/\\s+/);\n return words.filter(function(word) {\n return word.length > 2;\n });\n }\n\n /**\n * Convert string to slug\n */\n function slugify(str) {\n return str.toLowerCase().replace(/\\s+/g, '-').replace(/[^a-z0-9-]/g, '');\n }\n\n /**\n * Bind event listeners\n */\n function bindEvents() {\n // Store bound handlers for cleanup\n state.boundHandlers.handleInput = function(e) {\n handleInput(e);\n };\n state.boundHandlers.handleFocus = function() {\n if (state.query.length >= config.minQueryLength) {\n open();\n }\n };\n state.boundHandlers.handleKeydown = function(e) {\n handleKeydown(e);\n };\n state.boundHandlers.handleOutsideClick = function(e) {\n if (!state.container.contains(e.target)) {\n close();\n }\n };\n state.boundHandlers.handleGlobalKeydown = function(e) {\n if (config.keyboardShortcut && (e.metaKey || e.ctrlKey) && e.key === 'k') {\n e.preventDefault();\n state.input.focus();\n state.input.select();\n }\n };\n state.boundHandlers.handleResultClick = function(e) {\n const result = e.target.closest('.vd-doc-search-result');\n if (result) {\n const index = parseInt(result.dataset.index, 10);\n select(index);\n }\n };\n\n // Input events\n state.input.addEventListener('input', state.boundHandlers.handleInput);\n state.input.addEventListener('focus', state.boundHandlers.handleFocus);\n state.input.addEventListener('keydown', state.boundHandlers.handleKeydown);\n\n // Close on outside click\n document.addEventListener('click', state.boundHandlers.handleOutsideClick);\n\n // Global keyboard shortcut\n document.addEventListener('keydown', state.boundHandlers.handleGlobalKeydown);\n\n // Result click handling\n state.resultsContainer.addEventListener('click', state.boundHandlers.handleResultClick);\n }\n\n /**\n * Unbind event listeners\n */\n function unbindEvents() {\n if (state.input) {\n state.input.removeEventListener('input', state.boundHandlers.handleInput);\n state.input.removeEventListener('focus', state.boundHandlers.handleFocus);\n state.input.removeEventListener('keydown', state.boundHandlers.handleKeydown);\n }\n document.removeEventListener('click', state.boundHandlers.handleOutsideClick);\n document.removeEventListener('keydown', state.boundHandlers.handleGlobalKeydown);\n if (state.resultsContainer) {\n state.resultsContainer.removeEventListener('click', state.boundHandlers.handleResultClick);\n }\n }\n\n /**\n * Set up ARIA attributes\n */\n function setupAria() {\n const resultsId = state.resultsContainer.id || 'search-results-' + Math.random().toString(36).substr(2, 9);\n state.resultsContainer.id = resultsId;\n \n state.input.setAttribute('role', 'combobox');\n state.input.setAttribute('aria-autocomplete', 'list');\n state.input.setAttribute('aria-controls', resultsId);\n state.input.setAttribute('aria-expanded', 'false');\n \n state.resultsContainer.setAttribute('role', 'listbox');\n state.resultsContainer.setAttribute('aria-label', 'Search results');\n }\n\n /**\n * Handle input changes\n */\n function handleInput(e) {\n const query = e.target.value.trim();\n\n if (state.debounceTimer) {\n clearTimeout(state.debounceTimer);\n }\n\n state.debounceTimer = setTimeout(function() {\n state.query = query;\n\n if (query.length < config.minQueryLength) {\n close();\n return;\n }\n\n state.results = search(query);\n state.activeIndex = -1;\n render();\n open();\n\n // Callback\n if (typeof config.onSearch === 'function') {\n safeInvokeCallback('onSearch', config.onSearch, query, state.results);\n }\n }, config.debounceMs);\n }\n\n /**\n * Handle keyboard navigation\n */\n function handleKeydown(e) {\n if (!state.isOpen) {\n if (e.key === 'ArrowDown' && state.query.length >= config.minQueryLength) {\n e.preventDefault();\n state.results = search(state.query);\n render();\n open();\n }\n return;\n }\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n navigate(1);\n break;\n\n case 'ArrowUp':\n e.preventDefault();\n navigate(-1);\n break;\n\n case 'Enter':\n e.preventDefault();\n if (state.activeIndex >= 0) {\n select(state.activeIndex);\n } else if (state.results.length > 0) {\n select(0);\n }\n break;\n\n case 'Escape':\n e.preventDefault();\n close();\n break;\n\n case 'Tab':\n close();\n break;\n }\n }\n\n /**\n * Perform search\n */\n function search(query) {\n const terms = query.toLowerCase().split(/\\s+/).filter(function(t) {\n return t.length > 0;\n });\n const scored = [];\n\n state.index.forEach(function(entry) {\n let score = 0;\n const titleLower = entry.title.toLowerCase();\n const categoryLower = entry.category.toLowerCase();\n const contentLower = entry.content.toLowerCase();\n\n terms.forEach(function(term) {\n // Title match - highest priority\n if (titleLower.includes(term)) {\n score += 100;\n if (titleLower === term) {\n score += 50;\n } else if (titleLower.startsWith(term)) {\n score += 25;\n }\n }\n\n // Category match\n if (categoryLower.includes(term)) {\n score += 50;\n }\n\n // Keyword match\n const keywordMatch = entry.keywords.some(function(k) {\n return k.includes(term);\n });\n if (keywordMatch) {\n score += 30;\n }\n\n // Content match\n if (contentLower.includes(term)) {\n score += 10;\n }\n });\n\n if (score > 0) {\n scored.push({\n id: entry.id,\n title: entry.title,\n category: entry.category,\n categorySlug: entry.categorySlug,\n content: entry.content,\n url: entry.url,\n icon: entry.icon,\n score: score\n });\n }\n });\n\n scored.sort(function(a, b) {\n return b.score - a.score;\n });\n\n return scored.slice(0, config.maxResults);\n }\n\n /**\n * Render search results\n */\n function render() {\n if (state.results.length === 0) {\n setResultsHtml(renderEmpty());\n return;\n }\n\n let html = '
      ';\n\n state.results.forEach(function(result, index) {\n const isActive = index === state.activeIndex;\n const icon = result.icon || getCategoryIcon(result.categorySlug);\n const excerpt = getExcerpt(result.content, state.query);\n\n html += '
    • ' +\n '
      ' +\n '' +\n '
      ' +\n '
      ' +\n '
      ' + highlight(result.title, state.query) + '
      ' +\n '
      ' + escapeHtml(result.category) + '
      ' +\n '
      ' + highlight(excerpt, state.query) + '
      ' +\n '
      ' +\n '
    • ';\n });\n\n html += '
    ';\n html += renderFooter();\n\n setResultsHtml(html);\n }\n\n /**\n * Render empty state\n */\n function renderEmpty() {\n return '
    ' +\n '
    ' +\n '
    ' + escapeHtml(config.emptyTitle) + '
    ' +\n '
    ' + escapeHtml(config.emptyText) + '
    ' +\n '
    ';\n }\n\n /**\n * Render footer with keyboard hints\n */\n function renderFooter() {\n return '
    ' +\n '\u2191\u2193 to navigate' +\n '\u21B5 to select' +\n 'esc to close' +\n '
    ';\n }\n\n /**\n * Get icon for category\n */\n function getCategoryIcon(categorySlug) {\n return config.categoryIcons[categorySlug] || config.categoryIcons['default'] || 'ph-file-text';\n }\n\n /**\n * Get excerpt from content\n */\n function getExcerpt(content, query) {\n const terms = query.toLowerCase().split(/\\s+/);\n const contentLower = content.toLowerCase();\n const excerptLength = 100;\n\n let matchPos = -1;\n for (let i = 0; i < terms.length; i++) {\n const pos = contentLower.indexOf(terms[i]);\n if (pos !== -1 && (matchPos === -1 || pos < matchPos)) {\n matchPos = pos;\n }\n }\n\n if (matchPos === -1) {\n return content.substring(0, excerptLength) + '...';\n }\n\n const start = Math.max(0, matchPos - 30);\n const end = Math.min(content.length, matchPos + excerptLength);\n let excerpt = content.substring(start, end);\n\n if (start > 0) {\n excerpt = '...' + excerpt;\n }\n if (end < content.length) {\n excerpt = excerpt + '...';\n }\n\n return excerpt;\n }\n\n /**\n * Highlight matched terms in text\n */\n function highlight(text, query) {\n if (!query) return escapeHtml(text);\n\n const terms = query.toLowerCase().split(/\\s+/).filter(function(t) {\n return t.length > 0;\n });\n let escaped = escapeHtml(text);\n\n terms.forEach(function(term) {\n // Skip overly long terms to prevent ReDoS\n if (term.length > 50) return;\n const regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi');\n escaped = escaped.replace(regex, '<' + config.highlightTag + '>$1');\n });\n\n return escaped;\n }\n\n /**\n * Escape HTML entities\n */\n function escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Navigate results with keyboard\n */\n function navigate(direction) {\n let newIndex = state.activeIndex + direction;\n\n if (newIndex < 0) {\n newIndex = state.results.length - 1;\n } else if (newIndex >= state.results.length) {\n newIndex = 0;\n }\n\n setActiveIndex(newIndex);\n }\n\n /**\n * Set active result index\n */\n function setActiveIndex(index) {\n const prevActive = state.resultsContainer.querySelector('.vd-doc-search-result.is-active');\n if (prevActive) {\n prevActive.classList.remove('is-active');\n prevActive.setAttribute('aria-selected', 'false');\n }\n\n state.activeIndex = index;\n\n const newActive = state.resultsContainer.querySelector('[data-index=\"' + index + '\"]');\n if (newActive) {\n newActive.classList.add('is-active');\n newActive.setAttribute('aria-selected', 'true');\n state.input.setAttribute('aria-activedescendant', 'vd-doc-search-result-' + index);\n newActive.scrollIntoView({ block: 'nearest' });\n }\n }\n\n /**\n * Select a result\n */\n function select(index) {\n const result = state.results[index];\n if (!result) return;\n\n // Close search\n close();\n state.input.value = '';\n state.query = '';\n\n // Custom callback\n if (typeof config.onSelect === 'function') {\n safeInvokeCallback('onSelect', config.onSelect, result);\n return;\n }\n\n // Default behavior: navigate to section\n const section = document.querySelector(result.url);\n if (section) {\n section.scrollIntoView({ behavior: 'smooth', block: 'start' });\n window.history.pushState(null, '', result.url);\n updateSidebarActive(result.id);\n }\n }\n\n /**\n * Update sidebar navigation active state\n */\n function updateSidebarActive(sectionId) {\n const navLinks = document.querySelectorAll(config.navSelector);\n navLinks.forEach(function(link) {\n link.classList.remove('active');\n if (link.getAttribute('href') === '#' + sectionId) {\n link.classList.add('active');\n }\n });\n }\n\n /**\n * Open results dropdown\n */\n function open() {\n if (state.isOpen) return;\n\n state.isOpen = true;\n state.resultsContainer.classList.add('is-open');\n state.input.setAttribute('aria-expanded', 'true');\n\n if (typeof config.onOpen === 'function') {\n safeInvokeCallback('onOpen', config.onOpen);\n }\n }\n\n /**\n * Close results dropdown\n */\n function close() {\n if (!state.isOpen) return;\n\n state.isOpen = false;\n state.activeIndex = -1;\n state.resultsContainer.classList.remove('is-open');\n state.input.setAttribute('aria-expanded', 'false');\n state.input.removeAttribute('aria-activedescendant');\n\n if (typeof config.onClose === 'function') {\n safeInvokeCallback('onClose', config.onClose);\n }\n }\n\n /**\n * Destroy the component\n */\n function destroy() {\n unbindEvents();\n \n state.initialized = false;\n state.index = [];\n state.results = [];\n state.isOpen = false;\n state.query = '';\n\n if (state.debounceTimer) {\n clearTimeout(state.debounceTimer);\n }\n\n if (state.resultsContainer) {\n setResultsHtml('');\n }\n }\n\n /**\n * Rebuild the search index\n */\n function rebuild() {\n buildIndex();\n }\n\n /**\n * Update configuration\n */\n function setConfig(newConfig) {\n Object.assign(config, newConfig);\n }\n\n /**\n * Get current configuration\n */\n function getConfig() {\n return Object.assign({}, config);\n }\n\n /**\n * Get search index\n */\n function getIndex() {\n return state.index.slice();\n }\n\n // Public instance API\n const instance = {\n init: init,\n destroy: destroy,\n rebuild: rebuild,\n search: search,\n open: open,\n close: close,\n setConfig: setConfig,\n getConfig: getConfig,\n getIndex: getIndex\n };\n\n return instance;\n }\n\n /**\n * Search Component (singleton for backward compatibility)\n */\n const Search = {\n // Factory method \u2014 creates and auto-initializes a new independent instance.\n // Always returns the instance so callers retain a reference even if the\n // DOM container is not yet available (they can retry init() later).\n create: function(options) {\n const instance = createSearch(options);\n if (instance) {\n instance.init();\n }\n return instance || null;\n },\n \n // Default instance\n _instance: null,\n \n // Configuration (for default instance)\n config: Object.assign({}, DEFAULTS),\n\n /**\n * Initialize the default search instance\n */\n init: function(options) {\n if (this._instance) {\n this._instance.destroy();\n }\n \n if (options) {\n Object.assign(this.config, options);\n }\n \n this._instance = createSearch(this.config);\n return this._instance ? this._instance.init() : null;\n },\n\n /**\n * Destroy the default instance\n */\n destroy: function() {\n if (this._instance) {\n this._instance.destroy();\n this._instance = null;\n }\n },\n\n destroyAll: function() {\n this.destroy();\n },\n\n /**\n * Rebuild the default instance index\n */\n rebuild: function() {\n if (this._instance) {\n this._instance.rebuild();\n }\n },\n\n /**\n * Search using the default instance\n */\n search: function(query) {\n return this._instance ? this._instance.search(query) : [];\n },\n\n /**\n * Open the default instance\n */\n open: function() {\n if (this._instance) {\n this._instance.open();\n }\n },\n\n /**\n * Close the default instance\n */\n close: function() {\n if (this._instance) {\n this._instance.close();\n }\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('docSearch', Search);\n }\n\n // Expose globally (both names for compatibility)\n window.Search = Search;\n window.DocSearch = Search; // Backward compatibility\n window.VanduoDocSearch = Search; // New name compatibility\n\n})();\n", "/**\n * Vanduo Framework - Draggable Component\n * JavaScript functionality for draggable elements and drop zones\n */\n\n(function () {\n 'use strict';\n\n /**\n * Draggable Component\n */\n const Draggable = {\n // Store initialized draggables and their cleanup functions\n instances: new Map(),\n // Store current drag state\n currentDrag: null,\n // Store touch state\n touchState: null,\n // Feedback element\n feedbackElement: null,\n // Shared selector used by init and touch reorder\n containerSelector: '.vd-draggable-container, .vd-draggable-container-vertical',\n\n /**\n * Initialize draggable components\n */\n init: function () {\n const draggables = document.querySelectorAll('.vd-draggable, [data-draggable]');\n\n draggables.forEach(element => {\n if (this.instances.has(element)) {\n return;\n }\n this.initDraggable(element);\n });\n\n const containers = document.querySelectorAll(this.containerSelector);\n containers.forEach(container => {\n if (!this.instances.has(container)) {\n this.initContainer(container);\n }\n });\n\n const dropZones = document.querySelectorAll('.vd-drop-zone');\n dropZones.forEach(zone => {\n if (!this.instances.has(zone)) {\n this.initDropZone(zone);\n }\n });\n\n this.createFeedbackElement();\n },\n\n /**\n * Initialize a single draggable element\n * @param {HTMLElement} element - Draggable element\n */\n initDraggable: function (element) {\n const cleanupFunctions = [];\n\n // Make element draggable if not already\n if (!element.hasAttribute('draggable')) {\n element.setAttribute('draggable', 'true');\n }\n\n // Accessibility: add ARIA attributes\n if (!element.hasAttribute('tabindex')) {\n element.setAttribute('tabindex', '0');\n }\n element.setAttribute('role', 'option');\n element.setAttribute('aria-roledescription', 'draggable item');\n element.setAttribute('aria-grabbed', 'false');\n\n // Handle drag start\n const dragStartHandler = (e) => {\n this.handleDragStart(e, element);\n };\n element.addEventListener('dragstart', dragStartHandler);\n cleanupFunctions.push(() => element.removeEventListener('dragstart', dragStartHandler));\n\n // Handle drag\n const dragHandler = (e) => {\n this.handleDrag(e, element);\n };\n element.addEventListener('drag', dragHandler);\n cleanupFunctions.push(() => element.removeEventListener('drag', dragHandler));\n\n // Handle drag end\n const dragEndHandler = (e) => {\n this.handleDragEnd(e, element);\n };\n element.addEventListener('dragend', dragEndHandler);\n cleanupFunctions.push(() => element.removeEventListener('dragend', dragEndHandler));\n\n // Handle touch start (for mobile)\n const touchStartHandler = (e) => {\n this.handleTouchStart(e, element);\n };\n element.addEventListener('touchstart', touchStartHandler);\n cleanupFunctions.push(() => element.removeEventListener('touchstart', touchStartHandler));\n\n // Handle touch move (for mobile)\n // { passive: false } is required so that e.preventDefault() works\n // once the drag threshold is reached \u2014 without it, modern browsers\n // treat the listener as passive and silently ignore preventDefault().\n const touchMoveHandler = (e) => {\n this.handleTouchMove(e, element);\n };\n element.addEventListener('touchmove', touchMoveHandler, { passive: false });\n cleanupFunctions.push(() => element.removeEventListener('touchmove', touchMoveHandler));\n\n // Handle touch end (for mobile)\n const touchEndHandler = (e) => {\n this.handleTouchEnd(e, element);\n };\n element.addEventListener('touchend', touchEndHandler, { passive: false });\n cleanupFunctions.push(() => element.removeEventListener('touchend', touchEndHandler));\n\n // Handle touch cancel (for mobile)\n const touchCancelHandler = (e) => {\n this.handleTouchEnd(e, element);\n };\n element.addEventListener('touchcancel', touchCancelHandler);\n cleanupFunctions.push(() => element.removeEventListener('touchcancel', touchCancelHandler));\n\n // Keyboard navigation\n const keydownHandler = (e) => {\n this.handleKeydown(e, element);\n };\n element.addEventListener('keydown', keydownHandler);\n cleanupFunctions.push(() => element.removeEventListener('keydown', keydownHandler));\n\n this.instances.set(element, { cleanup: cleanupFunctions });\n },\n\n /**\n * Initialize a draggable container\n * @param {HTMLElement} container - Draggable container\n */\n initContainer: function (container) {\n // Accessibility: add ARIA role to container\n container.setAttribute('role', 'listbox');\n container.setAttribute('aria-label', container.getAttribute('aria-label') || 'Draggable items');\n\n const items = container.querySelectorAll('.vd-draggable-item');\n items.forEach(item => {\n if (!this.instances.has(item)) {\n this.initDraggable(item);\n }\n });\n\n const cleanupFunctions = [];\n\n // Handle drag enter\n const dragEnterHandler = (e) => {\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n };\n\n // Handle drag over for auto-sorting\n const dragOverHandler = (e) => {\n e.preventDefault(); // Necessary to allow drop\n e.dataTransfer.dropEffect = 'move';\n\n if (!this.currentDrag) return;\n const draggingEl = this.currentDrag.element;\n\n // Only reorder if dragging an item that belongs to this container (or if we support cross-container drag, but keep simple)\n if (!container.contains(draggingEl)) return;\n\n // Prevent jumps when the dragging finalizes with 0,0 coordinates near the end\n if (e.clientX === 0 && e.clientY === 0) return;\n\n this.handleReorder(container, draggingEl, e.clientX, e.clientY);\n };\n\n // Handle drop\n const dropHandler = (e) => {\n e.preventDefault(); // crucial to prevent the browser's default handling and snapping back\n };\n\n container.addEventListener('dragenter', dragEnterHandler);\n container.addEventListener('dragover', dragOverHandler);\n container.addEventListener('drop', dropHandler);\n\n cleanupFunctions.push(() => {\n container.removeEventListener('dragenter', dragEnterHandler);\n container.removeEventListener('dragover', dragOverHandler);\n container.removeEventListener('drop', dropHandler);\n });\n\n this.instances.set(container, { cleanup: cleanupFunctions });\n },\n\n /**\n * Initialize a drop zone\n * @param {HTMLElement} zone - Drop zone element\n */\n initDropZone: function (zone) {\n const cleanupFunctions = [];\n\n // Accessibility: add ARIA role to drop zone\n zone.setAttribute('role', 'region');\n zone.setAttribute('aria-dropeffect', 'move');\n if (!zone.hasAttribute('aria-label')) {\n zone.setAttribute('aria-label', 'Drop zone');\n }\n\n // Handle drag over\n const dragOverHandler = (e) => {\n e.preventDefault();\n this.handleDragOver(e, zone);\n };\n zone.addEventListener('dragover', dragOverHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragover', dragOverHandler));\n\n // Handle drag enter\n const dragEnterHandler = (e) => {\n e.preventDefault();\n this.handleDragEnter(e, zone);\n };\n zone.addEventListener('dragenter', dragEnterHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragenter', dragEnterHandler));\n\n // Handle drag leave\n const dragLeaveHandler = (e) => {\n this.handleDragLeave(e, zone);\n };\n zone.addEventListener('dragleave', dragLeaveHandler);\n cleanupFunctions.push(() => zone.removeEventListener('dragleave', dragLeaveHandler));\n\n // Handle drop\n const dropHandler = (e) => {\n e.preventDefault();\n this.handleDrop(e, zone);\n };\n zone.addEventListener('drop', dropHandler);\n cleanupFunctions.push(() => zone.removeEventListener('drop', dropHandler));\n\n this.instances.set(zone, { cleanup: cleanupFunctions });\n },\n\n /**\n * Create feedback element for drag operations\n */\n createFeedbackElement: function () {\n if (!this.feedbackElement) {\n // Reuse existing element if present\n const existing = document.querySelector('.vd-drag-feedback');\n if (existing) {\n this.feedbackElement = existing;\n return;\n }\n\n this.feedbackElement = document.createElement('div');\n this.feedbackElement.className = 'vd-drag-feedback hidden';\n this.feedbackElement.setAttribute('role', 'presentation');\n document.body.appendChild(this.feedbackElement);\n }\n },\n\n /**\n * Handle drag start event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDragStart: function (e, element) {\n // Add dragging class\n element.classList.add('is-dragging');\n\n // Accessibility: update aria-grabbed\n element.setAttribute('aria-grabbed', 'true');\n\n // Store drag state\n this.currentDrag = {\n element: element,\n initialPosition: { x: e.clientX, y: e.clientY },\n initialBounds: element.getBoundingClientRect(),\n data: this.getData(element)\n };\n\n // Set drag data\n e.dataTransfer.effectAllowed = 'move';\n e.dataTransfer.setData('text/plain', this.currentDrag.data);\n\n // We no longer suppress the native ghost image or manually update feedback\n // for mouse drags, relying on the browser's native rendering instead.\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:start', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: e.clientX, y: e.clientY }\n }\n }));\n },\n\n /**\n * Handle drag event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDrag: function (e, element) {\n // Guard against null state (race condition on fast interactions)\n if (!this.currentDrag) return;\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:drag', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: e.clientX, y: e.clientY },\n delta: {\n x: e.clientX - this.currentDrag.initialPosition.x,\n y: e.clientY - this.currentDrag.initialPosition.y\n }\n }\n }));\n },\n\n /**\n * Handle drag end event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} element - Draggable element\n */\n handleDragEnd: function (e, element) {\n // Remove dragging class\n element.classList.remove('is-dragging');\n element.classList.add('is-dropped');\n setTimeout(() => element.classList.remove('is-dropped'), 300);\n\n // Accessibility: update aria-grabbed\n element.setAttribute('aria-grabbed', 'false');\n\n // Hide feedback\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n\n // Guard against null state\n const data = this.currentDrag?.data || this.getData(element);\n const initialPos = this.currentDrag?.initialPosition || { x: 0, y: 0 };\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:end', {\n bubbles: true,\n detail: {\n element: element,\n data: data,\n position: { x: e.clientX, y: e.clientY },\n delta: {\n x: e.clientX - initialPos.x,\n y: e.clientY - initialPos.y\n }\n }\n }));\n\n // Reset drag state\n this.currentDrag = null;\n },\n\n /**\n * Handle touch start event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchStart: function (e, element) {\n // Don't prevent default here \u2014 it blocks scrolling.\n // We only prevent default in touchmove once drag threshold is reached.\n const touch = e.touches[0];\n const rect = element.getBoundingClientRect();\n this.touchState = {\n element: element,\n startX: touch.clientX,\n startY: touch.clientY,\n lastX: touch.clientX,\n lastY: touch.clientY,\n // Keep preview anchored to the original grab point.\n offsetX: touch.clientX - rect.left,\n offsetY: touch.clientY - rect.top,\n startTime: Date.now(),\n isDragging: false\n };\n },\n\n /**\n * Handle touch move event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchMove: function (e, element) {\n if (!this.touchState) return;\n\n const touch = e.touches[0];\n this.touchState.lastX = touch.clientX;\n this.touchState.lastY = touch.clientY;\n const deltaX = touch.clientX - this.touchState.startX;\n const deltaY = touch.clientY - this.touchState.startY;\n\n // Only start dragging if moved a minimum distance\n if (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10) {\n // Now we know it's a drag, not a scroll \u2014 prevent default\n if (e.cancelable) e.preventDefault();\n\n if (!this.touchState.isDragging) {\n this.touchState.isDragging = true;\n element.classList.add('is-dragging');\n element.setAttribute('aria-grabbed', 'true');\n\n // Store drag state\n this.currentDrag = {\n element: element,\n initialPosition: { x: this.touchState.startX, y: this.touchState.startY },\n initialBounds: element.getBoundingClientRect(),\n data: this.getData(element),\n // Preserve where inside the element the drag started for accurate ghost positioning.\n offsetX: this.touchState.offsetX,\n offsetY: this.touchState.offsetY\n };\n\n // Dispatch event\n element.dispatchEvent(new CustomEvent('draggable:start', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: touch.clientX, y: touch.clientY }\n }\n }));\n }\n\n // Update feedback\n this.updateFeedback(touch.clientX, touch.clientY);\n\n // Dispatch event\n if (this.currentDrag) {\n element.dispatchEvent(new CustomEvent('draggable:drag', {\n bubbles: true,\n detail: {\n element: element,\n data: this.currentDrag.data,\n position: { x: touch.clientX, y: touch.clientY },\n delta: { x: deltaX, y: deltaY }\n }\n }));\n\n this.updateTouchDropZone(touch.clientX, touch.clientY);\n\n // Reorder for touch\n const container = element.closest(this.containerSelector);\n if (container && container.contains(element)) {\n this.handleReorder(container, element, touch.clientX, touch.clientY);\n }\n }\n }\n },\n\n /**\n * Handle touch end event (for mobile)\n * @param {TouchEvent} e - Touch event\n * @param {HTMLElement} element - Draggable element\n */\n handleTouchEnd: function (e, element) {\n if (this.touchState && this.touchState.isDragging) {\n if (e.cancelable) e.preventDefault();\n const endTouch = e.changedTouches?.[0];\n const endPosition = {\n x: endTouch?.clientX ?? this.touchState.lastX ?? this.touchState.startX,\n y: endTouch?.clientY ?? this.touchState.lastY ?? this.touchState.startY\n };\n\n const dropZone = this.resolveDropZoneAtPoint(endPosition.x, endPosition.y) || this.touchState.overZone;\n if (dropZone) {\n this.dispatchDrop(dropZone, endPosition);\n } else if (this.touchState.overZone) {\n this.touchState.overZone.classList.remove('is-drag-over');\n }\n\n element.classList.remove('is-dragging');\n element.classList.add('is-dropped');\n element.setAttribute('aria-grabbed', 'false');\n setTimeout(() => element.classList.remove('is-dropped'), 300);\n\n // Hide feedback\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n\n // Dispatch event\n const data = this.currentDrag?.data || this.getData(element);\n const startX = this.touchState?.startX || 0;\n const startY = this.touchState?.startY || 0;\n\n element.dispatchEvent(new CustomEvent('draggable:end', {\n bubbles: true,\n detail: {\n element: element,\n data: data,\n position: endPosition,\n delta: {\n x: endPosition.x - startX,\n y: endPosition.y - startY\n }\n }\n }));\n }\n\n // Reset states\n this.touchState = null;\n this.currentDrag = null;\n },\n\n /**\n * Handle drag over event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} _zone - Drop zone element\n */\n handleDragOver: function (e, _zone) {\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n },\n\n /**\n * Handle drag enter event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDragEnter: function (e, zone) {\n e.preventDefault();\n zone.classList.add('is-drag-over');\n },\n\n /**\n * Handle drag leave event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDragLeave: function (e, zone) {\n zone.classList.remove('is-drag-over');\n },\n\n /**\n * Handle drop event\n * @param {DragEvent} e - Drag event\n * @param {HTMLElement} zone - Drop zone element\n */\n handleDrop: function (e, zone) {\n e.preventDefault();\n this.dispatchDrop(zone, { x: e.clientX, y: e.clientY });\n },\n\n /**\n * Resolve a drop zone from viewport coordinates\n * @param {number} x\n * @param {number} y\n * @returns {HTMLElement|null}\n */\n resolveDropZoneAtPoint: function (x, y) {\n if (!Number.isFinite(x) || !Number.isFinite(y)) return null;\n\n // Prefer the full stacking list so overlays/top elements don't hide real drop targets.\n if (typeof document.elementsFromPoint === 'function') {\n const stacked = document.elementsFromPoint(x, y);\n for (const element of stacked) {\n const zone = element.closest('.vd-drop-zone');\n if (zone) return zone;\n }\n }\n\n const target = document.elementFromPoint(x, y);\n const targetZone = target ? target.closest('.vd-drop-zone') : null;\n if (targetZone) return targetZone;\n\n // Last-resort fallback for mobile emulation edge cases.\n const zones = document.querySelectorAll('.vd-drop-zone');\n for (const zone of zones) {\n const rect = zone.getBoundingClientRect();\n if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {\n return zone;\n }\n }\n\n return null;\n },\n\n /**\n * Track and update active drop-zone hover state on touch devices\n * @param {number} x\n * @param {number} y\n */\n updateTouchDropZone: function (x, y) {\n if (!this.touchState) return;\n\n const nextZone = this.resolveDropZoneAtPoint(x, y);\n const prevZone = this.touchState.overZone || null;\n\n if (prevZone && prevZone !== nextZone) {\n prevZone.classList.remove('is-drag-over');\n }\n\n if (nextZone && nextZone !== prevZone) {\n nextZone.classList.add('is-drag-over');\n }\n\n this.touchState.overZone = nextZone || null;\n },\n\n /**\n * Dispatch a normalized drop event for mouse and touch flows\n * @param {HTMLElement} zone\n * @param {{x:number, y:number}} position\n */\n dispatchDrop: function (zone, position) {\n zone.classList.remove('is-drag-over');\n zone.dispatchEvent(new CustomEvent('draggable:drop', {\n bubbles: true,\n detail: {\n zone: zone,\n element: this.currentDrag?.element,\n data: this.currentDrag?.data,\n position: position\n }\n }));\n },\n\n /**\n * Reorder elements in container based on cursor position\n * @param {HTMLElement} container \n * @param {HTMLElement} element \n * @param {number} clientX \n * @param {number} clientY \n */\n handleReorder: function (container, element, clientX, clientY) {\n const isVertical = container.classList.contains('vd-draggable-container-vertical');\n const siblings = [...container.querySelectorAll('.vd-draggable-item:not(.is-dragging), .vd-draggable:not(.is-dragging)')];\n\n const nextSibling = siblings.reduce((closest, child) => {\n const box = child.getBoundingClientRect();\n const offset = isVertical\n ? clientY - box.top - box.height / 2\n : clientX - box.left - box.width / 2;\n\n if (offset < 0 && offset > closest.offset) {\n return { offset: offset, element: child };\n } else {\n return closest;\n }\n }, { offset: Number.NEGATIVE_INFINITY }).element;\n\n if (nextSibling == null) {\n container.appendChild(element);\n } else {\n container.insertBefore(element, nextSibling);\n }\n },\n\n /**\n * Handle keyboard events\n * @param {KeyboardEvent} e - Keyboard event\n * @param {HTMLElement} element - Draggable element\n */\n handleKeydown: function (e, element) {\n switch (e.key) {\n case 'Enter':\n case ' ':\n e.preventDefault();\n // Trigger click or custom action\n element.click();\n break;\n case 'Escape':\n // Cancel drag if in progress\n if (element.classList.contains('is-dragging')) {\n element.classList.remove('is-dragging');\n element.setAttribute('aria-grabbed', 'false');\n if (this.feedbackElement) {\n this.feedbackElement.classList.add('hidden');\n }\n this.currentDrag = null;\n }\n break;\n case 'ArrowUp':\n case 'ArrowLeft': {\n e.preventDefault();\n const prev = element.previousElementSibling;\n if (prev && (prev.classList.contains('vd-draggable') || prev.classList.contains('vd-draggable-item'))) {\n element.parentNode.insertBefore(element, prev);\n element.focus();\n element.dispatchEvent(new CustomEvent('draggable:reorder', {\n bubbles: true,\n detail: { element: element, direction: 'up' }\n }));\n }\n break;\n }\n case 'ArrowDown':\n case 'ArrowRight': {\n e.preventDefault();\n const next = element.nextElementSibling;\n if (next && (next.classList.contains('vd-draggable') || next.classList.contains('vd-draggable-item'))) {\n element.parentNode.insertBefore(next, element);\n element.focus();\n element.dispatchEvent(new CustomEvent('draggable:reorder', {\n bubbles: true,\n detail: { element: element, direction: 'down' }\n }));\n }\n break;\n }\n }\n },\n\n /**\n * Get data from draggable element\n * @param {HTMLElement} element - Draggable element\n * @returns {string} Data associated with the element\n */\n getData: function (element) {\n return element.dataset.draggable || element.textContent.trim();\n },\n\n /**\n * Update drag feedback element\n * @param {number} x - Current X coordinate\n * @param {number} y - Current Y coordinate\n */\n updateFeedback: function (x, y) {\n if (!this.currentDrag) return;\n\n // Show feedback\n this.feedbackElement.classList.remove('hidden');\n\n // Update feedback content\n const rect = this.currentDrag.initialBounds;\n this.feedbackElement.innerHTML = '';\n const clone = this.currentDrag.element.cloneNode(true);\n this.feedbackElement.appendChild(clone);\n\n // Set styles\n const offsetX = this.currentDrag.offsetX ?? 20;\n const offsetY = this.currentDrag.offsetY ?? 20;\n Object.assign(this.feedbackElement.style, {\n left: (x - offsetX) + 'px',\n top: (y - offsetY) + 'px',\n width: rect.width + 'px',\n height: rect.height + 'px'\n });\n },\n\n /**\n * Make an element draggable programmatically\n * @param {HTMLElement|string} element - Element or selector\n * @param {Object} options - Configuration options\n */\n makeDraggable: function (element, options = {}) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n\n if (el && !this.instances.has(el)) {\n // Add classes and attributes\n el.classList.add('vd-draggable');\n el.setAttribute('draggable', 'true');\n\n // Set options\n if (options.data) {\n el.dataset.draggable = options.data;\n }\n\n // Initialize\n this.initDraggable(el);\n }\n },\n\n /**\n * Remove draggable functionality from an element\n * @param {HTMLElement|string} element - Element or selector\n */\n removeDraggable: function (element) {\n const el = typeof element === 'string' ? document.querySelector(element) : element;\n\n if (el && this.instances.has(el)) {\n // Clean up\n const instance = this.instances.get(el);\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n\n // Remove classes and attributes\n el.classList.remove('vd-draggable');\n el.removeAttribute('draggable');\n el.removeAttribute('data-draggable');\n }\n },\n\n /**\n * Destroy a draggable instance and clean up event listeners\n * @param {HTMLElement} element - Draggable element\n */\n destroy: function (element) {\n this.removeDraggable(element);\n },\n\n /**\n * Destroy all draggable instances\n */\n destroyAll: function () {\n const instances = Array.from(this.instances.keys());\n instances.forEach(element => this.destroy(element));\n }\n };\n\n // Register with Vanduo framework if available\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('draggable', Draggable);\n }\n\n // Expose globally\n window.VanduoDraggable = Draggable;\n})();\n", "/**\n * Vanduo Framework \u2013 LazyLoad Component\n * v1.2.1\n *\n * Provides two levels of API:\n *\n * LOW-LEVEL (generic IntersectionObserver wrapper)\n * VanduoLazyLoad.observe(element, callback, options?)\n * VanduoLazyLoad.unobserve(element)\n * VanduoLazyLoad.unobserveAll()\n *\n * HIGH-LEVEL (HTML-section fetcher, mirrors vanduo-docs SPA behaviour)\n * VanduoLazyLoad.loadSection(url, containerEl, options?)\n * options: { placeholder, threshold, rootMargin, onLoaded, onError }\n * placeholder: 'skeleton' | 'spinner' | HTMLString (default: 'skeleton')\n *\n * ATTRIBUTE-DRIVEN (zero-JS usage, wired by init())\n *
    \u2026
    \n *\n * EVENTS dispatched on the host element:\n * lazysection:loading \u2014 fetch started\n * lazysection:loaded \u2014 content injected\n * lazysection:error \u2014 fetch failed\n */\n\n(function () {\n 'use strict';\n\n /* \u2500\u2500 Private state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /** @type {Map} */\n const _observerMap = new Map();\n\n /* \u2500\u2500 Security helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Returns true only if `url` resolves to the same origin as the page\n * (relative paths and same-origin absolute URLs are allowed).\n * Cross-origin URLs are rejected to prevent SSRF-style fetch abuse.\n * @param {string} url\n * @returns {boolean}\n */\n function _isSafeUrl(url) {\n try {\n // Relative URLs (no origin) are always safe\n const resolved = new URL(url, window.location.href);\n return resolved.origin === window.location.origin;\n } catch (_) {\n return false;\n }\n }\n\n /**\n * Safely inject fetched HTML into a container by parsing it with\n * DOMParser (avoids script execution) and replacing children via\n * standard DOM APIs instead of raw innerHTML assignment.\n * @param {Element} containerEl\n * @param {string} html\n */\n function _safeInjectHtml(containerEl, html) {\n const parser = new DOMParser();\n const doc = parser.parseFromString(html.trim(), 'text/html'); // CodeQL [js/xss-through-dom] Intentional: parsed nodes are sanitized below before adoption into live DOM\n\n const DANGEROUS_TAGS = ['SCRIPT', 'IFRAME', 'OBJECT', 'EMBED', 'FORM', 'BASE', 'LINK', 'META', 'STYLE'];\n for (const tag of DANGEROUS_TAGS) {\n const els = doc.querySelectorAll(tag);\n for (let i = els.length - 1; i >= 0; i--) {\n els[i].parentNode.removeChild(els[i]);\n }\n }\n\n // Strip dangerous attributes (e.g. onerror, javascript: urls) from all elements\n function _sanitizeNode(node) {\n if (node.nodeType === Node.ELEMENT_NODE) {\n const attrs = node.attributes;\n for (let i = attrs.length - 1; i >= 0; i--) {\n const attrName = attrs[i].name.toLowerCase();\n const attrValue = attrs[i].value.toLowerCase();\n const trimmedValue = attrValue.trim();\n if (\n attrName.startsWith('on') ||\n trimmedValue.startsWith('javascript:') ||\n trimmedValue.startsWith('data:') ||\n trimmedValue.startsWith('vbscript:')\n ) {\n node.removeAttribute(attrs[i].name);\n }\n }\n const children = node.childNodes;\n for (let i = 0; i < children.length; i++) {\n _sanitizeNode(children[i]);\n }\n }\n }\n _sanitizeNode(doc.body);\n\n // Collect all top-level body children\n const nodes = Array.from(doc.body.childNodes);\n // Clear container and append parsed nodes\n while (containerEl.firstChild) {\n containerEl.removeChild(containerEl.firstChild);\n }\n nodes.forEach(function (node) {\n containerEl.appendChild(document.adoptNode(node));\n });\n }\n\n /* \u2500\u2500 Placeholder HTML \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n function _skeletonHtml() {\n return '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + 'Loading\u2026
    ';\n }\n\n function _spinnerHtml() {\n return '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + '
    '\n + 'Loading\u2026
    ';\n }\n\n function _resolvePlaceholder(placeholder) {\n if (!placeholder || placeholder === 'skeleton') return _skeletonHtml();\n if (placeholder === 'spinner') return _spinnerHtml();\n // Caller-supplied HTML string\n return placeholder;\n }\n\n /* \u2500\u2500 Dispatch helper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n function _dispatch(el, eventName, detail) {\n el.dispatchEvent(new CustomEvent(eventName, { bubbles: true, detail: detail || {} }));\n }\n\n /* \u2500\u2500 VanduoLazyLoad \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const VanduoLazyLoad = {\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * LOW-LEVEL API\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Observe an element. `callback` is invoked once when the element\n * enters the viewport, then the element is automatically unobserved.\n *\n * @param {Element} element\n * @param {function(Element): void} callback\n * @param {{ threshold?: number, rootMargin?: string }} [options]\n */\n observe: function (element, callback, options) {\n if (!(element instanceof Element)) {\n console.warn('[VanduoLazyLoad] observe() requires a DOM Element.');\n return;\n }\n if (typeof callback !== 'function') {\n console.warn('[VanduoLazyLoad] observe() requires a callback function.');\n return;\n }\n // Already observed \u2014 ignore\n if (_observerMap.has(element)) return;\n\n const threshold = (options && options.threshold != null) ? options.threshold : 0;\n const rootMargin = (options && options.rootMargin) ? options.rootMargin : '0px';\n\n const observer = new IntersectionObserver(function (entries, obs) {\n entries.forEach(function (entry) {\n if (entry.isIntersecting) {\n obs.unobserve(entry.target);\n _observerMap.delete(entry.target);\n try {\n callback(entry.target);\n } catch (e) {\n console.error('[VanduoLazyLoad] Callback threw:', e);\n }\n }\n });\n }, { threshold: threshold, rootMargin: rootMargin });\n\n _observerMap.set(element, observer);\n observer.observe(element);\n },\n\n /**\n * Stop observing an element that was previously passed to observe().\n * @param {Element} element\n */\n unobserve: function (element) {\n const observer = _observerMap.get(element);\n if (observer) {\n observer.unobserve(element);\n _observerMap.delete(element);\n }\n },\n\n /**\n * Stop observing ALL currently observed elements.\n */\n unobserveAll: function () {\n _observerMap.forEach(function (observer, element) {\n observer.unobserve(element);\n });\n _observerMap.clear();\n },\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * HIGH-LEVEL API\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Fetch an HTML partial and inject it into `containerEl` when the\n * container enters the viewport. A placeholder is shown immediately.\n *\n * @param {string} url URL of the HTML partial to fetch\n * @param {Element} containerEl Target element whose content will be replaced\n * @param {{\n * placeholder?: 'skeleton'|'spinner'|string,\n * threshold?: number,\n * rootMargin?: string,\n * onLoaded?: function(Element): void,\n * onError?: function(Error): void\n * }} [options]\n */\n loadSection: function (url, containerEl, options) {\n if (typeof url !== 'string' || !url) {\n console.warn('[VanduoLazyLoad] loadSection() requires a non-empty URL string.');\n return;\n }\n if (!(containerEl instanceof Element)) {\n console.warn('[VanduoLazyLoad] loadSection() requires a DOM Element as containerEl.');\n return;\n }\n // Reject cross-origin URLs to prevent SSRF-style fetch abuse\n if (!_isSafeUrl(url)) {\n console.error('[VanduoLazyLoad] loadSection() blocked cross-origin URL:', url);\n return;\n }\n\n const opts = options || {};\n // Placeholders are known-safe static HTML strings built internally\n const placeholderHtml = _resolvePlaceholder(opts.placeholder);\n\n // Use _safeInjectHtml instead of innerHTML to prevent XSS from caller-supplied placeholders\n _safeInjectHtml(containerEl, placeholderHtml);\n _dispatch(containerEl, 'lazysection:loading', { url: url });\n\n // Fetch when visible\n this.observe(containerEl, function () {\n const controller = new window.AbortController();\n const timeoutId = setTimeout(function () { controller.abort(); }, 10000);\n\n window.fetch(url, { signal: controller.signal })\n .then(function (res) {\n clearTimeout(timeoutId);\n if (!res.ok) throw new Error('HTTP ' + res.status);\n return res.text();\n })\n .then(function (html) {\n // Use DOMParser to parse fetched content safely, avoiding\n // raw innerHTML assignment of externally-sourced strings\n _safeInjectHtml(containerEl, html);\n _dispatch(containerEl, 'lazysection:loaded', { url: url });\n // Re-init Vanduo components inside the new content\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.init();\n }\n if (typeof opts.onLoaded === 'function') {\n opts.onLoaded(containerEl);\n }\n })\n .catch(function (err) {\n // Build error node via DOM APIs \u2014 no dynamic HTML strings\n const alertEl = document.createElement('div');\n alertEl.className = 'vd-alert vd-alert-error';\n alertEl.setAttribute('role', 'alert');\n const msgEl = document.createElement('span');\n msgEl.textContent = 'Failed to load content. ';\n const detailEl = document.createElement('small');\n detailEl.style.opacity = '0.7';\n detailEl.textContent = err.message;\n alertEl.appendChild(msgEl);\n alertEl.appendChild(detailEl);\n while (containerEl.firstChild) {\n containerEl.removeChild(containerEl.firstChild);\n }\n containerEl.appendChild(alertEl);\n _dispatch(containerEl, 'lazysection:error', { url: url, error: err });\n console.error('[VanduoLazyLoad] loadSection failed:', err);\n if (typeof opts.onError === 'function') {\n opts.onError(err);\n }\n });\n }, { threshold: opts.threshold, rootMargin: opts.rootMargin });\n },\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * ATTRIBUTE-DRIVEN INIT\n * \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Scan the DOM for [data-vd-lazy] elements and wire them up.\n * Safe to call multiple times \u2014 already-observed elements are skipped.\n */\n init: function () {\n const self = this;\n const elements = document.querySelectorAll('[data-vd-lazy]');\n elements.forEach(function (el) {\n // Skip already-observed or already-loaded elements\n if (_observerMap.has(el) || el.dataset.vdLazyState === 'loading' || el.dataset.vdLazyState === 'loaded') return;\n\n const url = el.getAttribute('data-vd-lazy');\n if (!url) return;\n\n el.dataset.vdLazyState = 'loading';\n const placeholder = el.getAttribute('data-vd-lazy-placeholder') || 'skeleton';\n\n self.loadSection(url, el, {\n placeholder: placeholder,\n onLoaded: function () {\n el.dataset.vdLazyState = 'loaded';\n },\n onError: function () {\n el.dataset.vdLazyState = 'error';\n }\n });\n });\n }\n };\n\n /* \u2500\u2500 Register with Vanduo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('LazyLoad', VanduoLazyLoad);\n }\n\n /* \u2500\u2500 Global convenience alias \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n window.VanduoLazyLoad = VanduoLazyLoad;\n\n})();\n", "/**\n * Vanduo Framework - Glass Scroll Activation\n * Generic scroll-aware glass activation via IntersectionObserver.\n *\n * Usage:\n * Add `data-glass-scroll` to any element carrying a `.vd-glass*` class.\n * By default the previous sibling is used as the sentinel.\n * Point to a custom sentinel via `data-glass-sentinel=\"\"`.\n *\n * Behaviour:\n * - Adds `.is-glass-active` when the sentinel leaves the viewport (scrolled past).\n * - Removes `.is-glass-active` when the sentinel re-enters the viewport.\n */\n(function () {\n 'use strict';\n\n const GlassScroll = {\n /** @type {Map} */\n observers: new Map(),\n\n init: function () {\n document.querySelectorAll('[data-glass-scroll]').forEach(el => {\n if (this.observers.has(el)) return;\n this.initElement(el);\n });\n },\n\n /**\n * Wire up a single scroll-activated glass element.\n * @param {HTMLElement} el\n */\n initElement: function (el) {\n const sentinelSelector = el.dataset.glassSentinel;\n let sentinel;\n\n if (sentinelSelector) {\n sentinel = document.querySelector(sentinelSelector);\n }\n\n if (!sentinel) {\n // Fall back to the previous sibling element\n sentinel = el.previousElementSibling;\n }\n\n if (!sentinel) {\n // No sentinel available \u2014 activate immediately so glass is always shown\n el.classList.add('is-glass-active');\n return;\n }\n\n const observer = new IntersectionObserver(\n (entries) => {\n entries.forEach(entry => {\n // Active when sentinel is NOT intersecting (scrolled past it)\n el.classList.toggle('is-glass-active', !entry.isIntersecting);\n });\n },\n { threshold: 0, rootMargin: '0px' }\n );\n\n observer.observe(sentinel);\n this.observers.set(el, observer);\n },\n\n /**\n * Disconnect and remove a single element's observer.\n * @param {HTMLElement} el\n */\n destroy: function (el) {\n const observer = this.observers.get(el);\n if (observer) {\n observer.disconnect();\n this.observers.delete(el);\n }\n },\n\n destroyAll: function () {\n this.observers.forEach((observer, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('glassScroll', GlassScroll);\n }\n\n window.VanduoGlassScroll = GlassScroll;\n})();\n", "/**\n * Vanduo Framework - Water Morph Effect Component\n * Liquid wave content-swap animation on click\n *\n * Usage:\n * Add .vd-morph or [data-vd-morph] to any element.\n * Provide .vd-morph-content.vd-morph-current and .vd-morph-content.vd-morph-next children.\n * Wave and shine layers are auto-created if absent.\n *\n * JS API:\n * VanduoMorph.morph(el) \u2014 trigger morph programmatically (from center)\n * VanduoMorph.destroy(el) \u2014 tear down listeners for one element\n * VanduoMorph.destroyAll() \u2014 tear down all instances\n */\n\n(function () {\n 'use strict';\n\n const MORPH_DURATION_MS = 750;\n\n const Morph = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-morph, [data-vd-morph]');\n elements.forEach(function (el) {\n if (Morph.instances.has(el)) return;\n if (el.getAttribute('data-vd-morph') === 'manual') return;\n Morph.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n Morph._ensureLayers(el);\n\n const cleanup = [];\n let morphing = false;\n\n const handleClick = function (e) {\n if (morphing) return;\n Morph._runMorph(el, e, function () { morphing = false; });\n morphing = true;\n };\n\n el.addEventListener('click', handleClick);\n cleanup.push(function () { el.removeEventListener('click', handleClick); });\n\n this.instances.set(el, { cleanup: cleanup });\n },\n\n morph: function (el) {\n if (!el) return;\n if (!this.instances.has(el)) this.initInstance(el);\n this._runMorph(el, null, null);\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(function (fn) { fn(); });\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) { Morph.destroy(el); });\n },\n\n /* \u2500\u2500 Internal helpers \u2500\u2500 */\n\n _ensureLayers: function (el) {\n if (!el.querySelector('.vd-morph-wave')) {\n const wave = document.createElement('span');\n wave.className = 'vd-morph-wave';\n wave.setAttribute('aria-hidden', 'true');\n el.insertBefore(wave, el.firstChild);\n }\n if (!el.querySelector('.vd-morph-shine')) {\n const shine = document.createElement('span');\n shine.className = 'vd-morph-shine';\n shine.setAttribute('aria-hidden', 'true');\n const waveEl = el.querySelector('.vd-morph-wave');\n if (waveEl && waveEl.nextSibling) {\n el.insertBefore(shine, waveEl.nextSibling);\n } else {\n el.insertBefore(shine, el.firstChild);\n }\n }\n },\n\n _runMorph: function (el, pointerEvent, onComplete) {\n const wave = el.querySelector('.vd-morph-wave');\n if (wave) {\n const rect = el.getBoundingClientRect();\n const cx = rect.left + rect.width / 2;\n const cy = rect.top + rect.height / 2;\n const px = pointerEvent ? (pointerEvent.clientX || cx) : cx;\n const py = pointerEvent ? (pointerEvent.clientY || cy) : cy;\n wave.style.left = (px - rect.left) + 'px';\n wave.style.top = (py - rect.top) + 'px';\n }\n\n el.classList.add('is-morphing');\n\n let duration = MORPH_DURATION_MS;\n const custom = getComputedStyle(el).getPropertyValue('--morph-duration');\n if (custom) {\n const parsed = parseFloat(custom);\n if (!isNaN(parsed)) duration = parsed * (custom.indexOf('ms') !== -1 ? 1 : 1000);\n }\n\n setTimeout(function () {\n el.classList.remove('is-morphing');\n\n const current = el.querySelector('.vd-morph-current');\n const next = el.querySelector('.vd-morph-next');\n if (current && next) {\n current.classList.remove('vd-morph-current');\n current.classList.add('vd-morph-next');\n next.classList.remove('vd-morph-next');\n next.classList.add('vd-morph-current');\n }\n\n if (typeof onComplete === 'function') onComplete();\n }, duration);\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('morph', Morph);\n }\n\n window.VanduoMorph = Morph;\n\n})();\n", "/**\n * Vanduo Framework \u2014 Expanding flex cards\n * Click / Enter / Space / Arrow keys to change the active card.\n *\n * Usage:\n *
    \n * Use data-vd-expanding-cards=\"manual\" to skip auto-init.\n */\n\n(function () {\n 'use strict';\n\n const ExpandingCards = {\n instances: new Map(),\n\n init: function () {\n document.querySelectorAll('.vd-expanding-cards').forEach(function (el) {\n if (el.getAttribute('data-vd-expanding-cards') === 'manual') return;\n if (ExpandingCards.instances.has(el)) return;\n ExpandingCards.initContainer(el);\n });\n },\n\n initContainer: function (container) {\n const cleanup = [];\n\n const getCards = function () {\n return Array.prototype.slice.call(container.querySelectorAll('.vd-expanding-card'));\n };\n\n const setActive = function (card) {\n const cards = getCards();\n if (!card || cards.indexOf(card) === -1) return;\n cards.forEach(function (c) {\n c.classList.toggle('is-active', c === card);\n });\n card.focus({ preventScroll: true });\n };\n\n const onClick = function (e) {\n const t = e.target;\n const card = t.closest ? t.closest('.vd-expanding-card') : null;\n if (!card || !container.contains(card)) return;\n setActive(card);\n };\n\n const onKeydown = function (e) {\n if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'Home' && e.key !== 'End') {\n return;\n }\n const cards = getCards().filter(function (c) {\n return c.offsetParent !== null || c.getClientRects().length > 0;\n });\n if (!cards.length) return;\n const activeEl = document.activeElement;\n let idx = cards.indexOf(activeEl);\n if (idx < 0) {\n idx = cards.findIndex(function (c) {\n return c.classList.contains('is-active');\n });\n }\n if (idx < 0) idx = 0;\n\n if (e.key === 'ArrowLeft') {\n e.preventDefault();\n setActive(cards[Math.max(0, idx - 1)]);\n } else if (e.key === 'ArrowRight') {\n e.preventDefault();\n setActive(cards[Math.min(cards.length - 1, idx + 1)]);\n } else if (e.key === 'Home') {\n e.preventDefault();\n setActive(cards[0]);\n } else if (e.key === 'End') {\n e.preventDefault();\n setActive(cards[cards.length - 1]);\n }\n };\n\n container.addEventListener('click', onClick);\n cleanup.push(function () {\n container.removeEventListener('click', onClick);\n });\n\n container.addEventListener('keydown', onKeydown);\n cleanup.push(function () {\n container.removeEventListener('keydown', onKeydown);\n });\n\n getCards().forEach(function (card) {\n if (!card.hasAttribute('tabindex')) {\n card.setAttribute('tabindex', '0');\n }\n card.setAttribute('role', 'button');\n if (!card.hasAttribute('aria-pressed')) {\n card.setAttribute('aria-pressed', card.classList.contains('is-active') ? 'true' : 'false');\n }\n });\n\n const syncAria = function () {\n getCards().forEach(function (card) {\n card.setAttribute('aria-pressed', card.classList.contains('is-active') ? 'true' : 'false');\n });\n };\n\n const mo = new MutationObserver(syncAria);\n mo.observe(container, { attributes: true, subtree: true, attributeFilter: ['class'] });\n cleanup.push(function () {\n mo.disconnect();\n });\n syncAria();\n\n ExpandingCards.instances.set(container, { cleanup: cleanup });\n },\n\n destroy: function (container) {\n const inst = this.instances.get(container);\n if (!inst) return;\n inst.cleanup.forEach(function (fn) {\n fn();\n });\n this.instances.delete(container);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) {\n ExpandingCards.destroy(el);\n });\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('expandingCards', ExpandingCards);\n }\n\n window.VanduoExpandingCards = ExpandingCards;\n})();\n", "/**\n * Vanduo Framework \u2014 Timeline animated reveal\n * Opt-in: add `.vd-timeline-animated` to a `.vd-timeline` container.\n * Uses IntersectionObserver to add `.is-revealed` per item with staggered delays.\n * Optional: `.vd-timeline-playback` with `[data-vd-timeline-prev|next|play|pause]` for stepped control.\n */\n\n(function () {\n 'use strict';\n\n const STAGGER_MS = 140;\n const MAX_STAGGER_INDEX = 7;\n const PLAY_INTERVAL_MS = 800;\n\n function countRevealedPrefix(items) {\n let count = 0;\n for (let i = 0; i < items.length; i++) {\n if (!items[i].classList.contains('is-revealed')) break;\n count++;\n }\n return count;\n }\n\n function findPlaybackControls(container) {\n return container.parentElement || document.body;\n }\n\n function initPlayback(container, items, cleanup) {\n items.forEach(function (item) {\n item.classList.remove('is-revealed');\n });\n\n const scope = findPlaybackControls(container);\n const prevBtn = scope.querySelector('[data-vd-timeline-prev]');\n const nextBtn = scope.querySelector('[data-vd-timeline-next]');\n const playBtn = scope.querySelector('[data-vd-timeline-play]');\n const pauseBtn = scope.querySelector('[data-vd-timeline-pause]');\n\n let playTimer = null;\n\n function updateNavButtons() {\n const k = countRevealedPrefix(items);\n const n = items.length;\n if (prevBtn) {\n const atStart = k === 0;\n prevBtn.disabled = atStart;\n prevBtn.setAttribute('aria-disabled', atStart ? 'true' : 'false');\n }\n if (nextBtn) {\n const atEnd = k >= n;\n nextBtn.disabled = atEnd;\n nextBtn.setAttribute('aria-disabled', atEnd ? 'true' : 'false');\n }\n if (playBtn) {\n playBtn.setAttribute('aria-pressed', playTimer ? 'true' : 'false');\n }\n if (pauseBtn) {\n pauseBtn.disabled = !playTimer;\n }\n }\n\n function stepNext() {\n const k = countRevealedPrefix(items);\n if (k < items.length) {\n items[k].classList.add('is-revealed');\n }\n updateNavButtons();\n }\n\n function stepPrev() {\n const k = countRevealedPrefix(items);\n if (k > 0) {\n items[k - 1].classList.remove('is-revealed');\n }\n updateNavButtons();\n }\n\n function play() {\n if (playTimer) return;\n playTimer = setInterval(function () {\n if (countRevealedPrefix(items) >= items.length) {\n pause();\n return;\n }\n stepNext();\n }, PLAY_INTERVAL_MS);\n updateNavButtons();\n }\n\n function pause() {\n if (playTimer) {\n clearInterval(playTimer);\n playTimer = null;\n }\n updateNavButtons();\n }\n\n function addClick(el, fn) {\n if (!el) return;\n const handler = function (e) {\n e.preventDefault();\n fn();\n };\n el.addEventListener('click', handler);\n cleanup.push(function () {\n el.removeEventListener('click', handler);\n });\n }\n\n addClick(prevBtn, stepPrev);\n addClick(nextBtn, stepNext);\n addClick(playBtn, play);\n addClick(pauseBtn, pause);\n\n cleanup.push(function () {\n pause();\n });\n\n updateNavButtons();\n\n return {\n stepNext: stepNext,\n stepPrev: stepPrev,\n play: play,\n pause: pause\n };\n }\n\n const Timeline = {\n instances: new Map(),\n\n init: function () {\n document.querySelectorAll('.vd-timeline.vd-timeline-animated').forEach(function (el) {\n if (Timeline.instances.has(el)) return;\n Timeline.initInstance(el);\n });\n },\n\n reinit: function () {\n Timeline.destroyAll();\n Timeline.init();\n },\n\n initInstance: function (container) {\n const cleanup = [];\n const items = Array.prototype.filter.call(container.children, function (child) {\n return child.classList && child.classList.contains('vd-timeline-item');\n });\n\n items.forEach(function (item, i) {\n const idx = Math.min(i, MAX_STAGGER_INDEX);\n item.style.setProperty('--vd-timeline-reveal-delay', (idx * STAGGER_MS) + 'ms');\n });\n\n const reducedMotion = typeof window.matchMedia === 'function'\n && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n if (reducedMotion) {\n items.forEach(function (item) {\n item.classList.add('is-revealed');\n });\n Timeline.instances.set(container, { cleanup: cleanup });\n return;\n }\n\n const playback = container.classList && container.classList.contains('vd-timeline-playback');\n\n if (playback) {\n const playbackApi = initPlayback(container, items, cleanup);\n Timeline.instances.set(container, { cleanup: cleanup, playback: playbackApi });\n return;\n }\n\n if (typeof IntersectionObserver === 'undefined') {\n items.forEach(function (item) {\n item.classList.add('is-revealed');\n });\n Timeline.instances.set(container, { cleanup: cleanup });\n return;\n }\n\n const observer = new IntersectionObserver(function (entries) {\n entries.forEach(function (entry) {\n if (!entry.isIntersecting) return;\n entry.target.classList.add('is-revealed');\n observer.unobserve(entry.target);\n });\n }, {\n root: null,\n rootMargin: '0px 0px -10% 0px',\n threshold: 0.15\n });\n\n items.forEach(function (item) {\n observer.observe(item);\n });\n\n cleanup.push(function () {\n observer.disconnect();\n });\n\n Timeline.instances.set(container, { cleanup: cleanup });\n },\n\n destroy: function (container) {\n const inst = this.instances.get(container);\n if (!inst) return;\n inst.cleanup.forEach(function (fn) {\n fn();\n });\n this.instances.delete(container);\n },\n\n destroyAll: function () {\n this.instances.forEach(function (_, el) {\n Timeline.destroy(el);\n });\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('timeline', Timeline);\n }\n\n window.VanduoTimeline = Timeline;\n})();\n", "/**\n * Vanduo Framework - Flow (Carousel/Slider) Component\n * Touch-enabled carousel with slide/fade transitions, autoplay, indicators\n */\n\n(function () {\n 'use strict';\n\n const Flow = {\n instances: new Map(),\n\n init: function () {\n const carousels = document.querySelectorAll('.vd-flow, .vd-carousel');\n carousels.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const track = el.querySelector('.vd-flow-track');\n if (!track) return;\n\n const slides = Array.from(track.querySelectorAll('.vd-flow-slide'));\n if (slides.length === 0) return;\n\n const isFade = el.classList.contains('vd-flow-fade');\n const autoplay = el.hasAttribute('data-vd-autoplay');\n const interval = parseInt(el.getAttribute('data-vd-interval'), 10) || 5000;\n const loop = el.getAttribute('data-vd-loop') !== 'false';\n\n const state = {\n current: 0,\n total: slides.length,\n autoplayTimer: null,\n isFade: isFade,\n loop: loop,\n isDragging: false,\n startX: 0,\n currentX: 0,\n threshold: 50\n };\n\n const cleanup = [];\n\n // Set initial active slide\n slides.forEach((slide, i) => {\n slide.setAttribute('role', 'group');\n slide.setAttribute('aria-roledescription', 'slide');\n slide.setAttribute('aria-label', 'Slide ' + (i + 1) + ' of ' + slides.length);\n if (i === 0) slide.classList.add('is-active');\n });\n\n el.setAttribute('role', 'region');\n el.setAttribute('aria-roledescription', 'carousel');\n if (!el.getAttribute('aria-label')) {\n el.setAttribute('aria-label', 'Carousel');\n }\n\n // Live region for announcements\n const liveRegion = document.createElement('div');\n liveRegion.setAttribute('aria-live', 'polite');\n liveRegion.setAttribute('aria-atomic', 'true');\n liveRegion.className = 'sr-only';\n liveRegion.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);';\n el.appendChild(liveRegion);\n\n const goTo = (index, announce) => {\n if (announce === undefined) announce = true;\n let target = index;\n if (state.loop) {\n target = ((index % state.total) + state.total) % state.total;\n } else {\n target = Math.max(0, Math.min(index, state.total - 1));\n }\n\n const prev = state.current;\n state.current = target;\n\n if (state.isFade) {\n slides.forEach((s, i) => {\n s.classList.toggle('is-active', i === target);\n });\n } else {\n track.style.transform = 'translateX(-' + (target * 100) + '%)';\n }\n\n // Update indicators\n const indicators = el.querySelectorAll('.vd-flow-indicator');\n indicators.forEach((ind, i) => {\n ind.classList.toggle('is-active', i === target);\n ind.setAttribute('aria-selected', i === target ? 'true' : 'false');\n });\n\n // Update slide ARIA\n slides.forEach((s, i) => {\n s.setAttribute('aria-hidden', i !== target ? 'true' : 'false');\n });\n\n if (announce) {\n liveRegion.textContent = 'Slide ' + (target + 1) + ' of ' + state.total;\n }\n\n el.dispatchEvent(new CustomEvent('flow:change', {\n detail: { current: target, previous: prev, total: state.total }\n }));\n };\n\n const next = () => goTo(state.current + 1);\n const prev = () => goTo(state.current - 1);\n\n // Controls\n const prevBtn = el.querySelector('.vd-flow-prev');\n const nextBtn = el.querySelector('.vd-flow-next');\n\n if (prevBtn) {\n const h = () => prev();\n prevBtn.addEventListener('click', h);\n cleanup.push(() => prevBtn.removeEventListener('click', h));\n }\n if (nextBtn) {\n const h = () => next();\n nextBtn.addEventListener('click', h);\n cleanup.push(() => nextBtn.removeEventListener('click', h));\n }\n\n // Indicators\n const indicators = el.querySelectorAll('.vd-flow-indicator');\n indicators.forEach((ind, i) => {\n ind.setAttribute('role', 'tab');\n ind.setAttribute('aria-selected', i === 0 ? 'true' : 'false');\n ind.setAttribute('aria-label', 'Go to slide ' + (i + 1));\n const h = () => goTo(i);\n ind.addEventListener('click', h);\n cleanup.push(() => ind.removeEventListener('click', h));\n });\n\n // Keyboard navigation\n const keyHandler = (e) => {\n if (e.key === 'ArrowLeft') { prev(); e.preventDefault(); }\n if (e.key === 'ArrowRight') { next(); e.preventDefault(); }\n };\n el.setAttribute('tabindex', '0');\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n\n // Touch / pointer support\n const pointerDown = (e) => {\n state.isDragging = true;\n state.startX = e.clientX || (e.touches && e.touches[0].clientX) || 0;\n state.currentX = state.startX;\n el.classList.add('is-dragging');\n };\n\n const pointerMove = (e) => {\n if (!state.isDragging) return;\n state.currentX = e.clientX || (e.touches && e.touches[0].clientX) || 0;\n };\n\n const pointerUp = () => {\n if (!state.isDragging) return;\n state.isDragging = false;\n el.classList.remove('is-dragging');\n const diff = state.startX - state.currentX;\n if (Math.abs(diff) > state.threshold) {\n if (diff > 0) next();\n else prev();\n }\n };\n\n el.addEventListener('mousedown', pointerDown);\n el.addEventListener('mousemove', pointerMove);\n el.addEventListener('mouseup', pointerUp);\n el.addEventListener('mouseleave', pointerUp);\n el.addEventListener('touchstart', pointerDown, { passive: true });\n el.addEventListener('touchmove', pointerMove, { passive: true });\n el.addEventListener('touchend', pointerUp);\n\n cleanup.push(\n () => el.removeEventListener('mousedown', pointerDown),\n () => el.removeEventListener('mousemove', pointerMove),\n () => el.removeEventListener('mouseup', pointerUp),\n () => el.removeEventListener('mouseleave', pointerUp),\n () => el.removeEventListener('touchstart', pointerDown),\n () => el.removeEventListener('touchmove', pointerMove),\n () => el.removeEventListener('touchend', pointerUp)\n );\n\n // Autoplay\n const startAutoplay = () => {\n stopAutoplay();\n state.autoplayTimer = setInterval(next, interval);\n };\n\n const stopAutoplay = () => {\n if (state.autoplayTimer) {\n clearInterval(state.autoplayTimer);\n state.autoplayTimer = null;\n }\n };\n\n if (autoplay) {\n startAutoplay();\n const pauseHandler = () => stopAutoplay();\n const resumeHandler = () => startAutoplay();\n el.addEventListener('mouseenter', pauseHandler);\n el.addEventListener('mouseleave', resumeHandler);\n el.addEventListener('focusin', pauseHandler);\n el.addEventListener('focusout', resumeHandler);\n cleanup.push(\n () => el.removeEventListener('mouseenter', pauseHandler),\n () => el.removeEventListener('mouseleave', resumeHandler),\n () => el.removeEventListener('focusin', pauseHandler),\n () => el.removeEventListener('focusout', resumeHandler),\n () => stopAutoplay()\n );\n }\n\n // Initial ARIA state\n goTo(0, false);\n\n this.instances.set(el, {\n cleanup: cleanup,\n goTo: goTo,\n next: next,\n prev: prev,\n getState: () => ({ ...state })\n });\n },\n\n goTo: function (el, index) {\n const instance = this.instances.get(el);\n if (instance) instance.goTo(index);\n },\n\n next: function (el) {\n const instance = this.instances.get(el);\n if (instance) instance.next();\n },\n\n prev: function (el) {\n const instance = this.instances.get(el);\n if (instance) instance.prev();\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('flow', Flow);\n }\n\n window.VanduoFlow = Flow;\n\n})();\n", "/**\n * Vanduo Framework - Bubble (Popover) Component\n * Click-triggered rich HTML popover, reuses tooltip positioning concepts\n */\n\n(function () {\n 'use strict';\n\n const Bubble = {\n instances: new Map(),\n _globalCleanups: [],\n\n init: function () {\n const triggers = document.querySelectorAll('[data-vd-bubble], [data-vd-popover]');\n triggers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n\n if (this._globalCleanups.length === 0) {\n const outsideClick = (e) => {\n this.instances.forEach((inst, trigger) => {\n if (!inst.popover.contains(e.target) && !trigger.contains(e.target)) {\n this.hide(trigger);\n }\n });\n };\n const escHandler = (e) => {\n if (e.key === 'Escape') {\n this.instances.forEach((_, trigger) => this.hide(trigger));\n }\n };\n document.addEventListener('click', outsideClick, true);\n document.addEventListener('keydown', escHandler);\n this._globalCleanups.push(\n () => document.removeEventListener('click', outsideClick, true),\n () => document.removeEventListener('keydown', escHandler)\n );\n }\n },\n\n initInstance: function (trigger) {\n const cleanup = [];\n const placement = trigger.getAttribute('data-vd-bubble-placement') ||\n trigger.getAttribute('data-vd-popover-placement') || 'bottom';\n\n // Build popover element\n const popover = document.createElement('div');\n popover.className = 'vd-bubble-content';\n popover.setAttribute('role', 'dialog');\n popover.setAttribute('aria-modal', 'false');\n popover.setAttribute('data-placement', placement);\n\n const title = trigger.getAttribute('data-vd-bubble-title') ||\n trigger.getAttribute('data-vd-popover-title');\n const content = trigger.getAttribute('data-vd-bubble') ||\n trigger.getAttribute('data-vd-popover') || '';\n const htmlContent = trigger.getAttribute('data-vd-bubble-html') ||\n trigger.getAttribute('data-vd-popover-html');\n const allowSvg = trigger.hasAttribute('data-vd-bubble-allow-svg') ||\n trigger.hasAttribute('data-vd-popover-allow-svg');\n\n if (title) {\n const header = document.createElement('div');\n header.className = 'vd-bubble-header';\n const titleSpan = document.createElement('span');\n titleSpan.textContent = title;\n const closeBtn = document.createElement('button');\n closeBtn.className = 'vd-bubble-close';\n closeBtn.setAttribute('aria-label', 'Close');\n closeBtn.innerHTML = '×';\n header.appendChild(titleSpan);\n header.appendChild(closeBtn);\n popover.appendChild(header);\n\n const closeHandler = (e) => { e.stopPropagation(); this.hide(trigger); };\n closeBtn.addEventListener('click', closeHandler);\n cleanup.push(() => closeBtn.removeEventListener('click', closeHandler));\n }\n\n const body = document.createElement('div');\n body.className = 'vd-bubble-body';\n if (htmlContent) {\n if (typeof sanitizeHtml === 'function') {\n body.innerHTML = sanitizeHtml(htmlContent, { allowSvg });\n } else {\n body.textContent = htmlContent;\n }\n } else {\n body.textContent = content;\n }\n popover.appendChild(body);\n\n document.body.appendChild(popover);\n\n // ARIA on trigger\n const popId = 'vd-bubble-' + Math.random().toString(36).slice(2, 9);\n popover.id = popId;\n trigger.setAttribute('aria-haspopup', 'dialog');\n trigger.setAttribute('aria-expanded', 'false');\n trigger.setAttribute('aria-controls', popId);\n\n // Toggle on click\n const toggleHandler = (e) => {\n e.stopPropagation();\n if (popover.classList.contains('is-visible')) {\n this.hide(trigger);\n } else {\n this.hideAll();\n this.show(trigger);\n }\n };\n trigger.addEventListener('click', toggleHandler);\n cleanup.push(() => trigger.removeEventListener('click', toggleHandler));\n\n this.instances.set(trigger, { popover, cleanup, placement });\n },\n\n position: function (trigger, popover, placement) {\n const rect = trigger.getBoundingClientRect();\n const popRect = popover.getBoundingClientRect();\n const gap = 10;\n let top, left;\n\n switch (placement) {\n case 'top':\n top = rect.top - popRect.height - gap + window.scrollY;\n left = rect.left + (rect.width - popRect.width) / 2 + window.scrollX;\n break;\n case 'left':\n top = rect.top + (rect.height - popRect.height) / 2 + window.scrollY;\n left = rect.left - popRect.width - gap + window.scrollX;\n break;\n case 'right':\n top = rect.top + (rect.height - popRect.height) / 2 + window.scrollY;\n left = rect.right + gap + window.scrollX;\n break;\n default: // bottom\n top = rect.bottom + gap + window.scrollY;\n left = rect.left + (rect.width - popRect.width) / 2 + window.scrollX;\n }\n\n // Clamp to viewport\n left = Math.max(8, Math.min(left, window.innerWidth - popRect.width - 8));\n top = Math.max(8, top);\n\n popover.style.top = top + 'px';\n popover.style.left = left + 'px';\n },\n\n show: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n const { popover, placement } = instance;\n\n popover.style.display = 'block';\n popover.classList.add('is-visible');\n trigger.setAttribute('aria-expanded', 'true');\n\n requestAnimationFrame(() => {\n this.position(trigger, popover, placement);\n });\n\n trigger.dispatchEvent(new CustomEvent('bubble:show', {\n bubbles: true,\n detail: { trigger: trigger, placement: placement }\n }));\n },\n\n hide: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n instance.popover.classList.remove('is-visible');\n trigger.setAttribute('aria-expanded', 'false');\n trigger.dispatchEvent(new CustomEvent('bubble:hide', {\n bubbles: true,\n detail: { trigger: trigger }\n }));\n },\n\n hideAll: function () {\n this.instances.forEach((_, trigger) => this.hide(trigger));\n },\n\n destroy: function (trigger) {\n const instance = this.instances.get(trigger);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n if (instance.popover.parentNode) {\n instance.popover.parentNode.removeChild(instance.popover);\n }\n trigger.removeAttribute('aria-haspopup');\n trigger.removeAttribute('aria-expanded');\n trigger.removeAttribute('aria-controls');\n this.instances.delete(trigger);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, trigger) => this.destroy(trigger));\n this._globalCleanups.forEach(fn => fn());\n this._globalCleanups = [];\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('bubble', Bubble);\n }\n\n window.VanduoBubble = Bubble;\n\n})();\n", "/**\n * Vanduo Framework - Waypoint (Scrollspy) Component\n * Highlights navigation links based on scroll position using IntersectionObserver\n */\n\n(function () {\n 'use strict';\n\n const Waypoint = {\n instances: new Map(),\n\n init: function () {\n const navs = document.querySelectorAll('[data-vd-waypoint-nav], [data-vd-scrollspy-nav]');\n navs.forEach(nav => {\n if (this.instances.has(nav)) return;\n this.initInstance(nav);\n });\n },\n\n initInstance: function (nav) {\n const links = Array.from(nav.querySelectorAll('a[href^=\"#\"]'));\n if (links.length === 0) return;\n\n const cleanup = [];\n const offset = parseInt(nav.getAttribute('data-vd-waypoint-offset') || '80', 10);\n const sections = [];\n\n links.forEach(link => {\n const id = link.getAttribute('href').slice(1);\n const section = document.getElementById(id);\n if (section) {\n section.setAttribute('data-vd-waypoint-section', '');\n sections.push({ id, link, section });\n }\n });\n\n if (sections.length === 0) return;\n\n const activeSections = new Set();\n\n const setActive = (id) => {\n links.forEach(l => l.classList.remove('is-active'));\n const target = links.find(l => l.getAttribute('href') === '#' + id);\n if (target) {\n target.classList.add('is-active');\n nav.dispatchEvent(new CustomEvent('waypoint:change', {\n detail: { activeId: id, link: target }\n }));\n }\n };\n\n const rootMargin = '-' + offset + 'px 0px -40% 0px';\n\n const observer = new IntersectionObserver((entries) => {\n entries.forEach(entry => {\n if (entry.isIntersecting) {\n activeSections.add(entry.target.id);\n } else {\n activeSections.delete(entry.target.id);\n }\n });\n\n // Pick the topmost visible section\n for (let i = 0; i < sections.length; i++) {\n if (activeSections.has(sections[i].id)) {\n setActive(sections[i].id);\n return;\n }\n }\n }, {\n rootMargin: rootMargin,\n threshold: 0\n });\n\n sections.forEach(s => observer.observe(s.section));\n\n // Smooth scroll on click\n links.forEach(link => {\n const clickHandler = (e) => {\n e.preventDefault();\n const id = link.getAttribute('href').slice(1);\n const section = document.getElementById(id);\n if (section) {\n section.scrollIntoView({ behavior: 'smooth' });\n setActive(id);\n }\n };\n link.addEventListener('click', clickHandler);\n cleanup.push(() => link.removeEventListener('click', clickHandler));\n });\n\n cleanup.push(() => observer.disconnect());\n\n this.instances.set(nav, { observer, cleanup, sections, setActive });\n },\n\n refresh: function (nav) {\n this.destroy(nav);\n this.initInstance(nav);\n },\n\n destroy: function (nav) {\n const instance = this.instances.get(nav);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(nav);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, nav) => this.destroy(nav));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('waypoint', Waypoint);\n }\n\n window.VanduoWaypoint = Waypoint;\n\n})();\n", "/**\n * Vanduo Framework - Ripple (Waves Effect) Component\n * Adds expanding circle animation on click at pointer position\n */\n\n(function () {\n 'use strict';\n\n const Ripple = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-ripple, [data-vd-ripple]');\n elements.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n\n const createWave = (e) => {\n const rect = el.getBoundingClientRect();\n const size = Math.max(rect.width, rect.height);\n const x = (e.clientX || (e.touches && e.touches[0].clientX) || rect.left + rect.width / 2) - rect.left - size / 2;\n const y = (e.clientY || (e.touches && e.touches[0].clientY) || rect.top + rect.height / 2) - rect.top - size / 2;\n\n const wave = document.createElement('span');\n wave.className = 'vd-ripple-wave';\n wave.style.width = size + 'px';\n wave.style.height = size + 'px';\n wave.style.left = x + 'px';\n wave.style.top = y + 'px';\n\n el.appendChild(wave);\n\n wave.addEventListener('animationend', () => {\n if (wave.parentNode) wave.parentNode.removeChild(wave);\n });\n };\n\n el.addEventListener('mousedown', createWave);\n el.addEventListener('touchstart', createWave, { passive: true });\n\n cleanup.push(\n () => el.removeEventListener('mousedown', createWave),\n () => el.removeEventListener('touchstart', createWave)\n );\n\n this.instances.set(el, { cleanup });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n // Remove any lingering wave elements\n el.querySelectorAll('.vd-ripple-wave').forEach(w => w.remove());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('ripple', Ripple);\n }\n\n window.VanduoRipple = Ripple;\n\n})();\n", "/**\n * Vanduo Framework - Affix (Sticky) Component\n * Uses IntersectionObserver to toggle .is-stuck within the nearest scrollable parent\n */\n\n(function () {\n 'use strict';\n\n function isScrollable(element) {\n if (!element || element === document.body) return false;\n\n const style = window.getComputedStyle(element);\n const overflowY = style.overflowY;\n const overflowX = style.overflowX;\n const canScrollY = /(auto|scroll|overlay)/.test(overflowY) && element.scrollHeight > element.clientHeight;\n const canScrollX = /(auto|scroll|overlay)/.test(overflowX) && element.scrollWidth > element.clientWidth;\n\n return canScrollY || canScrollX;\n }\n\n function getScrollParent(element) {\n let parent = element.parentElement;\n\n while (parent && parent !== document.body && parent !== document.documentElement) {\n if (isScrollable(parent)) return parent;\n parent = parent.parentElement;\n }\n\n return null;\n }\n\n const Affix = {\n instances: new Map(),\n\n init: function () {\n const elements = document.querySelectorAll('.vd-affix, .vd-sticky, [data-vd-affix]');\n elements.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const parsedOffset = parseInt(el.getAttribute('data-vd-affix-offset') || '0', 10);\n const offset = Number.isNaN(parsedOffset) ? 0 : parsedOffset;\n const scrollParent = getScrollParent(el);\n let isStuck = false;\n\n const sentinel = document.createElement('div');\n sentinel.style.cssText = 'display:block;height:1px;margin-bottom:-1px;visibility:hidden;pointer-events:none;';\n el.parentNode.insertBefore(sentinel, el);\n\n el.style.setProperty('--affix-top-offset', offset + 'px');\n\n function stick() {\n if (isStuck) return;\n isStuck = true;\n el.classList.add('is-stuck');\n el.dispatchEvent(new CustomEvent('affix:stuck', {\n bubbles: true,\n detail: {\n offset: offset,\n root: scrollParent || window\n }\n }));\n }\n\n function unstick() {\n if (!isStuck) return;\n isStuck = false;\n el.classList.remove('is-stuck');\n el.dispatchEvent(new CustomEvent('affix:unstuck', {\n bubbles: true,\n detail: {\n offset: offset,\n root: scrollParent || window\n }\n }));\n }\n\n const observer = new IntersectionObserver(function (entries) {\n entries.forEach(entry => {\n if (!entry.isIntersecting) {\n stick();\n } else {\n unstick();\n }\n });\n }, {\n root: scrollParent,\n rootMargin: '-' + offset + 'px 0px 0px 0px',\n threshold: 0\n });\n\n observer.observe(sentinel);\n\n cleanup.push(\n () => observer.disconnect(),\n () => { if (sentinel.parentNode) sentinel.parentNode.removeChild(sentinel); },\n () => {\n el.classList.remove('is-stuck');\n el.style.removeProperty('--affix-top-offset');\n }\n );\n\n this.instances.set(el, { cleanup, observer, sentinel, scrollParent });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n el.classList.remove('is-stuck');\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('affix', Affix);\n }\n\n window.VanduoAffix = Affix;\n\n})();\n", "/**\n * Vanduo Framework - Suggest (Autocomplete) Component\n * Dropdown suggestion list with keyboard navigation, static/async data sources\n */\n\n(function () {\n 'use strict';\n\n /**\n * Escape HTML entities to prevent XSS when inserting into innerHTML\n * @param {string} text\n * @returns {string}\n */\n function _escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Allow same-origin URLs by default, or an explicit allowlist of origins.\n * @param {string} url\n * @param {string[]} allowlist\n * @returns {boolean}\n */\n function _isSafeUrl(url, allowlist) {\n try {\n const resolved = new URL(url, window.location.href);\n if (resolved.origin === window.location.origin) return true;\n return allowlist.includes(resolved.origin);\n } catch (_e) {\n return false;\n }\n }\n\n const Suggest = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-suggest], [data-vd-autocomplete]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const minChars = parseInt(input.getAttribute('data-vd-suggest-min-chars') || '1', 10);\n const url = input.getAttribute('data-vd-suggest-url') || '';\n const allowlistAttr = input.getAttribute('data-vd-suggest-allowlist') || '';\n const allowlist = allowlistAttr\n .split(',')\n .map(value => value.trim())\n .filter(Boolean);\n const staticData = input.getAttribute('data-vd-suggest') || input.getAttribute('data-vd-autocomplete') || '';\n let items = [];\n\n try { items = JSON.parse(staticData); } catch (_e) {\n items = staticData.split(',').map(s => s.trim()).filter(Boolean);\n }\n\n // Wrap input if not already wrapped\n let wrapper = input.closest('.vd-suggest-wrapper, .vd-autocomplete-wrapper');\n if (!wrapper) {\n wrapper = document.createElement('div');\n wrapper.className = 'vd-suggest-wrapper';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n }\n\n // Create dropdown list\n const list = document.createElement('ul');\n list.className = 'vd-suggest-list';\n list.setAttribute('role', 'listbox');\n const listId = 'vd-suggest-' + Math.random().toString(36).slice(2, 9);\n list.id = listId;\n wrapper.appendChild(list);\n\n // ARIA\n input.setAttribute('role', 'combobox');\n input.setAttribute('aria-autocomplete', 'list');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('aria-controls', listId);\n input.setAttribute('autocomplete', 'off');\n\n let highlighted = -1;\n let currentItems = [];\n let debounceTimer = null;\n\n const renderItems = (filtered, query) => {\n list.innerHTML = '';\n currentItems = filtered;\n highlighted = -1;\n\n if (filtered.length === 0) {\n const empty = document.createElement('li');\n empty.className = 'vd-suggest-empty';\n empty.textContent = 'No results';\n list.appendChild(empty);\n return;\n }\n\n filtered.forEach((item, i) => {\n const li = document.createElement('li');\n li.className = 'vd-suggest-item';\n li.setAttribute('role', 'option');\n li.id = listId + '-item-' + i;\n\n const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);\n if (query) {\n // Escape HTML first to prevent XSS, then highlight matches in the safe string\n const escaped = _escapeHtml(text);\n const re = new RegExp('(' + query.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi');\n li.innerHTML = escaped.replace(re, '$1');\n } else {\n li.textContent = text;\n }\n\n li.addEventListener('click', () => selectItem(i));\n list.appendChild(li);\n });\n };\n\n const open = () => {\n list.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n };\n\n const close = () => {\n list.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n highlighted = -1;\n input.removeAttribute('aria-activedescendant');\n };\n\n const selectItem = (index) => {\n const item = currentItems[index];\n const value = typeof item === 'object' ? (item.value || item.label || String(item)) : String(item);\n input.value = value;\n close();\n input.dispatchEvent(new CustomEvent('suggest:select', {\n detail: { value, item, index },\n bubbles: true\n }));\n };\n\n const highlight = (index) => {\n const listItems = list.querySelectorAll('.vd-suggest-item');\n listItems.forEach(li => li.classList.remove('is-highlighted'));\n if (index >= 0 && index < listItems.length) {\n highlighted = index;\n listItems[index].classList.add('is-highlighted');\n input.setAttribute('aria-activedescendant', listItems[index].id);\n listItems[index].scrollIntoView({ block: 'nearest' });\n }\n };\n\n const doSearch = async (query) => {\n if (query.length < minChars) { close(); return; }\n\n let filtered;\n if (url) {\n try {\n if (!_isSafeUrl(url, allowlist)) {\n console.warn('[VanduoSuggest] Blocked non-allowlisted URL:', url);\n filtered = [];\n } else {\n const separator = url.includes('?') ? '&' : '?';\n const res = await window.fetch(url + separator + 'q=' + encodeURIComponent(query));\n filtered = await res.json();\n }\n } catch (_e) {\n filtered = [];\n }\n } else {\n const lower = query.toLowerCase();\n filtered = items.filter(item => {\n const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);\n return text.toLowerCase().includes(lower);\n });\n }\n\n renderItems(filtered, query);\n if (filtered.length > 0) open();\n else open(); // show \"no results\"\n };\n\n const inputHandler = () => {\n clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => doSearch(input.value), 200);\n };\n\n const keyHandler = (e) => {\n if (!list.classList.contains('is-open')) {\n if (e.key === 'ArrowDown') { doSearch(input.value); e.preventDefault(); }\n return;\n }\n\n const total = currentItems.length;\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n highlight(highlighted < total - 1 ? highlighted + 1 : 0);\n break;\n case 'ArrowUp':\n e.preventDefault();\n highlight(highlighted > 0 ? highlighted - 1 : total - 1);\n break;\n case 'Enter':\n e.preventDefault();\n if (highlighted >= 0) selectItem(highlighted);\n break;\n case 'Escape':\n close();\n break;\n }\n };\n\n const blurHandler = () => {\n setTimeout(close, 200);\n };\n\n input.addEventListener('input', inputHandler);\n input.addEventListener('keydown', keyHandler);\n input.addEventListener('blur', blurHandler);\n input.addEventListener('focus', () => { if (input.value.length >= minChars) doSearch(input.value); });\n\n cleanup.push(\n () => input.removeEventListener('input', inputHandler),\n () => input.removeEventListener('keydown', keyHandler),\n () => input.removeEventListener('blur', blurHandler),\n () => clearTimeout(debounceTimer),\n () => { if (list.parentNode) list.parentNode.removeChild(list); }\n );\n\n this.instances.set(input, { cleanup, list, close });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('suggest', Suggest);\n }\n\n window.VanduoSuggest = Suggest;\n\n})();\n", "/**\n * Vanduo Framework - Validate (Form Validation) Component\n * Declarative validation via data attributes with real-time and on-submit modes\n */\n\n(function () {\n 'use strict';\n\n const Validate = {\n instances: new Map(),\n\n rules: {\n required: (value) => value.trim().length > 0,\n email: (value) => /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(value),\n url: (value) => { try { new URL(value); return true; } catch (_e) { return false; } },\n number: (value) => !isNaN(parseFloat(value)) && isFinite(value),\n min: (value, param) => value.length >= parseInt(param, 10),\n max: (value, param) => value.length <= parseInt(param, 10),\n minVal: (value, param) => parseFloat(value) >= parseFloat(param),\n maxVal: (value, param) => parseFloat(value) <= parseFloat(param),\n pattern: (value, param) => {\n try {\n // Cap regex length to prevent ReDoS from excessively complex patterns\n if (param.length > 100) return false;\n return new RegExp(param).test(value);\n } catch (_e) { return false; }\n },\n match: (value, param) => {\n try {\n const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(param) : param;\n const other = document.querySelector('[name=\"' + escaped + '\"]');\n return other ? value === other.value : false;\n } catch (_e) {\n return false;\n }\n }\n },\n\n messages: {\n required: 'This field is required',\n email: 'Please enter a valid email address',\n url: 'Please enter a valid URL',\n number: 'Please enter a valid number',\n min: 'Minimum {0} characters required',\n max: 'Maximum {0} characters allowed',\n minVal: 'Value must be at least {0}',\n maxVal: 'Value must be at most {0}',\n pattern: 'Invalid format',\n match: 'Fields do not match'\n },\n\n init: function () {\n const forms = document.querySelectorAll('[data-vd-validate], .vd-validate');\n forms.forEach(form => {\n if (this.instances.has(form)) return;\n this.initInstance(form);\n });\n },\n\n initInstance: function (form) {\n const cleanup = [];\n const mode = form.getAttribute('data-vd-validate-mode') || 'blur'; // blur | input | submit\n const fields = form.querySelectorAll('[data-vd-rules]');\n\n const validateField = (field) => {\n const rulesStr = field.getAttribute('data-vd-rules') || '';\n const rules = rulesStr.split('|').map(r => r.trim()).filter(Boolean);\n const value = field.value;\n const errors = [];\n\n for (const rule of rules) {\n const [name, ...params] = rule.split(':');\n const param = params.join(':');\n const validator = this.rules[name];\n\n if (validator && !validator(value, param)) {\n const customMsg = field.getAttribute('data-vd-msg-' + name);\n let msg = customMsg || this.messages[name] || 'Invalid';\n if (param) msg = msg.replace('{0}', param);\n errors.push(msg);\n break; // one error at a time\n }\n }\n\n this.setFieldState(field, errors);\n return errors.length === 0;\n };\n\n const validateAll = () => {\n let valid = true;\n fields.forEach(field => {\n if (!validateField(field)) valid = false;\n });\n return valid;\n };\n\n // Per-field listeners\n fields.forEach(field => {\n if (mode === 'input' || mode === 'blur') {\n const eventType = mode === 'input' ? 'input' : 'blur';\n const handler = () => validateField(field);\n field.addEventListener(eventType, handler);\n cleanup.push(() => field.removeEventListener(eventType, handler));\n\n if (mode === 'blur') {\n const inputClear = () => {\n if (field.classList.contains('is-invalid') || field.classList.contains('is-valid')) {\n validateField(field);\n }\n };\n field.addEventListener('input', inputClear);\n cleanup.push(() => field.removeEventListener('input', inputClear));\n }\n }\n });\n\n // Form submit\n const submitHandler = (e) => {\n const valid = validateAll();\n if (!valid) {\n e.preventDefault();\n e.stopPropagation();\n // Focus first invalid field\n const firstInvalid = form.querySelector('.is-invalid');\n if (firstInvalid) firstInvalid.focus();\n }\n form.dispatchEvent(new CustomEvent('validate:submit', {\n detail: { valid },\n bubbles: true\n }));\n };\n\n form.addEventListener('submit', submitHandler);\n cleanup.push(() => form.removeEventListener('submit', submitHandler));\n\n this.instances.set(form, { cleanup, validateAll, validateField });\n },\n\n setFieldState: function (field, errors) {\n const wrapper = field.closest('.vd-form-group') || field.parentElement;\n let errorEl = wrapper.querySelector('.vd-validate-error');\n\n field.classList.remove('is-valid', 'is-invalid');\n\n if (errors.length > 0) {\n field.classList.add('is-invalid');\n field.setAttribute('aria-invalid', 'true');\n if (!errorEl) {\n errorEl = document.createElement('div');\n errorEl.className = 'vd-validate-error';\n errorEl.id = 'vd-err-' + Math.random().toString(36).slice(2, 9);\n errorEl.setAttribute('role', 'alert');\n wrapper.appendChild(errorEl);\n }\n errorEl.textContent = errors[0];\n errorEl.style.display = '';\n field.setAttribute('aria-describedby', errorEl.id);\n } else if (field.value.trim()) {\n field.classList.add('is-valid');\n field.removeAttribute('aria-invalid');\n if (errorEl) errorEl.style.display = 'none';\n } else {\n field.removeAttribute('aria-invalid');\n if (errorEl) errorEl.style.display = 'none';\n }\n },\n\n validateForm: function (form) {\n const instance = this.instances.get(form);\n return instance ? instance.validateAll() : false;\n },\n\n addRule: function (name, validator, message) {\n this.rules[name] = validator;\n if (message) this.messages[name] = message;\n },\n\n destroy: function (form) {\n const instance = this.instances.get(form);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(form);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, form) => this.destroy(form));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('validate', Validate);\n }\n\n window.VanduoValidate = Validate;\n\n})();\n", "/**\n * Vanduo Framework - Datepicker Component\n * Calendar popup attached to input field with month/year navigation\n */\n\n(function () {\n 'use strict';\n\n const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];\n const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];\n\n function escapeRegexChar(c) {\n return c.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n }\n\n function buildParseFormat(format) {\n let regex = '^';\n const order = [];\n let i = 0;\n while (i < format.length) {\n const slice = format.slice(i);\n if (slice.toLowerCase().startsWith('yyyy')) {\n regex += '(\\\\d{4})';\n order.push('y');\n i += 4;\n } else if (slice.toLowerCase().startsWith('mm')) {\n regex += '(\\\\d{2})';\n order.push('m');\n i += 2;\n } else if (slice.toLowerCase().startsWith('dd')) {\n regex += '(\\\\d{2})';\n order.push('d');\n i += 2;\n } else {\n regex += escapeRegexChar(format[i]);\n i++;\n }\n }\n regex += '$';\n return { regex: new RegExp(regex), order };\n }\n\n function parseDateFromFormat(value, format) {\n if (!value || !format) return null;\n const { regex, order } = buildParseFormat(format);\n const m = value.trim().match(regex);\n if (!m) return null;\n let y;\n let mo;\n let d;\n let ci = 1;\n for (let k = 0; k < order.length; k++) {\n const part = order[k];\n const v = parseInt(m[ci++], 10);\n if (Number.isNaN(v)) return null;\n if (part === 'y') y = v;\n else if (part === 'm') mo = v - 1;\n else if (part === 'd') d = v;\n }\n if (y === undefined || mo === undefined || d === undefined) return null;\n const dt = new Date(y, mo, d);\n if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== d) return null;\n return dt;\n }\n\n function formatDate(d, format) {\n const yyyy = String(d.getFullYear());\n const mm = String(d.getMonth() + 1).padStart(2, '0');\n const dd = String(d.getDate()).padStart(2, '0');\n let out = '';\n let i = 0;\n while (i < format.length) {\n const slice = format.slice(i);\n if (slice.toLowerCase().startsWith('yyyy')) {\n out += yyyy;\n i += 4;\n } else if (slice.toLowerCase().startsWith('mm')) {\n out += mm;\n i += 2;\n } else if (slice.toLowerCase().startsWith('dd')) {\n out += dd;\n i += 2;\n } else {\n out += format[i];\n i++;\n }\n }\n return out;\n }\n\n function dateKey(d) {\n return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');\n }\n\n function addDays(d, n) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n x.setDate(x.getDate() + n);\n return x;\n }\n\n function addMonthsClamped(d, n) {\n return new Date(d.getFullYear(), d.getMonth() + n, d.getDate());\n }\n\n function parseYmdLocal(ymd) {\n if (!ymd || typeof ymd !== 'string') return null;\n const m = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(ymd.trim());\n if (!m) return null;\n const y = +m[1];\n const mo = +m[2] - 1;\n const day = +m[3];\n const dt = new Date(y, mo, day);\n if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== day) return null;\n return dt;\n }\n\n function startOfWeekSunday(d) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const day = x.getDay();\n x.setDate(x.getDate() - day);\n return x;\n }\n\n function endOfWeekSunday(d) {\n const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const day = x.getDay();\n x.setDate(x.getDate() + (6 - day));\n return x;\n }\n\n const Datepicker = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-datepicker]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const format = input.getAttribute('data-vd-datepicker-format') || 'YYYY-MM-DD';\n const minStr = input.getAttribute('data-vd-datepicker-min');\n const maxStr = input.getAttribute('data-vd-datepicker-max');\n const minDate = minStr ? parseYmdLocal(minStr) : null;\n const maxDate = maxStr ? parseYmdLocal(maxStr) : null;\n\n const today = new Date();\n let viewYear = today.getFullYear();\n let viewMonth = today.getMonth();\n let selectedDate = null;\n let viewMode = 'days'; // days | months | years\n let focusedDate = null;\n /** Prevents focus() after close from immediately re-opening the popup */\n let skipNextFocusOpen = false;\n\n const isDisabled = (d) => {\n if (minDate) {\n const t = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n if (t < minDate.getTime()) return true;\n }\n if (maxDate) {\n const t = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n if (t > maxDate.getTime()) return true;\n }\n return false;\n };\n\n const ensureMonthInRange = (y, m) => {\n if (!minDate && !maxDate) return { y: y, m: m };\n const first = new Date(y, m, 1);\n const last = new Date(y, m + 1, 0);\n if (minDate && last.getTime() < minDate.getTime()) {\n return { y: minDate.getFullYear(), m: minDate.getMonth() };\n }\n if (maxDate && first.getTime() > maxDate.getTime()) {\n return { y: maxDate.getFullYear(), m: maxDate.getMonth() };\n }\n return { y: y, m: m };\n };\n\n const firstSelectableInMonth = (y, m) => {\n const last = new Date(y, m + 1, 0).getDate();\n for (let day = 1; day <= last; day++) {\n const dt = new Date(y, m, day);\n if (!isDisabled(dt)) return dt;\n }\n return new Date(y, m, 1);\n };\n\n if (input.value) {\n const trimmed = input.value.trim();\n let parsed = parseDateFromFormat(trimmed, format);\n if (!parsed) {\n const fallback = new Date(trimmed);\n if (!isNaN(fallback.getTime())) parsed = fallback;\n }\n if (parsed) {\n selectedDate = parsed;\n viewYear = parsed.getFullYear();\n viewMonth = parsed.getMonth();\n }\n }\n\n const clampedInit = ensureMonthInRange(viewYear, viewMonth);\n viewYear = clampedInit.y;\n viewMonth = clampedInit.m;\n\n // Create popup\n const popup = document.createElement('div');\n popup.className = 'vd-datepicker-popup';\n popup.setAttribute('role', 'dialog');\n popup.setAttribute('aria-label', 'Choose date');\n popup.tabIndex = -1;\n\n const wrapper = document.createElement('div');\n wrapper.className = 'vd-suggest-wrapper';\n wrapper.style.position = 'relative';\n wrapper.style.display = 'inline-block';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n wrapper.appendChild(popup);\n\n const isSameDay = (a, b) => a && b &&\n a.getFullYear() === b.getFullYear() &&\n a.getMonth() === b.getMonth() &&\n a.getDate() === b.getDate();\n\n const selectDate = (date) => {\n selectedDate = date;\n viewYear = date.getFullYear();\n viewMonth = date.getMonth();\n input.value = formatDate(date, format);\n skipNextFocusOpen = true;\n close();\n input.dispatchEvent(new CustomEvent('datepicker:select', {\n detail: { date: date, formatted: input.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n input.focus();\n };\n\n const focusFocusedDay = () => {\n if (viewMode !== 'days' || !focusedDate) return;\n const key = dateKey(focusedDate);\n const btn = popup.querySelector('[data-vd-date=\"' + key + '\"]');\n if (btn && !btn.classList.contains('is-outside') && btn.getAttribute('aria-disabled') !== 'true') {\n btn.focus();\n }\n };\n\n const skipDisabled = (d, stepDir, maxSteps) => {\n let x = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n const step = stepDir > 0 ? 1 : -1;\n for (let i = 0; i < maxSteps; i++) {\n if (!isDisabled(x)) return x;\n x = addDays(x, step);\n }\n return d;\n };\n\n const createDayBtn = (day, outside, date) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-day';\n btn.textContent = day;\n btn.setAttribute('role', 'gridcell');\n\n if (outside) {\n btn.classList.add('is-outside');\n btn.tabIndex = -1;\n btn.setAttribute('aria-disabled', 'true');\n return btn;\n }\n\n btn.setAttribute('data-vd-date', dateKey(date));\n\n if (date && isSameDay(date, today)) btn.classList.add('is-today');\n if (date && isSameDay(date, selectedDate)) btn.classList.add('is-selected');\n if (date && isDisabled(date)) {\n btn.classList.add('is-disabled');\n btn.setAttribute('aria-disabled', 'true');\n btn.tabIndex = -1;\n return btn;\n }\n\n if (date) {\n const isFocused = focusedDate && isSameDay(date, focusedDate);\n btn.tabIndex = isFocused ? 0 : -1;\n\n btn.addEventListener('click', () => {\n selectedDate = date;\n viewYear = date.getFullYear();\n viewMonth = date.getMonth();\n focusedDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());\n input.value = formatDate(date, format);\n skipNextFocusOpen = true;\n close();\n input.dispatchEvent(new CustomEvent('datepicker:select', {\n detail: { date: date, formatted: input.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n input.focus();\n });\n }\n\n return btn;\n };\n\n const render = () => {\n popup.innerHTML = '';\n\n // Header\n const header = document.createElement('div');\n header.className = 'vd-datepicker-header';\n\n const prevBtn = document.createElement('button');\n prevBtn.type = 'button';\n prevBtn.className = 'vd-datepicker-prev';\n prevBtn.innerHTML = '‹';\n prevBtn.setAttribute('aria-label', 'Previous');\n\n const nextBtn = document.createElement('button');\n nextBtn.type = 'button';\n nextBtn.className = 'vd-datepicker-next';\n nextBtn.innerHTML = '›';\n nextBtn.setAttribute('aria-label', 'Next');\n\n const title = document.createElement('span');\n title.className = 'vd-datepicker-title';\n\n if (viewMode === 'days') {\n title.textContent = MONTHS[viewMonth] + ' ' + viewYear;\n title.addEventListener('click', () => { viewMode = 'months'; render(); });\n prevBtn.addEventListener('click', () => { viewMonth--; if (viewMonth < 0) { viewMonth = 11; viewYear--; } render(); });\n nextBtn.addEventListener('click', () => { viewMonth++; if (viewMonth > 11) { viewMonth = 0; viewYear++; } render(); });\n } else if (viewMode === 'months') {\n title.textContent = String(viewYear);\n title.addEventListener('click', () => { viewMode = 'years'; render(); });\n prevBtn.addEventListener('click', () => { viewYear--; render(); });\n nextBtn.addEventListener('click', () => { viewYear++; render(); });\n } else {\n const decadeStart = Math.floor(viewYear / 10) * 10;\n title.textContent = decadeStart + ' - ' + (decadeStart + 9);\n prevBtn.addEventListener('click', () => { viewYear -= 10; render(); });\n nextBtn.addEventListener('click', () => { viewYear += 10; render(); });\n }\n\n header.appendChild(prevBtn);\n header.appendChild(title);\n header.appendChild(nextBtn);\n popup.appendChild(header);\n\n if (viewMode === 'days') {\n const gridWrap = document.createElement('div');\n gridWrap.className = 'vd-datepicker-grid';\n gridWrap.setAttribute('role', 'grid');\n gridWrap.setAttribute('aria-label', 'Calendar');\n\n const weekdays = document.createElement('div');\n weekdays.className = 'vd-datepicker-weekdays';\n weekdays.setAttribute('role', 'row');\n DAYS.forEach(function (d) {\n const span = document.createElement('span');\n span.setAttribute('role', 'columnheader');\n span.setAttribute('aria-label', d);\n span.textContent = d;\n weekdays.appendChild(span);\n });\n gridWrap.appendChild(weekdays);\n\n const firstDay = new Date(viewYear, viewMonth, 1).getDay();\n const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();\n const daysInPrev = new Date(viewYear, viewMonth, 0).getDate();\n\n const cells = [];\n\n for (let i = firstDay - 1; i >= 0; i--) {\n const dayNum = daysInPrev - i;\n const prevMonth = viewMonth === 0 ? 11 : viewMonth - 1;\n const prevYear = viewMonth === 0 ? viewYear - 1 : viewYear;\n const date = new Date(prevYear, prevMonth, dayNum);\n cells.push({ day: dayNum, outside: true, date: date });\n }\n\n for (let d = 1; d <= daysInMonth; d++) {\n const date = new Date(viewYear, viewMonth, d);\n cells.push({ day: d, outside: false, date: date });\n }\n\n const totalCells = firstDay + daysInMonth;\n const remaining = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7);\n for (let i = 1; i <= remaining; i++) {\n const date = new Date(viewYear, viewMonth + 1, i);\n cells.push({ day: i, outside: true, date: date });\n }\n\n for (let r = 0; r < cells.length; r += 7) {\n const row = document.createElement('div');\n row.className = 'vd-datepicker-row';\n row.setAttribute('role', 'row');\n for (let c = 0; c < 7; c++) {\n const cell = cells[r + c];\n row.appendChild(createDayBtn(cell.day, cell.outside, cell.date));\n }\n gridWrap.appendChild(row);\n }\n\n popup.appendChild(gridWrap);\n } else if (viewMode === 'months') {\n const grid = document.createElement('div');\n grid.className = 'vd-datepicker-months';\n MONTHS.forEach((name, i) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-month-btn';\n btn.textContent = name.slice(0, 3);\n if (selectedDate && selectedDate.getFullYear() === viewYear && selectedDate.getMonth() === i) {\n btn.classList.add('is-selected');\n }\n btn.addEventListener('click', () => { viewMonth = i; viewMode = 'days'; render(); });\n grid.appendChild(btn);\n });\n popup.appendChild(grid);\n } else {\n const grid = document.createElement('div');\n grid.className = 'vd-datepicker-years';\n const decadeStart = Math.floor(viewYear / 10) * 10;\n for (let y = decadeStart - 1; y <= decadeStart + 10; y++) {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'vd-datepicker-year-btn';\n btn.textContent = y;\n if (selectedDate && selectedDate.getFullYear() === y) btn.classList.add('is-selected');\n if (y < decadeStart || y > decadeStart + 9) btn.style.opacity = '0.4';\n btn.addEventListener('click', () => { viewYear = y; viewMode = 'months'; render(); });\n grid.appendChild(btn);\n }\n popup.appendChild(grid);\n }\n };\n\n const handleGridKeydown = (e) => {\n if (!popup.classList.contains('is-open') || viewMode !== 'days') return;\n const grid = popup.querySelector('.vd-datepicker-grid');\n if (!grid || !grid.contains(e.target)) return;\n\n const key = e.key;\n if (key !== 'ArrowLeft' && key !== 'ArrowRight' && key !== 'ArrowUp' && key !== 'ArrowDown' &&\n key !== 'Home' && key !== 'End' && key !== 'PageUp' && key !== 'PageDown' &&\n key !== 'Enter' && key !== ' ' && key !== 'Escape') {\n return;\n }\n\n if (key === 'Escape') {\n e.preventDefault();\n e.stopPropagation();\n skipNextFocusOpen = true;\n close();\n input.focus();\n return;\n }\n\n if (!focusedDate) {\n focusedDate = firstSelectableInMonth(viewYear, viewMonth);\n }\n\n if (key === 'Enter' || key === ' ') {\n e.preventDefault();\n if (focusedDate && !isDisabled(focusedDate)) {\n selectDate(new Date(focusedDate.getFullYear(), focusedDate.getMonth(), focusedDate.getDate()));\n }\n return;\n }\n\n e.preventDefault();\n\n let next = new Date(focusedDate.getFullYear(), focusedDate.getMonth(), focusedDate.getDate());\n let skipDir = 1;\n\n if (key === 'ArrowLeft') {\n next = addDays(next, -1);\n skipDir = -1;\n } else if (key === 'ArrowRight') {\n next = addDays(next, 1);\n skipDir = 1;\n } else if (key === 'ArrowUp') {\n next = addDays(next, -7);\n skipDir = -1;\n } else if (key === 'ArrowDown') {\n next = addDays(next, 7);\n skipDir = 1;\n } else if (key === 'Home') {\n next = startOfWeekSunday(next);\n skipDir = 1;\n } else if (key === 'End') {\n next = endOfWeekSunday(next);\n skipDir = -1;\n } else if (key === 'PageUp') {\n next = addMonthsClamped(next, -1);\n skipDir = -1;\n } else if (key === 'PageDown') {\n next = addMonthsClamped(next, 1);\n skipDir = 1;\n }\n\n next = skipDisabled(next, skipDir, 400);\n\n if (next.getMonth() !== viewMonth || next.getFullYear() !== viewYear) {\n viewYear = next.getFullYear();\n viewMonth = next.getMonth();\n const cl = ensureMonthInRange(viewYear, viewMonth);\n viewYear = cl.y;\n viewMonth = cl.m;\n }\n\n focusedDate = next;\n render();\n requestAnimationFrame(focusFocusedDay);\n };\n\n const open = () => {\n viewMode = 'days';\n if (selectedDate) {\n viewYear = selectedDate.getFullYear();\n viewMonth = selectedDate.getMonth();\n }\n const cl = ensureMonthInRange(viewYear, viewMonth);\n viewYear = cl.y;\n viewMonth = cl.m;\n\n if (selectedDate) {\n focusedDate = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate());\n } else {\n focusedDate = firstSelectableInMonth(viewYear, viewMonth);\n }\n\n render();\n popup.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n requestAnimationFrame(focusFocusedDay);\n };\n\n const close = () => {\n popup.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n viewMode = 'days';\n };\n\n const focusHandler = () => {\n if (skipNextFocusOpen) {\n skipNextFocusOpen = false;\n return;\n }\n open();\n };\n const outsideHandler = (e) => {\n if (!wrapper.contains(e.target)) close();\n };\n const escHandler = (e) => {\n if (e.key === 'Escape' && popup.classList.contains('is-open')) {\n skipNextFocusOpen = true;\n close();\n input.focus();\n }\n };\n\n input.addEventListener('focus', focusHandler);\n document.addEventListener('click', outsideHandler, true);\n document.addEventListener('keydown', escHandler);\n popup.addEventListener('keydown', handleGridKeydown);\n\n input.setAttribute('aria-haspopup', 'dialog');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('autocomplete', 'off');\n\n cleanup.push(\n () => input.removeEventListener('focus', focusHandler),\n () => document.removeEventListener('click', outsideHandler, true),\n () => document.removeEventListener('keydown', escHandler),\n () => popup.removeEventListener('keydown', handleGridKeydown)\n );\n\n this.instances.set(input, { cleanup: cleanup, open: open, close: close, popup: popup });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('datepicker', Datepicker);\n }\n\n window.VanduoDatepicker = Datepicker;\n\n})();\n", "/**\n * Vanduo Framework - Timepicker Component\n * Dropdown time selection with 12h/24h format and configurable step intervals\n */\n\n(function () {\n 'use strict';\n\n const Timepicker = {\n instances: new Map(),\n\n init: function () {\n const inputs = document.querySelectorAll('[data-vd-timepicker]');\n inputs.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (input) {\n const cleanup = [];\n const is24h = input.getAttribute('data-vd-timepicker-format') === '24h';\n const step = parseInt(input.getAttribute('data-vd-timepicker-step') || '30', 10);\n\n // Create wrapper\n let wrapper = input.closest('.vd-suggest-wrapper');\n if (!wrapper) {\n wrapper = document.createElement('div');\n wrapper.style.position = 'relative';\n wrapper.style.display = 'inline-block';\n input.parentNode.insertBefore(wrapper, input);\n wrapper.appendChild(input);\n }\n\n // Create popup\n const popup = document.createElement('div');\n popup.className = 'vd-timepicker-popup';\n popup.setAttribute('role', 'listbox');\n wrapper.appendChild(popup);\n\n // Generate time slots\n const times = [];\n for (let h = 0; h < 24; h++) {\n for (let m = 0; m < 60; m += step) {\n const hh24 = String(h).padStart(2, '0');\n const mm = String(m).padStart(2, '0');\n\n if (is24h) {\n times.push({ display: hh24 + ':' + mm, value: hh24 + ':' + mm });\n } else {\n const period = h < 12 ? 'AM' : 'PM';\n const h12 = h === 0 ? 12 : (h > 12 ? h - 12 : h);\n const display = h12 + ':' + mm + ' ' + period;\n times.push({ display, value: hh24 + ':' + mm });\n }\n }\n }\n\n const render = () => {\n popup.innerHTML = '';\n times.forEach(t => {\n const item = document.createElement('div');\n item.className = 'vd-timepicker-item';\n item.setAttribute('role', 'option');\n item.textContent = t.display;\n\n if (input.value === t.value || input.value === t.display) {\n item.classList.add('is-selected');\n }\n\n item.addEventListener('click', () => {\n input.value = t.display;\n popup.querySelectorAll('.vd-timepicker-item').forEach(i => i.classList.remove('is-selected'));\n item.classList.add('is-selected');\n close();\n input.dispatchEvent(new CustomEvent('timepicker:select', {\n detail: { display: t.display, value: t.value },\n bubbles: true\n }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n });\n\n popup.appendChild(item);\n });\n };\n\n const open = () => {\n render();\n popup.classList.add('is-open');\n input.setAttribute('aria-expanded', 'true');\n // Scroll to selected\n const selected = popup.querySelector('.is-selected');\n if (selected) selected.scrollIntoView({ block: 'center' });\n };\n\n const close = () => {\n popup.classList.remove('is-open');\n input.setAttribute('aria-expanded', 'false');\n };\n\n const focusHandler = () => open();\n const outsideHandler = (e) => {\n if (!wrapper.contains(e.target)) close();\n };\n const escHandler = (e) => { if (e.key === 'Escape') close(); };\n\n input.addEventListener('focus', focusHandler);\n document.addEventListener('click', outsideHandler, true);\n document.addEventListener('keydown', escHandler);\n input.setAttribute('aria-haspopup', 'listbox');\n input.setAttribute('aria-expanded', 'false');\n input.setAttribute('autocomplete', 'off');\n input.readOnly = true;\n\n cleanup.push(\n () => input.removeEventListener('focus', focusHandler),\n () => document.removeEventListener('click', outsideHandler, true),\n () => document.removeEventListener('keydown', escHandler)\n );\n\n this.instances.set(input, { cleanup, open, close });\n },\n\n destroy: function (el) {\n const instance = this.instances.get(el);\n if (!instance) return;\n instance.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('timepicker', Timepicker);\n }\n\n window.VanduoTimepicker = Timepicker;\n\n})();\n", "/**\n * Vanduo Framework - Stepper Component\n * Multi-step progress indicator with state management\n */\n\n(function () {\n 'use strict';\n\n const Stepper = {\n instances: new Map(),\n\n init: function () {\n const steppers = document.querySelectorAll('.vd-stepper');\n steppers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const items = Array.from(el.querySelectorAll('.vd-stepper-item'));\n const isClickable = el.classList.contains('vd-stepper-clickable');\n let currentIndex = items.findIndex(i => i.classList.contains('is-active'));\n if (currentIndex === -1) currentIndex = 0;\n\n const setStep = (index) => {\n if (index < 0 || index >= items.length) return;\n const prev = currentIndex;\n currentIndex = index;\n\n items.forEach((item, i) => {\n item.classList.remove('is-active', 'is-completed');\n if (i < index) item.classList.add('is-completed');\n else if (i === index) item.classList.add('is-active');\n });\n\n el.dispatchEvent(new CustomEvent('stepper:change', {\n detail: { current: index, previous: prev, total: items.length },\n bubbles: true\n }));\n };\n\n if (isClickable) {\n items.forEach((item, i) => {\n const handler = () => setStep(i);\n item.addEventListener('click', handler);\n cleanup.push(() => item.removeEventListener('click', handler));\n });\n }\n\n // Initialize current state\n setStep(currentIndex);\n\n this.instances.set(el, {\n cleanup,\n setStep,\n next: () => setStep(currentIndex + 1),\n prev: () => setStep(currentIndex - 1),\n getCurrent: () => currentIndex\n });\n },\n\n setStep: function (el, index) {\n const inst = this.instances.get(el);\n if (inst) inst.setStep(index);\n },\n\n next: function (el) {\n const inst = this.instances.get(el);\n if (inst) inst.next();\n },\n\n prev: function (el) {\n const inst = this.instances.get(el);\n if (inst) inst.prev();\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('stepper', Stepper);\n }\n\n window.VanduoStepper = Stepper;\n\n})();\n", "/**\n * Vanduo Framework - Rating Component\n * Star-based rating input with hover preview and read-only mode\n */\n\n(function () {\n 'use strict';\n\n const Rating = {\n instances: new Map(),\n\n init: function () {\n const ratings = document.querySelectorAll('[data-vd-rating]');\n ratings.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const max = parseInt(el.getAttribute('data-vd-rating-max') || '5', 10);\n const initialValue = parseFloat(el.getAttribute('data-vd-rating-value') || '0');\n const readonly = el.classList.contains('vd-rating-readonly') || el.hasAttribute('data-vd-rating-readonly');\n let currentValue = initialValue;\n\n el.classList.add('vd-rating');\n el.setAttribute('role', 'radiogroup');\n el.setAttribute('aria-label', el.getAttribute('aria-label') || 'Rating');\n\n // Clear existing stars\n el.innerHTML = '';\n\n // Create stars\n const stars = [];\n for (let i = 1; i <= max; i++) {\n const star = document.createElement('button');\n star.type = 'button';\n star.className = 'vd-rating-star';\n star.setAttribute('role', 'radio');\n star.setAttribute('aria-label', i + ' star' + (i > 1 ? 's' : ''));\n star.setAttribute('aria-checked', i <= currentValue ? 'true' : 'false');\n if (readonly) star.tabIndex = -1;\n stars.push(star);\n el.appendChild(star);\n }\n\n // Value display\n const valueDisplay = document.createElement('span');\n valueDisplay.className = 'vd-rating-value';\n valueDisplay.textContent = currentValue > 0 ? currentValue.toString() : '';\n el.appendChild(valueDisplay);\n\n const updateStars = (value) => {\n stars.forEach((star, i) => {\n star.classList.remove('is-active', 'is-half');\n const starNum = i + 1;\n if (starNum <= Math.floor(value)) {\n star.classList.add('is-active');\n } else if (starNum - 0.5 <= value) {\n star.classList.add('is-half');\n }\n star.setAttribute('aria-checked', starNum <= value ? 'true' : 'false');\n });\n valueDisplay.textContent = value > 0 ? value.toString() : '';\n };\n\n updateStars(currentValue);\n\n if (!readonly) {\n stars.forEach((star, i) => {\n const enterHandler = () => {\n stars.forEach((s, j) => {\n s.classList.toggle('is-hovered', j <= i);\n });\n };\n const leaveHandler = () => {\n stars.forEach(s => s.classList.remove('is-hovered'));\n };\n const clickHandler = () => {\n currentValue = i + 1;\n el.setAttribute('data-vd-rating-value', currentValue);\n updateStars(currentValue);\n el.dispatchEvent(new CustomEvent('rating:change', {\n detail: { value: currentValue, max },\n bubbles: true\n }));\n };\n\n star.addEventListener('mouseenter', enterHandler);\n star.addEventListener('mouseleave', leaveHandler);\n star.addEventListener('click', clickHandler);\n\n cleanup.push(\n () => star.removeEventListener('mouseenter', enterHandler),\n () => star.removeEventListener('mouseleave', leaveHandler),\n () => star.removeEventListener('click', clickHandler)\n );\n });\n\n // Keyboard\n const keyHandler = (e) => {\n if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {\n e.preventDefault();\n if (currentValue < max) {\n currentValue++;\n updateStars(currentValue);\n stars[currentValue - 1].focus();\n el.dispatchEvent(new CustomEvent('rating:change', { detail: { value: currentValue, max }, bubbles: true }));\n }\n } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {\n e.preventDefault();\n if (currentValue > 1) {\n currentValue--;\n updateStars(currentValue);\n stars[currentValue - 1].focus();\n el.dispatchEvent(new CustomEvent('rating:change', { detail: { value: currentValue, max }, bubbles: true }));\n }\n }\n };\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n }\n\n this.instances.set(el, {\n cleanup,\n getValue: () => currentValue,\n setValue: (v) => { currentValue = v; updateStars(v); }\n });\n },\n\n getValue: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getValue() : 0;\n },\n\n setValue: function (el, value) {\n const inst = this.instances.get(el);\n if (inst) inst.setValue(value);\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('rating', Rating);\n }\n\n window.VanduoRating = Rating;\n\n})();\n", "/**\n * Vanduo Framework - Transfer (Dual-list) Component\n * Source-to-target list transfer with search, select-all, and move actions\n */\n\n(function () {\n 'use strict';\n\n const Transfer = {\n instances: new Map(),\n\n init: function () {\n const transfers = document.querySelectorAll('[data-vd-transfer]');\n transfers.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n el.classList.add('vd-transfer');\n\n let sourceData, targetData;\n try {\n const raw = JSON.parse(el.getAttribute('data-vd-transfer') || '[]');\n sourceData = raw.map((item, i) => ({\n id: item.id || 'item-' + i,\n label: item.label || item.text || String(item),\n selected: false\n }));\n } catch (_e) {\n sourceData = [];\n }\n targetData = [];\n\n const sourceSelected = new Set();\n const targetSelected = new Set();\n\n const render = () => {\n el.innerHTML = '';\n\n // Source panel\n const sourcePanel = createPanel('Source', sourceData, sourceSelected, 'source');\n // Actions\n const actions = document.createElement('div');\n actions.className = 'vd-transfer-actions';\n\n const moveRightBtn = document.createElement('button');\n moveRightBtn.type = 'button';\n moveRightBtn.className = 'vd-transfer-btn';\n moveRightBtn.innerHTML = '›';\n moveRightBtn.setAttribute('aria-label', 'Move to target');\n moveRightBtn.disabled = sourceSelected.size === 0;\n moveRightBtn.addEventListener('click', () => moveRight());\n\n const moveLeftBtn = document.createElement('button');\n moveLeftBtn.type = 'button';\n moveLeftBtn.className = 'vd-transfer-btn';\n moveLeftBtn.innerHTML = '‹';\n moveLeftBtn.setAttribute('aria-label', 'Move to source');\n moveLeftBtn.disabled = targetSelected.size === 0;\n moveLeftBtn.addEventListener('click', () => moveLeft());\n\n actions.appendChild(moveRightBtn);\n actions.appendChild(moveLeftBtn);\n\n // Target panel\n const targetPanel = createPanel('Target', targetData, targetSelected, 'target');\n\n el.appendChild(sourcePanel);\n el.appendChild(actions);\n el.appendChild(targetPanel);\n };\n\n const createPanel = (title, data, selected, _side) => {\n const panel = document.createElement('div');\n panel.className = 'vd-transfer-panel';\n\n const header = document.createElement('div');\n header.className = 'vd-transfer-header';\n const titleSpan = document.createElement('span');\n titleSpan.textContent = title;\n const count = document.createElement('span');\n count.className = 'vd-transfer-count';\n count.textContent = selected.size + '/' + data.length;\n header.appendChild(titleSpan);\n header.appendChild(count);\n panel.appendChild(header);\n\n // Search\n const searchDiv = document.createElement('div');\n searchDiv.className = 'vd-transfer-search';\n const searchInput = document.createElement('input');\n searchInput.type = 'text';\n searchInput.placeholder = 'Search...';\n searchInput.setAttribute('aria-label', 'Search ' + title.toLowerCase());\n searchDiv.appendChild(searchInput);\n panel.appendChild(searchDiv);\n\n // List\n const list = document.createElement('ul');\n list.className = 'vd-transfer-list';\n list.setAttribute('role', 'listbox');\n\n const renderList = (filter) => {\n list.innerHTML = '';\n const filtered = filter ? data.filter(d => {\n const label = (d.label || d.text || String(d)).toLowerCase();\n return label.includes(filter.toLowerCase());\n }) : data;\n filtered.forEach(item => {\n const li = document.createElement('li');\n li.className = 'vd-transfer-item';\n li.setAttribute('role', 'option');\n if (selected.has(item.id)) li.classList.add('is-selected');\n\n const checkbox = document.createElement('input');\n checkbox.type = 'checkbox';\n checkbox.checked = selected.has(item.id);\n checkbox.setAttribute('aria-label', item.label);\n\n const label = document.createElement('span');\n label.textContent = item.label;\n\n li.addEventListener('click', () => {\n if (selected.has(item.id)) selected.delete(item.id);\n else selected.add(item.id);\n render();\n });\n\n li.appendChild(checkbox);\n li.appendChild(label);\n list.appendChild(li);\n });\n };\n\n searchInput.addEventListener('input', () => renderList(searchInput.value));\n renderList('');\n\n panel.appendChild(list);\n return panel;\n };\n\n const moveRight = () => {\n const toMove = sourceData.filter(d => sourceSelected.has(d.id));\n sourceData = sourceData.filter(d => !sourceSelected.has(d.id));\n targetData = targetData.concat(toMove);\n sourceSelected.clear();\n render();\n fireChange();\n };\n\n const moveLeft = () => {\n const toMove = targetData.filter(d => targetSelected.has(d.id));\n targetData = targetData.filter(d => !targetSelected.has(d.id));\n sourceData = sourceData.concat(toMove);\n targetSelected.clear();\n render();\n fireChange();\n };\n\n const fireChange = () => {\n el.dispatchEvent(new CustomEvent('transfer:change', {\n detail: {\n source: sourceData.map(d => d.id),\n target: targetData.map(d => d.id)\n },\n bubbles: true\n }));\n };\n\n render();\n\n this.instances.set(el, {\n cleanup,\n getTarget: () => targetData.map(d => d.id),\n getSource: () => sourceData.map(d => d.id)\n });\n },\n\n getSelected: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getTarget() : [];\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n el.innerHTML = '';\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('transfer', Transfer);\n }\n\n window.VanduoTransfer = Transfer;\n\n})();\n", "/**\n * Vanduo Framework - Tree View Component\n * Hierarchical collapsible tree with checkbox selection and keyboard navigation\n */\n\n(function () {\n 'use strict';\n\n const Tree = {\n instances: new Map(),\n\n init: function () {\n const trees = document.querySelectorAll('[data-vd-tree]');\n trees.forEach(el => {\n if (this.instances.has(el)) return;\n this.initInstance(el);\n });\n },\n\n initInstance: function (el) {\n const cleanup = [];\n const cascade = el.getAttribute('data-vd-tree-cascade') !== 'false';\n\n let data;\n try { data = JSON.parse(el.getAttribute('data-vd-tree') || '[]'); } catch (_e) { data = []; }\n\n el.classList.add('vd-tree');\n el.setAttribute('role', 'tree');\n\n const render = (items, parent) => {\n parent.innerHTML = '';\n items.forEach(item => {\n const node = document.createElement('li');\n node.className = 'vd-tree-node';\n node.setAttribute('role', 'treeitem');\n node.setAttribute('aria-expanded', item.open ? 'true' : 'false');\n if (item.open) node.classList.add('is-open');\n\n const content = document.createElement('div');\n content.className = 'vd-tree-node-content';\n\n // Toggle\n if (item.children && item.children.length > 0) {\n const toggle = document.createElement('button');\n toggle.type = 'button';\n toggle.className = 'vd-tree-toggle';\n toggle.setAttribute('aria-label', 'Toggle');\n toggle.addEventListener('click', (e) => {\n e.stopPropagation();\n item.open = !item.open;\n node.classList.toggle('is-open');\n node.setAttribute('aria-expanded', item.open ? 'true' : 'false');\n });\n content.appendChild(toggle);\n } else {\n const ph = document.createElement('span');\n ph.className = 'vd-tree-toggle-placeholder';\n content.appendChild(ph);\n }\n\n // Checkbox\n if (el.hasAttribute('data-vd-tree-checkbox')) {\n const cb = document.createElement('input');\n cb.type = 'checkbox';\n cb.className = 'vd-tree-checkbox';\n cb.checked = !!item.checked;\n cb.setAttribute('aria-label', item.label);\n cb.addEventListener('change', (e) => {\n e.stopPropagation();\n item.checked = cb.checked;\n if (cascade && item.children) {\n setChildChecked(item.children, cb.checked);\n render(data, el);\n }\n el.dispatchEvent(new CustomEvent('tree:check', {\n detail: { id: item.id, checked: cb.checked, label: item.label },\n bubbles: true\n }));\n });\n content.appendChild(cb);\n }\n\n // Icon\n if (item.icon) {\n const icon = document.createElement('span');\n icon.className = 'vd-tree-icon ' + item.icon;\n content.appendChild(icon);\n }\n\n // Label\n const label = document.createElement('span');\n label.className = 'vd-tree-label';\n label.textContent = item.label || '';\n content.appendChild(label);\n\n node.appendChild(content);\n\n // Children\n if (item.children && item.children.length > 0) {\n const childList = document.createElement('ul');\n childList.className = 'vd-tree-children';\n childList.setAttribute('role', 'group');\n render(item.children, childList);\n node.appendChild(childList);\n }\n\n parent.appendChild(node);\n });\n };\n\n const setChildChecked = (items, checked) => {\n items.forEach(item => {\n item.checked = checked;\n if (item.children) setChildChecked(item.children, checked);\n });\n };\n\n // Keyboard\n const keyHandler = (e) => {\n const focused = document.activeElement;\n if (!el.contains(focused)) return;\n\n const nodes = Array.from(el.querySelectorAll('.vd-tree-node-content'));\n const idx = nodes.indexOf(focused.closest('.vd-tree-node-content'));\n if (idx === -1) return;\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n if (idx < nodes.length - 1) {\n const next = nodes[idx + 1].querySelector('.vd-tree-toggle, .vd-tree-label');\n if (next) next.focus();\n }\n break;\n case 'ArrowUp':\n e.preventDefault();\n if (idx > 0) {\n const prev = nodes[idx - 1].querySelector('.vd-tree-toggle, .vd-tree-label');\n if (prev) prev.focus();\n }\n break;\n }\n };\n\n el.addEventListener('keydown', keyHandler);\n cleanup.push(() => el.removeEventListener('keydown', keyHandler));\n\n render(data, el);\n\n this.instances.set(el, {\n cleanup,\n getData: () => data,\n getChecked: () => {\n const checked = [];\n const collect = (items) => {\n items.forEach(i => {\n if (i.checked) checked.push(i.id || i.label);\n if (i.children) collect(i.children);\n });\n };\n collect(data);\n return checked;\n }\n });\n },\n\n getChecked: function (el) {\n const inst = this.instances.get(el);\n return inst ? inst.getChecked() : [];\n },\n\n destroy: function (el) {\n const inst = this.instances.get(el);\n if (!inst) return;\n inst.cleanup.forEach(fn => fn());\n el.innerHTML = '';\n this.instances.delete(el);\n },\n\n destroyAll: function () {\n this.instances.forEach((_, el) => this.destroy(el));\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('tree', Tree);\n }\n\n window.VanduoTree = Tree;\n\n})();\n", "/**\n * Vanduo Framework - Spotlight (Feature Discovery) Component\n * Guided tour with overlay highlight and step-through tooltip\n */\n\n(function () {\n 'use strict';\n\n const Spotlight = {\n _active: false,\n _steps: [],\n _currentStep: 0,\n _elements: {},\n _cleanup: [],\n _boundTriggers: new WeakMap(),\n _triggerElement: null,\n\n init: function () {\n const triggers = document.querySelectorAll('[data-vd-spotlight]');\n\n triggers.forEach(trigger => {\n if (this._boundTriggers.has(trigger)) return;\n\n const clickHandler = (event) => {\n event.preventDefault();\n\n const steps = this._parseSteps(trigger.getAttribute('data-vd-spotlight'));\n if (steps.length === 0) return;\n\n this.start(steps, { trigger });\n };\n\n trigger.addEventListener('click', clickHandler);\n this._boundTriggers.set(trigger, clickHandler);\n });\n },\n\n _parseSteps: function (raw) {\n if (typeof raw !== 'string' || raw.trim() === '') return [];\n\n try {\n const parsed = JSON.parse(raw);\n return this._normalizeSteps(parsed);\n } catch (error) {\n console.error('VanduoSpotlight: invalid data-vd-spotlight payload.', error);\n return [];\n }\n },\n\n _normalizeStep: function (step) {\n if (!step || typeof step !== 'object') return null;\n\n const target = step.target;\n const hasSelectorTarget = typeof target === 'string' && target.trim() !== '';\n const hasElementTarget = typeof Element !== 'undefined' && target instanceof Element;\n\n if (!hasSelectorTarget && !hasElementTarget) return null;\n\n const title = typeof step.title === 'string' ? step.title : '';\n const description = typeof step.description === 'string'\n ? step.description\n : (typeof step.content === 'string' ? step.content : '');\n\n return {\n target,\n title,\n description\n };\n },\n\n _normalizeSteps: function (steps) {\n if (!Array.isArray(steps)) return [];\n\n return steps\n .map(step => this._normalizeStep(step))\n .filter(Boolean);\n },\n\n start: function (steps, options) {\n if (this._active) this.stop();\n\n const normalizedSteps = this._normalizeSteps(steps);\n if (normalizedSteps.length === 0) return;\n\n const startOptions = options || {};\n\n this._steps = normalizedSteps;\n this._currentStep = 0;\n this._active = true;\n this._triggerElement = startOptions.trigger || (document.activeElement instanceof HTMLElement ? document.activeElement : null);\n\n // Create overlay\n const overlay = document.createElement('div');\n overlay.className = 'vd-spotlight-overlay';\n overlay.setAttribute('aria-hidden', 'true');\n document.body.appendChild(overlay);\n\n // Create tooltip\n const tooltip = document.createElement('div');\n tooltip.className = 'vd-spotlight-tooltip';\n tooltip.setAttribute('role', 'dialog');\n tooltip.setAttribute('aria-modal', 'true');\n tooltip.tabIndex = -1;\n document.body.appendChild(tooltip);\n\n this._elements = { overlay, tooltip };\n\n // ESC to close\n const escHandler = (e) => { if (e.key === 'Escape') this.stop(); };\n document.addEventListener('keydown', escHandler);\n this._cleanup.push(() => document.removeEventListener('keydown', escHandler));\n\n // Overlay click to close\n overlay.addEventListener('click', () => this.stop());\n\n this._showStep(this._currentStep);\n },\n\n _showStep: function (index) {\n const step = this._steps[index];\n if (!step) return;\n\n const target = typeof step.target === 'string' ? document.querySelector(step.target) : step.target;\n const { tooltip } = this._elements;\n\n // Remove previous highlight\n document.querySelectorAll('.vd-spotlight-target').forEach(el => {\n el.classList.remove('vd-spotlight-target');\n });\n\n // Highlight target\n if (target) {\n target.classList.add('vd-spotlight-target');\n target.scrollIntoView({ behavior: 'smooth', block: 'center' });\n }\n\n // Build tooltip content\n const total = this._steps.length;\n tooltip.innerHTML = '';\n tooltip.removeAttribute('aria-labelledby');\n tooltip.removeAttribute('aria-describedby');\n\n if (step.title) {\n const title = document.createElement('h4');\n title.className = 'vd-spotlight-title';\n title.id = 'vd-spotlight-title-' + index + '-' + Date.now();\n title.textContent = step.title;\n tooltip.appendChild(title);\n tooltip.setAttribute('aria-labelledby', title.id);\n }\n\n if (step.description) {\n const desc = document.createElement('p');\n desc.className = 'vd-spotlight-description';\n desc.id = 'vd-spotlight-description-' + index + '-' + Date.now();\n desc.textContent = step.description;\n tooltip.appendChild(desc);\n tooltip.setAttribute('aria-describedby', desc.id);\n }\n\n // Footer\n const footer = document.createElement('div');\n footer.className = 'vd-spotlight-footer';\n footer.setAttribute('aria-label', 'Step ' + (index + 1) + ' of ' + total);\n\n const counter = document.createElement('span');\n counter.className = 'vd-spotlight-counter';\n counter.textContent = (index + 1) + ' / ' + total;\n\n const actions = document.createElement('div');\n actions.className = 'vd-spotlight-actions';\n\n if (index > 0) {\n const prevBtn = document.createElement('button');\n prevBtn.type = 'button';\n prevBtn.className = 'vd-spotlight-btn';\n prevBtn.textContent = 'Back';\n prevBtn.addEventListener('click', () => this.prev());\n actions.appendChild(prevBtn);\n }\n\n const skipBtn = document.createElement('button');\n skipBtn.type = 'button';\n skipBtn.className = 'vd-spotlight-btn';\n skipBtn.textContent = 'Skip';\n skipBtn.addEventListener('click', () => this.stop());\n actions.appendChild(skipBtn);\n\n if (index < total - 1) {\n const nextBtn = document.createElement('button');\n nextBtn.type = 'button';\n nextBtn.className = 'vd-spotlight-btn vd-spotlight-btn-primary';\n nextBtn.textContent = 'Next';\n nextBtn.addEventListener('click', () => this.next());\n actions.appendChild(nextBtn);\n } else {\n const doneBtn = document.createElement('button');\n doneBtn.type = 'button';\n doneBtn.className = 'vd-spotlight-btn vd-spotlight-btn-primary';\n doneBtn.textContent = 'Done';\n doneBtn.addEventListener('click', () => this.stop());\n actions.appendChild(doneBtn);\n }\n\n footer.appendChild(counter);\n footer.appendChild(actions);\n tooltip.appendChild(footer);\n\n // Position tooltip near target\n if (target) {\n requestAnimationFrame(() => {\n const rect = target.getBoundingClientRect();\n const tRect = tooltip.getBoundingClientRect();\n let top = rect.bottom + 12 + window.scrollY;\n let left = rect.left + (rect.width - tRect.width) / 2 + window.scrollX;\n\n // Keep in viewport\n left = Math.max(8, Math.min(left, window.innerWidth - tRect.width - 8));\n if (top + tRect.height > window.innerHeight + window.scrollY) {\n top = rect.top - tRect.height - 12 + window.scrollY;\n }\n\n tooltip.style.top = top + 'px';\n tooltip.style.left = left + 'px';\n });\n }\n\n document.dispatchEvent(new CustomEvent('spotlight:step', {\n detail: { index, step: index, total, data: step }\n }));\n },\n\n next: function () {\n if (this._currentStep < this._steps.length - 1) {\n this._currentStep++;\n this._showStep(this._currentStep);\n }\n },\n\n prev: function () {\n if (this._currentStep > 0) {\n this._currentStep--;\n this._showStep(this._currentStep);\n }\n },\n\n stop: function () {\n if (!this._active) return;\n\n const total = this._steps.length;\n const detail = {\n completedSteps: total === 0 ? 0 : Math.min(this._currentStep + 1, total),\n total,\n completed: total > 0 && this._currentStep >= total - 1\n };\n\n this._active = false;\n\n document.querySelectorAll('.vd-spotlight-target').forEach(el => {\n el.classList.remove('vd-spotlight-target');\n });\n\n if (this._elements.overlay && this._elements.overlay.parentNode) {\n this._elements.overlay.parentNode.removeChild(this._elements.overlay);\n }\n if (this._elements.tooltip && this._elements.tooltip.parentNode) {\n this._elements.tooltip.parentNode.removeChild(this._elements.tooltip);\n }\n\n this._cleanup.forEach(fn => fn());\n this._cleanup = [];\n this._elements = {};\n this._steps = [];\n this._currentStep = 0;\n\n if (this._triggerElement && this._triggerElement.isConnected && typeof this._triggerElement.focus === 'function') {\n this._triggerElement.focus();\n }\n this._triggerElement = null;\n\n document.dispatchEvent(new CustomEvent('spotlight:end', { detail }));\n },\n\n destroyAll: function () {\n this.stop();\n }\n };\n\n if (typeof window.Vanduo !== 'undefined') {\n window.Vanduo.register('spotlight', Spotlight);\n }\n\n window.VanduoSpotlight = Spotlight;\n\n})();\n", "/**\n * Vanduo Framework - Music Player Component\n * HTML5 Audio-based music player with transport controls, volume,\n * and optional shuffle, seek bar, and playlist features.\n *\n * Options (passed to MusicPlayer.init or data-music-player-options):\n * tracks {Array} - [{name, url}] \u2014 required\n * volume {number} - Initial volume 0\u20131 (default 0.5)\n * shuffle {boolean} - Shuffle on init (default false)\n * showProgress {boolean} - Show seek/progress bar (default false)\n * showPlaylist {boolean} - Show expandable playlist panel (default false)\n * autoAdvance {boolean} - Auto-play next track on end (default true)\n * glass {boolean} - Frosted-glass surface styling (default false)\n * detachable {boolean} - Show detach/attach; float above page when detached (default false)\n * floatingPosition {string|null} - After detach: 'bottom-left' or 'bottom-right' (default null = bottom-right)\n * draggable {boolean} - When detached+detachable, drag by handle (default false)\n * minimizable {boolean} - Minimize/expand control (default false)\n * startMinimized {boolean} - On first detach, start minimized (default false)\n * persistPosition {boolean} - Save floating x/y in localStorage (default false)\n * persistKey {string} - Storage key for persist (default from element id)\n *\n * Custom events (all bubble, dispatched on container):\n * musicplayer:play \u2014 playback started\n * musicplayer:pause \u2014 playback paused\n * musicplayer:trackchange \u2014 detail: { index, name, url }\n * musicplayer:volumechange \u2014 detail: { volume }\n * musicplayer:ended \u2014 last track ended (autoAdvance=false only)\n * musicplayer:detach \u2014 after floating player is created\n * musicplayer:attach \u2014 after returned to document flow\n * musicplayer:minimize \u2014 collapsed to minimal controls\n * musicplayer:expand \u2014 full controls visible again\n *\n * Programmatic API:\n * MusicPlayer.init(container?, options?)\n * MusicPlayer.play(container)\n * MusicPlayer.pause(container)\n * MusicPlayer.toggle(container)\n * MusicPlayer.next(container)\n * MusicPlayer.previous(container)\n * MusicPlayer.setVolume(container, value)\n * MusicPlayer.setTrack(container, index)\n * MusicPlayer.shuffle(container)\n * MusicPlayer.detach(container, position?)\n * MusicPlayer.attach(container)\n * MusicPlayer.minimize(container)\n * MusicPlayer.expand(container)\n * MusicPlayer.toggleMinimize(container)\n * MusicPlayer.setPosition(container, 'bottom-left'|'bottom-right'|{x,y})\n * MusicPlayer.getState(container)\n * MusicPlayer.destroy(container)\n * MusicPlayer.destroyAll()\n */\n\n(function () {\n 'use strict';\n\n /* \u2500\u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /**\n * Fisher-Yates shuffle (returns new array).\n * @param {Array} arr\n * @returns {Array}\n */\n function shuffleArray(arr) {\n const shuffled = arr.slice();\n for (let i = shuffled.length - 1; i > 0; i--) {\n const j = Math.floor(Math.random() * (i + 1));\n const tmp = shuffled[i];\n shuffled[i] = shuffled[j];\n shuffled[j] = tmp;\n }\n return shuffled;\n }\n\n /**\n * Format seconds as m:ss.\n * @param {number} seconds\n * @returns {string}\n */\n function formatTime(seconds) {\n if (!isFinite(seconds) || seconds < 0) return '0:00';\n const m = Math.floor(seconds / 60);\n const s = Math.floor(seconds % 60);\n return m + ':' + (s < 10 ? '0' : '') + s;\n }\n\n /**\n * Set CSS background-size on a range input to visually fill the track.\n * @param {HTMLInputElement} input\n */\n /**\n * @param {string|undefined} id\n * @returns {string}\n */\n function persistStorageKey(id) {\n return 'vanduo:music-player:' + (id && id.trim() ? id.trim() : 'default') + ':pos';\n }\n\n function updateRangeFill(input) {\n const min = parseFloat(input.min) || 0;\n const max = parseFloat(input.max) || 1;\n const val = parseFloat(input.value) || 0;\n const pct = ((val - min) / (max - min)) * 100;\n input.style.setProperty('--fill', pct + '%');\n // Fallback inline gradient for browsers without ::-moz-range-progress\n input.style.backgroundImage =\n 'linear-gradient(to right, var(--music-player-track-fill, currentColor) 0%, ' +\n 'var(--music-player-track-fill, currentColor) ' + pct + '%, ' +\n 'var(--music-player-track-bg, #ccc) ' + pct + '%, ' +\n 'var(--music-player-track-bg, #ccc) 100%)';\n }\n\n /* \u2500\u2500\u2500 Phosphor icon helper (matches framework icon style) \u2500 */\n\n /**\n * Return an element (Phosphor icon).\n * @param {string} name\n * @returns {HTMLElement}\n */\n function icon(name) {\n const el = document.createElement('i');\n el.className = 'ph ph-' + name;\n el.setAttribute('aria-hidden', 'true');\n return el;\n }\n\n /* \u2500\u2500\u2500 Component \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const MusicPlayer = {\n /** @type {Map} */\n instances: new Map(),\n\n /**\n * Default options.\n */\n defaults: {\n tracks: [],\n volume: 0.5,\n shuffle: false,\n showProgress: false,\n showPlaylist: false,\n autoAdvance: true,\n glass: false,\n detachable: false,\n /** @type {null|string} */\n floatingPosition: null,\n draggable: false,\n minimizable: false,\n startMinimized: false,\n persistPosition: false,\n persistKey: '',\n },\n\n /**\n * Auto-initialize all .vd-music-player / [data-music-player] elements.\n * Options can be provided via data-music-player-options (JSON string).\n */\n init: function () {\n document.querySelectorAll('.vd-music-player, [data-music-player]').forEach((el) => {\n if (this.instances.has(el)) return;\n\n let opts = {};\n const attr = el.getAttribute('data-music-player-options');\n if (attr) {\n try { opts = JSON.parse(attr); } catch (_) { /* ignore malformed JSON */ }\n }\n this.initPlayer(el, opts);\n });\n },\n\n /**\n * Initialize a single player element.\n * @param {HTMLElement} container\n * @param {Object} [options]\n */\n initPlayer: function (container, options) {\n const opts = Object.assign({}, this.defaults, options || {});\n\n // Validate and normalise tracks\n const rawTracks = Array.isArray(opts.tracks) ? opts.tracks : [];\n const tracks = rawTracks.filter((t) => t && typeof t.url === 'string' && t.url.trim());\n\n // Build shuffled working copy without mutating opts\n const trackList = opts.shuffle ? shuffleArray(tracks) : tracks.slice();\n\n /* \u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const state = {\n tracks: trackList,\n originalTracks: tracks.slice(),\n currentIndex: 0,\n isPlaying: false,\n volume: Math.max(0, Math.min(1, opts.volume)),\n shuffle: opts.shuffle,\n showProgress: opts.showProgress,\n showPlaylist: opts.showPlaylist,\n autoAdvance: opts.autoAdvance,\n audio: null,\n glass: Boolean(opts.glass),\n detachable: Boolean(opts.detachable),\n floatingPosition: opts.floatingPosition || 'bottom-right',\n draggable: Boolean(opts.draggable) && Boolean(opts.detachable),\n minimizable: Boolean(opts.minimizable),\n startMinimized: Boolean(opts.startMinimized),\n persistPosition: Boolean(opts.persistPosition),\n persistKey: typeof opts.persistKey === 'string' ? opts.persistKey : '',\n isDetached: false,\n isMinimized: false,\n _startMinimizeApplied: false,\n };\n\n /* \u2500\u2500 Audio element \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const audio = new Audio();\n audio.volume = state.volume;\n audio.preload = 'metadata';\n state.audio = audio;\n\n /* \u2500\u2500 Build DOM \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n this._buildDOM(container, state);\n\n // Grab references after DOM build\n const refs = {\n btnPlay: container.querySelector('.vd-music-player-btn-play'),\n btnPrev: container.querySelector('.vd-music-player-btn-prev'),\n btnNext: container.querySelector('.vd-music-player-btn-next'),\n btnShuffle: container.querySelector('.vd-music-player-btn-shuffle'),\n btnPlaylist: container.querySelector('.vd-music-player-btn-playlist'),\n btnDetach: container.querySelector('.vd-music-player-btn-detach'),\n btnAttach: container.querySelector('.vd-music-player-btn-attach'),\n btnMinimize: container.querySelector('.vd-music-player-btn-minimize'),\n dragHandle: container.querySelector('.vd-music-player-drag-handle'),\n trackName: container.querySelector('.vd-music-player-track-name'),\n volumeSlider: container.querySelector('.vd-music-player-volume-slider'),\n volumeIcon: container.querySelector('.vd-music-player-volume-icon'),\n progressBar: container.querySelector('.vd-music-player-progress-bar'),\n timeElapsed: container.querySelector('.vd-music-player-time-elapsed'),\n timeDuration: container.querySelector('.vd-music-player-time-duration'),\n playlistPanel: container.querySelector('.vd-music-player-playlist'),\n };\n\n /* \u2500\u2500 Internal render helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const renderPlayIcon = () => {\n const btn = refs.btnPlay;\n if (!btn) return;\n btn.innerHTML = '';\n btn.appendChild(icon(state.isPlaying ? 'pause' : 'play'));\n btn.setAttribute('aria-label', state.isPlaying ? 'Pause' : 'Play');\n btn.classList.toggle('is-active', state.isPlaying);\n };\n\n const renderTrackName = () => {\n const el = refs.trackName;\n if (!el) return;\n const track = state.tracks[state.currentIndex];\n if (track) {\n el.textContent = track.name || 'Unknown Track';\n el.classList.remove('is-idle');\n } else {\n el.textContent = 'No tracks loaded';\n el.classList.add('is-idle');\n }\n };\n\n const renderVolumeIcon = () => {\n const el = refs.volumeIcon;\n if (!el) return;\n el.innerHTML = '';\n const v = state.volume;\n const name = v === 0 ? 'speaker-none' : v < 0.5 ? 'speaker-low' : 'speaker-high';\n el.appendChild(icon(name));\n };\n\n const renderShuffleBtn = () => {\n const btn = refs.btnShuffle;\n if (!btn) return;\n btn.classList.toggle('is-active', state.shuffle);\n btn.setAttribute('aria-pressed', state.shuffle ? 'true' : 'false');\n };\n\n const renderPlaylistItems = () => {\n const panel = refs.playlistPanel;\n if (!panel) return;\n panel.innerHTML = '';\n state.tracks.forEach((track, i) => {\n const item = document.createElement('button');\n item.className =\n 'vd-music-player-playlist-item' + (i === state.currentIndex ? ' is-active' : '');\n item.type = 'button';\n item.setAttribute('data-index', String(i));\n item.setAttribute('aria-current', i === state.currentIndex ? 'true' : 'false');\n\n const num = document.createElement('span');\n num.className = 'vd-music-player-playlist-num';\n num.textContent = String(i + 1);\n\n const name = document.createElement('span');\n name.className = 'vd-music-player-playlist-name';\n name.textContent = track.name || 'Track ' + (i + 1);\n\n item.appendChild(num);\n item.appendChild(name);\n panel.appendChild(item);\n });\n };\n\n const renderProgress = () => {\n const bar = refs.progressBar;\n if (!bar || !audio.duration) return;\n const pct = (audio.currentTime / audio.duration) * 100;\n bar.value = String(pct);\n updateRangeFill(bar);\n if (refs.timeElapsed) refs.timeElapsed.textContent = formatTime(audio.currentTime);\n if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);\n };\n\n /* \u2500\u2500 Load track \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n const loadTrack = (index, autoPlay) => {\n const track = state.tracks[index];\n if (!track) return;\n state.currentIndex = index;\n audio.src = track.url;\n renderTrackName();\n renderPlaylistItems();\n\n // Reset progress\n if (refs.progressBar) {\n refs.progressBar.value = '0';\n updateRangeFill(refs.progressBar);\n }\n if (refs.timeElapsed) refs.timeElapsed.textContent = '0:00';\n if (refs.timeDuration) refs.timeDuration.textContent = '0:00';\n\n container.dispatchEvent(\n new CustomEvent('musicplayer:trackchange', {\n bubbles: true,\n detail: { index, name: track.name, url: track.url },\n })\n );\n\n if (autoPlay) {\n audio.play().catch(() => { /* browser may block autoplay */ });\n }\n };\n\n /* \u2500\u2500 Audio event listeners \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const cleanupFunctions = [];\n\n const onPlay = () => {\n state.isPlaying = true;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:play', { bubbles: true }));\n };\n\n const onPause = () => {\n state.isPlaying = false;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:pause', { bubbles: true }));\n };\n\n const onEnded = () => {\n if (state.autoAdvance && state.tracks.length > 1) {\n const next = (state.currentIndex + 1) % state.tracks.length;\n loadTrack(next, true);\n } else {\n state.isPlaying = false;\n renderPlayIcon();\n container.dispatchEvent(new CustomEvent('musicplayer:ended', { bubbles: true }));\n }\n };\n\n const onTimeUpdate = () => {\n if (state.showProgress) renderProgress();\n };\n\n const onLoadedMetadata = () => {\n if (refs.timeDuration) refs.timeDuration.textContent = formatTime(audio.duration);\n if (refs.progressBar) {\n refs.progressBar.max = '100';\n updateRangeFill(refs.progressBar);\n }\n };\n\n audio.addEventListener('play', onPlay);\n audio.addEventListener('pause', onPause);\n audio.addEventListener('ended', onEnded);\n audio.addEventListener('timeupdate', onTimeUpdate);\n audio.addEventListener('loadedmetadata', onLoadedMetadata);\n cleanupFunctions.push(() => {\n audio.removeEventListener('play', onPlay);\n audio.removeEventListener('pause', onPause);\n audio.removeEventListener('ended', onEnded);\n audio.removeEventListener('timeupdate', onTimeUpdate);\n audio.removeEventListener('loadedmetadata', onLoadedMetadata);\n audio.pause();\n audio.src = '';\n });\n\n /* \u2500\u2500 Control button listeners \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n if (refs.btnPlay) {\n const handler = () => {\n if (!audio.src && state.tracks.length) loadTrack(state.currentIndex, false);\n if (state.isPlaying) {\n audio.pause();\n } else {\n audio.play().catch(() => {});\n }\n };\n refs.btnPlay.addEventListener('click', handler);\n cleanupFunctions.push(() => refs.btnPlay.removeEventListener('click', handler));\n\n // Keyboard: Space / Enter (already native for
    @@ -33,7 +33,7 @@

    Font Switcher Test Fixture

    Current Font State

    -

    data-font attribute: lato

    +

    data-font attribute: ubuntu

    localStorage preference: -