robert преди 1 ден
родител
ревизия
dcf9ae2bd6
променени са 8 файла, в които са добавени 1037 реда и са изтрити 2121 реда
  1. 77 0
      Guide.md
  2. 1 0
      doc/index.html
  3. 7 0
      doc/layer/list.html
  4. 66 45
      doc/layer/overview.html
  5. 320 0
      doc/layer/test_dom_steps.html
  6. 119 59
      doc/layer/test_icons_svg_animation.html
  7. 447 17
      layer.js
  8. 0 2000
      xjs.js

+ 77 - 0
Guide.md

@@ -237,3 +237,80 @@ go run main.go build path/to/xjs.js
 1. **以本文档为准**(作为未来统一标准)
 2. 在不破坏对外 API 的前提下,逐步把历史实现迁移到本文档规范
 
+---
+
+## 11. Layer 新增能力:DOM 内容 / Steps(分步弹窗)
+
+### 11.1 DOM 内容(显示指定 DOM,而不是一句话)
+
+Layer 现在支持把“页面里已有的 DOM(可默认隐藏)”挂载到弹窗内容区域展示:
+
+- **推荐参数**:`dom: '#selector' | Element | <template>`
+- **兼容参数**:`content: '#selector' | Element`(历史用法仍可用)
+- **行为**:
+  - 默认模式为 **move**:把 DOM 节点移动进弹窗,关闭时会 **自动还原到原位置**(同时还原 `hidden` / `style.display`)
+  - 可选 **clone**:通过 `domMode:'clone'` 或 `content:{ dom:'#el', clone:true }` 克隆节点展示(不移动原节点)
+
+示例:
+
+```js
+// 页面中一个默认隐藏的 DOM
+// <div id="my_form" style="display:none">...</div>
+
+xjs.layer({
+  title: 'Edit profile',
+  dom: '#my_form',
+  showCancelButton: true,
+  confirmButtonText: 'Save'
+});
+```
+
+### 11.2 Steps(多个对话框 next 平滑转化 / 分步填写)
+
+Layer 支持“同一弹窗内”的步骤流:点击确认按钮不关闭,而是平滑切换到下一步;最后一步确认后 resolve。
+
+推荐写法(链式):
+
+```js
+xjs.Layer.$({
+  icon: 'info',
+  closeOnClickOutside: false,
+  closeOnEsc: false
+})
+.step({
+  title: 'Step 1',
+  dom: '#step1',
+  showCancelButton: true,
+  cancelButtonText: 'Cancel',
+  preConfirm(popup) {
+    // return false 阻止进入下一步(用于校验)
+    const v = popup.querySelector('input[name="name"]').value.trim();
+    if (!v) return false;
+    return { name: v };
+  }
+})
+.step({
+  title: 'Step 2',
+  dom: '#step2',
+  preConfirm(popup) {
+    return { plan: popup.querySelector('select[name="plan"]').value };
+  }
+})
+.fire()
+.then((res) => {
+  if (res.isConfirmed) {
+    // res.value 是每一步 preConfirm 的返回值数组
+    console.log(res.value);
+  }
+});
+```
+
+也支持便捷写法:
+
+```js
+Layer.flow([
+  { title: 'Step 1', dom: '#step1' },
+  { title: 'Step 2', dom: '#step2' }
+], { icon: 'info' });
+```
+

+ 1 - 0
doc/index.html

@@ -511,6 +511,7 @@
             { group: 'Layer', title: 'Confirm flow', url: 'layer/test_confirm_flow.html' },
             { group: 'Layer', title: 'Selection.layer + dataset', url: 'layer/test_selection_layer_dataset.html' },
             { group: 'Layer', title: 'Static API / Builder API', url: 'layer/test_static_fire.html' },
+            { group: 'Layer', title: 'DOM content + Step flow', url: 'layer/test_dom_steps.html' },
 
             // Developer
             { group: 'Developer', title: '模块开发与测试指南', url: 'module.html' }

+ 7 - 0
doc/layer/list.html

@@ -119,6 +119,13 @@
         </div>
         <div class="card-footer">Static API / Builder API</div>
       </div>
+
+      <div class="card" data-content="layer/test_dom_steps.html" onclick="selectItem('test_dom_steps.html', this)">
+        <div class="card-preview">
+          <div class="preview-pill">dom: '#el' + step() wizard</div>
+        </div>
+        <div class="card-footer">DOM content + Step flow</div>
+      </div>
     </div>
   </div>
 

+ 66 - 45
doc/layer/overview.html

@@ -64,25 +64,38 @@
         </div>
       </div>
 
-      <pre id="js-code" class="code-view active">// SweetAlert-like icons (SVG + draw)
-xjs.layer({ title: 'Success', text: 'Saved!', icon: 'success' });
-xjs.layer({ title: 'Error', text: 'Something went wrong', icon: 'error' });
-
-// Confirm flow
-xjs.layer({
-  title: 'Delete item?',
-  text: 'This action cannot be undone.',
-  icon: 'warning',
-  showCancelButton: true
-}).then((res) => {
-  if (res.isConfirmed) xjs.layer({ title: 'Deleted', icon: 'success' });
+      <pre id="js-code" class="code-view active">// Bind the buttons below (selector + click handlers)
+// Icon buttons: read config from data-layer-* and open popup on click
+xjs('[data-layer-icon]').layer();
+
+// Confirm flow: custom handler so we can handle the Promise result
+xjs('.btn.confirm').forEach((el) =&gt; {
+  // Idempotent binding (in case this demo re-inits)
+  if (el._xjsConfirmHandler) el.removeEventListener('click', el._xjsConfirmHandler);
+  el._xjsConfirmHandler = () =&gt; {
+    xjs.layer({
+      title: 'Delete item?',
+      text: 'This action cannot be undone.',
+      icon: 'question',
+      showCancelButton: true,
+      confirmButtonText: 'Delete',
+      cancelButtonText: 'Cancel'
+    }).then((res) =&gt; {
+      if (res.isConfirmed) {
+        xjs.layer({ title: 'Deleted', text: 'Done', icon: 'success' });
+      } else if (res.dismiss === 'cancel') {
+        xjs.layer({ title: 'Cancelled', text: 'Nothing happened', icon: 'info' });
+      }
+    });
+  };
+  el.addEventListener('click', el._xjsConfirmHandler);
 });</pre>
 
-      <pre id="html-code" class="html-view">&lt;button class="btn success"&gt;Success&lt;/button&gt;
-&lt;button class="btn error"&gt;Error&lt;/button&gt;
-&lt;button class="btn warning"&gt;Warning&lt;/button&gt;
-&lt;button class="btn info"&gt;Info&lt;/button&gt;
-&lt;button class="btn"&gt;Confirm&lt;/button&gt;</pre>
+      <pre id="html-code" class="html-view">&lt;button class="btn success" data-layer-title="Success" data-layer-text="SVG icon draw + spring entrance" data-layer-icon="success"&gt;Success&lt;/button&gt;
+&lt;button class="btn error" data-layer-title="Error" data-layer-text="SVG icon draw + spring entrance" data-layer-icon="error"&gt;Error&lt;/button&gt;
+&lt;button class="btn warning" data-layer-title="Warning" data-layer-text="SVG icon draw + spring entrance" data-layer-icon="warning"&gt;Warning&lt;/button&gt;
+&lt;button class="btn info" data-layer-title="Info" data-layer-text="SVG icon draw + spring entrance" data-layer-icon="info"&gt;Info&lt;/button&gt;
+&lt;button class="btn confirm"&gt;Confirm&lt;/button&gt;</pre>
 
       <pre id="css-code" class="css-view">/* This page's button styles are local.
    Layer's own CSS is injected by layer.js */</pre>
@@ -93,11 +106,11 @@ xjs.layer({
 
       <div class="demo-visual">
         <div class="row">
-          <button class="btn success" onclick="openIcon('success')">Success</button>
-          <button class="btn error" onclick="openIcon('error')">Error</button>
-          <button class="btn warning" onclick="openIcon('warning')">Warning</button>
-          <button class="btn info" onclick="openIcon('info')">Info</button>
-          <button class="btn" onclick="openConfirm()">Confirm</button>
+          <button class="btn success" data-layer-title="Success" data-layer-text="SVG icon draw + spring entrance" data-layer-icon="success">Success</button>
+          <button class="btn error" data-layer-title="Error" data-layer-text="SVG icon draw + spring entrance" data-layer-icon="error">Error</button>
+          <button class="btn warning" data-layer-title="Warning" data-layer-text="SVG icon draw + spring entrance" data-layer-icon="warning">Warning</button>
+          <button class="btn info" data-layer-title="Info" data-layer-text="SVG icon draw + spring entrance" data-layer-icon="info">Info</button>
+          <button class="btn confirm">Confirm</button>
         </div>
         <div class="hint" style="margin-top: 12px;">
           Tip: try pressing <code class="inline">ESC</code> while the popup is open.
@@ -153,29 +166,35 @@ xjs.layer({
       }
     }
 
-    function openIcon(type) {
-      xjs.layer({
-        title: type[0].toUpperCase() + type.slice(1),
-        text: 'SVG icon draw + spring entrance',
-        icon: type
-      });
-    }
-
-    function openConfirm() {
-      xjs.layer({
-        title: 'Delete item?',
-        text: 'This action cannot be undone.',
-        icon: 'question',
-        showCancelButton: true,
-        confirmButtonText: 'Delete',
-        cancelButtonText: 'Cancel'
-      }).then((res) => {
-        if (res.isConfirmed) {
-          xjs.layer({ title: 'Deleted', text: 'Done', icon: 'success' });
-        } else if (res.dismiss === 'cancel') {
-          xjs.layer({ title: 'Cancelled', text: 'Nothing happened', icon: 'info' });
-        }
-      });
+    function bindQuickDemo() {
+      try {
+        // Icon buttons (dataset-driven)
+        xjs('[data-layer-icon]').layer();
+      } catch {}
+
+      try {
+        // Confirm button (custom Promise flow)
+        xjs('.btn.confirm').forEach((el) => {
+          if (el._xjsConfirmHandler) el.removeEventListener('click', el._xjsConfirmHandler);
+          el._xjsConfirmHandler = () => {
+            xjs.layer({
+              title: 'Delete item?',
+              text: 'This action cannot be undone.',
+              icon: 'question',
+              showCancelButton: true,
+              confirmButtonText: 'Delete',
+              cancelButtonText: 'Cancel'
+            }).then((res) => {
+              if (res.isConfirmed) {
+                xjs.layer({ title: 'Deleted', text: 'Done', icon: 'success' });
+              } else if (res.dismiss === 'cancel') {
+                xjs.layer({ title: 'Cancelled', text: 'Nothing happened', icon: 'info' });
+              }
+            });
+          };
+          el.addEventListener('click', el._xjsConfirmHandler);
+        });
+      } catch {}
     }
 
     function goPrev() { window.parent?.docNavigatePrev?.(CURRENT); }
@@ -194,6 +213,8 @@ xjs.layer({
       document.getElementById('navCenter').textContent = (current && current.group) ? current.group : 'Layer';
     }
 
+    bindQuickDemo();
+
     // Sync middle list selection when loaded inside doc/index.html
     try { window.parent?.setMiddleActive?.(CURRENT); } catch {}
     syncNavLabels();

+ 320 - 0
doc/layer/test_dom_steps.html

@@ -0,0 +1,320 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>Layer DOM Content + Steps</title>
+  <link rel="stylesheet" href="../demo.css">
+  <style>
+    .row {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 10px;
+      align-items: center;
+    }
+    .btn {
+      appearance: none;
+      border: 1px solid rgba(255,255,255,0.12);
+      background: rgba(255,255,255,0.04);
+      color: #fff;
+      border-radius: 999px;
+      padding: 10px 14px;
+      font-weight: 650;
+      cursor: pointer;
+      transition: border-color 0.15s ease, background 0.15s ease;
+    }
+    .btn:hover { border-color: rgba(255,255,255,0.22); background: rgba(255,255,255,0.06); }
+    .btn.primary { border-color: rgba(63,195,238,0.55); background: rgba(63,195,238,0.12); }
+    .btn.success { border-color: rgba(165,220,134,0.55); background: rgba(165,220,134,0.12); }
+    .demo-visual { padding: 22px 24px; }
+    .hint { font-size: 12px; color: #8c8c8c; line-height: 1.5; margin-top: 10px; }
+
+    /* The DOM blocks below are hidden by default. Layer will move them into popup and temporarily show them. */
+    .hidden-dom { display: none; }
+
+    .form {
+      width: 100%;
+      text-align: left;
+      display: grid;
+      gap: 10px;
+    }
+    .field label {
+      display: block;
+      font-size: 12px;
+      color: #777;
+      margin-bottom: 6px;
+    }
+    .field input, .field select {
+      width: 100%;
+      box-sizing: border-box;
+      padding: 10px 12px;
+      border-radius: 10px;
+      border: 1px solid rgba(0,0,0,0.12);
+      outline: none;
+      background: #fff;
+      color: #111;
+      font-size: 14px;
+    }
+    .error {
+      color: #c0392b;
+      font-size: 12px;
+      min-height: 16px;
+    }
+    pre {
+      width: 100%;
+      padding: 12px;
+      border-radius: 12px;
+      background: rgba(0,0,0,0.06);
+      overflow: auto;
+    }
+  </style>
+</head>
+<body>
+  <div class="container">
+    <div class="page-top">
+      <div class="crumb">UI › LAYER</div>
+      <div class="since">DOM + STEPS</div>
+    </div>
+
+    <h1>DOM content + Step flow</h1>
+    <p class="description">
+      Demonstrates <code class="inline">dom</code> (mount/restore hidden DOM into popup) and a multi-step wizard using <code class="inline">step()</code> / <code class="inline">steps()</code>.
+    </p>
+
+    <div class="box-container">
+      <div class="box-header">
+        <div class="box-title">Example</div>
+        <div class="tabs">
+          <div class="tab active" onclick="switchTab('js')">JavaScript</div>
+          <div class="tab" onclick="switchTab('html')">HTML</div>
+          <div class="tab" onclick="switchTab('css')">CSS</div>
+        </div>
+      </div>
+
+      <pre id="js-code" class="code-view active">// 1) Single popup: show a hidden DOM block inside Layer
+xjs.layer({
+  title: 'DOM content',
+  dom: '#demo_dom_block',
+  showCancelButton: true,
+  confirmButtonText: 'OK',
+  cancelButtonText: 'Close'
+});
+
+// 2) Step flow (wizard)
+xjs.Layer.$({
+  icon: 'info',
+  closeOnClickOutside: false,
+  closeOnEsc: false,
+  confirmButtonColor: '#3085d6',
+  cancelButtonColor: '#444'
+})
+.step({
+  title: 'Step 1: Basic info',
+  dom: '#wizard_step_1',
+  showCancelButton: true,
+  cancelButtonText: 'Cancel',
+  preConfirm(popup) {
+    const name = popup.querySelector('input[name="name"]').value.trim();
+    const err = popup.querySelector('[data-error]');
+    if (!name) { err.textContent = 'Please enter your name.'; return false; }
+    err.textContent = '';
+    return { name };
+  }
+})
+.step({
+  title: 'Step 2: Preferences',
+  dom: '#wizard_step_2',
+  preConfirm(popup) {
+    const plan = popup.querySelector('select[name="plan"]').value;
+    return { plan };
+  }
+})
+.fire()
+.then((res) =&gt; {
+  if (res.isConfirmed) {
+    xjs.layer({
+      title: 'Done',
+      icon: 'success',
+      html: '&lt;pre&gt;' + JSON.stringify(res.value, null, 2) + '&lt;/pre&gt;'
+    });
+  }
+});</pre>
+
+      <pre id="html-code" class="html-view">&lt;button class="btn primary" onclick="openDom()"&gt;Open DOM popup&lt;/button&gt;
+&lt;button class="btn success" onclick="openWizard()"&gt;Open step wizard&lt;/button&gt;
+
+&lt;!-- Hidden DOM blocks (default display:none) --&gt;
+&lt;div id="demo_dom_block" class="hidden-dom"&gt;...&lt;/div&gt;
+&lt;div id="wizard_step_1" class="hidden-dom"&gt;...&lt;/div&gt;
+&lt;div id="wizard_step_2" class="hidden-dom"&gt;...&lt;/div&gt;</pre>
+
+      <pre id="css-code" class="css-view">/* This page hides DOM blocks by default:
+.hidden-dom { display: none; }
+
+Layer will move the element into the popup while open,
+then restore it back (and restore display/hidden state) on close. */</pre>
+
+      <div class="feature-desc">
+        <strong>功能说明:</strong>
+        <code class="inline">dom</code> 支持把指定 DOM(可默认隐藏)挂进弹窗并在关闭时还原;步骤流通过同一弹窗内平滑切换实现“分步填写”体验,最终 <code class="inline">res.value</code> 返回每一步的 <code class="inline">preConfirm</code> 结果数组。
+      </div>
+
+      <div class="demo-visual">
+        <div class="row">
+          <button class="btn primary" onclick="openDom()">Open DOM popup</button>
+          <button class="btn success" onclick="openWizard()">Open step wizard</button>
+        </div>
+        <div class="hint">
+          Tip: step wizard disables backdrop/ESC close to avoid accidental dismiss.
+        </div>
+      </div>
+    </div>
+
+    <!-- Hidden DOM content (these are mounted into Layer) -->
+    <div id="demo_dom_block" class="hidden-dom">
+      <div class="form">
+        <div class="field">
+          <label>Quick note</label>
+          <input placeholder="This input lives in a hidden DOM block" value="Hello from DOM!">
+        </div>
+        <div class="hint" style="margin-top: -4px;">
+          This DOM node is moved into the popup and restored on close.
+        </div>
+      </div>
+    </div>
+
+    <div id="wizard_step_1" class="hidden-dom">
+      <div class="form">
+        <div class="field">
+          <label>Name</label>
+          <input name="name" placeholder="Your name">
+        </div>
+        <div class="error" data-error></div>
+      </div>
+    </div>
+
+    <div id="wizard_step_2" class="hidden-dom">
+      <div class="form">
+        <div class="field">
+          <label>Plan</label>
+          <select name="plan">
+            <option value="free">Free</option>
+            <option value="pro">Pro</option>
+            <option value="team">Team</option>
+          </select>
+        </div>
+        <div class="hint">Confirm will finish the wizard.</div>
+      </div>
+    </div>
+
+    <div class="doc-nav" aria-label="Previous and next navigation">
+      <a href="#" id="prevLink" onclick="goPrev(); return false;">
+        <span><span class="nav-label">Previous</span><br><span class="nav-title" id="prevTitle">—</span></span>
+        <span aria-hidden="true">←</span>
+      </a>
+      <div class="nav-center" id="navCenter">Layer</div>
+      <a href="#" id="nextLink" onclick="goNext(); return false;">
+        <span><span class="nav-label">Next</span><br><span class="nav-title" id="nextTitle">—</span></span>
+        <span aria-hidden="true">→</span>
+      </a>
+    </div>
+  </div>
+
+  <script src="../highlight_css.js"></script>
+  <!-- NOTE: xjs.js bundles an older Layer in some builds.
+       For local testing, load standalone layer.js last to ensure this page uses the edited Layer implementation. -->
+  <script src="../../xjs.js?v=dev"></script>
+  <script src="../../layer.js?v=dev"></script>
+  <script>
+    const CURRENT = 'layer/test_dom_steps.html';
+
+    function switchTab(tab) {
+      document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
+      document.querySelectorAll('.code-view, .html-view, .css-view').forEach(v => v.classList.remove('active'));
+      if (tab === 'js') {
+        document.querySelector('.tabs .tab:nth-child(1)')?.classList.add('active');
+        document.getElementById('js-code')?.classList.add('active');
+      } else if (tab === 'html') {
+        document.querySelector('.tabs .tab:nth-child(2)')?.classList.add('active');
+        document.getElementById('html-code')?.classList.add('active');
+      } else {
+        document.querySelector('.tabs .tab:nth-child(3)')?.classList.add('active');
+        document.getElementById('css-code')?.classList.add('active');
+      }
+    }
+
+    function openDom() {
+      xjs.layer({
+        title: 'DOM content',
+        dom: '#demo_dom_block',
+        showCancelButton: true,
+        confirmButtonText: 'OK',
+        cancelButtonText: 'Close'
+      });
+    }
+
+    function openWizard() {
+      xjs.Layer.$({
+        icon: 'info',
+        closeOnClickOutside: false,
+        closeOnEsc: false,
+        confirmButtonColor: '#3085d6',
+        cancelButtonColor: '#444'
+      })
+      .step({
+        title: 'Step 1: Basic info',
+        dom: '#wizard_step_1',
+        showCancelButton: true,
+        cancelButtonText: 'Cancel',
+        preConfirm(popup) {
+          const name = popup.querySelector('input[name="name"]').value.trim();
+          const err = popup.querySelector('[data-error]');
+          if (!name) { err.textContent = 'Please enter your name.'; return false; }
+          err.textContent = '';
+          return { name };
+        }
+      })
+      .step({
+        title: 'Step 2: Preferences',
+        dom: '#wizard_step_2',
+        preConfirm(popup) {
+          const plan = popup.querySelector('select[name="plan"]').value;
+          return { plan };
+        }
+      })
+      .fire()
+      .then((res) => {
+        if (res.isConfirmed) {
+          xjs.layer({
+            title: 'Done',
+            icon: 'success',
+            html: '<pre>' + JSON.stringify(res.value, null, 2) + '</pre>'
+          });
+        } else {
+          xjs.layer({ title: 'Dismissed', icon: 'info', text: 'Flow cancelled' });
+        }
+      });
+    }
+
+    function goPrev() { window.parent?.docNavigatePrev?.(CURRENT); }
+    function goNext() { window.parent?.docNavigateNext?.(CURRENT); }
+
+    function syncNavLabels() {
+      const api = window.parent?.docGetPrevNext;
+      if (typeof api !== 'function') return;
+      const { prev, next, current } = api(CURRENT) || {};
+      const prevLink = document.getElementById('prevLink');
+      const nextLink = document.getElementById('nextLink');
+      if (prev) document.getElementById('prevTitle').textContent = prev.title || prev.url;
+      else { prevLink.style.visibility = 'hidden'; }
+      if (next) document.getElementById('nextTitle').textContent = next.title || next.url;
+      else { nextLink.style.visibility = 'hidden'; }
+      document.getElementById('navCenter').textContent = (current && current.group) ? current.group : 'Layer';
+    }
+
+    try { window.parent?.setMiddleActive?.(CURRENT); } catch {}
+    syncNavLabels();
+  </script>
+</body>
+</html>
+

+ 119 - 59
doc/layer/test_icons_svg_animation.html

@@ -5,6 +5,11 @@
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Layer SVG Icon Animation - Animal.js</title>
   <link rel="stylesheet" href="../demo.css">
+  <link rel="stylesheet" href="../../xjs.css?v=2">
+  <style>
+    /* Keep Layer above the doc UI */
+    .layer-overlay { z-index: 99999; }
+  </style>
   <style>
     .controls {
       display: flex;
@@ -72,14 +77,22 @@
 
       <pre id="js-code" class="code-view active">xjs.layer({
   title: 'Success',
-  text: 'SVG draw + spring',
+  text: 'SVG draw + spring entrance',
   icon: 'success',
   iconAnimation: true,
-  popupAnimation: true
+  popupAnimation: true,
+  closeOnEsc: true
 });</pre>
 
-      <pre id="html-code" class="html-view">&lt;label&gt;&lt;input type="checkbox" checked&gt; iconAnimation&lt;/label&gt;
-&lt;label&gt;&lt;input type="checkbox" checked&gt; popupAnimation&lt;/label&gt;</pre>
+      <pre id="html-code" class="html-view">&lt;label&gt;&lt;input id="optIcon" type="checkbox" checked&gt; iconAnimation&lt;/label&gt;
+&lt;label&gt;&lt;input id="optPopup" type="checkbox" checked&gt; popupAnimation&lt;/label&gt;
+&lt;label&gt;&lt;input id="optEsc" type="checkbox" checked&gt; closeOnEsc&lt;/label&gt;
+
+&lt;button class="btn success" type="button" onclick="openDemo('success')"&gt;Success&lt;/button&gt;
+&lt;button class="btn error" type="button" onclick="openDemo('error')"&gt;Error&lt;/button&gt;
+&lt;button class="btn warning" type="button" onclick="openDemo('warning')"&gt;Warning&lt;/button&gt;
+&lt;button class="btn info" type="button" onclick="openDemo('info')"&gt;Info&lt;/button&gt;
+&lt;button class="btn" type="button" onclick="openDemo('question')"&gt;Question&lt;/button&gt;</pre>
 
       <pre id="css-code" class="css-view">/* Icon styling lives inside layer.js */</pre>
 
@@ -94,11 +107,11 @@
           <label><input id="optEsc" type="checkbox" checked> closeOnEsc</label>
         </div>
         <div class="row">
-          <button class="btn success" type="button" onclick="open('success')">Success</button>
-          <button class="btn error" type="button" onclick="open('error')">Error</button>
-          <button class="btn warning" type="button" onclick="open('warning')">Warning</button>
-          <button class="btn info" type="button" onclick="open('info')">Info</button>
-          <button class="btn" type="button" onclick="open('question')">Question</button>
+          <button class="btn success" type="button" onclick="openDemo('success')">Success</button>
+          <button class="btn error" type="button" onclick="openDemo('error')">Error</button>
+          <button class="btn warning" type="button" onclick="openDemo('warning')">Warning</button>
+          <button class="btn info" type="button" onclick="openDemo('info')">Info</button>
+          <button class="btn" type="button" onclick="openDemo('question')">Question</button>
         </div>
         <div class="hint">
           Tip: open “Error” to see the two stroke segments staggered.
@@ -120,47 +133,15 @@
   </div>
 
   <script src="../highlight_css.js"></script>
-  <!-- NOTE: xjs.js bundles an older Layer in some builds.
-       For local testing, load standalone layer.js last to ensure this page uses the edited Layer implementation. -->
-  <script src="../../xjs.js?v=dev"></script>
+  <!-- Dev/test: load source files directly (sync, avoids async loader timing) -->
+  <script src="../../animal.js?v=dev"></script>
   <script src="../../layer.js?v=dev"></script>
+  <script>
+    // Ensure global alias
+    if (window.animal && !window.xjs) window.xjs = window.animal;
+  </script>
   <script>
     const CURRENT = 'layer/test_icons_svg_animation.html';
-    // On-page error surface (helps debug inside iframe)
-    (function () {
-      const showErr = (title, detail) => {
-        try {
-          let el = document.getElementById('__err');
-          if (!el) {
-            el = document.createElement('div');
-            el.id = '__err';
-            el.style.position = 'fixed';
-            el.style.left = '16px';
-            el.style.right = '16px';
-            el.style.bottom = '16px';
-            el.style.zIndex = '999999';
-            el.style.background = 'rgba(10,10,10,0.92)';
-            el.style.border = '1px solid rgba(255,75,75,0.35)';
-            el.style.borderRadius = '12px';
-            el.style.padding = '10px 12px';
-            el.style.color = '#ffd1d1';
-            el.style.fontFamily = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
-            el.style.fontSize = '12px';
-            el.style.whiteSpace = 'pre-wrap';
-            el.style.maxHeight = '40vh';
-            el.style.overflow = 'auto';
-            document.body.appendChild(el);
-          }
-          el.textContent = `[${title}]\n` + String(detail || '');
-        } catch {}
-      };
-      window.addEventListener('error', (e) => {
-        showErr('error', e?.error?.stack || e?.message || e);
-      });
-      window.addEventListener('unhandledrejection', (e) => {
-        showErr('unhandledrejection', e?.reason?.stack || e?.reason || e);
-      });
-    })();
 
     function switchTab(tab) {
       document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
@@ -177,21 +158,100 @@
       }
     }
 
-    function open(type) {
+    // Small on-page debug panel (helps when running inside iframe)
+    function __showDebug(title, detail) {
+      try {
+        let el = document.getElementById('__dbg');
+        if (!el) {
+          el = document.createElement('div');
+          el.id = '__dbg';
+          el.style.position = 'fixed';
+          el.style.left = '16px';
+          el.style.right = '16px';
+          el.style.bottom = '16px';
+          el.style.zIndex = '999999';
+          el.style.background = 'rgba(10,10,10,0.92)';
+          el.style.border = '1px solid rgba(255,255,255,0.18)';
+          el.style.borderRadius = '12px';
+          el.style.padding = '10px 12px';
+          el.style.color = '#eaeaea';
+          el.style.fontFamily = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
+          el.style.fontSize = '12px';
+          el.style.whiteSpace = 'pre-wrap';
+          el.style.maxHeight = '40vh';
+          el.style.overflow = 'auto';
+          document.body.appendChild(el);
+        }
+        el.textContent = `[${title}]\n` + String(detail || '');
+      } catch {}
+    }
+
+    function __debugLayerDom() {
+      try {
+        const overlay = document.querySelector('.layer-overlay');
+        const popup = document.querySelector('.layer-popup');
+        const info = [];
+        info.push(`overlay: ${overlay ? 'yes' : 'no'}`);
+        info.push(`popup:   ${popup ? 'yes' : 'no'}`);
+        if (overlay) {
+          const s = getComputedStyle(overlay);
+          const r = overlay.getBoundingClientRect();
+          info.push(`overlay display=${s.display} visibility=${s.visibility} opacity=${s.opacity} zIndex=${s.zIndex} bg=${s.backgroundColor}`);
+          info.push(`overlay rect=${Math.round(r.width)}x${Math.round(r.height)} at (${Math.round(r.left)},${Math.round(r.top)})`);
+        }
+        if (popup) {
+          const s = getComputedStyle(popup);
+          const r = popup.getBoundingClientRect();
+          info.push(`popup display=${s.display} visibility=${s.visibility} opacity=${s.opacity} zIndex=${s.zIndex} bg=${s.backgroundColor} color=${s.color}`);
+          info.push(`popup transform=${s.transform}`);
+          info.push(`popup rect=${Math.round(r.width)}x${Math.round(r.height)} at (${Math.round(r.left)},${Math.round(r.top)})`);
+          info.push(`popup children=${popup.children.length}`);
+        }
+        __showDebug('layer debug', info.join('\n'));
+      } catch (e) {
+        __showDebug('layer debug error', e?.stack || e?.message || e);
+      }
+    }
+
+    function openDemo(type) {
+      const X = window.xjs || window.animal;
+      if (typeof X !== 'function') {
+        __showDebug('xjs not ready', 'animal.js not loaded or xjs alias missing.');
+        return;
+      }
+
       const iconAnimation = !!document.getElementById('optIcon')?.checked;
       const popupAnimation = !!document.getElementById('optPopup')?.checked;
       const closeOnEsc = !!document.getElementById('optEsc')?.checked;
-      // Defensive: if Layer failed to load, surface a useful error
-      if (typeof xjs !== 'function') throw new Error('xjs is not loaded');
-      if (!window.Layer) throw new Error('Layer is not loaded');
-      return xjs.layer({
-        title: type[0].toUpperCase() + type.slice(1),
-        text: 'SVG draw + spring entrance',
-        icon: type,
-        iconAnimation,
-        popupAnimation,
-        closeOnEsc
-      });
+
+      const title = String(type || '');
+      const titleCap = title ? (title[0].toUpperCase() + title.slice(1)) : '';
+
+      // Keep the displayed code consistent with what is executed.
+      const code = `xjs.layer({
+  title: '${titleCap}',
+  text: 'SVG draw + spring entrance',
+  icon: '${title}',
+  iconAnimation: ${iconAnimation},
+  popupAnimation: ${popupAnimation},
+  closeOnEsc: ${closeOnEsc}
+});`;
+      const jsPre = document.getElementById('js-code');
+      if (jsPre) jsPre.textContent = code;
+
+      try {
+        X.layer({
+          title: titleCap,
+          text: 'SVG draw + spring entrance',
+          icon: title,
+          iconAnimation,
+          popupAnimation,
+          closeOnEsc
+        });
+        setTimeout(__debugLayerDom, 30);
+      } catch (e) {
+        __showDebug('openDemo error', e?.stack || e?.message || e);
+      }
     }
 
     function goPrev() { window.parent?.docNavigatePrev?.(CURRENT); }

+ 447 - 17
layer.js

@@ -103,6 +103,14 @@
       this.resolve = null;
       this.reject = null;
       this._onKeydown = null;
+      this._mounted = null; // { kind, originalEl, placeholder, parent, nextSibling, prevHidden, prevInlineDisplay }
+      this._isClosing = false;
+
+      // Step/flow support
+      this._flowSteps = null; // Array<options>
+      this._flowIndex = 0;
+      this._flowValues = [];
+      this._flowBase = null; // base options merged into each step
     }
 
     // Constructor helper when called as function: const popup = Layer({...})
@@ -131,9 +139,40 @@
       this.params = { ...(this.params || {}), ...options };
       return this;
     }
+
+    // Add a single step (chainable)
+    // Usage:
+    //   Layer.$().step({ title:'A', dom:'#step1' }).step({ title:'B', dom:'#step2' }).fire()
+    step(options = {}) {
+      if (!this._flowSteps) this._flowSteps = [];
+      this._flowSteps.push(normalizeOptions(options, arguments[1], arguments[2]));
+      return this;
+    }
+
+    // Add multiple steps at once (chainable)
+    steps(steps = []) {
+      if (!Array.isArray(steps)) return this;
+      if (!this._flowSteps) this._flowSteps = [];
+      steps.forEach((s) => this.step(s));
+      return this;
+    }
+
+    // Convenience static helper: Layer.flow([steps], baseOptions?)
+    static flow(steps = [], baseOptions = {}) {
+      return Layer.$(baseOptions).steps(steps).fire();
+    }
     
     // Instance entry point (chainable)
     fire(options) {
+      // Flow mode: if configured via .step()/.steps(), ignore per-call options and use steps
+      if (this._flowSteps && this._flowSteps.length) {
+        if (options !== undefined && options !== null) {
+          // allow providing base options at fire-time
+          this.params = { ...(this.params || {}), ...normalizeOptions(options, arguments[1], arguments[2]) };
+        }
+        return this._fireFlow();
+      }
+
       const merged = (options === undefined) ? (this.params || {}) : options;
       return this._fire(merged);
     }
@@ -155,6 +194,18 @@
         closeOnEsc: true,
         iconAnimation: true,
         popupAnimation: true,
+        // Content:
+        // - text: plain text
+        // - html: innerHTML
+        // - dom: selector / Element / <template> (preferred)
+        // - content: backward compat for selector/Element OR advanced { dom, mode, clone }
+        dom: null,
+        domMode: 'move', // 'move' (default) | 'clone'
+        // Hook for confirming (also used in flow steps)
+        // Return false to prevent close / next.
+        // Return a value to be attached as `value`.
+        // May return Promise.
+        preConfirm: null,
         ...options
       };
 
@@ -167,6 +218,22 @@
       return this.promise;
     }
 
+    _fireFlow() {
+      this._flowIndex = 0;
+      this._flowValues = [];
+      this._flowBase = { ...(this.params || {}) };
+
+      this.promise = new Promise((resolve, reject) => {
+        this.resolve = resolve;
+        this.reject = reject;
+      });
+
+      const first = this._getFlowStepOptions(0);
+      this.params = first;
+      this._render({ flow: true });
+      return this.promise;
+    }
+
     static _getXjs() {
       // Prefer xjs, fallback to animal (compat)
       const g = (typeof window !== 'undefined') ? window : (typeof globalThis !== 'undefined' ? globalThis : null);
@@ -175,13 +242,20 @@
       return (typeof x === 'function') ? x : null;
     }
 
-    _render() {
-      // Remove existing if any
+    _render(meta = null) {
+      // Remove existing if any (but first, try to restore any mounted DOM from the previous instance)
       const existing = document.querySelector(`.${PREFIX}overlay`);
-      if (existing) existing.remove();
+      if (existing) {
+        try {
+          const prev = existing._layerInstance;
+          if (prev && typeof prev._forceDestroy === 'function') prev._forceDestroy('replace');
+        } catch {}
+        try { existing.remove(); } catch {}
+      }
 
       // Create Overlay
       this.dom.overlay = el('div', `${PREFIX}overlay`);
+      this.dom.overlay._layerInstance = this;
 
       // Create Popup
       this.dom.popup = el('div', `${PREFIX}popup`);
@@ -200,14 +274,13 @@
       }
 
       // Content (Text / HTML / Element)
-      if (this.params.text || this.params.html || this.params.content) {
+      if (this.params.text || this.params.html || this.params.content || this.params.dom) {
         this.dom.content = el('div', `${PREFIX}content`);
-        
-        if (this.params.content) {
-           // DOM Element or Selector
-           let el = this.params.content;
-           if (typeof el === 'string') el = document.querySelector(el);
-           if (el instanceof Element) this.dom.content.appendChild(el); 
+
+        // DOM content (preferred: dom, backward: content)
+        const domSpec = this._normalizeDomSpec(this.params);
+        if (domSpec) {
+          this._mountDomContent(domSpec);
         } else if (this.params.html) {
            this.dom.content.innerHTML = this.params.html;
         } else {
@@ -224,14 +297,14 @@
       if (this.params.showCancelButton) {
         this.dom.cancelBtn = el('button', `${PREFIX}button ${PREFIX}cancel`, this.params.cancelButtonText);
         this.dom.cancelBtn.style.backgroundColor = this.params.cancelButtonColor;
-        this.dom.cancelBtn.onclick = () => this._close(false);
+        this.dom.cancelBtn.onclick = () => this._handleCancel();
         this.dom.actions.appendChild(this.dom.cancelBtn);
       }
 
       // Confirm Button
       this.dom.confirmBtn = el('button', `${PREFIX}button ${PREFIX}confirm`, this.params.confirmButtonText);
       this.dom.confirmBtn.style.backgroundColor = this.params.confirmButtonColor;
-      this.dom.confirmBtn.onclick = () => this._close(true);
+      this.dom.confirmBtn.onclick = () => this._handleConfirm();
       this.dom.actions.appendChild(this.dom.confirmBtn);
 
       this.dom.popup.appendChild(this.dom.actions);
@@ -402,7 +475,7 @@
       if (this.params.closeOnEsc) {
         this._onKeydown = (e) => {
           if (!e) return;
-          if (e.key === 'Escape') this._close(null);
+          if (e.key === 'Escape') this._close(null, 'esc');
         };
         document.addEventListener('keydown', this._onKeydown);
       }
@@ -497,7 +570,27 @@
       }
     }
 
-    _close(isConfirmed) {
+    _forceDestroy(reason = 'replace') {
+      // Restore mounted DOM (if any) and cleanup listeners; also resolve the promise so it doesn't hang.
+      try { this._unmountDomContent(); } catch {}
+      if (this._onKeydown) {
+        try { document.removeEventListener('keydown', this._onKeydown); } catch {}
+        this._onKeydown = null;
+      }
+      try {
+        if (this.resolve) {
+          this.resolve({ isConfirmed: false, isDenied: false, isDismissed: true, dismiss: reason });
+        }
+      } catch {}
+    }
+
+    _close(isConfirmed, reason) {
+      if (this._isClosing) return;
+      this._isClosing = true;
+
+      // Restore mounted DOM (moved into popup) before removing overlay
+      try { this._unmountDomContent(); } catch {}
+
       this.dom.overlay.classList.remove('show');
       setTimeout(() => {
         if (this.dom.overlay && this.dom.overlay.parentNode) {
@@ -517,14 +610,351 @@
         if (typeof this.params.didClose === 'function') this.params.didClose();
       } catch {}
 
+      const value = (this._flowSteps && this._flowSteps.length) ? (this._flowValues || []) : undefined;
       if (isConfirmed === true) {
-        this.resolve({ isConfirmed: true, isDenied: false, isDismissed: false });
+        this.resolve({ isConfirmed: true, isDenied: false, isDismissed: false, value });
       } else if (isConfirmed === false) {
-        this.resolve({ isConfirmed: false, isDenied: false, isDismissed: true, dismiss: 'cancel' });
+        this.resolve({ isConfirmed: false, isDenied: false, isDismissed: true, dismiss: reason || 'cancel', value });
       } else {
-        this.resolve({ isConfirmed: false, isDenied: false, isDismissed: true, dismiss: 'backdrop' });
+        this.resolve({ isConfirmed: false, isDenied: false, isDismissed: true, dismiss: reason || 'backdrop', value });
+      }
+    }
+
+    _normalizeDomSpec(params) {
+      // Preferred: params.dom (selector/Element/template)
+      // Backward: params.content (selector/Element) OR advanced object { dom, mode, clone }
+      const p = params || {};
+      let dom = p.dom;
+      let mode = p.domMode || 'move';
+
+      if (dom == null && p.content != null) {
+        if (typeof p.content === 'object' && !(p.content instanceof Element)) {
+          if (p.content && (p.content.dom != null || p.content.selector != null)) {
+            dom = (p.content.dom != null) ? p.content.dom : p.content.selector;
+            if (p.content.mode) mode = p.content.mode;
+            if (p.content.clone === true) mode = 'clone';
+          }
+        } else {
+          dom = p.content;
+        }
+      }
+
+      if (dom == null) return null;
+      return { dom, mode };
+    }
+
+    _mountDomContent(domSpec) {
+      try {
+        if (!this.dom.content) return;
+        this._unmountDomContent(); // ensure only one mount at a time
+
+        const forceVisible = (el) => {
+          if (!el) return { prevHidden: false, prevInlineDisplay: '' };
+          const prevHidden = !!el.hidden;
+          const prevInlineDisplay = (el.style && typeof el.style.display === 'string') ? el.style.display : '';
+          try { el.hidden = false; } catch {}
+          try {
+            const cs = (typeof getComputedStyle === 'function') ? getComputedStyle(el) : null;
+            const display = cs ? String(cs.display || '') : '';
+            // If element is hidden via CSS (e.g. .hidden-dom{display:none}),
+            // add an inline override so it becomes visible inside the popup.
+            if (display === 'none') el.style.display = 'block';
+            else if (el.style && el.style.display === 'none') el.style.display = 'block';
+          } catch {}
+          return { prevHidden, prevInlineDisplay };
+        };
+
+        let node = domSpec.dom;
+        if (typeof node === 'string') node = document.querySelector(node);
+        if (!node) return;
+
+        // <template> support: always clone template content
+        if (typeof HTMLTemplateElement !== 'undefined' && node instanceof HTMLTemplateElement) {
+          const frag = node.content.cloneNode(true);
+          this.dom.content.appendChild(frag);
+          this._mounted = { kind: 'template' };
+          return;
+        }
+
+        if (!(node instanceof Element)) return;
+
+        if (domSpec.mode === 'clone') {
+          const clone = node.cloneNode(true);
+          // If original is hidden (via attr or CSS), the clone may inherit; force show it in popup.
+          try { clone.hidden = false; } catch {}
+          try {
+            const cs = (typeof getComputedStyle === 'function') ? getComputedStyle(node) : null;
+            const display = cs ? String(cs.display || '') : '';
+            if (display === 'none') clone.style.display = 'block';
+          } catch {}
+          this.dom.content.appendChild(clone);
+          this._mounted = { kind: 'clone' };
+          return;
+        }
+
+        // Default: move into popup but restore on close
+        const placeholder = document.createComment('layer-dom-placeholder');
+        const parent = node.parentNode;
+        const nextSibling = node.nextSibling;
+        if (!parent) {
+          // Detached node: moving would lose it when overlay is removed; clone instead.
+          const clone = node.cloneNode(true);
+          try { clone.hidden = false; } catch {}
+          try { if (clone.style && clone.style.display === 'none') clone.style.display = ''; } catch {}
+          this.dom.content.appendChild(clone);
+          this._mounted = { kind: 'clone' };
+          return;
+        }
+
+        try { parent.insertBefore(placeholder, nextSibling); } catch {}
+
+        const { prevHidden, prevInlineDisplay } = forceVisible(node);
+
+        this.dom.content.appendChild(node);
+        this._mounted = { kind: 'move', originalEl: node, placeholder, parent, nextSibling, prevHidden, prevInlineDisplay };
+      } catch {
+        // ignore
       }
     }
+
+    _unmountDomContent() {
+      const m = this._mounted;
+      if (!m) return;
+      this._mounted = null;
+
+      if (m.kind !== 'move') return;
+      const node = m.originalEl;
+      if (!node) return;
+
+      // Restore hidden/display
+      try { node.hidden = !!m.prevHidden; } catch {}
+      try {
+        if (node.style && typeof m.prevInlineDisplay === 'string') node.style.display = m.prevInlineDisplay;
+      } catch {}
+
+      // Move back to original position
+      try {
+        const ph = m.placeholder;
+        if (ph && ph.parentNode) {
+          ph.parentNode.insertBefore(node, ph);
+          ph.parentNode.removeChild(ph);
+          return;
+        }
+      } catch {}
+
+      // Fallback: append to original parent
+      try {
+        if (m.parent) m.parent.appendChild(node);
+      } catch {}
+    }
+
+    _setButtonsDisabled(disabled) {
+      try {
+        if (this.dom && this.dom.confirmBtn) this.dom.confirmBtn.disabled = !!disabled;
+        if (this.dom && this.dom.cancelBtn) this.dom.cancelBtn.disabled = !!disabled;
+      } catch {}
+    }
+
+    async _handleConfirm() {
+      // Flow next / finalize
+      if (this._flowSteps && this._flowSteps.length) {
+        return this._flowNext();
+      }
+
+      // Single popup: support async preConfirm
+      const pre = this.params && this.params.preConfirm;
+      if (typeof pre === 'function') {
+        try {
+          this._setButtonsDisabled(true);
+          const r = pre(this.dom && this.dom.popup);
+          const v = (r && typeof r.then === 'function') ? await r : r;
+          if (v === false) {
+            this._setButtonsDisabled(false);
+            return;
+          }
+          // store value in non-flow mode as single value
+          this._flowValues = [v];
+        } catch (e) {
+          console.error(e);
+          this._setButtonsDisabled(false);
+          return;
+        }
+      }
+
+      this._close(true, 'confirm');
+    }
+
+    _handleCancel() {
+      if (this._flowSteps && this._flowSteps.length) {
+        // default: if not first step, cancel acts as "back"
+        if (this._flowIndex > 0) {
+          this._flowPrev();
+          return;
+        }
+      }
+      this._close(false, 'cancel');
+    }
+
+    _getFlowStepOptions(index) {
+      const step = (this._flowSteps && this._flowSteps[index]) ? this._flowSteps[index] : {};
+      const base = this._flowBase || {};
+      const merged = { ...base, ...step };
+
+      // Default button texts for flow
+      const isLast = index >= (this._flowSteps.length - 1);
+      if (!('confirmButtonText' in step)) merged.confirmButtonText = isLast ? (base.confirmButtonText || 'OK') : 'Next';
+
+      // Show cancel as Back after first step (unless step explicitly overrides)
+      if (index > 0) {
+        if (!('showCancelButton' in step)) merged.showCancelButton = true;
+        if (!('cancelButtonText' in step)) merged.cancelButtonText = 'Back';
+      } else {
+        // First step default keeps base settings
+        if (!('cancelButtonText' in step) && merged.showCancelButton) merged.cancelButtonText = merged.cancelButtonText || 'Cancel';
+      }
+
+      return merged;
+    }
+
+    async _flowNext() {
+      const idx = this._flowIndex;
+      const total = this._flowSteps.length;
+      const isLast = idx >= (total - 1);
+
+      // preConfirm hook for current step
+      const pre = this.params && this.params.preConfirm;
+      if (typeof pre === 'function') {
+        try {
+          this._setButtonsDisabled(true);
+          const r = pre(this.dom && this.dom.popup, idx);
+          const v = (r && typeof r.then === 'function') ? await r : r;
+          if (v === false) {
+            this._setButtonsDisabled(false);
+            return;
+          }
+          this._flowValues[idx] = v;
+        } catch (e) {
+          console.error(e);
+          this._setButtonsDisabled(false);
+          return;
+        }
+      }
+
+      if (isLast) {
+        this._close(true, 'confirm');
+        return;
+      }
+
+      this._setButtonsDisabled(false);
+      await this._flowGo(idx + 1, 'next');
+    }
+
+    async _flowPrev() {
+      const idx = this._flowIndex;
+      if (idx <= 0) return;
+      await this._flowGo(idx - 1, 'prev');
+    }
+
+    async _flowGo(index, direction) {
+      const next = this._getFlowStepOptions(index);
+      this._flowIndex = index;
+      await this._transitionTo(next, direction);
+    }
+
+    async _transitionTo(nextOptions, direction) {
+      // Animate popup swap (keep overlay)
+      const popup = this.dom && this.dom.popup;
+      if (!popup) {
+        this.params = nextOptions;
+        this._rerenderInside();
+        return;
+      }
+
+      // First, unmount moved DOM from current step so it can be remounted later
+      try { this._unmountDomContent(); } catch {}
+
+      const outKeyframes = [
+        { opacity: 1, transform: 'translateY(0px)' },
+        { opacity: 0.55, transform: `translateY(${direction === 'prev' ? '8px' : '-8px'})` }
+      ];
+      const inKeyframes = [
+        { opacity: 0.55, transform: `translateY(${direction === 'prev' ? '-8px' : '8px'})` },
+        { opacity: 1, transform: 'translateY(0px)' }
+      ];
+
+      try {
+        const a = popup.animate(outKeyframes, { duration: 140, easing: 'ease-out', fill: 'forwards' });
+        await (a && a.finished ? a.finished.catch(() => {}) : Promise.resolve());
+      } catch {}
+
+      // Apply new options
+      this.params = nextOptions;
+      this._rerenderInside();
+
+      try {
+        const b = popup.animate(inKeyframes, { duration: 200, easing: 'cubic-bezier(0.2, 0.9, 0.2, 1)', fill: 'forwards' });
+        await (b && b.finished ? b.finished.catch(() => {}) : Promise.resolve());
+      } catch {}
+
+      // Re-adjust icon ring bg in case popup background differs
+      try { this._adjustRingBackgroundColor(); } catch {}
+    }
+
+    _rerenderInside() {
+      // Update popup content without recreating overlay (used in flow transitions)
+      const popup = this.dom && this.dom.popup;
+      if (!popup) return;
+
+      // Clear popup (but keep reference)
+      while (popup.firstChild) popup.removeChild(popup.firstChild);
+
+      // Icon
+      this.dom.icon = null;
+      if (this.params.icon) {
+        this.dom.icon = this._createIcon(this.params.icon);
+        popup.appendChild(this.dom.icon);
+      }
+
+      // Title
+      this.dom.title = null;
+      if (this.params.title) {
+        this.dom.title = el('h2', `${PREFIX}title`, this.params.title);
+        popup.appendChild(this.dom.title);
+      }
+
+      // Content
+      this.dom.content = null;
+      if (this.params.text || this.params.html || this.params.content || this.params.dom) {
+        this.dom.content = el('div', `${PREFIX}content`);
+        const domSpec = this._normalizeDomSpec(this.params);
+        if (domSpec) this._mountDomContent(domSpec);
+        else if (this.params.html) this.dom.content.innerHTML = this.params.html;
+        else this.dom.content.textContent = this.params.text;
+        popup.appendChild(this.dom.content);
+      }
+
+      // Actions / buttons
+      this.dom.actions = el('div', `${PREFIX}actions`);
+      this.dom.cancelBtn = null;
+      if (this.params.showCancelButton) {
+        this.dom.cancelBtn = el('button', `${PREFIX}button ${PREFIX}cancel`, this.params.cancelButtonText);
+        this.dom.cancelBtn.style.backgroundColor = this.params.cancelButtonColor;
+        this.dom.cancelBtn.onclick = () => this._handleCancel();
+        this.dom.actions.appendChild(this.dom.cancelBtn);
+      }
+
+      this.dom.confirmBtn = el('button', `${PREFIX}button ${PREFIX}confirm`, this.params.confirmButtonText);
+      this.dom.confirmBtn.style.backgroundColor = this.params.confirmButtonColor;
+      this.dom.confirmBtn.onclick = () => this._handleConfirm();
+      this.dom.actions.appendChild(this.dom.confirmBtn);
+
+      popup.appendChild(this.dom.actions);
+
+      // Re-run open hooks for each step
+      try {
+        if (typeof this.params.didOpen === 'function') this.params.didOpen(this.dom.popup);
+      } catch {}
+    }
   }
 
   return Layer;

+ 0 - 2000
xjs.js

@@ -49,2003 +49,3 @@
     try { console.error('[xjs.dev] load failed', err); } catch {}
   });
 })(typeof window !== 'undefined' ? window : globalThis);
-
-// xjs.js - combined single-file build (animal + layer)
-// Generated from source files in this repo.
-// Usage:
-//   <script src="xjs.js"></script>
-// Globals:
-//   - window.xjs (primary, same as animal)
-//   - window.animal (compat)
-//   - window.Layer
-// Optional:
-//   - set window.XJS_GLOBAL_DOLLAR = true before loading to also export window.$ (only if not already defined)
-
-(function(){
-  var __GLOBAL__ = (typeof window !== 'undefined') ? window : (typeof globalThis !== 'undefined' ? globalThis : this);
-
-
-/* --- layer.js --- */
-(function (global, factory) {
-  const LayerClass = factory();
-  // Allow usage as `Layer({...})` or `new Layer()`
-  // But Layer is a class. We can wrap it in a proxy or factory function.
-  
-  function LayerFactory(options) {
-     if (options && typeof options === 'object') {
-         return LayerClass.$(options);
-     }
-     return new LayerClass();
-  }
-  
-  // Copy static methods (including non-enumerable class statics like `fire` / `$`)
-  // Class static methods are non-enumerable by default, so Object.assign() would miss them.
-  const copyStatic = (to, from) => {
-    try {
-      Object.getOwnPropertyNames(from).forEach((k) => {
-        if (k === 'prototype' || k === 'name' || k === 'length') return;
-        const desc = Object.getOwnPropertyDescriptor(from, k);
-        if (!desc) return;
-        Object.defineProperty(to, k, desc);
-      });
-    } catch (e) {
-      // Best-effort fallback
-      try { Object.assign(to, from); } catch {}
-    }
-  };
-  copyStatic(LayerFactory, LayerClass);
-  // Also copy prototype for instanceof checks if needed (though tricky with factory)
-  LayerFactory.prototype = LayerClass.prototype;
-  
-  typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = LayerFactory :
-    typeof define === 'function' && define.amd ? define(() => LayerFactory) :
-      (global.Layer = LayerFactory);
-}(__GLOBAL__, (function () {
-  'use strict';
-
-  const PREFIX = 'layer-';
-  
-  // Ensure library CSS is loaded (xjs.css)
-  // - CSS is centralized in xjs.css (no runtime <style> injection).
-  // - We still auto-load it for convenience/compat, since consumers may forget the <link>.
-  const ensureXjsCss = () => {
-    try {
-      if (typeof document === 'undefined') return;
-      if (!document.head) return;
-
-      // If any stylesheet already points to xjs.css, do nothing.
-      const existingHref = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
-        .map((l) => (l.getAttribute('href') || '').trim())
-        .find((h) => /(^|\/)xjs\.css(\?|#|$)/.test(h));
-      if (existingHref) return;
-
-      const id = 'xjs-css';
-      if (document.getElementById(id)) return;
-
-      // Try to derive xjs.css from a loaded script URL (xjs.js or layer.js).
-      const scripts = Array.from(document.getElementsByTagName('script'));
-      const scriptSrc = scripts
-        .map((s) => s && s.src)
-        .find((src) => /(^|\/)(xjs|layer)\.js(\?|#|$)/.test(String(src || '')));
-
-      let href = 'xjs.css';
-      if (scriptSrc) {
-        href = String(scriptSrc)
-          .replace(/(^|\/)xjs\.js(\?|#|$)/, '$1xjs.css$2')
-          .replace(/(^|\/)layer\.js(\?|#|$)/, '$1xjs.css$2');
-      }
-
-      const link = document.createElement('link');
-      link.id = id;
-      link.rel = 'stylesheet';
-      link.href = href;
-      document.head.appendChild(link);
-    } catch {
-      // ignore
-    }
-  };
-
-  class Layer {
-    constructor() {
-      ensureXjsCss();
-      this.params = {};
-      this.dom = {};
-      this.promise = null;
-      this.resolve = null;
-      this.reject = null;
-      this._onKeydown = null;
-    }
-
-    // Constructor helper when called as function: const popup = Layer({...})
-    static get isProxy() { return true; }
-    
-    // Static entry point
-    static fire(options) {
-      const instance = new Layer();
-      return instance._fire(options);
-    }
-    
-    // Chainable entry point (builder-style)
-    // Example:
-    //   Layer.$({ title: 'Hi' }).fire().then(...)
-    //   Layer.$().config({ title: 'Hi' }).fire()
-    static $(options) {
-      const instance = new Layer();
-      if (options !== undefined) instance.config(options);
-      return instance;
-    }
-    
-    // Chainable config helper (does not render until `.fire()` is called)
-    config(options = {}) {
-      // Support the same shorthand as Layer.fire(title, text, icon)
-      if (typeof options === 'string') {
-        options = { title: options };
-        if (arguments[1]) options.text = arguments[1];
-        if (arguments[2]) options.icon = arguments[2];
-      }
-      this.params = { ...(this.params || {}), ...options };
-      return this;
-    }
-    
-    // Instance entry point (chainable)
-    fire(options) {
-      const merged = (options === undefined) ? (this.params || {}) : options;
-      return this._fire(merged);
-    }
-
-    _fire(options = {}) {
-      if (typeof options === 'string') {
-        options = { title: options };
-        if (arguments[1]) options.text = arguments[1];
-        if (arguments[2]) options.icon = arguments[2];
-      }
-
-      this.params = {
-        title: '',
-        text: '',
-        icon: null,
-        iconSize: null, // e.g. '6em' / '72px'
-        confirmButtonText: 'OK',
-        cancelButtonText: 'Cancel',
-        showCancelButton: false,
-        confirmButtonColor: '#3085d6',
-        cancelButtonColor: '#aaa',
-        closeOnClickOutside: true,
-        closeOnEsc: true,
-        iconAnimation: true,
-        popupAnimation: true,
-        ...options
-      };
-
-      this.promise = new Promise((resolve, reject) => {
-        this.resolve = resolve;
-        this.reject = reject;
-      });
-
-      this._render();
-      return this.promise;
-    }
-
-    static _getXjs() {
-      // Prefer xjs, fallback to animal (compat)
-      const g = (typeof window !== 'undefined') ? window : (typeof globalThis !== 'undefined' ? globalThis : null);
-      if (!g) return null;
-      const x = g.xjs || g.animal;
-      return (typeof x === 'function') ? x : null;
-    }
-
-    _render() {
-      // Remove existing if any
-      const existing = document.querySelector(`.${PREFIX}overlay`);
-      if (existing) existing.remove();
-
-      // Create Overlay
-      this.dom.overlay = document.createElement('div');
-      this.dom.overlay.className = `${PREFIX}overlay`;
-
-      // Create Popup
-      this.dom.popup = document.createElement('div');
-      this.dom.popup.className = `${PREFIX}popup`;
-      this.dom.overlay.appendChild(this.dom.popup);
-
-      // Icon
-      if (this.params.icon) {
-        this.dom.icon = this._createIcon(this.params.icon);
-        this.dom.popup.appendChild(this.dom.icon);
-      }
-
-      // Title
-      if (this.params.title) {
-        this.dom.title = document.createElement('h2');
-        this.dom.title.className = `${PREFIX}title`;
-        this.dom.title.textContent = this.params.title;
-        this.dom.popup.appendChild(this.dom.title);
-      }
-
-      // Content (Text / HTML / Element)
-      if (this.params.text || this.params.html || this.params.content) {
-        this.dom.content = document.createElement('div');
-        this.dom.content.className = `${PREFIX}content`;
-        
-        if (this.params.content) {
-           // DOM Element or Selector
-           let el = this.params.content;
-           if (typeof el === 'string') el = document.querySelector(el);
-           if (el instanceof Element) this.dom.content.appendChild(el); 
-        } else if (this.params.html) {
-           this.dom.content.innerHTML = this.params.html;
-        } else {
-           this.dom.content.textContent = this.params.text;
-        }
-        
-        this.dom.popup.appendChild(this.dom.content);
-      }
-
-      // Actions
-      this.dom.actions = document.createElement('div');
-      this.dom.actions.className = `${PREFIX}actions`;
-      
-      // Cancel Button
-      if (this.params.showCancelButton) {
-        this.dom.cancelBtn = document.createElement('button');
-        this.dom.cancelBtn.className = `${PREFIX}button ${PREFIX}cancel`;
-        this.dom.cancelBtn.textContent = this.params.cancelButtonText;
-        this.dom.cancelBtn.style.backgroundColor = this.params.cancelButtonColor;
-        this.dom.cancelBtn.onclick = () => this._close(false);
-        this.dom.actions.appendChild(this.dom.cancelBtn);
-      }
-
-      // Confirm Button
-      this.dom.confirmBtn = document.createElement('button');
-      this.dom.confirmBtn.className = `${PREFIX}button ${PREFIX}confirm`;
-      this.dom.confirmBtn.textContent = this.params.confirmButtonText;
-      this.dom.confirmBtn.style.backgroundColor = this.params.confirmButtonColor;
-      this.dom.confirmBtn.onclick = () => this._close(true);
-      this.dom.actions.appendChild(this.dom.confirmBtn);
-
-      this.dom.popup.appendChild(this.dom.actions);
-
-      // Event Listeners
-      if (this.params.closeOnClickOutside) {
-        this.dom.overlay.addEventListener('click', (e) => {
-          if (e.target === this.dom.overlay) {
-            this._close(null); // Dismiss
-          }
-        });
-      }
-
-      document.body.appendChild(this.dom.overlay);
-
-      // Animation
-      requestAnimationFrame(() => {
-        this.dom.overlay.classList.add('show');
-        this._didOpen();
-      });
-    }
-
-    _createIcon(type) {
-      const icon = document.createElement('div');
-      icon.className = `${PREFIX}icon ${type}`;
-      
-      const applyIconSize = (mode) => {
-        if (!(this.params && this.params.iconSize)) return;
-        try {
-          const raw = this.params.iconSize;
-          const s = String(raw).trim();
-          if (!s) return;
-          // For SweetAlert2-style success icon, scale via font-size so all `em`-based
-          // parts remain proportional.
-          if (mode === 'font') {
-            const m = s.match(/^(-?\d*\.?\d+)\s*(px|em|rem)$/i);
-            if (m) {
-              const n = parseFloat(m[1]);
-              const unit = m[2];
-              if (Number.isFinite(n) && n > 0) {
-                icon.style.fontSize = (n / 5) + unit; // icon is 5em wide/tall
-                return;
-              }
-            }
-          }
-          // Fallback: directly size the box (works great for SVG icons)
-          icon.style.width = s;
-          icon.style.height = s;
-        } catch {}
-      };
-
-      const svgNs = 'http://www.w3.org/2000/svg';
-
-      if (type === 'success') {
-        // SweetAlert2-compatible DOM structure (circle + tick) for exact style parity
-        const left = document.createElement('div');
-        left.className = `${PREFIX}success-circular-line-left`;
-        const tip = document.createElement('span');
-        tip.className = `${PREFIX}success-line-tip`;
-        const long = document.createElement('span');
-        long.className = `${PREFIX}success-line-long`;
-        const ring = document.createElement('div');
-        ring.className = `${PREFIX}success-ring`;
-        const fix = document.createElement('div');
-        fix.className = `${PREFIX}success-fix`;
-        const right = document.createElement('div');
-        right.className = `${PREFIX}success-circular-line-right`;
-        icon.appendChild(left);
-        icon.appendChild(tip);
-        icon.appendChild(long);
-        icon.appendChild(ring);
-        icon.appendChild(fix);
-        icon.appendChild(right);
-
-        applyIconSize('font');
-        return icon;
-      }
-
-      if (type === 'error' || type === 'warning' || type === 'info' || type === 'question') {
-        // Use the same "success-like" ring parts for every icon
-        const left = document.createElement('div');
-        left.className = `${PREFIX}success-circular-line-left`;
-        const ring = document.createElement('div');
-        ring.className = `${PREFIX}success-ring`;
-        const fix = document.createElement('div');
-        fix.className = `${PREFIX}success-fix`;
-        const right = document.createElement('div');
-        right.className = `${PREFIX}success-circular-line-right`;
-        icon.appendChild(left);
-        icon.appendChild(ring);
-        icon.appendChild(fix);
-        icon.appendChild(right);
-
-        applyIconSize('font');
-
-        // SVG only draws the inner symbol (no SVG ring)
-        const svg = document.createElementNS(svgNs, 'svg');
-        svg.setAttribute('viewBox', '0 0 80 80');
-        svg.setAttribute('aria-hidden', 'true');
-        svg.setAttribute('focusable', 'false');
-
-        const addPath = (d, extraClass) => {
-          const p = document.createElementNS(svgNs, 'path');
-          p.setAttribute('d', d);
-          p.setAttribute('class', `${PREFIX}svg-mark ${PREFIX}svg-draw${extraClass ? ' ' + extraClass : ''}`);
-          p.setAttribute('stroke', 'currentColor');
-          svg.appendChild(p);
-          return p;
-        };
-
-        if (type === 'error') {
-          addPath('M28 28 L52 52', `${PREFIX}svg-error-left`);
-          addPath('M52 28 L28 52', `${PREFIX}svg-error-right`);
-        } else if (type === 'warning') {
-          addPath('M40 20 L40 46', `${PREFIX}svg-warning-line`);
-          const dot = document.createElementNS(svgNs, 'circle');
-          dot.setAttribute('class', `${PREFIX}svg-dot`);
-          dot.setAttribute('cx', '40');
-          dot.setAttribute('cy', '58');
-          dot.setAttribute('r', '3.2');
-          dot.setAttribute('fill', 'currentColor');
-          svg.appendChild(dot);
-        } else if (type === 'info') {
-          addPath('M40 34 L40 56', `${PREFIX}svg-info-line`);
-          const dot = document.createElementNS(svgNs, 'circle');
-          dot.setAttribute('class', `${PREFIX}svg-dot`);
-          dot.setAttribute('cx', '40');
-          dot.setAttribute('cy', '25');
-          dot.setAttribute('r', '3.2');
-          dot.setAttribute('fill', 'currentColor');
-          svg.appendChild(dot);
-        } else if (type === 'question') {
-          addPath('M30 30 C30 23 35 19 42 19 C49 19 54 23 54 30 C54 36 50 39 46 41 C43 42 42 44 42 48 L42 52', `${PREFIX}svg-question`);
-          const dot = document.createElementNS(svgNs, 'circle');
-          dot.setAttribute('class', `${PREFIX}svg-dot`);
-          dot.setAttribute('cx', '42');
-          dot.setAttribute('cy', '61');
-          dot.setAttribute('r', '3.2');
-          dot.setAttribute('fill', 'currentColor');
-          svg.appendChild(dot);
-        }
-
-        icon.appendChild(svg);
-        return icon;
-      }
-
-      applyIconSize('box');
-
-      const svg = document.createElementNS(svgNs, 'svg');
-      svg.setAttribute('viewBox', '0 0 80 80');
-      svg.setAttribute('aria-hidden', 'true');
-      svg.setAttribute('focusable', 'false');
-
-      const ring = document.createElementNS(svgNs, 'circle');
-      ring.setAttribute('class', `${PREFIX}svg-ring ${PREFIX}svg-draw`);
-      ring.setAttribute('cx', '40');
-      ring.setAttribute('cy', '40');
-      ring.setAttribute('r', '34');
-      ring.setAttribute('stroke', 'currentColor');
-      svg.appendChild(ring);
-
-      const addPath = (d, extraClass) => {
-        const p = document.createElementNS(svgNs, 'path');
-        p.setAttribute('d', d);
-        p.setAttribute('class', `${PREFIX}svg-mark ${PREFIX}svg-draw${extraClass ? ' ' + extraClass : ''}`);
-        p.setAttribute('stroke', 'currentColor');
-        svg.appendChild(p);
-        return p;
-      };
-
-      if (type === 'error') {
-        addPath('M28 28 L52 52', `${PREFIX}svg-error-left`);
-        addPath('M52 28 L28 52', `${PREFIX}svg-error-right`);
-      } else if (type === 'warning') {
-        addPath('M40 20 L40 46', `${PREFIX}svg-warning-line`);
-        const dot = document.createElementNS(svgNs, 'circle');
-        dot.setAttribute('class', `${PREFIX}svg-dot`);
-        dot.setAttribute('cx', '40');
-        dot.setAttribute('cy', '58');
-        dot.setAttribute('r', '3.2');
-        dot.setAttribute('fill', 'currentColor');
-        svg.appendChild(dot);
-      } else if (type === 'info') {
-        addPath('M40 34 L40 56', `${PREFIX}svg-info-line`);
-        const dot = document.createElementNS(svgNs, 'circle');
-        dot.setAttribute('class', `${PREFIX}svg-dot`);
-        dot.setAttribute('cx', '40');
-        dot.setAttribute('cy', '25');
-        dot.setAttribute('r', '3.2');
-        dot.setAttribute('fill', 'currentColor');
-        svg.appendChild(dot);
-      } else if (type === 'question') {
-        addPath('M30 30 C30 23 35 19 42 19 C49 19 54 23 54 30 C54 36 50 39 46 41 C43 42 42 44 42 48 L42 52', `${PREFIX}svg-question`);
-        const dot = document.createElementNS(svgNs, 'circle');
-        dot.setAttribute('class', `${PREFIX}svg-dot`);
-        dot.setAttribute('cx', '42');
-        dot.setAttribute('cy', '61');
-        dot.setAttribute('r', '3.2');
-        dot.setAttribute('fill', 'currentColor');
-        svg.appendChild(dot);
-      } else {
-        // ring only
-      }
-
-      icon.appendChild(svg);
-      
-      return icon;
-    }
-
-  _adjustRingBackgroundColor() {
-      try {
-        const icon = this.dom && this.dom.icon;
-        const popup = this.dom && this.dom.popup;
-        if (!icon || !popup) return;
-        const bg = getComputedStyle(popup).backgroundColor;
-        const parts = icon.querySelectorAll(`.${PREFIX}success-circular-line-left, .${PREFIX}success-circular-line-right, .${PREFIX}success-fix`);
-        parts.forEach((el) => {
-          try { el.style.backgroundColor = bg; } catch {}
-        });
-      } catch {}
-    }
-
-    _didOpen() {
-      // Keyboard close (ESC)
-      if (this.params.closeOnEsc) {
-        this._onKeydown = (e) => {
-          if (!e) return;
-          if (e.key === 'Escape') this._close(null);
-        };
-        document.addEventListener('keydown', this._onKeydown);
-      }
-
-      // Keep the "success-like" ring perfectly blended with popup bg
-      this._adjustRingBackgroundColor();
-
-      // Popup animation (optional)
-      if (this.params.popupAnimation) {
-        const X = Layer._getXjs();
-        if (X && this.dom.popup) {
-          try {
-            this.dom.popup.style.transition = 'none';
-            this.dom.popup.style.transform = 'scale(0.92)';
-            X(this.dom.popup).animate({
-              scale: [0.92, 1],
-              y: [-6, 0],
-              duration: 520,
-              easing: { stiffness: 260, damping: 16 }
-            });
-          } catch {}
-        }
-      }
-
-      if (this.params.iconAnimation) {
-        this._animateIcon();
-      }
-
-      try {
-        if (typeof this.params.didOpen === 'function') this.params.didOpen(this.dom.popup);
-      } catch {}
-    }
-
-    _animateIcon() {
-      const icon = this.dom.icon;
-      if (!icon) return;
-      const type = (this.params && this.params.icon) || '';
-
-      const ringTypes = new Set(['success', 'error', 'warning', 'info', 'question']);
-
-      // Ring animation (same as success) for all built-in icons
-      if (ringTypes.has(type)) this._adjustRingBackgroundColor();
-      try { icon.classList.remove(`${PREFIX}icon-show`); } catch {}
-      requestAnimationFrame(() => {
-        try { icon.classList.add(`${PREFIX}icon-show`); } catch {}
-      });
-
-      // Success tick is CSS-driven; others still draw their SVG mark after the ring starts.
-      if (type === 'success') return;
-
-      const X = Layer._getXjs();
-      if (!X) return;
-
-      const svg = icon.querySelector('svg');
-      if (!svg) return;
-      const marks = Array.from(svg.querySelectorAll(`.${PREFIX}svg-mark`));
-      const dot = svg.querySelector(`.${PREFIX}svg-dot`);
-
-      // If this is a built-in icon, keep the SweetAlert-like order:
-      // ring sweep first (~0.51s), then draw the inner mark.
-      const baseDelay = ringTypes.has(type) ? 520 : 0;
-
-      if (type === 'error') {
-        // Draw order: left-top -> right-bottom, then right-top -> left-bottom
-        // NOTE: A tiny delay (like 70ms) looks simultaneous; make it strictly sequential.
-        const a = svg.querySelector(`.${PREFIX}svg-error-left`) || marks[0];  // M28 28 L52 52
-        const b = svg.querySelector(`.${PREFIX}svg-error-right`) || marks[1]; // M52 28 L28 52
-        const dur = 320;
-        const gap = 60;
-        try { if (a) X(a).draw({ duration: dur, easing: 'ease-out', delay: baseDelay }); } catch {}
-        try { if (b) X(b).draw({ duration: dur, easing: 'ease-out', delay: baseDelay + dur + gap }); } catch {}
-      } else {
-        marks.forEach((m, i) => {
-          try { X(m).draw({ duration: 420, easing: 'ease-out', delay: baseDelay + i * 60 }); } catch {}
-        });
-      }
-
-      if (dot) {
-        try {
-          dot.style.opacity = '0';
-          const d = baseDelay + 140;
-          if (type === 'info') {
-            X(dot).animate({ opacity: [0, 1], y: [-8, 0], scale: [0.2, 1], duration: 420, delay: d, easing: { stiffness: 300, damping: 14 } });
-          } else {
-            X(dot).animate({ opacity: [0, 1], scale: [0.2, 1], duration: 320, delay: d, easing: { stiffness: 320, damping: 18 } });
-          }
-        } catch {}
-      }
-    }
-
-    _close(isConfirmed) {
-      this.dom.overlay.classList.remove('show');
-      setTimeout(() => {
-        if (this.dom.overlay && this.dom.overlay.parentNode) {
-          this.dom.overlay.parentNode.removeChild(this.dom.overlay);
-        }
-      }, 300);
-
-      if (this._onKeydown) {
-        try { document.removeEventListener('keydown', this._onKeydown); } catch {}
-        this._onKeydown = null;
-      }
-
-      try {
-        if (typeof this.params.willClose === 'function') this.params.willClose(this.dom.popup);
-      } catch {}
-      try {
-        if (typeof this.params.didClose === 'function') this.params.didClose();
-      } catch {}
-
-      if (isConfirmed === true) {
-        this.resolve({ isConfirmed: true, isDenied: false, isDismissed: false });
-      } else if (isConfirmed === false) {
-        this.resolve({ isConfirmed: false, isDenied: false, isDismissed: true, dismiss: 'cancel' });
-      } else {
-        this.resolve({ isConfirmed: false, isDenied: false, isDismissed: true, dismiss: 'backdrop' });
-      }
-    }
-  }
-
-  return Layer;
-
-})));
-
-
-
-/* --- animal.js --- */
-(function (global, factory) {
-  typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
-    typeof define === 'function' && define.amd ? define(factory) :
-      (global.animal = factory());
-}(__GLOBAL__, (function () {
-  'use strict';
-
-  // --- Utils ---
-  const isArr = (a) => Array.isArray(a);
-  const isStr = (s) => typeof s === 'string';
-  const isFunc = (f) => typeof f === 'function';
-  const isNil = (v) => v === undefined || v === null;
-  const isSVG = (el) => (typeof SVGElement !== 'undefined' && el instanceof SVGElement) || ((typeof Element !== 'undefined') && (el instanceof Element) && el.namespaceURI === 'http://www.w3.org/2000/svg');
-  const isEl = (v) => (typeof Element !== 'undefined') && (v instanceof Element);
-  const clamp01 = (n) => Math.max(0, Math.min(1, n));
-  const toKebab = (s) => s.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
-  
-  const toArray = (targets) => {
-    if (isArr(targets)) return targets;
-    if (isStr(targets)) {
-      if (typeof document === 'undefined') return [];
-      return Array.from(document.querySelectorAll(targets));
-    }
-    if ((typeof NodeList !== 'undefined') && (targets instanceof NodeList)) return Array.from(targets);
-    if ((typeof Element !== 'undefined') && (targets instanceof Element)) return [targets];
-    if ((typeof Window !== 'undefined') && (targets instanceof Window)) return [targets];
-    // Plain objects (for anime.js-style object tweening)
-    if (!isNil(targets) && (typeof targets === 'object' || isFunc(targets))) return [targets];
-    return [];
-  };
-
-  // Selection helper (chainable, still Array-compatible)
-  const _layerHandlerByEl = (typeof WeakMap !== 'undefined') ? new WeakMap() : null;
-  class Selection extends Array {
-    animate(params) { return animate(this, params); }
-    draw(params = {}) { return svgDraw(this, params); }
-    inViewAnimate(params, options = {}) { return inViewAnimate(this, params, options); }
-    // Layer.js plugin-style helper:
-    //   const $ = animal.$;
-    //   $('.btn').layer({ title: 'Hi' });
-    // Clicking the element will open Layer.
-    layer(options) {
-      const LayerCtor =
-        (typeof window !== 'undefined' && window.Layer) ? window.Layer :
-        (typeof globalThis !== 'undefined' && globalThis.Layer) ? globalThis.Layer :
-        (typeof Layer !== 'undefined' ? Layer : null);
-      if (!LayerCtor) return this;
-      
-      this.forEach((el) => {
-        if (!isEl(el)) return;
-        // De-dupe / replace previous binding
-        if (_layerHandlerByEl) {
-          const prev = _layerHandlerByEl.get(el);
-          if (prev) el.removeEventListener('click', prev);
-        }
-        
-        const handler = () => {
-          let opts = options;
-          if (isFunc(options)) {
-            opts = options(el);
-          } else if (isNil(options)) {
-            opts = null;
-          }
-          // If no explicit options, allow data-* configuration
-          if (!opts || (typeof opts === 'object' && Object.keys(opts).length === 0)) {
-            const d = el.dataset || {};
-            opts = {
-              title: d.layerTitle || el.getAttribute('data-layer-title') || (el.textContent || '').trim(),
-              text: d.layerText || el.getAttribute('data-layer-text') || '',
-              icon: d.layerIcon || el.getAttribute('data-layer-icon') || null,
-              showCancelButton: (d.layerCancel === 'true') || (el.getAttribute('data-layer-cancel') === 'true')
-            };
-          }
-          
-          // Prefer builder API if present, fallback to static fire.
-          if (LayerCtor.$ && isFunc(LayerCtor.$)) return LayerCtor.$(opts).fire();
-          if (LayerCtor.fire && isFunc(LayerCtor.fire)) return LayerCtor.fire(opts);
-        };
-        
-        if (_layerHandlerByEl) _layerHandlerByEl.set(el, handler);
-        el.addEventListener('click', handler);
-      });
-      
-      return this;
-    }
-    
-    // Remove click bindings added by `.layer()`
-    unlayer() {
-      this.forEach((el) => {
-        if (!isEl(el)) return;
-        if (!_layerHandlerByEl) return;
-        const prev = _layerHandlerByEl.get(el);
-        if (prev) el.removeEventListener('click', prev);
-        _layerHandlerByEl.delete(el);
-      });
-      return this;
-    }
-  }
-  
-  const $ = (targets) => Selection.from(toArray(targets));
-
-  const UNITLESS_KEYS = ['opacity', 'scale', 'scaleX', 'scaleY', 'scaleZ', 'zIndex', 'fontWeight', 'strokeDashoffset', 'strokeDasharray', 'strokeWidth'];
-
-  const getUnit = (val, prop) => {
-    if (UNITLESS_KEYS.includes(prop)) return '';
-    const split = /[+-]?\d*\.?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?(%|px|pt|em|rem|in|cm|mm|ex|ch|pc|vw|vh|vmin|vmax|deg|rad|turn)?$/.exec(val);
-    return split ? split[1] : undefined;
-  };
-  
-  const isCssVar = (k) => isStr(k) && k.startsWith('--');
-  // Keep this intentionally broad so "unknown" config keys don't accidentally become animated props.
-  // Prefer putting non-anim-prop config under `params.options`.
-  const isAnimOptionKey = (k) => [
-    'options',
-    'duration', 'delay', 'easing', 'direction', 'fill', 'loop', 'endDelay', 'autoplay',
-    'update', 'begin', 'complete',
-    // WAAPI-ish common keys (ignored unless we explicitly support them)
-    'iterations', 'iterationStart', 'iterationComposite', 'composite', 'playbackRate',
-    // Spring helpers
-    'springFrames'
-  ].includes(k);
-  
-  const isEasingFn = (e) => typeof e === 'function';
-  
-  // Minimal cubic-bezier implementation (for JS engine easing)
-  function cubicBezier(x1, y1, x2, y2) {
-    // Inspired by https://github.com/gre/bezier-easing (simplified)
-    const NEWTON_ITERATIONS = 4;
-    const NEWTON_MIN_SLOPE = 0.001;
-    const SUBDIVISION_PRECISION = 0.0000001;
-    const SUBDIVISION_MAX_ITERATIONS = 10;
-    
-    const kSplineTableSize = 11;
-    const kSampleStepSize = 1.0 / (kSplineTableSize - 1.0);
-    
-    const float32ArraySupported = typeof Float32Array === 'function';
-    
-    function A(aA1, aA2) { return 1.0 - 3.0 * aA2 + 3.0 * aA1; }
-    function B(aA1, aA2) { return 3.0 * aA2 - 6.0 * aA1; }
-    function C(aA1) { return 3.0 * aA1; }
-    
-    function calcBezier(aT, aA1, aA2) {
-      return ((A(aA1, aA2) * aT + B(aA1, aA2)) * aT + C(aA1)) * aT;
-    }
-    
-    function getSlope(aT, aA1, aA2) {
-      return 3.0 * A(aA1, aA2) * aT * aT + 2.0 * B(aA1, aA2) * aT + C(aA1);
-    }
-    
-    function binarySubdivide(aX, aA, aB) {
-      let currentX, currentT, i = 0;
-      do {
-        currentT = aA + (aB - aA) / 2.0;
-        currentX = calcBezier(currentT, x1, x2) - aX;
-        if (currentX > 0.0) aB = currentT;
-        else aA = currentT;
-      } while (Math.abs(currentX) > SUBDIVISION_PRECISION && ++i < SUBDIVISION_MAX_ITERATIONS);
-      return currentT;
-    }
-    
-    function newtonRaphsonIterate(aX, aGuessT) {
-      for (let i = 0; i < NEWTON_ITERATIONS; ++i) {
-        const currentSlope = getSlope(aGuessT, x1, x2);
-        if (currentSlope === 0.0) return aGuessT;
-        const currentX = calcBezier(aGuessT, x1, x2) - aX;
-        aGuessT -= currentX / currentSlope;
-      }
-      return aGuessT;
-    }
-    
-    const sampleValues = float32ArraySupported ? new Float32Array(kSplineTableSize) : new Array(kSplineTableSize);
-    for (let i = 0; i < kSplineTableSize; ++i) {
-      sampleValues[i] = calcBezier(i * kSampleStepSize, x1, x2);
-    }
-    
-    function getTForX(aX) {
-      let intervalStart = 0.0;
-      let currentSample = 1;
-      const lastSample = kSplineTableSize - 1;
-      
-      for (; currentSample !== lastSample && sampleValues[currentSample] <= aX; ++currentSample) {
-        intervalStart += kSampleStepSize;
-      }
-      --currentSample;
-      
-      const dist = (aX - sampleValues[currentSample]) / (sampleValues[currentSample + 1] - sampleValues[currentSample]);
-      const guessForT = intervalStart + dist * kSampleStepSize;
-      
-      const initialSlope = getSlope(guessForT, x1, x2);
-      if (initialSlope >= NEWTON_MIN_SLOPE) return newtonRaphsonIterate(aX, guessForT);
-      if (initialSlope === 0.0) return guessForT;
-      return binarySubdivide(aX, intervalStart, intervalStart + kSampleStepSize);
-    }
-    
-    return (x) => {
-      if (x === 0 || x === 1) return x;
-      return calcBezier(getTForX(x), y1, y2);
-    };
-  }
-  
-  function resolveJsEasing(easing, springValues) {
-    if (isEasingFn(easing)) return easing;
-    if (typeof easing === 'object' && easing && !Array.isArray(easing)) {
-      // Spring: map linear progress -> simulated spring curve (0..1)
-      const values = springValues || getSpringValues(easing);
-      const last = Math.max(0, values.length - 1);
-      return (t) => {
-        const p = clamp01(t);
-        const idx = p * last;
-        const i0 = Math.floor(idx);
-        const i1 = Math.min(last, i0 + 1);
-        const frac = idx - i0;
-        const v0 = values[i0] ?? p;
-        const v1 = values[i1] ?? p;
-        return v0 + (v1 - v0) * frac;
-      };
-    }
-    if (Array.isArray(easing) && easing.length === 4) {
-      return cubicBezier(easing[0], easing[1], easing[2], easing[3]);
-    }
-    // Common named easings
-    switch (easing) {
-      case 'linear': return (t) => t;
-      case 'ease': return cubicBezier(0.25, 0.1, 0.25, 1);
-      case 'ease-in': return cubicBezier(0.42, 0, 1, 1);
-      case 'ease-out': return cubicBezier(0, 0, 0.58, 1);
-      case 'ease-in-out': return cubicBezier(0.42, 0, 0.58, 1);
-      default: return (t) => t;
-    }
-  }
-
-  // --- Spring Physics (Simplified) ---
-  // Returns an array of [time, value] or just value for WAAPI linear easing
-  function spring({ stiffness = 100, damping = 10, mass = 1, velocity = 0, precision = 0.01 } = {}) {
-    const values = [];
-    let t = 0;
-    const timeStep = 1 / 60; // 60fps simulation
-    let current = 0;
-    let v = velocity;
-    const target = 1;
-
-    let running = true;
-    while (running && t < 10) { // Safety break at 10s
-      const fSpring = -stiffness * (current - target);
-      const fDamper = -damping * v;
-      const a = (fSpring + fDamper) / mass;
-      
-      v += a * timeStep;
-      current += v * timeStep;
-      
-      values.push(current);
-      t += timeStep;
-
-      if (Math.abs(current - target) < precision && Math.abs(v) < precision) {
-        running = false;
-      }
-    }
-    if (values[values.length - 1] !== 1) values.push(1);
-    
-    return values;
-  }
-  
-  // Cache spring curves by config (perf: avoid recomputing for many targets)
-  // LRU-ish capped cache to avoid unbounded growth in long-lived apps.
-  const SPRING_CACHE_MAX = 50;
-  const springCache = new Map();
-  function springCacheGet(key) {
-    const v = springCache.get(key);
-    if (!v) return v;
-    // refresh LRU
-    springCache.delete(key);
-    springCache.set(key, v);
-    return v;
-  }
-  function springCacheSet(key, values) {
-    if (springCache.has(key)) springCache.delete(key);
-    springCache.set(key, values);
-    if (springCache.size > SPRING_CACHE_MAX) {
-      const firstKey = springCache.keys().next().value;
-      if (firstKey !== undefined) springCache.delete(firstKey);
-    }
-  }
-  function getSpringValues(config = {}) {
-    const {
-      stiffness = 100,
-      damping = 10,
-      mass = 1,
-      velocity = 0,
-      precision = 0.01
-    } = config || {};
-    const key = `${stiffness}|${damping}|${mass}|${velocity}|${precision}`;
-    const cached = springCacheGet(key);
-    if (cached) return cached;
-    const values = spring({ stiffness, damping, mass, velocity, precision });
-    springCacheSet(key, values);
-    return values;
-  }
-  
-  function downsample(values, maxFrames = 120) {
-    if (!values || values.length <= maxFrames) return values || [];
-    const out = [];
-    const lastIndex = values.length - 1;
-    const step = lastIndex / (maxFrames - 1);
-    for (let i = 0; i < maxFrames; i++) {
-      const idx = i * step;
-      const i0 = Math.floor(idx);
-      const i1 = Math.min(lastIndex, i0 + 1);
-      const frac = idx - i0;
-      const v0 = values[i0];
-      const v1 = values[i1];
-      out.push(v0 + (v1 - v0) * frac);
-    }
-    if (out[out.length - 1] !== 1) out[out.length - 1] = 1;
-    return out;
-  }
-
-  // --- WAAPI Core ---
-  const TRANSFORMS = ['translateX', 'translateY', 'translateZ', 'rotate', 'rotateX', 'rotateY', 'rotateZ', 'scale', 'scaleX', 'scaleY', 'scaleZ', 'skew', 'skewX', 'skewY', 'perspective', 'x', 'y'];
-  
-  const ALIASES = {
-    x: 'translateX',
-    y: 'translateY',
-    z: 'translateZ'
-  };
-
-  // Basic rAF loop for non-WAAPI props or fallback
-  class RafEngine {
-    constructor() {
-      this.animations = [];
-      this.tick = this.tick.bind(this);
-      this.running = false;
-    }
-    add(anim) {
-      this.animations.push(anim);
-      if (!this.running) {
-        this.running = true;
-        requestAnimationFrame(this.tick);
-      }
-    }
-    remove(anim) {
-      this.animations = this.animations.filter(a => a !== anim);
-    }
-    tick(t) {
-      const now = t;
-      this.animations = this.animations.filter(anim => {
-        return anim.tick(now); // return true to keep
-      });
-      if (this.animations.length) {
-        requestAnimationFrame(this.tick);
-      } else {
-        this.running = false;
-      }
-    }
-  }
-  const rafEngine = new RafEngine();
-  
-  // --- WAAPI update sampler (only used when update callback is provided) ---
-  class WaapiUpdateSampler {
-    constructor() {
-      this.items = new Set();
-      this._running = false;
-      this._tick = this._tick.bind(this);
-    }
-    add(item) {
-      this.items.add(item);
-      if (!this._running) {
-        this._running = true;
-        requestAnimationFrame(this._tick);
-      }
-    }
-    remove(item) {
-      this.items.delete(item);
-    }
-    _tick(t) {
-      if (!this.items.size) {
-        this._running = false;
-        return;
-      }
-      let anyActive = false;
-      let anyChanged = false;
-      this.items.forEach((item) => {
-        const { anim, target, update } = item;
-        if (!anim || !anim.effect) return;
-        let dur = item._dur || 0;
-        if (!dur) {
-          const timing = readWaapiEffectTiming(anim.effect);
-          dur = timing.duration || 0;
-          item._dur = dur;
-        }
-        if (!dur) return;
-        const p = clamp01((anim.currentTime || 0) / dur);
-        if (anim.playState === 'running') anyActive = true;
-        if (item._lastP !== p) {
-          anyChanged = true;
-          item._lastP = p;
-          update({ target, progress: p, time: t });
-        }
-        if (anim.playState === 'finished' || anim.playState === 'idle') {
-          this.items.delete(item);
-        }
-      });
-      if (this.items.size && (anyActive || anyChanged)) {
-        requestAnimationFrame(this._tick);
-      } else {
-        this._running = false;
-      }
-    }
-  }
-  const waapiUpdateSampler = new WaapiUpdateSampler();
-  
-  function readWaapiEffectTiming(effect) {
-    // We compute this once per animation and cache it to avoid per-frame getComputedTiming().
-    // We primarily cache `duration` to preserve existing behavior (progress maps to one iteration).
-    let duration = 0;
-    let endTime = 0;
-    if (!effect) return { duration, endTime };
-    try {
-      const ct = effect.getComputedTiming ? effect.getComputedTiming() : null;
-      if (ct) {
-        if (typeof ct.duration === 'number' && Number.isFinite(ct.duration)) duration = ct.duration;
-        if (typeof ct.endTime === 'number' && Number.isFinite(ct.endTime)) endTime = ct.endTime;
-      }
-    } catch (e) {
-      // ignore
-    }
-    if (!endTime) endTime = duration || 0;
-    return { duration, endTime };
-  }
-  
-  function getDefaultUnit(prop) {
-    if (!prop) return '';
-    if (UNITLESS_KEYS.includes(prop)) return '';
-    if (prop.startsWith('scale')) return '';
-    if (prop === 'opacity') return '';
-    if (prop.startsWith('rotate') || prop.startsWith('skew')) return 'deg';
-    return 'px';
-  }
-  
-  function normalizeTransformPartValue(prop, v) {
-    if (typeof v !== 'number') return v;
-    if (prop && prop.startsWith('scale')) return '' + v;
-    if (prop && (prop.startsWith('rotate') || prop.startsWith('skew'))) return v + 'deg';
-    return v + 'px';
-  }
-
-  // String number template: interpolate numeric tokens inside a string.
-  // Useful for SVG path `d`, `points`, and other attributes that contain many numbers.
-  const _PURE_NUMBER_RE = /^[+-]?(?:\d*\.)?\d+(?:[eE][+-]?\d+)?(?:%|px|pt|em|rem|in|cm|mm|ex|ch|pc|vw|vh|vmin|vmax|deg|rad|turn)?$/;
-  function isPureNumberLike(str) {
-    return _PURE_NUMBER_RE.test(String(str || '').trim());
-  }
-  function countDecimals(numStr) {
-    const s = String(numStr || '');
-    const dot = s.indexOf('.');
-    if (dot < 0) return 0;
-    const e = s.search(/[eE]/);
-    const end = e >= 0 ? e : s.length;
-    const frac = s.slice(dot + 1, end);
-    const trimmed = frac.replace(/0+$/, '');
-    return Math.min(6, trimmed.length);
-  }
-  function parseNumberTemplate(str) {
-    const s = String(str ?? '');
-    const nums = [];
-    const parts = [];
-    let last = 0;
-    const re = /[+-]?(?:\d*\.)?\d+(?:[eE][+-]?\d+)?/g;
-    let m;
-    while ((m = re.exec(s))) {
-      const start = m.index;
-      const end = start + m[0].length;
-      parts.push(s.slice(last, start));
-      nums.push(parseFloat(m[0]));
-      last = end;
-    }
-    parts.push(s.slice(last));
-    return { nums, parts };
-  }
-  function extractNumberStrings(str) {
-    return String(str ?? '').match(/[+-]?(?:\d*\.)?\d+(?:[eE][+-]?\d+)?/g) || [];
-  }
-  function formatInterpolatedNumber(n, decimals) {
-    let v = n;
-    if (!Number.isFinite(v)) v = 0;
-    if (Math.abs(v) < 1e-12) v = 0;
-    if (!decimals) return String(Math.round(v));
-    return v.toFixed(decimals).replace(/\.?0+$/, '');
-  }
-  
-  // --- JS Interpolator (anime.js-ish, simplified) ---
-  class JsAnimation {
-    constructor(target, propValues, opts = {}, callbacks = {}) {
-      this.target = target;
-      this.propValues = propValues;
-      this.duration = opts.duration ?? 1000;
-      this.delay = opts.delay ?? 0;
-      this.direction = opts.direction ?? 'normal';
-      this.loop = opts.loop ?? 1;
-      this.endDelay = opts.endDelay ?? 0;
-      this.easing = opts.easing ?? 'linear';
-      this.autoplay = opts.autoplay !== false;
-      
-      this.update = callbacks.update;
-      this.begin = callbacks.begin;
-      this.complete = callbacks.complete;
-      
-      this._resolve = null;
-      this.finished = new Promise((res) => (this._resolve = res));
-      
-      this._started = false;
-      this._running = false;
-      this._paused = false;
-      this._cancelled = false;
-      this._startTime = 0;
-      this._progress = 0;
-      this._didBegin = false;
-      
-      this._ease = resolveJsEasing(this.easing, opts.springValues);
-      
-      this._tween = this._buildTween();
-      this.tick = this.tick.bind(this);
-      
-      if (this.autoplay) this.play();
-    }
-    
-    _readCurrentValue(k) {
-      const t = this.target;
-      // JS object
-      if (!isEl(t) && !isSVG(t)) return t[k];
-      
-      // scroll
-      if (k === 'scrollTop' || k === 'scrollLeft') return t[k];
-      
-      // CSS var
-      if (isEl(t) && isCssVar(k)) {
-        const cs = getComputedStyle(t);
-        return (cs.getPropertyValue(k) || t.style.getPropertyValue(k) || '').trim();
-      }
-      
-      // style
-      if (isEl(t)) {
-        const cs = getComputedStyle(t);
-        // Prefer computed style (kebab) because many props aren't direct keys on cs
-        const v = cs.getPropertyValue(toKebab(k));
-        if (v && v.trim()) return v.trim();
-        // Fallback to inline style access
-        if (k in t.style) return t.style[k];
-      }
-      
-      // SVG
-      // For many SVG presentation attributes (e.g. strokeDashoffset), style overrides attribute.
-      // Prefer computed style / inline style when available, and fallback to attributes.
-      if (isSVG(t)) {
-        // Geometry attrs must remain attrs (not style)
-        if (k === 'd' || k === 'points') {
-          const attr = toKebab(k);
-          if (t.hasAttribute(attr)) return t.getAttribute(attr);
-          if (t.hasAttribute(k)) return t.getAttribute(k);
-          return t.getAttribute(k);
-        }
-        try {
-          const cs = getComputedStyle(t);
-          const v = cs.getPropertyValue(toKebab(k));
-          if (v && v.trim()) return v.trim();
-        } catch (_) {}
-        try {
-          if (k in t.style) return t.style[k];
-        } catch (_) {}
-        const attr = toKebab(k);
-        if (t.hasAttribute(attr)) return t.getAttribute(attr);
-        if (t.hasAttribute(k)) return t.getAttribute(k);
-      }
-      
-      // Generic property
-      return t[k];
-    }
-    
-    _writeValue(k, v) {
-      const t = this.target;
-      // JS object
-      if (!isEl(t) && !isSVG(t)) {
-        t[k] = v;
-        return;
-      }
-      
-      // scroll
-      if (k === 'scrollTop' || k === 'scrollLeft') {
-        t[k] = v;
-        return;
-      }
-      
-      // CSS var
-      if (isEl(t) && isCssVar(k)) {
-        t.style.setProperty(k, v);
-        return;
-      }
-      
-      // style
-      if (isEl(t) && (k in t.style)) {
-        t.style[k] = v;
-        return;
-      }
-      
-      // SVG
-      // Prefer style for presentation attrs so it can animate when svgDraw initialized styles.
-      if (isSVG(t)) {
-        if (k === 'd' || k === 'points') {
-          t.setAttribute(k, v);
-          return;
-        }
-        try {
-          if (k in t.style) {
-            t.style[k] = v;
-            return;
-          }
-        } catch (_) {}
-        const attr = toKebab(k);
-        t.setAttribute(attr, v);
-        return;
-      }
-      
-      // Fallback for path/d/points even if isSVG check somehow failed
-      if ((k === 'd' || k === 'points') && isEl(t)) {
-         t.setAttribute(k, v);
-         return;
-      }
-      
-      // Generic property
-      t[k] = v;
-    }
-    
-    _buildTween() {
-      const tween = {};
-      Object.keys(this.propValues).forEach((k) => {
-        const raw = this.propValues[k];
-        const fromRaw = isArr(raw) ? raw[0] : this._readCurrentValue(k);
-        const toRaw = isArr(raw) ? raw[1] : raw;
-        
-        const fromStr = isNil(fromRaw) ? '0' : ('' + fromRaw).trim();
-        const toStr = isNil(toRaw) ? '0' : ('' + toRaw).trim();
-
-        const fromScalar = isPureNumberLike(fromStr);
-        const toScalar = isPureNumberLike(toStr);
-
-        // numeric tween (with unit preservation) — only when BOTH sides are pure scalars.
-        if (fromScalar && toScalar) {
-          const fromNum = parseFloat(fromStr);
-          const toNum = parseFloat(toStr);
-          const unit = getUnit(toStr, k) ?? getUnit(fromStr, k) ?? '';
-          tween[k] = { type: 'number', from: fromNum, to: toNum, unit };
-          return;
-        }
-
-        // string number-template tween (SVG path `d`, `points`, etc.)
-        const a = parseNumberTemplate(fromStr);
-        const b = parseNumberTemplate(toStr);
-        if (a.nums.length >= 2 && a.nums.length === b.nums.length && a.parts.length === b.parts.length) {
-          const aStrs = extractNumberStrings(fromStr);
-          const bStrs = extractNumberStrings(toStr);
-          const decs = a.nums.map((_, i) => Math.max(countDecimals(aStrs[i]), countDecimals(bStrs[i])));
-          tween[k] = { type: 'number-template', fromNums: a.nums, toNums: b.nums, parts: b.parts, decimals: decs };
-          return;
-        } else if ((k === 'd' || k === 'points') && (a.nums.length > 0 || b.nums.length > 0)) {
-           console.warn(`[Animal.js] Morph mismatch for property "${k}".\nValues must have matching number count.`,
-             { from: a.nums.length, to: b.nums.length, fromVal: fromStr, toVal: toStr }
-           );
-        }
-
-        // Non-numeric: fall back to "switch" (still useful for seek endpoints)
-        tween[k] = { type: 'discrete', from: fromStr, to: toStr };
-      });
-      return tween;
-    }
-    
-    _apply(progress, time) {
-      this._progress = clamp01(progress);
-      Object.keys(this._tween).forEach((k) => {
-        const t = this._tween[k];
-        if (t.type === 'number') {
-          const eased = this._ease ? this._ease(this._progress) : this._progress;
-          const val = t.from + (t.to - t.from) * eased;
-          if (!isEl(this.target) && !isSVG(this.target) && t.unit === '') {
-            this._writeValue(k, val);
-          } else {
-            this._writeValue(k, (val + t.unit));
-          }
-        } else if (t.type === 'number-template') {
-          const eased = this._ease ? this._ease(this._progress) : this._progress;
-          const { fromNums, toNums, parts, decimals } = t;
-          let out = parts[0] || '';
-          for (let i = 0; i < fromNums.length; i++) {
-            const v = fromNums[i] + (toNums[i] - fromNums[i]) * eased;
-            out += formatInterpolatedNumber(v, decimals ? decimals[i] : 0);
-            out += parts[i + 1] || '';
-          }
-          this._writeValue(k, out);
-        } else {
-          const val = this._progress >= 1 ? t.to : t.from;
-          this._writeValue(k, val);
-        }
-      });
-      
-      if (this.update) this.update({ target: this.target, progress: this._progress, time });
-    }
-    
-    seek(progress) {
-      // Seek does not auto-play; it's intended for scroll-linked or manual control.
-      const t = (typeof performance !== 'undefined' ? performance.now() : 0);
-      this._apply(progress, t);
-    }
-    
-    play() {
-      if (this._cancelled) return;
-      if (!this._started) {
-        this._started = true;
-        this._startTime = performance.now() + this.delay - (this._progress * this.duration);
-        // begin fired on first active tick to avoid firing during delay.
-      }
-      this._paused = false;
-      if (!this._running) {
-        this._running = true;
-        rafEngine.add(this);
-      }
-    }
-    
-    pause() {
-      this._paused = true;
-    }
-    
-    cancel() {
-      this._cancelled = true;
-      this._running = false;
-      rafEngine.remove(this);
-      // Resolve to avoid hanging awaits
-      if (this._resolve) this._resolve();
-    }
-    
-    finish() {
-      this.seek(1);
-      this._running = false;
-      rafEngine.remove(this);
-      if (this.complete) this.complete(this.target);
-      if (this._resolve) this._resolve();
-    }
-    
-    tick(now) {
-      if (this._cancelled) return false;
-      if (this._paused) return true;
-      
-      if (!this._started) {
-        this._started = true;
-        this._startTime = now + this.delay;
-      }
-      
-      if (now < this._startTime) return true;
-      if (!this._didBegin) {
-        this._didBegin = true;
-        if (this.begin) this.begin(this.target);
-      }
-      
-      const totalDur = this.duration + (this.endDelay || 0);
-      const elapsed = now - this._startTime;
-      const iter = totalDur > 0 ? Math.floor(elapsed / totalDur) : 0;
-      const inIter = totalDur > 0 ? (elapsed - iter * totalDur) : elapsed;
-      
-      const iterations = this.loop === true ? Infinity : this.loop;
-      if (iterations !== Infinity && iter >= iterations) {
-        this._apply(this._mapDirection(1, iterations - 1));
-        this._running = false;
-        if (this.complete) this.complete(this.target);
-        if (this._resolve) this._resolve();
-        return false;
-      }
-      
-      // if we're in endDelay portion, hold the end state
-      let p = clamp01(inIter / this.duration);
-      if (this.duration <= 0) p = 1;
-      if (this.endDelay && inIter > this.duration) p = 1;
-      
-      this._apply(this._mapDirection(p, iter), now);
-      
-      // Keep running until loops exhausted
-      return true;
-    }
-    
-    _mapDirection(p, iterIndex) {
-      const dir = this.direction;
-      const flip = (dir === 'reverse') || (dir === 'alternate-reverse');
-      const isAlt = (dir === 'alternate') || (dir === 'alternate-reverse');
-      let t = flip ? (1 - p) : p;
-      if (isAlt && (iterIndex % 2 === 1)) t = 1 - t;
-      return t;
-    }
-  }
-  
-  // --- Controls (Motion One-ish, chainable / thenable) ---
-  class Controls {
-    constructor({ waapi = [], js = [], finished }) {
-      this.animations = waapi; // backward compat with old `.animations` usage
-      this.jsAnimations = js;
-      this.finished = finished || Promise.resolve();
-    }
-    then(onFulfilled, onRejected) { return this.finished.then(onFulfilled, onRejected); }
-    catch(onRejected) { return this.finished.catch(onRejected); }
-    finally(onFinally) { return this.finished.finally(onFinally); }
-    
-    play() {
-      if (this._onPlay) this._onPlay.forEach((fn) => fn && fn());
-      if (this._ensureWaapiUpdate) this._ensureWaapiUpdate();
-      this.animations.forEach((a) => a && a.play && a.play());
-      this.jsAnimations.forEach((a) => a && a.play && a.play());
-      return this;
-    }
-    pause() {
-      this.animations.forEach((a) => a && a.pause && a.pause());
-      this.jsAnimations.forEach((a) => a && a.pause && a.pause());
-      return this;
-    }
-    cancel() {
-      this.animations.forEach((a) => a && a.cancel && a.cancel());
-      this.jsAnimations.forEach((a) => a && a.cancel && a.cancel());
-      return this;
-    }
-    finish() {
-      this.animations.forEach((a) => a && a.finish && a.finish());
-      this.jsAnimations.forEach((a) => a && a.finish && a.finish());
-      return this;
-    }
-    seek(progress) {
-      const p = clamp01(progress);
-      const t = (typeof performance !== 'undefined' ? performance.now() : 0);
-      this.animations.forEach((anim) => {
-        if (anim && anim.effect) {
-          let timing = this._waapiTimingByAnim && this._waapiTimingByAnim.get(anim);
-          if (!timing) {
-            timing = readWaapiEffectTiming(anim.effect);
-            if (this._waapiTimingByAnim) this._waapiTimingByAnim.set(anim, timing);
-          }
-          const dur = timing.duration || 0;
-          if (!dur) return;
-          anim.currentTime = dur * p;
-          const target = this._waapiTargetByAnim && this._waapiTargetByAnim.get(anim);
-          if (this._fireUpdate && target) this._fireUpdate({ target, progress: p, time: t });
-        }
-      });
-      this.jsAnimations.forEach((a) => a && a.seek && a.seek(p));
-      return this;
-    }
-  }
-
-  // --- Main Animation Logic ---
-  function animate(targets, params) {
-    const elements = toArray(targets);
-    const safeParams = params || {};
-    const optionsNamespace = (safeParams.options && typeof safeParams.options === 'object') ? safeParams.options : {};
-    // `params.options` provides a safe namespace for config keys without risking them being treated as animated props.
-    // Top-level keys still win for backward compatibility.
-    const merged = { ...optionsNamespace, ...safeParams };
-    const { 
-      duration = 1000, 
-      delay = 0, 
-      easing = 'ease-out', 
-      direction = 'normal', 
-      fill = 'forwards',
-      loop = 1,
-      endDelay = 0,
-      autoplay = true,
-      springFrames = 120,
-      update, // callback
-      begin, // callback
-      complete // callback
-    } = merged;
-
-    let isSpring = false;
-    let springValuesRaw = null;
-    let springValuesSampled = null;
-    let springDurationMs = null;
-
-    if (typeof easing === 'object' && !Array.isArray(easing)) {
-        isSpring = true;
-        springValuesRaw = getSpringValues(easing);
-        const frames = (typeof springFrames === 'number' && springFrames > 1) ? Math.floor(springFrames) : 120;
-        springValuesSampled = downsample(springValuesRaw, frames);
-        springDurationMs = springValuesRaw.length * 1000 / 60;
-    }
-    
-    // Callback aggregation (avoid double-calling when WAAPI+JS both run)
-    const cbState = typeof WeakMap !== 'undefined' ? new WeakMap() : null;
-    const getState = (t) => {
-      if (!cbState) return { begun: false, completed: false, lastUpdateBucket: -1 };
-      let s = cbState.get(t);
-      if (!s) {
-        s = { begun: false, completed: false, lastUpdateBucket: -1 };
-        cbState.set(t, s);
-      }
-      return s;
-    };
-    const fireBegin = (t) => {
-      if (!begin) return;
-      const s = getState(t);
-      if (s.begun) return;
-      s.begun = true;
-      begin(t);
-    };
-    const fireComplete = (t) => {
-      if (!complete) return;
-      const s = getState(t);
-      if (s.completed) return;
-      s.completed = true;
-      complete(t);
-    };
-    const fireUpdate = (payload) => {
-      if (!update) return;
-      const t = payload && payload.target;
-      const time = payload && payload.time;
-      if (!t) return update(payload);
-      const s = getState(t);
-      const bucket = Math.floor(((typeof time === 'number' ? time : (typeof performance !== 'undefined' ? performance.now() : 0))) / 16);
-      if (bucket === s.lastUpdateBucket) return;
-      s.lastUpdateBucket = bucket;
-      update(payload);
-    };
-
-    const propEntries = [];
-    Object.keys(safeParams).forEach((key) => {
-      if (key === 'options') return;
-      if (isAnimOptionKey(key)) return;
-      propEntries.push({ key, canonical: ALIASES[key] || key, val: safeParams[key] });
-    });
-
-    // Create animations but don't play if autoplay is false
-    const waapiAnimations = [];
-    const jsAnimations = [];
-    const engineInfo = {
-      isSpring,
-      waapiKeys: [],
-      jsKeys: []
-    };
-    const waapiKeySet = new Set();
-    const jsKeySet = new Set();
-    
-    const onPlayHooks = [];
-    const waapiUpdateItems = [];
-    const waapiTargetByAnim = (typeof WeakMap !== 'undefined') ? new WeakMap() : null;
-    const waapiTimingByAnim = (typeof WeakMap !== 'undefined') ? new WeakMap() : null;
-    let waapiUpdateStarted = false;
-    const ensureWaapiUpdate = () => {
-      if (waapiUpdateStarted) return;
-      waapiUpdateStarted = true;
-      waapiUpdateItems.forEach((item) => waapiUpdateSampler.add(item));
-    };
-
-    const promises = elements.map((el) => {
-      // Route props per target (fix mixed HTML/SVG/object target arrays).
-      const waapiProps = {};
-      const jsProps = {};
-      propEntries.forEach(({ key, canonical, val }) => {
-        const isTransform = TRANSFORMS.includes(canonical) || TRANSFORMS.includes(key);
-        if (!el || (!isEl(el) && !isSVG(el))) {
-          jsProps[key] = val;
-          jsKeySet.add(key);
-          return;
-        }
-        const isSvgTarget = isSVG(el);
-        if (isSvgTarget) {
-          if (isTransform || key === 'opacity' || key === 'filter') {
-            waapiProps[canonical] = val;
-            waapiKeySet.add(canonical);
-          } else {
-            jsProps[key] = val;
-            jsKeySet.add(key);
-          }
-          return;
-        }
-        // HTML element
-        if (isCssVar(key)) {
-          jsProps[key] = val;
-          jsKeySet.add(key);
-          return;
-        }
-        const isCssLike = isTransform || key === 'opacity' || key === 'filter' || (isEl(el) && (key in el.style));
-        if (isCssLike) {
-          waapiProps[canonical] = val;
-          waapiKeySet.add(canonical);
-        } else {
-          jsProps[key] = val;
-          jsKeySet.add(key);
-        }
-      });
-
-      // 1. WAAPI Animation
-      let waapiAnim = null;
-      let waapiPromise = Promise.resolve();
-      
-      if (Object.keys(waapiProps).length > 0) {
-        const buildFrames = (propValues) => {
-          // Spring: share sampled progress; per-target we only compute start/end once per prop.
-          if (isSpring && springValuesSampled && springValuesSampled.length) {
-            const cs = (isEl(el) && typeof getComputedStyle !== 'undefined') ? getComputedStyle(el) : null;
-            const metas = Object.keys(propValues).map((k) => {
-              const raw = propValues[k];
-              const rawFrom = Array.isArray(raw) ? raw[0] : undefined;
-              const rawTo = Array.isArray(raw) ? raw[1] : raw;
-              const toNum = parseFloat(('' + rawTo).trim());
-              const fromNumExplicit = Array.isArray(raw) ? parseFloat(('' + rawFrom).trim()) : NaN;
-              const isT = TRANSFORMS.includes(ALIASES[k] || k) || TRANSFORMS.includes(k);
-              const unit = getUnit(('' + rawTo), k) ?? getUnit(('' + rawFrom), k) ?? getDefaultUnit(k);
-              
-              let fromNum = 0;
-              if (Number.isFinite(fromNumExplicit)) {
-                fromNum = fromNumExplicit;
-              } else if (k.startsWith('scale')) {
-                fromNum = 1;
-              } else if (k === 'opacity' && cs) {
-                const n = parseFloat(cs.opacity);
-                if (!Number.isNaN(n)) fromNum = n;
-              } else if (!isT && cs) {
-                const cssVal = cs.getPropertyValue(toKebab(k));
-                const n = parseFloat(cssVal);
-                if (!Number.isNaN(n)) fromNum = n;
-              }
-              
-              const to = Number.isFinite(toNum) ? toNum : 0;
-              return { k, isT, unit: unit ?? '', from: fromNum, to };
-            });
-            
-            const frames = new Array(springValuesSampled.length);
-            for (let i = 0; i < springValuesSampled.length; i++) {
-              const v = springValuesSampled[i];
-              const frame = {};
-              let transformStr = '';
-              for (let j = 0; j < metas.length; j++) {
-                const m = metas[j];
-                const current = m.from + (m.to - m.from) * v;
-                const outVal = (m.unit === '' ? ('' + current) : (current + m.unit));
-                if (m.isT) transformStr += `${m.k}(${outVal}) `;
-                else frame[m.k] = outVal;
-              }
-              if (transformStr) frame.transform = transformStr.trim();
-              frames[i] = frame;
-            }
-            if (frames[0] && Object.keys(frames[0]).length === 0) frames.shift();
-            return frames;
-          }
-          
-          // Non-spring: 2-keyframe path
-          const frame0 = {};
-          const frame1 = {};
-          let transform0 = '';
-          let transform1 = '';
-          Object.keys(propValues).forEach((k) => {
-            const val = propValues[k];
-            const isT = TRANSFORMS.includes(ALIASES[k] || k) || TRANSFORMS.includes(k);
-            if (Array.isArray(val)) {
-              const from = isT ? normalizeTransformPartValue(k, val[0]) : val[0];
-              const to = isT ? normalizeTransformPartValue(k, val[1]) : val[1];
-              if (isT) {
-                transform0 += `${k}(${from}) `;
-                transform1 += `${k}(${to}) `;
-              } else {
-                frame0[k] = from;
-                frame1[k] = to;
-              }
-            } else {
-              if (isT) transform1 += `${k}(${normalizeTransformPartValue(k, val)}) `;
-              else frame1[k] = val;
-            }
-          });
-          if (transform0) frame0.transform = transform0.trim();
-          if (transform1) frame1.transform = transform1.trim();
-          const out = [frame0, frame1];
-          if (Object.keys(out[0]).length === 0) out.shift();
-          return out;
-        };
-
-        const finalFrames = buildFrames(waapiProps);
-
-        const opts = {
-            duration: isSpring ? springDurationMs : duration,
-            delay,
-            fill,
-            iterations: loop,
-            easing: isSpring ? 'linear' : easing,
-            direction,
-            endDelay
-        };
-
-        const animation = el.animate(finalFrames, opts);
-        if (!autoplay) animation.pause();
-        waapiAnim = animation;
-        waapiPromise = animation.finished;
-        waapiAnimations.push(waapiAnim);
-        if (waapiTargetByAnim) waapiTargetByAnim.set(waapiAnim, el);
-        if (waapiTimingByAnim) waapiTimingByAnim.set(waapiAnim, readWaapiEffectTiming(waapiAnim.effect));
-        
-        if (begin) {
-          // Fire begin when play starts (and also for autoplay on next frame)
-          onPlayHooks.push(() => fireBegin(el));
-          if (autoplay && typeof requestAnimationFrame !== 'undefined') requestAnimationFrame(() => fireBegin(el));
-        }
-        if (complete) {
-          waapiAnim.addEventListener?.('finish', () => fireComplete(el));
-        }
-        if (update) {
-          const timing = (waapiTimingByAnim && waapiTimingByAnim.get(waapiAnim)) || readWaapiEffectTiming(waapiAnim.effect);
-          const item = { anim: waapiAnim, target: el, update: fireUpdate, _lastP: null, _dur: timing.duration || 0 };
-          waapiUpdateItems.push(item);
-          // Ensure removal on finish/cancel
-          waapiAnim.addEventListener?.('finish', () => waapiUpdateSampler.remove(item));
-          waapiAnim.addEventListener?.('cancel', () => waapiUpdateSampler.remove(item));
-          // Start sampler only when needed (autoplay or explicit play)
-          if (autoplay) ensureWaapiUpdate();
-        }
-      }
-
-      // 2. JS Animation (Fallback / Attributes)
-      let jsPromise = Promise.resolve();
-      if (Object.keys(jsProps).length > 0) {
-        const jsAnim = new JsAnimation(
-          el,
-          jsProps,
-          { duration, delay, easing, autoplay, direction, loop, endDelay, springValues: isSpring ? springValuesRaw : null },
-          { update: fireUpdate, begin: fireBegin, complete: fireComplete }
-        );
-        jsAnimations.push(jsAnim);
-        jsPromise = jsAnim.finished;
-      }
-
-      return Promise.all([waapiPromise, jsPromise]);
-    });
-
-    const finished = Promise.all(promises);
-    const controls = new Controls({ waapi: waapiAnimations, js: jsAnimations, finished });
-    controls.engine = engineInfo;
-    controls._onPlay = onPlayHooks;
-    controls._fireUpdate = fireUpdate;
-    controls._waapiTargetByAnim = waapiTargetByAnim;
-    controls._waapiTimingByAnim = waapiTimingByAnim;
-    controls._ensureWaapiUpdate = update ? ensureWaapiUpdate : null;
-    if (!autoplay) controls.pause();
-    engineInfo.waapiKeys = Array.from(waapiKeySet);
-    engineInfo.jsKeys = Array.from(jsKeySet);
-    return controls;
-  }
-
-  // --- SVG Draw ---
-  function svgDraw(targets, params = {}) {
-     const elements = toArray(targets);
-     elements.forEach(el => {
-         if (!isSVG(el)) return;
-         const len = el.getTotalLength ? el.getTotalLength() : 0;
-         el.style.strokeDasharray = len;
-         el.style.strokeDashoffset = len; 
-         
-         animate(el, {
-             strokeDashoffset: [len, 0],
-             ...params
-         });
-     });
-  }
-
-  // --- In View ---
-  function inViewAnimate(targets, params, options = {}) {
-      const elements = toArray(targets);
-      const observer = new IntersectionObserver((entries) => {
-          entries.forEach(entry => {
-              if (entry.isIntersecting) {
-                  animate(entry.target, params);
-                  if (options.once !== false) observer.unobserve(entry.target);
-              }
-          });
-      }, { threshold: options.threshold || 0.1 });
-      
-      elements.forEach(el => observer.observe(el));
-      // Return cleanup so callers can disconnect observers in long-lived pages (esp. once:false).
-      return () => {
-        try {
-          elements.forEach((el) => observer.unobserve(el));
-          observer.disconnect();
-        } catch (e) {
-          // ignore
-        }
-      };
-  }
-  
-  // --- Scroll Linked ---
-  function scroll(animationPromise, options = {}) {
-      // options: container (default window), range [start, end] (default viewport logic)
-      const container = options.container || window;
-      const target = options.target || document.body; // Element to track for progress
-      // If passing an animation promise, we control its WAAPI animations
-      
-      const controls = animationPromise;
-      // Back-compat: old return value was a Promise with `.animations`
-      const hasSeek = controls && isFunc(controls.seek);
-      const anims = (controls && controls.animations) || (animationPromise && animationPromise.animations) || [];
-      const jsAnims = (controls && controls.jsAnimations) || [];
-      if (!hasSeek && !anims.length && !jsAnims.length) return;
-      
-      // Cache WAAPI timing per animation to avoid repeated getComputedTiming() during scroll.
-      const timingCache = (typeof WeakMap !== 'undefined') ? new WeakMap() : null;
-      const getEndTime = (anim) => {
-        if (!anim || !anim.effect) return 0;
-        if (timingCache) {
-          const cached = timingCache.get(anim);
-          if (cached) return cached;
-        }
-        const timing = readWaapiEffectTiming(anim.effect);
-        const end = timing.duration || 0;
-        if (timingCache && end) timingCache.set(anim, end);
-        return end;
-      };
-
-      const updateScroll = () => {
-          let progress = 0;
-          
-          if (container === window) {
-              const scrollY = window.scrollY;
-              const winH = window.innerHeight;
-              const docH = document.body.scrollHeight;
-              
-              // Simple progress: how far down the page (0 to 1)
-              // Or element based?
-              // Motion One defaults to element entering view.
-              
-              if (options.target) {
-                  const rect = options.target.getBoundingClientRect();
-                  const start = winH; 
-                  const end = -rect.height;
-                  // progress 0 when rect.top == start (just entering)
-                  // progress 1 when rect.top == end (just left)
-                  
-                  const totalDistance = start - end;
-                  const currentDistance = start - rect.top;
-                  progress = currentDistance / totalDistance;
-              } else {
-                  // Whole page scroll
-                  progress = scrollY / (docH - winH);
-              }
-          } else if (container && (typeof Element !== 'undefined') && (container instanceof Element)) {
-              // Scroll container progress
-              const el = container;
-              const scrollTop = el.scrollTop;
-              const max = (el.scrollHeight - el.clientHeight) || 1;
-              if (options.target) {
-                const containerRect = el.getBoundingClientRect();
-                const rect = options.target.getBoundingClientRect();
-                const start = containerRect.height;
-                const end = -rect.height;
-                const totalDistance = start - end;
-                const currentDistance = start - (rect.top - containerRect.top);
-                progress = currentDistance / totalDistance;
-              } else {
-                progress = scrollTop / max;
-              }
-          }
-          
-          // Clamp
-          progress = clamp01(progress);
-          
-          if (hasSeek) {
-            controls.seek(progress);
-            return;
-          }
-          
-          anims.forEach((anim) => {
-            if (anim.effect) {
-              const end = getEndTime(anim);
-              if (!end) return;
-              anim.currentTime = end * progress;
-            }
-          });
-      };
-
-      let rafId = 0;
-      const onScroll = () => {
-        if (rafId) return;
-        rafId = requestAnimationFrame(() => {
-          rafId = 0;
-          updateScroll();
-        });
-      };
-      
-      const eventTarget = (container && container.addEventListener) ? container : window;
-      eventTarget.addEventListener('scroll', onScroll, { passive: true });
-      updateScroll(); // Initial
-      
-      return () => {
-        if (rafId) cancelAnimationFrame(rafId);
-        eventTarget.removeEventListener('scroll', onScroll);
-      };
-  }
-
-  // --- Timeline ---
-  function timeline(defaults = {}) {
-      const steps = [];
-      const api = {
-        currentTime: 0,
-        add: (targets, params, offset) => {
-          const animParams = { ...defaults, ...params };
-          let start = api.currentTime;
-          
-          if (offset !== undefined) {
-            if (isStr(offset) && offset.startsWith('-=')) start -= parseFloat(offset.slice(2));
-            else if (isStr(offset) && offset.startsWith('+=')) start += parseFloat(offset.slice(2));
-            else if (typeof offset === 'number') start = offset;
-          }
-          
-          const dur = animParams.duration || 1000;
-          const step = { targets, animParams, start, _scheduled: false };
-          steps.push(step);
-          
-          // Backward compatible: schedule immediately (existing docs rely on this)
-          if (start <= 0) {
-            animate(targets, animParams);
-            step._scheduled = true;
-          } else {
-            setTimeout(() => {
-              animate(targets, animParams);
-            }, start);
-            step._scheduled = true;
-          }
-          
-          api.currentTime = Math.max(api.currentTime, start + dur);
-          return api;
-        },
-        // Optional: if you create a timeline and want to defer scheduling yourself
-        play: () => {
-          steps.forEach((s) => {
-            if (s._scheduled) return;
-            if (s.start <= 0) animate(s.targets, s.animParams);
-            else setTimeout(() => animate(s.targets, s.animParams), s.start);
-            s._scheduled = true;
-          });
-          return api;
-        }
-      };
-      return api;
-  }
-
-  // --- Export ---
-  // transform `$` to be the main export `animal`, with statics attached
-  const animal = $;
-  
-  // Extend $ behavior to act as a global selector and property accessor
-  Object.assign(animal, {
-    animate,
-    timeline,
-    draw: svgDraw,
-    svgDraw,
-    inViewAnimate,
-    spring,
-    scroll,
-    $: animal // Self-reference for backward compatibility
-  });
-
-  // Expose Layer if available or allow lazy loading
-  Object.defineProperty(animal, 'Layer', {
-    get: () => {
-      return (typeof window !== 'undefined' && window.Layer) ? window.Layer :
-             (typeof globalThis !== 'undefined' && globalThis.Layer) ? globalThis.Layer :
-             (typeof Layer !== 'undefined' ? Layer : null);
-    }
-  });
-
-  // Shortcut for Layer.fire or new Layer()
-  // Allows $.fire({ title: 'Hi' }) or $.layer({ title: 'Hi' })
-  animal.fire = (options) => {
-    const L = animal.Layer;
-    if (L) return (L.fire ? L.fire(options) : new L(options).fire());
-    console.warn('Layer module not loaded.');
-    return Promise.reject('Layer module not loaded');
-  };
-  // 'layer' alias for static usage
-  animal.layer = animal.fire;
-
-  return animal;
-
-})));
-
-
-  // Unified entrypoint
-  if (__GLOBAL__ && __GLOBAL__.animal) {
-    __GLOBAL__.xjs = __GLOBAL__.animal;
-  }
-
-  // Optional jQuery-like alias (opt-in, and won't clobber existing $)
-  try {
-    if (__GLOBAL__ && __GLOBAL__.XJS_GLOBAL_DOLLAR === true && !__GLOBAL__.$ && __GLOBAL__.xjs) {
-      __GLOBAL__.$ = __GLOBAL__.xjs;
-    }
-  } catch (_) {}
-})();