robert vor 1 Tag
Ursprung
Commit
c9159bc225

+ 17 - 0
README.md

@@ -16,6 +16,7 @@
 ## 📦 安装与引入
 
 ```html
+<!-- 开发/测试(推荐):xjs.js 会自动加载 animal.js + layer.js -->
 <script src="xjs.js"></script>
 ```
 
@@ -32,6 +33,22 @@
 <script src="xjs.js"></script>
 ```
 
+### 生产构建(合并为单文件)
+
+开发阶段请只修改 `animal.js` / `layer.js`(不要手改合并产物)。
+
+运行下面命令会把两个源文件合并输出到 `dist/xjs.js`:
+
+```bash
+go run main.go build
+```
+
+你也可以指定输出路径:
+
+```bash
+go run main.go build xjs.bundle.js
+```
+
 ---
 
 ## 🚀 快速上手

+ 2240 - 0
dist/xjs.js

@@ -0,0 +1,2240 @@
+// xjs.js - combined single-file build (animal + layer)
+// Generated by `go run main.go build`.
+// NOTE: Do not edit this file directly in development.
+//       Edit animal.js / layer.js instead.
+
+/* --- 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);
+}(this, (function () {
+  'use strict';
+
+  const PREFIX = 'layer-';
+  
+  // Theme & Styles
+  const css = `
+    .${PREFIX}overlay {
+      position: fixed;
+      top: 0;
+      left: 0;
+      width: 100%;
+      height: 100%;
+      background: rgba(0, 0, 0, 0.4);
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      z-index: 1000;
+      opacity: 0;
+      transition: opacity 0.3s;
+    }
+    .${PREFIX}overlay.show {
+      opacity: 1;
+    }
+    .${PREFIX}popup {
+      background: #fff;
+      border-radius: 8px;
+      box-shadow: 0 4px 12px rgba(0,0,0,0.15);
+      width: 32em;
+      max-width: 90%;
+      padding: 1.5em;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      position: relative;
+      z-index: 1;
+      transform: scale(0.92);
+      transition: transform 0.26s ease;
+      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+    }
+    .${PREFIX}overlay.show .${PREFIX}popup {
+      transform: scale(1);
+    }
+    .${PREFIX}title {
+      font-size: 1.8em;
+      font-weight: 600;
+      color: #333;
+      margin: 0 0 0.5em;
+      text-align: center;
+    }
+    .${PREFIX}content {
+      font-size: 1.125em;
+      color: #545454;
+      margin-bottom: 1.5em;
+      text-align: center;
+      line-height: 1.5;
+    }
+    .${PREFIX}actions {
+      display: flex;
+      justify-content: center;
+      gap: 1em;
+      width: 100%;
+    }
+    .${PREFIX}button {
+      border: none;
+      border-radius: 4px;
+      padding: 0.6em 1.2em;
+      font-size: 1em;
+      cursor: pointer;
+      transition: background-color 0.2s, box-shadow 0.2s;
+      color: #fff;
+    }
+    .${PREFIX}confirm {
+      background-color: #3085d6;
+    }
+    .${PREFIX}confirm:hover {
+      background-color: #2b77c0;
+    }
+    .${PREFIX}cancel {
+      background-color: #aaa;
+    }
+    .${PREFIX}cancel:hover {
+      background-color: #999;
+    }
+    .${PREFIX}icon {
+      position: relative;
+      box-sizing: content-box;
+      width: 5em;
+      height: 5em;
+      margin: 2.5em auto 0.6em;
+      border: 0.25em solid rgba(0, 0, 0, 0);
+      border-radius: 50%;
+      font-family: inherit;
+      line-height: 5em;
+      cursor: default;
+      user-select: none;
+    }
+
+    /* SVG mark content (kept), sits above the ring */
+    .${PREFIX}icon svg {
+      width: 100%;
+      height: 100%;
+      display: block;
+      overflow: visible;
+      position: relative;
+      z-index: 3;
+    }
+    .${PREFIX}svg-ring,
+    .${PREFIX}svg-mark {
+      fill: none;
+      stroke-linecap: round;
+      stroke-linejoin: round;
+    }
+    .${PREFIX}svg-ring {
+      stroke-width: 4.5;
+      opacity: 0.95;
+    }
+    .${PREFIX}svg-mark {
+      stroke-width: 6;
+    }
+    .${PREFIX}svg-dot {
+      transform-box: fill-box;
+      transform-origin: center;
+    }
+
+    /* =========================================
+       "success-like" ring (shared by all icons)
+       - we reuse the success ring pieces/rotation for every icon type
+       - color is driven by currentColor
+       ========================================= */
+    .${PREFIX}icon.success { border-color: #a5dc86; color: #a5dc86; }
+    .${PREFIX}icon.error { border-color: #f27474; color: #f27474; }
+    .${PREFIX}icon.warning { border-color: #f8bb86; color: #f8bb86; }
+    .${PREFIX}icon.info { border-color: #3fc3ee; color: #3fc3ee; }
+    .${PREFIX}icon.question { border-color: #b18cff; color: #b18cff; }
+
+    .${PREFIX}icon .${PREFIX}success-ring {
+      position: absolute;
+      z-index: 2;
+      top: -0.25em;
+      left: -0.25em;
+      box-sizing: content-box;
+      width: 100%;
+      height: 100%;
+      border: 0.25em solid currentColor;
+      opacity: 0.3;
+      border-radius: 50%;
+    }
+    .${PREFIX}icon .${PREFIX}success-fix {
+      position: absolute;
+      z-index: 1;
+      top: 0.5em;
+      left: 1.625em;
+      width: 0.4375em;
+      height: 5.625em;
+      transform: rotate(-45deg);
+      background-color: #fff; /* adjusted at runtime to popup bg */
+    }
+    .${PREFIX}icon .${PREFIX}success-circular-line-left,
+    .${PREFIX}icon .${PREFIX}success-circular-line-right {
+      position: absolute;
+      width: 3.75em;
+      height: 7.5em;
+      border-radius: 50%;
+      background-color: #fff; /* adjusted at runtime to popup bg */
+    }
+    .${PREFIX}icon .${PREFIX}success-circular-line-left {
+      top: -0.4375em;
+      left: -2.0635em;
+      transform: rotate(-45deg);
+      transform-origin: 3.75em 3.75em;
+      border-radius: 7.5em 0 0 7.5em;
+    }
+    .${PREFIX}icon .${PREFIX}success-circular-line-right {
+      top: -0.6875em;
+      left: 1.875em;
+      transform: rotate(-45deg);
+      transform-origin: 0 3.75em;
+      border-radius: 0 7.5em 7.5em 0;
+    }
+    .${PREFIX}icon .${PREFIX}success-line-tip,
+    .${PREFIX}icon .${PREFIX}success-line-long {
+      display: block;
+      position: absolute;
+      z-index: 2;
+      height: 0.3125em;
+      border-radius: 0.125em;
+      background-color: currentColor;
+    }
+    .${PREFIX}icon .${PREFIX}success-line-tip {
+      top: 2.875em;
+      left: 0.8125em;
+      width: 1.5625em;
+      transform: rotate(45deg);
+    }
+    .${PREFIX}icon .${PREFIX}success-line-long {
+      top: 2.375em;
+      right: 0.5em;
+      width: 2.9375em;
+      transform: rotate(-45deg);
+    }
+
+    /* Triggered when icon has .${PREFIX}icon-show */
+    .${PREFIX}icon.${PREFIX}icon-show .${PREFIX}success-line-tip {
+      animation: ${PREFIX}animate-success-line-tip 0.75s;
+    }
+    .${PREFIX}icon.${PREFIX}icon-show .${PREFIX}success-line-long {
+      animation: ${PREFIX}animate-success-line-long 0.75s;
+    }
+    .${PREFIX}icon.${PREFIX}icon-show .${PREFIX}success-circular-line-right {
+      animation: ${PREFIX}rotate-success-circular-line 4.25s ease-in;
+    }
+    /* Warning needs a shorter arc (45deg) */
+    .${PREFIX}icon.warning.${PREFIX}icon-show .${PREFIX}success-circular-line-right {
+      animation: ${PREFIX}rotate-warning-circular-line 4.25s ease-in;
+    }
+
+    @keyframes ${PREFIX}animate-success-line-tip {
+      0% {
+        top: 1.1875em;
+        left: 0.0625em;
+        width: 0;
+      }
+      54% {
+        top: 1.0625em;
+        left: 0.125em;
+        width: 0;
+      }
+      70% {
+        top: 2.1875em;
+        left: -0.375em;
+        width: 3.125em;
+      }
+      84% {
+        top: 3em;
+        left: 1.3125em;
+        width: 1.0625em;
+      }
+      100% {
+        top: 2.8125em;
+        left: 0.8125em;
+        width: 1.5625em;
+      }
+    }
+    @keyframes ${PREFIX}animate-success-line-long {
+      0% {
+        top: 3.375em;
+        right: 2.875em;
+        width: 0;
+      }
+      65% {
+        top: 3.375em;
+        right: 2.875em;
+        width: 0;
+      }
+      84% {
+        top: 2.1875em;
+        right: 0;
+        width: 3.4375em;
+      }
+      100% {
+        top: 2.375em;
+        right: 0.5em;
+        width: 2.9375em;
+      }
+    }
+    @keyframes ${PREFIX}rotate-success-circular-line {
+      0% { transform: rotate(-45deg); }
+      5% { transform: rotate(-45deg); }
+      12% { transform: rotate(-405deg); }
+      100% { transform: rotate(-405deg); }
+    }
+    @keyframes ${PREFIX}rotate-warning-circular-line {
+      0% { transform: rotate(-45deg); }
+      5% { transform: rotate(-45deg); }
+      12% { transform: rotate(-180deg); }
+      100% { transform: rotate(-180deg); } /* match 12% so it "sweeps then stops" like success */
+    }
+
+    /* (Other icon shapes stay SVG-driven; only the ring animation is unified above) */
+  `;
+
+  // Inject Styles
+  const injectStyles = () => {
+    if (document.getElementById(`${PREFIX}styles`)) return;
+    const styleSheet = document.createElement('style');
+    styleSheet.id = `${PREFIX}styles`;
+    styleSheet.textContent = css;
+    document.head.appendChild(styleSheet);
+  };
+
+  class Layer {
+    constructor() {
+      injectStyles();
+      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
+        //   <div class="...success-circular-line-left"></div>
+        //   <span class="...success-line-tip"></span>
+        //   <span class="...success-line-long"></span>
+        //   <div class="...success-ring"></div>
+        //   <div class="...success-fix"></div>
+        //   <div class="...success-circular-line-right"></div>
+        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;
+      }
+
+      // Default to SVG icons for other/custom types
+      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') {
+        // Question mark (single stroke + dot)
+        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 {
+        // Fallback: 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) {
+          // Override the CSS scale transition with a spring-ish entrance.
+          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 {}
+        }
+      }
+
+      // Icon SVG draw animation
+      if (this.params.iconAnimation) {
+        this._animateIcon();
+      }
+
+      // User hook (SweetAlert-ish naming)
+      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, we unify the "ring first, then mark" order.
+      const baseDelay = ringTypes.has(type) ? 180 : 0;
+
+      if (type === 'error') {
+        const left = marks[0];
+        const right = marks[1];
+        try { if (left) X(left).draw({ duration: 320, easing: 'ease-out', delay: baseDelay }); } catch {}
+        try { if (right) X(right).draw({ duration: 320, easing: 'ease-out', delay: baseDelay + 70 }); } catch {}
+      } else {
+        // warning / info / question (single stroke) or custom SVG symbols
+        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';
+          // Keep dot pop after the ring begins
+          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());
+}(this, (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;
+    // Strip exponent part for decimal count
+    const e = s.search(/[eE]/);
+    const end = e >= 0 ? e : s.length;
+    const frac = s.slice(dot + 1, end);
+    // Ignore trailing zeros for display
+    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) {
+    // Use a fresh regex to avoid any `lastIndex` surprises.
+    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));
+    // Use fixed, then strip trailing zeros/dot to keep strings compact.
+    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.)
+        // Requires both strings to have the same count of numeric tokens (>= 2).
+        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;
+
+})));
+
+;(function(g){
+  try { if (g && g.animal && !g.xjs) g.xjs = g.animal; } catch {}
+  try { if (g && g.XJS_GLOBAL_DOLLAR && !g.$ && g.xjs) g.$ = g.xjs; } catch {}
+})(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : this));

+ 4 - 1
doc/examples/layer.html

@@ -185,7 +185,10 @@ document.querySelector('.btn-direct').addEventListener('click', () =&gt; {
   </div>
 
   <script src="../highlight_css.js"></script>
-  <script src="../../xjs.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>
     function switchTab(tab) {
       document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));

+ 182 - 12
doc/index.html

@@ -231,20 +231,20 @@
         
         <div class="nav-tree" aria-label="主菜单(扁平化)">
             <div class="nav-section-title">Animal.js 动画</div>
-            <div class="nav-item active" onclick="selectCategory('targets/list.html', 'targets/overview.html', this)">目标 Targets</div>
-            <div class="nav-item" onclick="selectCategory('animatable_properties/list.html', 'animatable_properties/test_transforms.html', this)">可动画属性 Properties</div>
-            <div class="nav-item" onclick="selectCategory('tween_value_types/list.html', 'tween_value_types/test_numerical.html', this)">补间值类型 Tween 值</div>
-            <div class="nav-item" onclick="selectCategory('tween_parameters/list.html', 'tween_parameters/overview.html', this)">补间参数 Tween 参数</div>
-            <div class="nav-item" onclick="selectCategory('svg/list.html', 'svg/overview.html', this)">SVG 动画</div>
-            <div class="nav-item" onclick="selectCategory('list_callbacks.html', 'test_on_update.html', this)">回调 Callbacks</div>
-            <div class="nav-item" onclick="selectCategory('examples/list.html', 'examples/controls.html', this)">示例 Examples</div>
+            <div class="nav-item active" data-list="targets/list.html" data-default="targets/overview.html" onclick="selectCategory('targets/list.html', 'targets/overview.html', this)">目标 Targets</div>
+            <div class="nav-item" data-list="animatable_properties/list.html" data-default="animatable_properties/test_transforms.html" onclick="selectCategory('animatable_properties/list.html', 'animatable_properties/test_transforms.html', this)">可动画属性 Properties</div>
+            <div class="nav-item" data-list="tween_value_types/list.html" data-default="tween_value_types/test_numerical.html" onclick="selectCategory('tween_value_types/list.html', 'tween_value_types/test_numerical.html', this)">补间值类型 Tween 值</div>
+            <div class="nav-item" data-list="tween_parameters/list.html" data-default="tween_parameters/overview.html" onclick="selectCategory('tween_parameters/list.html', 'tween_parameters/overview.html', this)">补间参数 Tween 参数</div>
+            <div class="nav-item" data-list="svg/list.html" data-default="svg/overview.html" onclick="selectCategory('svg/list.html', 'svg/overview.html', this)">SVG 动画</div>
+            <div class="nav-item" data-list="list_callbacks.html" data-default="test_on_update.html" onclick="selectCategory('list_callbacks.html', 'test_on_update.html', this)">回调 Callbacks</div>
+            <div class="nav-item" data-list="examples/list.html" data-default="examples/controls.html" onclick="selectCategory('examples/list.html', 'examples/controls.html', this)">示例 Examples</div>
 
             <div class="nav-section-title">Layer 弹窗</div>
-            <div class="nav-item" onclick="selectCategory('layer/list.html', 'layer/overview.html', this)">Layer API / Tests</div>
-            <div class="nav-item" onclick="selectCategory('examples/list.html', 'examples/layer.html', this)">交互示例(Layer + 动画)</div>
+            <div class="nav-item" data-list="layer/list.html" data-default="layer/overview.html" onclick="selectCategory('layer/list.html', 'layer/overview.html', this)">Layer API / Tests</div>
+            <div class="nav-item" data-list="examples/list.html" data-default="examples/layer.html" onclick="selectCategory('examples/list.html', 'examples/layer.html', this)">交互示例(Layer + 动画)</div>
 
             <div class="nav-section-title">开发者</div>
-            <div class="nav-item" onclick="selectCategory('search.html', 'module.html', this)">模块开发与测试指南</div>
+            <div class="nav-item" data-list="search.html" data-default="module.html" onclick="selectCategory('search.html', 'module.html', this)">模块开发与测试指南</div>
         </div>
     </div>
 
@@ -267,6 +267,136 @@
         // Bump this when you want to force-refresh iframe pages
         const DOC_CACHE_VERSION = '2';
 
+        // =========================================================
+        // URL state (hash routing)
+        // - When navigating: write selected content page into URL: #p=<encoded>
+        // - On refresh / back-forward: restore the same content page + sync left/middle
+        // =========================================================
+        let _docApplyingRoute = false;
+
+        function ensureHtmlPath(u) {
+            const n = normalizeDocUrl(u);
+            if (!n) return null;
+            return n.endsWith('.html') ? n : (n + '.html');
+        }
+
+        // New readable token:
+        // - omit ".html"
+        // - "/" becomes "_" (separator)
+        // - existing "_" becomes "__" (escape), so it's reversible
+        function routeTokenFromContentUrl(contentUrl) {
+            let u = normalizeDocUrl(contentUrl);
+            if (!u) return '';
+            if (u.endsWith('.html')) u = u.slice(0, -5);
+            // escape "_" first, then turn "/" into "_"
+            return u.replace(/_/g, '__').replace(/\//g, '_');
+        }
+
+        function contentUrlFromRouteToken(token) {
+            let t = String(token || '');
+            if (!t) return null;
+            // In case someone pasted an encoded token, be tolerant.
+            try { t = decodeURIComponent(t); } catch {}
+
+            // If it already looks like a path (legacy), keep it.
+            if (t.includes('/')) return ensureHtmlPath(t);
+
+            // Decode: "_" => "/", "__" => "_"
+            let out = '';
+            for (let i = 0; i < t.length; i++) {
+                const ch = t[i];
+                if (ch === '_') {
+                    if (t[i + 1] === '_') { out += '_'; i++; }
+                    else out += '/';
+                } else {
+                    out += ch;
+                }
+            }
+            return ensureHtmlPath(out);
+        }
+
+        function getRouteContentUrl() {
+            const h = String(location.hash || '');
+            if (!h) return null;
+            const hash = h.startsWith('#') ? h.slice(1) : h;
+            // Legacy-friendly:
+            // - "#p=<encoded path>"
+            // - "#/path/to/page.html"
+            // New:
+            // - "#layer_overview" / "#layer_test__confirm__flow"
+            if (hash.startsWith('/')) {
+                const p = hash.slice(1);
+                return p ? ensureHtmlPath(p) : null;
+            }
+            if (hash.startsWith('p=')) {
+                const params = new URLSearchParams(hash);
+                const p = params.get('p');
+                return p ? ensureHtmlPath(p) : null;
+            }
+            return contentUrlFromRouteToken(hash);
+        }
+
+        function setRouteContentUrl(contentUrl, { replace = false } = {}) {
+            const token = routeTokenFromContentUrl(contentUrl);
+            if (!token) return;
+            const nextHash = '#' + token;
+            if (location.hash === nextHash) return;
+            if (replace) history.replaceState(null, '', nextHash);
+            else location.hash = nextHash;
+        }
+
+        function baseDocPath(url) {
+            return normalizeDocUrl(url);
+        }
+
+        function guessListUrlByContent(contentUrl) {
+            const u = normalizeDocUrl(contentUrl);
+            if (!u) return null;
+            if (u === 'module.html' || u === 'search.html') return 'search.html';
+            if (u === 'test_on_update.html') return 'list_callbacks.html';
+            if (u.startsWith('targets/')) return 'targets/list.html';
+            if (u.startsWith('animatable_properties/')) return 'animatable_properties/list.html';
+            if (u.startsWith('tween_value_types/')) return 'tween_value_types/list.html';
+            if (u.startsWith('tween_parameters/')) return 'tween_parameters/list.html';
+            if (u.startsWith('svg/')) return 'svg/list.html';
+            if (u.startsWith('layer/')) return 'layer/list.html';
+            if (u.startsWith('examples/')) return 'examples/list.html';
+            return null;
+        }
+
+        function syncLeftActiveByListUrl(listUrl, contentUrl) {
+            const u = normalizeDocUrl(listUrl);
+            if (!u) return;
+            const items = Array.from(document.querySelectorAll('.nav-item'));
+            items.forEach(i => i.classList.remove('active'));
+
+            // Prefer exact (list + default) match, then fall back to first list match
+            const normContent = normalizeDocUrl(contentUrl);
+            const exact = items.find(i =>
+                normalizeDocUrl(i.getAttribute('data-list')) === u &&
+                normalizeDocUrl(i.getAttribute('data-default')) === normContent
+            );
+            const any = items.find(i => normalizeDocUrl(i.getAttribute('data-list')) === u);
+            (exact || any)?.classList.add('active');
+        }
+
+        function ensureCategoryForContent(contentUrl) {
+            const listUrl = guessListUrlByContent(contentUrl);
+            if (!listUrl) return;
+
+            // Left highlight
+            syncLeftActiveByListUrl(listUrl, contentUrl);
+
+            // Middle list
+            const middleFrame = document.getElementById('middle-frame');
+            if (middleFrame) {
+                const current = baseDocPath(middleFrame.getAttribute('src') || '');
+                if (current !== normalizeDocUrl(listUrl)) {
+                    middleFrame.src = withDocVersion(listUrl);
+                }
+            }
+        }
+
         function selectCategory(listUrl, defaultContentUrl, el) {
             // 1. Highlight Nav Item
             document.querySelectorAll('.nav-item').forEach(item => item.classList.remove('active'));
@@ -280,7 +410,7 @@
             }
 
             // 3. Load Right Column (Default Content)
-            loadContent(defaultContentUrl);
+            loadContent(defaultContentUrl, { updateRoute: true });
         }
 
         function withDocVersion(url) {
@@ -291,7 +421,8 @@
             return u + '?v=' + encodeURIComponent(DOC_CACHE_VERSION);
         }
 
-        function loadContent(url) {
+        function loadContent(url, opts = {}) {
+            const { updateRoute = true, replaceRoute = false } = opts || {};
             const contentFrame = document.getElementById('content-frame');
             const urlV = withDocVersion(url);
             if (contentFrame.getAttribute('src') !== urlV) {
@@ -303,6 +434,16 @@
                 setMiddleActive(url);
             } catch {}
 
+            // Keep left + middle category consistent even if navigation came from middle list / prev-next
+            try {
+                ensureCategoryForContent(url);
+            } catch {}
+
+            // Persist current selection in URL (so refresh stays on same page)
+            if (updateRoute) {
+                try { setRouteContentUrl(url, { replace: !!replaceRoute }); } catch {}
+            }
+
             // Update connection line after navigation
             scheduleConnectionLineUpdate();
         }
@@ -604,6 +745,21 @@
 
         // Make initial active state robust against iframe load ordering
         window.addEventListener('load', () => {
+            // Restore route (if present) before doing any extra syncing
+            try {
+                const routeUrl = getRouteContentUrl();
+                if (routeUrl) {
+                    _docApplyingRoute = true;
+                    ensureCategoryForContent(routeUrl);
+                    loadContent(routeUrl, { updateRoute: false });
+                } else {
+                    // No route: persist current default content into URL (so first refresh is stable)
+                    const current = baseDocPath(document.getElementById('content-frame')?.getAttribute('src') || '');
+                    if (current) setRouteContentUrl(current, { replace: true });
+                }
+            } catch {}
+            _docApplyingRoute = false;
+
             const contentFrame = document.getElementById('content-frame');
             const src = contentFrame?.getAttribute('src');
             if (src) setMiddleActive(src);
@@ -636,6 +792,20 @@
             scheduleConnectionLineUpdate();
             startConnectionLineLoop();
         });
+
+        // Back/forward + manual hash edits: restore selection
+        window.addEventListener('hashchange', () => {
+            if (_docApplyingRoute) return;
+            const routeUrl = getRouteContentUrl();
+            if (!routeUrl) return;
+            _docApplyingRoute = true;
+            try {
+                ensureCategoryForContent(routeUrl);
+                loadContent(routeUrl, { updateRoute: false });
+            } finally {
+                _docApplyingRoute = false;
+            }
+        });
     </script>
 </body>
 </html>

+ 4 - 1
doc/layer/overview.html

@@ -131,7 +131,10 @@ xjs.layer({
   </div>
 
   <script src="../highlight_css.js"></script>
-  <script src="../../xjs.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/overview.html';
 

+ 4 - 1
doc/layer/test_confirm_flow.html

@@ -99,7 +99,10 @@
   </div>
 
   <script src="../highlight_css.js"></script>
-  <script src="../../xjs.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_confirm_flow.html';
 

+ 4 - 1
doc/layer/test_icons_svg_animation.html

@@ -120,7 +120,10 @@
   </div>
 
   <script src="../highlight_css.js"></script>
-  <script src="../../xjs.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_icons_svg_animation.html';
     // On-page error surface (helps debug inside iframe)

+ 4 - 1
doc/layer/test_selection_layer_dataset.html

@@ -130,7 +130,10 @@ xjs('.btn-data').layer();</pre>
   </div>
 
   <script src="../highlight_css.js"></script>
-  <script src="../../xjs.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_selection_layer_dataset.html';
 

+ 4 - 1
doc/layer/test_static_fire.html

@@ -96,7 +96,10 @@ Layer.$()
   </div>
 
   <script src="../highlight_css.js"></script>
-  <script src="../../xjs.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_static_fire.html';
 

+ 158 - 184
layer.js

@@ -117,20 +117,31 @@
       background-color: #999;
     }
     .${PREFIX}icon {
-      width: 6em;
-      height: 6em;
-      margin: 1.5em auto 1.2em;
       position: relative;
-      display: grid;
-      place-items: center;
+      box-sizing: content-box;
+      width: 5em;
+      height: 5em;
+      margin: 2.5em auto 0.6em;
+      border: 0.25em solid rgba(0, 0, 0, 0);
+      border-radius: 50%;
+      font-family: inherit;
+      line-height: 5em;
+      cursor: default;
+      user-select: none;
+      /* Ring sweep angles (match SweetAlert2 success feel) */
+      --${PREFIX}ring-start-rotate: -45deg;
+      /* End is one full turn from start so it "sweeps then settles" */
+      --${PREFIX}ring-end-rotate: -405deg;
     }
 
-    /* SVG Icon (SweetAlert-like, animated via xjs.draw + spring) */
+    /* SVG mark content (kept), sits above the ring */
     .${PREFIX}icon svg {
       width: 100%;
       height: 100%;
       display: block;
       overflow: visible;
+      position: relative;
+      z-index: 3;
     }
     .${PREFIX}svg-ring,
     .${PREFIX}svg-mark {
@@ -151,27 +162,24 @@
     }
 
     /* =========================================
-       SweetAlert2-compatible SUCCESS icon (DOM)
-       (matches sweetalert2 v11.26.17)
+       "success-like" ring (shared by all icons)
+       - we reuse the success ring pieces/rotation for every icon type
+       - color is driven by currentColor
        ========================================= */
-    .${PREFIX}icon.success {
-      /* Override the generic SVG icon box */
-      display: block;
-      place-items: initial;
-      box-sizing: content-box;
-      width: 5em;
-      height: 5em;
-      margin: 2.5em auto 0.6em;
-      border: 0.25em solid rgba(0, 0, 0, 0);
-      border-radius: 50%;
-      border-color: #a5dc86;
-      color: #a5dc86;
-      font-family: inherit;
-      line-height: 5em;
-      cursor: default;
-      user-select: none;
-    }
-    .${PREFIX}icon.success .${PREFIX}success-ring {
+    .${PREFIX}icon.success { border-color: #a5dc86; color: #a5dc86; }
+    .${PREFIX}icon.error { border-color: #f27474; color: #f27474; }
+    .${PREFIX}icon.warning { border-color: #f8bb86; color: #f8bb86; }
+    .${PREFIX}icon.info { border-color: #3fc3ee; color: #3fc3ee; }
+    .${PREFIX}icon.question { border-color: #b18cff; color: #b18cff; }
+
+    /* Per-icon ring sweep stop (same timing, different final position) */
+    /* IMPORTANT: keep a full 360° sweep (end = start - 360) for the same "draw then vanish" feel */
+    .${PREFIX}icon.warning { --${PREFIX}ring-start-rotate: -90deg;  --${PREFIX}ring-end-rotate: -450deg; }  /* stop at top */
+    .${PREFIX}icon.error { --${PREFIX}ring-start-rotate: -135deg;   --${PREFIX}ring-end-rotate: -495deg; }  /* stop at top-left */
+    .${PREFIX}icon.info { --${PREFIX}ring-start-rotate: -90deg;     --${PREFIX}ring-end-rotate: -450deg; }  /* stop at top */
+    .${PREFIX}icon.question { --${PREFIX}ring-start-rotate: -135deg; --${PREFIX}ring-end-rotate: -495deg; } /* stop at top-left */
+
+    .${PREFIX}icon .${PREFIX}success-ring {
       position: absolute;
       z-index: 2;
       top: -0.25em;
@@ -179,10 +187,11 @@
       box-sizing: content-box;
       width: 100%;
       height: 100%;
-      border: 0.25em solid rgba(165, 220, 134, 0.3);
+      border: 0.25em solid currentColor;
+      opacity: 0.3;
       border-radius: 50%;
     }
-    .${PREFIX}icon.success .${PREFIX}success-fix {
+    .${PREFIX}icon .${PREFIX}success-fix {
       position: absolute;
       z-index: 1;
       top: 0.5em;
@@ -192,44 +201,44 @@
       transform: rotate(-45deg);
       background-color: #fff; /* adjusted at runtime to popup bg */
     }
-    .${PREFIX}icon.success .${PREFIX}success-circular-line-left,
-    .${PREFIX}icon.success .${PREFIX}success-circular-line-right {
+    .${PREFIX}icon .${PREFIX}success-circular-line-left,
+    .${PREFIX}icon .${PREFIX}success-circular-line-right {
       position: absolute;
       width: 3.75em;
       height: 7.5em;
       border-radius: 50%;
       background-color: #fff; /* adjusted at runtime to popup bg */
     }
-    .${PREFIX}icon.success .${PREFIX}success-circular-line-left {
+    .${PREFIX}icon .${PREFIX}success-circular-line-left {
       top: -0.4375em;
       left: -2.0635em;
       transform: rotate(-45deg);
       transform-origin: 3.75em 3.75em;
       border-radius: 7.5em 0 0 7.5em;
     }
-    .${PREFIX}icon.success .${PREFIX}success-circular-line-right {
+    .${PREFIX}icon .${PREFIX}success-circular-line-right {
       top: -0.6875em;
       left: 1.875em;
       transform: rotate(-45deg);
       transform-origin: 0 3.75em;
       border-radius: 0 7.5em 7.5em 0;
     }
-    .${PREFIX}icon.success .${PREFIX}success-line-tip,
-    .${PREFIX}icon.success .${PREFIX}success-line-long {
+    .${PREFIX}icon .${PREFIX}success-line-tip,
+    .${PREFIX}icon .${PREFIX}success-line-long {
       display: block;
       position: absolute;
       z-index: 2;
       height: 0.3125em;
       border-radius: 0.125em;
-      background-color: #a5dc86;
+      background-color: currentColor;
     }
-    .${PREFIX}icon.success .${PREFIX}success-line-tip {
+    .${PREFIX}icon .${PREFIX}success-line-tip {
       top: 2.875em;
       left: 0.8125em;
       width: 1.5625em;
       transform: rotate(45deg);
     }
-    .${PREFIX}icon.success .${PREFIX}success-line-long {
+    .${PREFIX}icon .${PREFIX}success-line-long {
       top: 2.375em;
       right: 0.5em;
       width: 2.9375em;
@@ -237,14 +246,14 @@
     }
 
     /* Triggered when icon has .${PREFIX}icon-show */
-    .${PREFIX}icon.success.${PREFIX}icon-show .${PREFIX}success-line-tip {
+    .${PREFIX}icon.${PREFIX}icon-show .${PREFIX}success-line-tip {
       animation: ${PREFIX}animate-success-line-tip 0.75s;
     }
-    .${PREFIX}icon.success.${PREFIX}icon-show .${PREFIX}success-line-long {
+    .${PREFIX}icon.${PREFIX}icon-show .${PREFIX}success-line-long {
       animation: ${PREFIX}animate-success-line-long 0.75s;
     }
-    .${PREFIX}icon.success.${PREFIX}icon-show .${PREFIX}success-circular-line-right {
-      animation: ${PREFIX}rotate-success-circular-line 4.25s ease-in;
+    .${PREFIX}icon.${PREFIX}icon-show .${PREFIX}success-circular-line-right {
+      animation: ${PREFIX}rotate-icon-circular-line 4.25s ease-in;
     }
 
     @keyframes ${PREFIX}animate-success-line-tip {
@@ -296,17 +305,14 @@
         width: 2.9375em;
       }
     }
-    @keyframes ${PREFIX}rotate-success-circular-line {
-      0% { transform: rotate(-45deg); }
-      5% { transform: rotate(-45deg); }
-      12% { transform: rotate(-405deg); }
-      100% { transform: rotate(-405deg); }
+    @keyframes ${PREFIX}rotate-icon-circular-line {
+      0% { transform: rotate(var(--${PREFIX}ring-start-rotate)); }
+      5% { transform: rotate(var(--${PREFIX}ring-start-rotate)); }
+      12% { transform: rotate(var(--${PREFIX}ring-end-rotate)); }
+      100% { transform: rotate(var(--${PREFIX}ring-end-rotate)); } /* match 12% so it "sweeps then stops" */
     }
 
-    .${PREFIX}icon.error { color: #f27474; }
-    .${PREFIX}icon.warning { color: #f8bb86; }
-    .${PREFIX}icon.info { color: #3fc3ee; }
-    .${PREFIX}icon.question { color: #b18cff; }
+    /* (Other icon shapes stay SVG-driven; only the ring animation is unified above) */
   `;
 
   // Inject Styles
@@ -557,7 +563,75 @@
         return icon;
       }
 
-      // Default to SVG icons for other types
+      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;
+      }
+
+      // Default to SVG icons for other/custom types
       applyIconSize('box');
 
       const svg = document.createElementNS(svgNs, 'svg');
@@ -623,12 +697,11 @@
       return icon;
     }
 
-    _adjustSuccessIconBackgroundColor() {
+    _adjustRingBackgroundColor() {
       try {
         const icon = this.dom && this.dom.icon;
         const popup = this.dom && this.dom.popup;
         if (!icon || !popup) return;
-        if (!(icon.classList && icon.classList.contains('success'))) 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) => {
@@ -647,8 +720,8 @@
         document.addEventListener('keydown', this._onKeydown);
       }
 
-      // Keep SweetAlert2 success icon perfectly blended with popup bg
-      this._adjustSuccessIconBackgroundColor();
+      // Keep the "success-like" ring perfectly blended with popup bg
+      this._adjustRingBackgroundColor();
 
       // Popup animation (optional)
       if (this.params.popupAnimation) {
@@ -682,157 +755,58 @@
     _animateIcon() {
       const icon = this.dom.icon;
       if (!icon) return;
-      const X = Layer._getXjs();
       const type = (this.params && this.params.icon) || '';
 
-      // SweetAlert2-style success icon uses pure CSS keyframes; keep the look identical.
-      if (type === 'success') {
-        this._adjustSuccessIconBackgroundColor();
-        // Force restart by removing/adding the class
-        try { icon.classList.remove(`${PREFIX}icon-show`); } catch {}
-        requestAnimationFrame(() => {
-          try { icon.classList.add(`${PREFIX}icon-show`); } catch {}
-        });
-        return;
-      }
+      const ringTypes = new Set(['success', 'error', 'warning', 'info', 'question']);
 
-      if (!X) return;
+      // 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 {}
+      });
 
-      try {
-        // Entrance varies slightly by type (SweetAlert-like feel)
-        if (type === 'error') {
-          X(icon).animate({
-            opacity: [0, 1],
-            scale: [0.62, 1],
-            rotate: [-10, 0],
-            duration: 520,
-            easing: { stiffness: 240, damping: 14 }
-          });
-        } else if (type === 'warning') {
-          X(icon).animate({
-            opacity: [0, 1],
-            scale: [0.62, 1],
-            y: [-10, 0],
-            duration: 620,
-            easing: { stiffness: 260, damping: 15 }
-          });
-        } else if (type === 'question') {
-          X(icon).animate({
-            opacity: [0, 1],
-            scale: [0.62, 1],
-            rotate: [8, 0],
-            duration: 620,
-            easing: { stiffness: 240, damping: 14 }
-          });
-        } else {
-          X(icon).animate({
-            opacity: [0, 1],
-            scale: [0.66, 1],
-            duration: 520,
-            easing: { stiffness: 240, damping: 14 }
-          });
-        }
-      } 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 ring = svg.querySelector(`.${PREFIX}svg-ring`);
       const marks = Array.from(svg.querySelectorAll(`.${PREFIX}svg-mark`));
       const dot = svg.querySelector(`.${PREFIX}svg-dot`);
 
-      // Draw ring first (timing varies by type)
-      try {
-        if (ring) {
-          const ringDur = (type === 'success') ? 520 : (type === 'error') ? 420 : 560;
-          const ringEase = (type === 'warning' || type === 'question') ? 'ease-in-out' : 'ease-out';
-          X(ring).draw({ duration: ringDur, easing: ringEase, delay: 40 });
-        }
-      } catch {}
+      // 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') {
-        // Two lines + shake after draw
-        const left = marks[0];
-        const right = marks[1];
-        try { if (left) X(left).draw({ duration: 320, easing: 'ease-out', delay: 170 }); } catch {}
-        try { if (right) X(right).draw({ duration: 320, easing: 'ease-out', delay: 240 }); } catch {}
-        try {
-          X(icon).animate({
-            rotate: [-10, 10],
-            duration: 70,
-            loop: 4,
-            direction: 'alternate',
-            easing: 'ease-in-out',
-            delay: 360
-          });
-        } catch {}
-        return;
-      }
-
-      // Single-mark types (success / warning / info / question)
-      if (type === 'success') {
-        const m = marks[0];
-        try { if (m) X(m).draw({ duration: 420, easing: 'ease-out', delay: 180 }); } catch {}
-        try {
-          if (ring) X(ring).animate({ strokeWidth: [4.5, 6], duration: 220, delay: 360, easing: { stiffness: 300, damping: 18 } });
-        } catch {}
-      } else if (type === 'warning') {
-        const m = marks[0];
-        try { if (m) X(m).draw({ duration: 380, easing: 'ease-in-out', delay: 190 }); } catch {}
-        try {
-          if (ring) X(ring).animate({ opacity: [1, 0.55], duration: 260, delay: 420, loop: 2, direction: 'alternate', easing: 'ease-in-out' });
-        } catch {}
-      } else if (type === 'info') {
-        const m = marks[0];
-        try { if (m) X(m).draw({ duration: 420, easing: 'ease-out', delay: 180 }); } catch {}
-      } else if (type === 'question') {
-        const m = marks[0];
-        try { if (m) X(m).draw({ duration: 520, easing: 'ease-in-out', delay: 170 }); } catch {}
-        try {
-          X(icon).animate({ rotate: [-6, 6], duration: 90, loop: 3, direction: 'alternate', easing: 'ease-in-out', delay: 420 });
-        } catch {}
+        // 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 {
+        // warning / info / question (single stroke) or custom SVG symbols
         marks.forEach((m, i) => {
-          try { X(m).draw({ duration: 420, easing: 'ease-out', delay: 180 + i * 60 }); } catch {}
+          try { X(m).draw({ duration: 420, easing: 'ease-out', delay: baseDelay + i * 60 }); } catch {}
         });
       }
 
       if (dot) {
         try {
           dot.style.opacity = '0';
-          if (type === 'warning') {
-            X(dot).animate({
-              opacity: [0, 1],
-              scale: [0.2, 1],
-              duration: 260,
-              delay: 320,
-              easing: { stiffness: 360, damping: 18 }
-            });
-          } else if (type === 'info') {
-            X(dot).animate({
-              opacity: [0, 1],
-              y: [-8, 0],
-              scale: [0.2, 1],
-              duration: 420,
-              delay: 260,
-              easing: { stiffness: 300, damping: 14 }
-            });
-          } else if (type === 'question') {
-            X(dot).animate({
-              opacity: [0, 1],
-              scale: [0.2, 1],
-              duration: 360,
-              delay: 360,
-              easing: { stiffness: 320, damping: 16 }
-            });
+          // Keep dot pop after the ring begins
+          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: 280,
-              easing: { stiffness: 320, damping: 18 }
-            });
+            X(dot).animate({ opacity: [0, 1], scale: [0.2, 1], duration: 320, delay: d, easing: { stiffness: 320, damping: 18 } });
           }
         } catch {}
       }

+ 91 - 2
main.go

@@ -1,19 +1,108 @@
 package main
 
 import (
+	"bytes"
+	"fmt"
+	"io"
 	"log"
 	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
 )
 
 func main() {
+	if len(os.Args) <= 1 || os.Args[1] == "serve" {
+		serve()
+		return
+	}
+
+	switch os.Args[1] {
+	case "build":
+		out := "dist/xjs.js"
+		if len(os.Args) >= 3 && strings.TrimSpace(os.Args[2]) != "" {
+			out = os.Args[2]
+		}
+		if err := build(out); err != nil {
+			log.Fatal(err)
+		}
+		log.Printf("Built %s\n", out)
+	default:
+		fmt.Println("Usage:")
+		fmt.Println("  go run main.go serve            # dev server (default)")
+		fmt.Println("  go run main.go build [out.js]   # bundle animal.js + layer.js into dist/xjs.js (or out.js)")
+		os.Exit(2)
+	}
+}
+
+func serve() {
 	// Serve files from the current directory
 	fs := http.FileServer(http.Dir("."))
 	http.Handle("/", fs)
 
 	log.Println("Listening on :8080...")
-	err := http.ListenAndServe(":8080", nil)
-	if err != nil {
+	if err := http.ListenAndServe(":8080", nil); err != nil {
 		log.Fatal(err)
 	}
 }
 
+func readFile(path string) ([]byte, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+	return io.ReadAll(f)
+}
+
+func ensureDirForFile(out string) error {
+	dir := filepath.Dir(out)
+	if dir == "." || dir == "/" {
+		return nil
+	}
+	return os.MkdirAll(dir, 0o755)
+}
+
+func build(out string) error {
+	animal, err := readFile("animal.js")
+	if err != nil {
+		return fmt.Errorf("read animal.js: %w", err)
+	}
+	layer, err := readFile("layer.js")
+	if err != nil {
+		return fmt.Errorf("read layer.js: %w", err)
+	}
+
+	var buf bytes.Buffer
+	buf.WriteString("// xjs.js - combined single-file build (animal + layer)\n")
+	buf.WriteString("// Generated by `go run main.go build`.\n")
+	buf.WriteString("// NOTE: Do not edit this file directly in development.\n")
+	buf.WriteString("//       Edit animal.js / layer.js instead.\n\n")
+
+	// Keep layer first to match previous bundled layout; runtime lookups are lazy anyway.
+	buf.WriteString("/* --- layer.js --- */\n")
+	buf.Write(layer)
+	if !bytes.HasSuffix(layer, []byte("\n")) {
+		buf.WriteByte('\n')
+	}
+	buf.WriteString("\n/* --- animal.js --- */\n")
+	buf.Write(animal)
+	if !bytes.HasSuffix(animal, []byte("\n")) {
+		buf.WriteByte('\n')
+	}
+
+	// Export globals expected by docs/users.
+	buf.WriteString("\n;(function(g){\n")
+	buf.WriteString("  try { if (g && g.animal && !g.xjs) g.xjs = g.animal; } catch {}\n")
+	buf.WriteString("  try { if (g && g.XJS_GLOBAL_DOLLAR && !g.$ && g.xjs) g.$ = g.xjs; } catch {}\n")
+	buf.WriteString("})(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : this));\n")
+
+	if err := ensureDirForFile(out); err != nil {
+		return fmt.Errorf("mkdir %s: %w", filepath.Dir(out), err)
+	}
+	if err := os.WriteFile(out, buf.Bytes(), 0o644); err != nil {
+		return fmt.Errorf("write %s: %w", out, err)
+	}
+	return nil
+}
+

+ 207 - 179
xjs.js

@@ -1,3 +1,55 @@
+// xjs.js (development loader)
+// - Dev/test: lightweight loader that imports source files (animal.js + layer.js)
+// - Production: use the bundled output from `go run main.go build` (defaults to dist/xjs.js)
+;(function (global) {
+  'use strict';
+
+  const g = global || (typeof window !== 'undefined' ? window : globalThis);
+  const doc = (typeof document !== 'undefined') ? document : null;
+  if (!doc) return;
+
+  // Avoid double-loading
+  if (g.xjs && g.Layer) return;
+
+  const current = doc.currentScript;
+  const base = (() => {
+    try {
+      const u = new URL(current && current.src ? current.src : 'xjs.js', location.href);
+      return u.href.replace(/[^/]*$/, ''); // directory of xjs.js
+    } catch {
+      return '';
+    }
+  })();
+
+  const loadScript = (src) => new Promise((resolve, reject) => {
+    const s = doc.createElement('script');
+    s.src = src;
+    s.async = false; // preserve execution order
+    s.onload = () => resolve();
+    s.onerror = () => reject(new Error('Failed to load: ' + src));
+    (doc.head || doc.documentElement).appendChild(s);
+  });
+
+  const ensureGlobals = () => {
+    try {
+      if (g.animal && !g.xjs) g.xjs = g.animal;
+      // Optional: export `$` only when explicitly requested
+      if (g.XJS_GLOBAL_DOLLAR && !g.$ && g.xjs) g.$ = g.xjs;
+    } catch {}
+  };
+
+  (async () => {
+    // Load animal first so xjs alias is available before layer tries to query it
+    if (!g.animal) await loadScript(base + 'animal.js');
+    ensureGlobals();
+
+    if (!g.Layer) await loadScript(base + 'layer.js');
+    ensureGlobals();
+  })().catch((err) => {
+    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:
@@ -133,20 +185,31 @@
       background-color: #999;
     }
     .${PREFIX}icon {
-      width: 6em;
-      height: 6em;
-      margin: 1.5em auto 1.2em;
       position: relative;
-      display: grid;
-      place-items: center;
+      box-sizing: content-box;
+      width: 5em;
+      height: 5em;
+      margin: 2.5em auto 0.6em;
+      border: 0.25em solid rgba(0, 0, 0, 0);
+      border-radius: 50%;
+      font-family: inherit;
+      line-height: 5em;
+      cursor: default;
+      user-select: none;
+      /* Ring sweep angles (match SweetAlert2 success feel) */
+      --${PREFIX}ring-start-rotate: -45deg;
+      /* End is one full turn from start so it "sweeps then settles" */
+      --${PREFIX}ring-end-rotate: -405deg;
     }
 
-    /* SVG Icon (SweetAlert-like, animated via xjs.draw + spring) */
+    /* SVG mark content (kept), sits above the ring */
     .${PREFIX}icon svg {
       width: 100%;
       height: 100%;
       display: block;
       overflow: visible;
+      position: relative;
+      z-index: 3;
     }
     .${PREFIX}svg-ring,
     .${PREFIX}svg-mark {
@@ -167,27 +230,24 @@
     }
 
     /* =========================================
-       SweetAlert2-compatible SUCCESS icon (DOM)
-       (matches sweetalert2 v11.26.17)
+       "success-like" ring (shared by all icons)
+       - we reuse the success ring pieces/rotation for every icon type
+       - color is driven by currentColor
        ========================================= */
-    .${PREFIX}icon.success {
-      /* Override the generic SVG icon box */
-      display: block;
-      place-items: initial;
-      box-sizing: content-box;
-      width: 5em;
-      height: 5em;
-      margin: 2.5em auto 0.6em;
-      border: 0.25em solid rgba(0, 0, 0, 0);
-      border-radius: 50%;
-      border-color: #a5dc86;
-      color: #a5dc86;
-      font-family: inherit;
-      line-height: 5em;
-      cursor: default;
-      user-select: none;
-    }
-    .${PREFIX}icon.success .${PREFIX}success-ring {
+    .${PREFIX}icon.success { border-color: #a5dc86; color: #a5dc86; }
+    .${PREFIX}icon.error { border-color: #f27474; color: #f27474; }
+    .${PREFIX}icon.warning { border-color: #f8bb86; color: #f8bb86; }
+    .${PREFIX}icon.info { border-color: #3fc3ee; color: #3fc3ee; }
+    .${PREFIX}icon.question { border-color: #b18cff; color: #b18cff; }
+
+    /* Per-icon ring sweep stop (same timing, different final position) */
+    /* IMPORTANT: keep a full 360° sweep (end = start - 360) for the same "draw then vanish" feel */
+    .${PREFIX}icon.warning { --${PREFIX}ring-start-rotate: -90deg;  --${PREFIX}ring-end-rotate: -450deg; }  /* stop at top */
+    .${PREFIX}icon.error { --${PREFIX}ring-start-rotate: -135deg;   --${PREFIX}ring-end-rotate: -495deg; }  /* stop at top-left */
+    .${PREFIX}icon.info { --${PREFIX}ring-start-rotate: -90deg;     --${PREFIX}ring-end-rotate: -450deg; }  /* stop at top */
+    .${PREFIX}icon.question { --${PREFIX}ring-start-rotate: -135deg; --${PREFIX}ring-end-rotate: -495deg; } /* stop at top-left */
+
+    .${PREFIX}icon .${PREFIX}success-ring {
       position: absolute;
       z-index: 2;
       top: -0.25em;
@@ -195,10 +255,11 @@
       box-sizing: content-box;
       width: 100%;
       height: 100%;
-      border: 0.25em solid rgba(165, 220, 134, 0.3);
+      border: 0.25em solid currentColor;
+      opacity: 0.3;
       border-radius: 50%;
     }
-    .${PREFIX}icon.success .${PREFIX}success-fix {
+    .${PREFIX}icon .${PREFIX}success-fix {
       position: absolute;
       z-index: 1;
       top: 0.5em;
@@ -208,44 +269,44 @@
       transform: rotate(-45deg);
       background-color: #fff; /* adjusted at runtime to popup bg */
     }
-    .${PREFIX}icon.success .${PREFIX}success-circular-line-left,
-    .${PREFIX}icon.success .${PREFIX}success-circular-line-right {
+    .${PREFIX}icon .${PREFIX}success-circular-line-left,
+    .${PREFIX}icon .${PREFIX}success-circular-line-right {
       position: absolute;
       width: 3.75em;
       height: 7.5em;
       border-radius: 50%;
       background-color: #fff; /* adjusted at runtime to popup bg */
     }
-    .${PREFIX}icon.success .${PREFIX}success-circular-line-left {
+    .${PREFIX}icon .${PREFIX}success-circular-line-left {
       top: -0.4375em;
       left: -2.0635em;
       transform: rotate(-45deg);
       transform-origin: 3.75em 3.75em;
       border-radius: 7.5em 0 0 7.5em;
     }
-    .${PREFIX}icon.success .${PREFIX}success-circular-line-right {
+    .${PREFIX}icon .${PREFIX}success-circular-line-right {
       top: -0.6875em;
       left: 1.875em;
       transform: rotate(-45deg);
       transform-origin: 0 3.75em;
       border-radius: 0 7.5em 7.5em 0;
     }
-    .${PREFIX}icon.success .${PREFIX}success-line-tip,
-    .${PREFIX}icon.success .${PREFIX}success-line-long {
+    .${PREFIX}icon .${PREFIX}success-line-tip,
+    .${PREFIX}icon .${PREFIX}success-line-long {
       display: block;
       position: absolute;
       z-index: 2;
       height: 0.3125em;
       border-radius: 0.125em;
-      background-color: #a5dc86;
+      background-color: currentColor;
     }
-    .${PREFIX}icon.success .${PREFIX}success-line-tip {
+    .${PREFIX}icon .${PREFIX}success-line-tip {
       top: 2.875em;
       left: 0.8125em;
       width: 1.5625em;
       transform: rotate(45deg);
     }
-    .${PREFIX}icon.success .${PREFIX}success-line-long {
+    .${PREFIX}icon .${PREFIX}success-line-long {
       top: 2.375em;
       right: 0.5em;
       width: 2.9375em;
@@ -253,14 +314,14 @@
     }
 
     /* Triggered when icon has .${PREFIX}icon-show */
-    .${PREFIX}icon.success.${PREFIX}icon-show .${PREFIX}success-line-tip {
+    .${PREFIX}icon.${PREFIX}icon-show .${PREFIX}success-line-tip {
       animation: ${PREFIX}animate-success-line-tip 0.75s;
     }
-    .${PREFIX}icon.success.${PREFIX}icon-show .${PREFIX}success-line-long {
+    .${PREFIX}icon.${PREFIX}icon-show .${PREFIX}success-line-long {
       animation: ${PREFIX}animate-success-line-long 0.75s;
     }
-    .${PREFIX}icon.success.${PREFIX}icon-show .${PREFIX}success-circular-line-right {
-      animation: ${PREFIX}rotate-success-circular-line 4.25s ease-in;
+    .${PREFIX}icon.${PREFIX}icon-show .${PREFIX}success-circular-line-right {
+      animation: ${PREFIX}rotate-icon-circular-line 4.25s ease-in;
     }
 
     @keyframes ${PREFIX}animate-success-line-tip {
@@ -312,17 +373,14 @@
         width: 2.9375em;
       }
     }
-    @keyframes ${PREFIX}rotate-success-circular-line {
-      0% { transform: rotate(-45deg); }
-      5% { transform: rotate(-45deg); }
-      12% { transform: rotate(-405deg); }
-      100% { transform: rotate(-405deg); }
+    @keyframes ${PREFIX}rotate-icon-circular-line {
+      0% { transform: rotate(var(--${PREFIX}ring-start-rotate)); }
+      5% { transform: rotate(var(--${PREFIX}ring-start-rotate)); }
+      12% { transform: rotate(var(--${PREFIX}ring-end-rotate)); }
+      100% { transform: rotate(var(--${PREFIX}ring-end-rotate)); } /* match 12% so it "sweeps then stops" */
     }
 
-    .${PREFIX}icon.error { color: #f27474; }
-    .${PREFIX}icon.warning { color: #f8bb86; }
-    .${PREFIX}icon.info { color: #3fc3ee; }
-    .${PREFIX}icon.question { color: #b18cff; }
+    /* (Other icon shapes stay SVG-driven; only the ring animation is unified above) */
   `;
 
   // Inject Styles
@@ -568,6 +626,74 @@
         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');
@@ -631,12 +757,11 @@
       return icon;
     }
 
-    _adjustSuccessIconBackgroundColor() {
+  _adjustRingBackgroundColor() {
       try {
         const icon = this.dom && this.dom.icon;
         const popup = this.dom && this.dom.popup;
         if (!icon || !popup) return;
-        if (!(icon.classList && icon.classList.contains('success'))) 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) => {
@@ -655,8 +780,8 @@
         document.addEventListener('keydown', this._onKeydown);
       }
 
-      // Keep SweetAlert2 success icon perfectly blended with popup bg
-      this._adjustSuccessIconBackgroundColor();
+      // Keep the "success-like" ring perfectly blended with popup bg
+      this._adjustRingBackgroundColor();
 
       // Popup animation (optional)
       if (this.params.popupAnimation) {
@@ -687,152 +812,55 @@
     _animateIcon() {
       const icon = this.dom.icon;
       if (!icon) return;
-      const X = Layer._getXjs();
-
       const type = (this.params && this.params.icon) || '';
 
-      // SweetAlert2-style success icon uses pure CSS keyframes; keep the look identical.
-      if (type === 'success') {
-        this._adjustSuccessIconBackgroundColor();
-        try { icon.classList.remove(`${PREFIX}icon-show`); } catch {}
-        requestAnimationFrame(() => {
-          try { icon.classList.add(`${PREFIX}icon-show`); } catch {}
-        });
-        return;
-      }
+      const ringTypes = new Set(['success', 'error', 'warning', 'info', 'question']);
 
-      if (!X) return;
+      // 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 {}
+      });
 
-      try {
-        if (type === 'error') {
-          X(icon).animate({
-            opacity: [0, 1],
-            scale: [0.62, 1],
-            rotate: [-10, 0],
-            duration: 520,
-            easing: { stiffness: 240, damping: 14 }
-          });
-        } else if (type === 'warning') {
-          X(icon).animate({
-            opacity: [0, 1],
-            scale: [0.62, 1],
-            y: [-10, 0],
-            duration: 620,
-            easing: { stiffness: 260, damping: 15 }
-          });
-        } else if (type === 'question') {
-          X(icon).animate({
-            opacity: [0, 1],
-            scale: [0.62, 1],
-            rotate: [8, 0],
-            duration: 620,
-            easing: { stiffness: 240, damping: 14 }
-          });
-        } else {
-          X(icon).animate({
-            opacity: [0, 1],
-            scale: [0.66, 1],
-            duration: 520,
-            easing: { stiffness: 240, damping: 14 }
-          });
-        }
-      } 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 ring = svg.querySelector(`.${PREFIX}svg-ring`);
       const marks = Array.from(svg.querySelectorAll(`.${PREFIX}svg-mark`));
       const dot = svg.querySelector(`.${PREFIX}svg-dot`);
 
-      try {
-        if (ring) {
-          const ringDur = (type === 'success') ? 520 : (type === 'error') ? 420 : 560;
-          const ringEase = (type === 'warning' || type === 'question') ? 'ease-in-out' : 'ease-out';
-          X(ring).draw({ duration: ringDur, easing: ringEase, delay: 40 });
-        }
-      } catch {}
+      // 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') {
-        const left = marks[0];
-        const right = marks[1];
-        try { if (left) X(left).draw({ duration: 320, easing: 'ease-out', delay: 170 }); } catch {}
-        try { if (right) X(right).draw({ duration: 320, easing: 'ease-out', delay: 240 }); } catch {}
-        try {
-          X(icon).animate({
-            rotate: [-10, 10],
-            duration: 70,
-            loop: 4,
-            direction: 'alternate',
-            easing: 'ease-in-out',
-            delay: 360
-          });
-        } catch {}
-        return;
-      }
-
-      if (type === 'success') {
-        const m = marks[0];
-        try { if (m) X(m).draw({ duration: 420, easing: 'ease-out', delay: 180 }); } catch {}
-        try {
-          if (ring) X(ring).animate({ strokeWidth: [4.5, 6], duration: 220, delay: 360, easing: { stiffness: 300, damping: 18 } });
-        } catch {}
-      } else if (type === 'warning') {
-        const m = marks[0];
-        try { if (m) X(m).draw({ duration: 380, easing: 'ease-in-out', delay: 190 }); } catch {}
-        try {
-          if (ring) X(ring).animate({ opacity: [1, 0.55], duration: 260, delay: 420, loop: 2, direction: 'alternate', easing: 'ease-in-out' });
-        } catch {}
-      } else if (type === 'info') {
-        const m = marks[0];
-        try { if (m) X(m).draw({ duration: 420, easing: 'ease-out', delay: 180 }); } catch {}
-      } else if (type === 'question') {
-        const m = marks[0];
-        try { if (m) X(m).draw({ duration: 520, easing: 'ease-in-out', delay: 170 }); } catch {}
-        try {
-          X(icon).animate({ rotate: [-6, 6], duration: 90, loop: 3, direction: 'alternate', easing: 'ease-in-out', delay: 420 });
-        } catch {}
+        // 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: 180 + i * 60 }); } catch {}
+          try { X(m).draw({ duration: 420, easing: 'ease-out', delay: baseDelay + i * 60 }); } catch {}
         });
       }
 
       if (dot) {
         try {
           dot.style.opacity = '0';
-          if (type === 'warning') {
-            X(dot).animate({
-              opacity: [0, 1],
-              scale: [0.2, 1],
-              duration: 260,
-              delay: 320,
-              easing: { stiffness: 360, damping: 18 }
-            });
-          } else if (type === 'info') {
-            X(dot).animate({
-              opacity: [0, 1],
-              y: [-8, 0],
-              scale: [0.2, 1],
-              duration: 420,
-              delay: 260,
-              easing: { stiffness: 300, damping: 14 }
-            });
-          } else if (type === 'question') {
-            X(dot).animate({
-              opacity: [0, 1],
-              scale: [0.2, 1],
-              duration: 360,
-              delay: 360,
-              easing: { stiffness: 320, damping: 16 }
-            });
+          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: 280,
-              easing: { stiffness: 320, damping: 18 }
-            });
+            X(dot).animate({ opacity: [0, 1], scale: [0.2, 1], duration: 320, delay: d, easing: { stiffness: 320, damping: 18 } });
           }
         } catch {}
       }