robert 2 часов назад
Родитель
Сommit
32a6e7fb9d

+ 2 - 0
Guide.md

@@ -371,6 +371,8 @@ $('#step').click(function () {
 - `stepItem` 不传时,会自动用 `#stepContainer` 的一级子元素作为每一步
 - 单步标题可由每个 step DOM 自身提供:`data-step-title` / `data-layer-title` / `title`
 - `res.data` 会汇总 steps 内所有 `input/select/textarea` 的值(适合一次性提交)
+- Steps 按钮可加图标:`nextIcon`(下一步)/ `prevIcon`(上一步),支持 HTML 字符串或 DOM 节点
+- `confirmButtonColor` / `cancelButtonColor` 已改为 CSS 控制(见 `xjs.css` 里的 `--layer-confirm-*` / `--layer-cancel-*`)
 
 也支持便捷写法:
 

+ 9 - 4
doc/examples/layer.html

@@ -185,10 +185,15 @@ document.querySelector('.btn-direct').addEventListener('click', () => {
   </div>
 
   <script src="../highlight_css.js"></script>
-  <!-- NOTE: xjs.js bundles an older Layer in some builds.
-       For local testing, load standalone layer.js last to ensure this page uses the edited Layer implementation. -->
-  <script src="../../xjs.js?v=dev"></script>
-  <script src="../../layer.js?v=dev"></script>
+  <!-- Dev/test: load source files directly with cache-bust (avoid stale iframe cache) -->
+  <script>
+    (function () {
+      var b = Date.now();
+      document.write('<script src="../../animal.js?_=' + b + '"><\/script>');
+      document.write('<script src="../../layer.js?_=' + b + '"><\/script>');
+      document.write('<script>try{if(window.animal&&!window.xjs)window.xjs=window.animal;if(window.xjs&&!window.$)window.$=window.xjs;}catch(e){}<\/script>');
+    })();
+  </script>
   <script>
     function switchTab(tab) {
       document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));

+ 41 - 2
doc/index.html

@@ -241,8 +241,14 @@
             <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" 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="tree-group" data-tree="layer">
+                <div class="tree-header" onclick="toggleTree(this)">Layer 子菜单</div>
+                <div class="tree-children expanded">
+                    <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="layer/custom_list.html" data-default="layer/test_custom_animation.html" onclick="selectCategory('layer/custom_list.html', 'layer/test_custom_animation.html', this)">自定义动画</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>
+            </div>
 
             <div class="nav-section-title">开发者</div>
             <div class="nav-item" data-list="search.html" data-default="module.html" onclick="selectCategory('search.html', 'module.html', this)">模块开发与测试指南</div>
@@ -360,6 +366,9 @@
             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 === 'layer/test_custom_animation.html') return 'layer/custom_list.html';
+            if (u === 'layer/test_custom_animation_banner.html') return 'layer/custom_list.html';
+            if (u === 'layer/test_custom_animation_ballsorting.html') return 'layer/custom_list.html';
             if (u.startsWith('layer/')) return 'layer/list.html';
             if (u.startsWith('examples/')) return 'examples/list.html';
             return null;
@@ -379,6 +388,7 @@
             );
             const any = items.find(i => normalizeDocUrl(i.getAttribute('data-list')) === u);
             (exact || any)?.classList.add('active');
+            syncNavTreeState();
         }
 
         function ensureCategoryForContent(contentUrl) {
@@ -402,6 +412,7 @@
             // 1. Highlight Nav Item
             document.querySelectorAll('.nav-item').forEach(item => item.classList.remove('active'));
             if (el) el.classList.add('active');
+            syncNavTreeState();
 
             // 2. Load Middle Column (List)
             const middleFrame = document.getElementById('middle-frame');
@@ -414,6 +425,31 @@
             loadContent(defaultContentUrl, { updateRoute: true });
         }
 
+        function toggleTree(headerEl) {
+            if (!headerEl) return;
+            const next = headerEl.nextElementSibling;
+            if (!next || !next.classList.contains('tree-children')) return;
+            next.classList.toggle('expanded');
+            const isExpanded = next.classList.contains('expanded');
+            headerEl.classList.toggle('active-header', isExpanded);
+        }
+
+        function syncNavTreeState() {
+            try {
+                const active = document.querySelector('.nav-item.active');
+                const children = active?.closest('.tree-children');
+                const header = children ? children.previousElementSibling : null;
+                document.querySelectorAll('.tree-children').forEach(c => c.classList.remove('expanded'));
+                document.querySelectorAll('.tree-header').forEach(h => h.classList.remove('active-header'));
+                if (children) {
+                    children.classList.add('expanded');
+                    if (header && header.classList.contains('tree-header')) {
+                        header.classList.add('active-header');
+                    }
+                }
+            } catch {}
+        }
+
         function withDocVersion(url) {
             const u = String(url || '');
             if (!u) return u;
@@ -507,6 +543,9 @@
 
             // Layer
             { group: 'Layer', title: 'Layer 概览', url: 'layer/overview.html' },
+            { group: 'Layer', title: '自定义动画', url: 'layer/test_custom_animation.html' },
+            { group: 'Layer', title: '自定义动画(Banner)', url: 'layer/test_custom_animation_banner.html' },
+            { group: 'Layer', title: '自定义动画(BallSorting)', url: 'layer/test_custom_animation_ballsorting.html' },
             { group: 'Layer', title: 'SVG Icon animation', url: 'layer/test_icons_svg_animation.html' },
             { group: 'Layer', title: 'Confirm flow', url: 'layer/test_confirm_flow.html' },
             { group: 'Layer', title: 'Selection.layer + dataset', url: 'layer/test_selection_layer_dataset.html' },

+ 110 - 0
doc/layer/custom_list.html

@@ -0,0 +1,110 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>Layer - Custom Animation List</title>
+  <link rel="stylesheet" href="../list.css">
+  <style>
+    .card { min-height: 120px; padding: 18px 18px 16px 18px; }
+    .card-preview {
+      height: 62px;
+      border-radius: 10px;
+      background: rgba(255,255,255,0.02);
+      border: 1px solid rgba(255,255,255,0.04);
+      position: relative;
+      overflow: hidden;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      margin-bottom: 14px;
+    }
+    .preview-pill {
+      font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+      color: #9a9a9a;
+      background: rgba(255,255,255,0.04);
+      border: 1px solid rgba(255,255,255,0.06);
+      padding: 10px 14px;
+      border-radius: 10px;
+      font-size: 13px;
+      letter-spacing: 0.2px;
+    }
+    .card.reserved {
+      opacity: 0.6;
+      cursor: default;
+      pointer-events: none;
+    }
+    .card.reserved:hover {
+      background: rgba(26, 26, 26, 0.78);
+      border-color: #303033;
+      box-shadow: none;
+    }
+  </style>
+</head>
+<body>
+  <div class="search-header">
+    <div class="search-title">Layer / 自定义动画</div>
+    <div class="search-input-container">
+      <div class="search-icon"></div>
+      <input type="text" class="search-input" placeholder="Filter custom animations..." disabled>
+    </div>
+  </div>
+
+  <div class="grid-section">
+    <div class="header-card active" onclick="selectItem('test_custom_animation.html', this)">
+      <div class="header-dots"></div>
+      <div class="header-title">自定义动画</div>
+    </div>
+
+    <div class="card-grid" style="margin-top: 40px;">
+      <div class="card" data-content="layer/test_custom_animation.html" onclick="selectItem('test_custom_animation.html', this)">
+        <div class="card-preview">
+          <div class="preview-pill">icon: "svg:loadding"</div>
+        </div>
+        <div class="card-footer">Loading 动画示例</div>
+      </div>
+
+      <div class="card" data-content="layer/test_custom_animation_banner.html" onclick="selectItem('test_custom_animation_banner.html', this)">
+        <div class="card-preview">
+          <div class="preview-pill">icon: "svg:banner"</div>
+        </div>
+        <div class="card-footer">Banner 动画示例</div>
+      </div>
+
+      <div class="card" data-content="layer/test_custom_animation_ballsorting.html" onclick="selectItem('test_custom_animation_ballsorting.html', this)">
+        <div class="card-preview">
+          <div class="preview-pill">icon: "svg:BallSorting"</div>
+        </div>
+        <div class="card-footer">BallSorting 动画示例</div>
+      </div>
+    </div>
+  </div>
+
+  <script>
+    function selectItem(url, el) {
+      document.querySelectorAll('.card').forEach(c => c.classList.remove('active'));
+      document.querySelectorAll('.header-card').forEach(c => c.classList.remove('active'));
+      el.classList.add('active');
+      if (window.parent && window.parent.loadContent) {
+        window.parent.loadContent('layer/' + url);
+      }
+    }
+
+    function setActiveByContentUrl(contentUrl) {
+      const normalized = String(contentUrl || '').replace(/^\.\//, '');
+      const header = document.querySelector('.header-card');
+      const cards = Array.from(document.querySelectorAll('.card[data-content]'));
+
+      document.querySelectorAll('.card').forEach(c => c.classList.remove('active'));
+      document.querySelectorAll('.header-card').forEach(c => c.classList.remove('active'));
+
+      if (!normalized) return;
+      header?.classList.add('active');
+      const match = cards.find(c => normalized.endsWith(c.dataset.content) || normalized.includes(c.dataset.content));
+      if (match) match.classList.add('active');
+    }
+
+    window.setActiveByContentUrl = setActiveByContentUrl;
+  </script>
+</body>
+</html>

+ 1 - 3
doc/layer/test_confirm_flow.html

@@ -135,9 +135,7 @@
         popupAnimation: true,
         showCancelButton: true,
         confirmButtonText: 'Delete',
-        cancelButtonText: 'Cancel',
-        confirmButtonColor: '#d33',
-        cancelButtonColor: '#444'
+        cancelButtonText: 'Cancel'
       }).then((res) => {
         if (res.isConfirmed) {
           xjs.layer({ title: 'Deleted', text: 'Done', icon: 'success' });

+ 274 - 0
doc/layer/test_custom_animation.html

@@ -0,0 +1,274 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>Layer 自定义动画(Lottie)- Animal.js</title>
+  <link rel="stylesheet" href="../demo.css">
+  <link rel="stylesheet" href="../../xjs.css">
+  <style>
+    /* Keep Layer above the doc UI */
+    .layer-overlay { z-index: 99999; }
+  </style>
+  <style>
+    .controls {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 10px 12px;
+      align-items: center;
+      font-size: 13px;
+      color: #b7b7b7;
+    }
+    .controls label {
+      display: inline-flex;
+      gap: 8px;
+      align-items: center;
+      cursor: pointer;
+      user-select: none;
+    }
+    .controls input { accent-color: var(--highlight-color); }
+    .row {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 10px;
+      align-items: center;
+    }
+    .btn {
+      appearance: none;
+      border: 1px solid rgba(255,255,255,0.12);
+      background: rgba(255,255,255,0.04);
+      color: #fff;
+      border-radius: 999px;
+      padding: 10px 14px;
+      font-weight: 650;
+      cursor: pointer;
+      transition: border-color 0.15s ease, background 0.15s ease;
+    }
+    .btn:hover { border-color: rgba(255,255,255,0.22); background: rgba(255,255,255,0.06); }
+    .btn.disabled {
+      opacity: 0.5;
+      cursor: not-allowed;
+    }
+    .demo-visual { padding: 22px 24px; }
+    .hint { font-size: 12px; color: #8c8c8c; line-height: 1.5; margin-top: 10px; }
+  </style>
+</head>
+<body>
+  <div class="container">
+    <div class="page-top">
+      <div class="crumb">UI › LAYER</div>
+      <div class="since">CUSTOM ANIMATION</div>
+    </div>
+
+    <h1>自定义动画(Lottie)</h1>
+    <p class="description">
+      使用 <code class="inline">icon: "svg:&lt;name&gt;"</code> 触发 Lottie 图标渲染(基于 <code class="inline">svg/svg.js</code>)。
+      下面是当前内置的动画示例,后续可以继续往列表里加。
+    </p>
+
+    <div class="box-container">
+      <div class="demo-visual">
+        <div class="controls" style="margin-bottom: 14px;">
+          <label><input id="optIcon" type="checkbox" checked> iconAnimation</label>
+          <label><input id="optPopup" type="checkbox" checked> popupAnimation</label>
+          <label><input id="optEsc" type="checkbox" checked> closeOnEsc</label>
+        </div>
+        <div class="row">
+          <button class="btn" type="button" onclick="openDemo('loadding')">Loading</button>
+          <button class="btn" type="button" onclick="openDemo('banner')">Banner</button>
+          <button class="btn" type="button" onclick="openDemo('BallSorting')">BallSorting</button>
+          <button class="btn disabled" type="button" disabled>更多动画待加入...</button>
+        </div>
+        <div class="hint">
+          Tip: 将 Lottie JSON 放在 <code class="inline">/svg</code> 目录,按钮参数填 <code class="inline">svg:&lt;json文件名&gt;</code>。
+        </div>
+      </div>
+
+      <div class="box-header">
+        <div class="box-title">Controls</div>
+        <div class="tabs">
+          <div class="tab active" onclick="switchTab('js')">JavaScript</div>
+          <div class="tab" onclick="switchTab('html')">HTML</div>
+          <div class="tab" onclick="switchTab('css')">CSS</div>
+        </div>
+      </div>
+
+      <pre id="js-code" class="code-view active">function openDemo(name) {
+  if (typeof window.$ !== 'function') return;
+
+  const iconAnimation = !!document.getElementById('optIcon')?.checked;
+  const popupAnimation = !!document.getElementById('optPopup')?.checked;
+  const closeOnEsc = !!document.getElementById('optEsc')?.checked;
+
+  const titleCap = name ? (name[0].toUpperCase() + name.slice(1)) : '';
+
+  $.layer({
+    title: titleCap,
+    text: 'Lottie animation icon',
+    icon: 'svg:' + name,
+    iconAnimation,
+    popupAnimation,
+    closeOnEsc
+  });
+}</pre>
+
+      <pre id="html-code" class="html-view">&lt;div class="demo-visual"&gt;
+  &lt;div class="controls" style="margin-bottom: 14px;"&gt;
+  &lt;label&gt;&lt;input id="optIcon" type="checkbox" checked&gt; iconAnimation&lt;/label&gt;
+  &lt;label&gt;&lt;input id="optPopup" type="checkbox" checked&gt; popupAnimation&lt;/label&gt;
+  &lt;label&gt;&lt;input id="optEsc" type="checkbox" checked&gt; closeOnEsc&lt;/label&gt;
+  &lt;/div&gt;
+  &lt;div class="row"&gt;
+  &lt;button class="btn" type="button" onclick="openDemo('loadding')"&gt;Loading&lt;/button&gt;
+  &lt;button class="btn" type="button" onclick="openDemo('banner')"&gt;Banner&lt;/button&gt;
+  &lt;button class="btn" type="button" onclick="openDemo('BallSorting')"&gt;BallSorting&lt;/button&gt;
+  &lt;button class="btn disabled" type="button" disabled&gt;更多动画待加入...&lt;/button&gt;
+  &lt;/div&gt;
+  &lt;div class="hint"&gt;
+    Tip: 将 Lottie JSON 放在 &lt;code class="inline"&gt;/svg&lt;/code&gt; 目录,按钮参数填 &lt;code class="inline"&gt;svg:&amp;lt;json文件名&amp;gt;&lt;/code&gt;。
+  &lt;/div&gt;
+&lt;/div&gt;</pre>
+
+      <pre id="css-code" class="css-view">/* Keep Layer above the doc UI */
+.layer-overlay { z-index: 99999; }
+
+.controls {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px 12px;
+  align-items: center;
+  font-size: 13px;
+  color: #b7b7b7;
+}
+.controls label {
+  display: inline-flex;
+  gap: 8px;
+  align-items: center;
+  cursor: pointer;
+  user-select: none;
+}
+.controls input { accent-color: var(--highlight-color); }
+.row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+  align-items: center;
+}
+.btn {
+  appearance: none;
+  border: 1px solid rgba(255,255,255,0.12);
+  background: rgba(255,255,255,0.04);
+  color: #fff;
+  border-radius: 999px;
+  padding: 10px 14px;
+  font-weight: 650;
+  cursor: pointer;
+  transition: border-color 0.15s ease, background 0.15s ease;
+}
+.btn:hover { border-color: rgba(255,255,255,0.22); background: rgba(255,255,255,0.06); }
+.btn.disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+.demo-visual { padding: 22px 24px; }
+.hint { font-size: 12px; color: #8c8c8c; line-height: 1.5; margin-top: 10px; }</pre>
+
+      <div class="feature-desc">
+        <strong>功能说明:</strong>本页验证 Layer 在 <code class="inline">icon</code> 传入 <code class="inline">svg:</code> 前缀时,
+        会自动加载 Lottie 库并渲染对应 JSON 动画。
+      </div>
+    </div>
+
+    <div class="doc-nav" aria-label="Previous and next navigation">
+      <a href="#" id="prevLink" onclick="goPrev(); return false;">
+        <span><span class="nav-label">Previous</span><br><span class="nav-title" id="prevTitle">—</span></span>
+        <span aria-hidden="true">←</span>
+      </a>
+      <div class="nav-center" id="navCenter">Layer</div>
+      <a href="#" id="nextLink" onclick="goNext(); return false;">
+        <span><span class="nav-label">Next</span><br><span class="nav-title" id="nextTitle">—</span></span>
+        <span aria-hidden="true">→</span>
+      </a>
+    </div>
+  </div>
+
+  <script src="../highlight_css.js"></script>
+  <!-- Dev/test: load source files directly with cache-bust (avoid stale iframe cache) -->
+  <script>
+    (function () {
+      var b = Date.now();
+      document.write('<script src="../../animal.js?_=' + b + '"><\/script>');
+      document.write('<script src="../../layer.js?_=' + b + '"><\/script>');
+      document.write('<script>try{if(window.animal&&!window.xjs)window.xjs=window.animal;}catch(e){}<\/script>');
+    })();
+  </script>
+  <script>
+    const CURRENT = 'layer/test_custom_animation.html';
+
+    function switchTab(tab) {
+      document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
+      document.querySelectorAll('.code-view, .html-view, .css-view').forEach(v => v.classList.remove('active'));
+      if (tab === 'js') {
+        document.querySelector('.tabs .tab:nth-child(1)')?.classList.add('active');
+        document.getElementById('js-code')?.classList.add('active');
+      } else if (tab === 'html') {
+        document.querySelector('.tabs .tab:nth-child(2)')?.classList.add('active');
+        document.getElementById('html-code')?.classList.add('active');
+      } else {
+        document.querySelector('.tabs .tab:nth-child(3)')?.classList.add('active');
+        document.getElementById('css-code')?.classList.add('active');
+      }
+    }
+
+    function openDemo(name) {
+      if (typeof window.$ !== 'function') return;
+
+      const iconAnimation = !!document.getElementById('optIcon')?.checked;
+      const popupAnimation = !!document.getElementById('optPopup')?.checked;
+      const closeOnEsc = !!document.getElementById('optEsc')?.checked;
+
+      const titleCap = name ? (name[0].toUpperCase() + name.slice(1)) : '';
+
+      // Keep the displayed code consistent with what is executed.
+      const code = `$.layer({
+  title: '${titleCap}',
+  text: 'Lottie animation icon',
+  icon: 'svg:${name}',
+  iconAnimation: ${iconAnimation},
+  popupAnimation: ${popupAnimation},
+  closeOnEsc: ${closeOnEsc}
+});`;
+      const jsPre = document.getElementById('js-code');
+      if (jsPre) jsPre.textContent = code;
+
+      $.layer({
+        title: titleCap,
+        text: 'Lottie animation icon',
+        icon: 'svg:' + name,
+        iconAnimation,
+        popupAnimation,
+        closeOnEsc
+      });
+    }
+
+    function goPrev() { window.parent?.docNavigatePrev?.(CURRENT); }
+    function goNext() { window.parent?.docNavigateNext?.(CURRENT); }
+
+    function syncNavLabels() {
+      const api = window.parent?.docGetPrevNext;
+      if (typeof api !== 'function') return;
+      const { prev, next, current } = api(CURRENT) || {};
+      const prevLink = document.getElementById('prevLink');
+      const nextLink = document.getElementById('nextLink');
+      if (prev) document.getElementById('prevTitle').textContent = prev.title || prev.url;
+      else { prevLink.style.visibility = 'hidden'; }
+      if (next) document.getElementById('nextTitle').textContent = next.title || next.url;
+      else { nextLink.style.visibility = 'hidden'; }
+      document.getElementById('navCenter').textContent = (current && current.group) ? current.group : 'Layer';
+    }
+
+    try { window.parent?.setMiddleActive?.(CURRENT); } catch {}
+    syncNavLabels();
+  </script>
+</body>
+</html>

+ 259 - 0
doc/layer/test_custom_animation_ballsorting.html

@@ -0,0 +1,259 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>Layer 自定义动画(BallSorting)- Animal.js</title>
+  <link rel="stylesheet" href="../demo.css">
+  <link rel="stylesheet" href="../../xjs.css">
+  <style>
+    /* Keep Layer above the doc UI */
+    .layer-overlay { z-index: 99999; }
+  </style>
+  <style>
+    .controls {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 10px 12px;
+      align-items: center;
+      font-size: 13px;
+      color: #b7b7b7;
+    }
+    .controls label {
+      display: inline-flex;
+      gap: 8px;
+      align-items: center;
+      cursor: pointer;
+      user-select: none;
+    }
+    .controls input { accent-color: var(--highlight-color); }
+    .row {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 10px;
+      align-items: center;
+    }
+    .btn {
+      appearance: none;
+      border: 1px solid rgba(255,255,255,0.12);
+      background: rgba(255,255,255,0.04);
+      color: #fff;
+      border-radius: 999px;
+      padding: 10px 14px;
+      font-weight: 650;
+      cursor: pointer;
+      transition: border-color 0.15s ease, background 0.15s ease;
+    }
+    .btn:hover { border-color: rgba(255,255,255,0.22); background: rgba(255,255,255,0.06); }
+    .demo-visual { padding: 22px 24px; }
+    .hint { font-size: 12px; color: #8c8c8c; line-height: 1.5; margin-top: 10px; }
+  </style>
+</head>
+<body>
+  <div class="container">
+    <div class="page-top">
+      <div class="crumb">UI › LAYER</div>
+      <div class="since">CUSTOM ANIMATION</div>
+    </div>
+
+    <h1>自定义动画(BallSorting)</h1>
+    <p class="description">
+      使用 <code class="inline">icon: "svg:BallSorting"</code> 渲染 <code class="inline">svg/BallSorting.json</code>。
+    </p>
+
+    <div class="box-container">
+      <div class="demo-visual">
+        <div class="controls" style="margin-bottom: 14px;">
+          <label><input id="optIcon" type="checkbox" checked> iconAnimation</label>
+          <label><input id="optPopup" type="checkbox" checked> popupAnimation</label>
+          <label><input id="optEsc" type="checkbox" checked> closeOnEsc</label>
+        </div>
+        <div class="row">
+          <button class="btn" type="button" onclick="openDemo('BallSorting')">Open BallSorting</button>
+        </div>
+        <div class="hint">
+          Tip: <code class="inline">BallSorting.json</code> 位于 <code class="inline">/svg</code> 目录。
+        </div>
+      </div>
+
+      <div class="box-header">
+        <div class="box-title">Controls</div>
+        <div class="tabs">
+          <div class="tab active" onclick="switchTab('js')">JavaScript</div>
+          <div class="tab" onclick="switchTab('html')">HTML</div>
+          <div class="tab" onclick="switchTab('css')">CSS</div>
+        </div>
+      </div>
+
+      <pre id="js-code" class="code-view active">function openDemo(name) {
+  if (typeof window.$ !== 'function') return;
+
+  const iconAnimation = !!document.getElementById('optIcon')?.checked;
+  const popupAnimation = !!document.getElementById('optPopup')?.checked;
+  const closeOnEsc = !!document.getElementById('optEsc')?.checked;
+
+  const titleCap = name ? (name[0].toUpperCase() + name.slice(1)) : '';
+
+  $.layer({
+    title: titleCap,
+    text: 'Lottie animation icon',
+    icon: 'svg:' + name,
+    iconAnimation,
+    popupAnimation,
+    closeOnEsc
+  });
+}</pre>
+
+      <pre id="html-code" class="html-view">&lt;div class="demo-visual"&gt;
+  &lt;div class="controls" style="margin-bottom: 14px;"&gt;
+  &lt;label&gt;&lt;input id="optIcon" type="checkbox" checked&gt; iconAnimation&lt;/label&gt;
+  &lt;label&gt;&lt;input id="optPopup" type="checkbox" checked&gt; popupAnimation&lt;/label&gt;
+  &lt;label&gt;&lt;input id="optEsc" type="checkbox" checked&gt; closeOnEsc&lt;/label&gt;
+  &lt;/div&gt;
+  &lt;div class="row"&gt;
+  &lt;button class="btn" type="button" onclick="openDemo('BallSorting')"&gt;Open BallSorting&lt;/button&gt;
+  &lt;/div&gt;
+  &lt;div class="hint"&gt;
+    Tip: &lt;code class="inline"&gt;BallSorting.json&lt;/code&gt; 位于 &lt;code class="inline"&gt;/svg&lt;/code&gt; 目录。
+  &lt;/div&gt;
+&lt;/div&gt;</pre>
+
+      <pre id="css-code" class="css-view">/* Keep Layer above the doc UI */
+.layer-overlay { z-index: 99999; }
+
+.controls {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px 12px;
+  align-items: center;
+  font-size: 13px;
+  color: #b7b7b7;
+}
+.controls label {
+  display: inline-flex;
+  gap: 8px;
+  align-items: center;
+  cursor: pointer;
+  user-select: none;
+}
+.controls input { accent-color: var(--highlight-color); }
+.row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+  align-items: center;
+}
+.btn {
+  appearance: none;
+  border: 1px solid rgba(255,255,255,0.12);
+  background: rgba(255,255,255,0.04);
+  color: #fff;
+  border-radius: 999px;
+  padding: 10px 14px;
+  font-weight: 650;
+  cursor: pointer;
+  transition: border-color 0.15s ease, background 0.15s ease;
+}
+.btn:hover { border-color: rgba(255,255,255,0.22); background: rgba(255,255,255,0.06); }
+.demo-visual { padding: 22px 24px; }
+.hint { font-size: 12px; color: #8c8c8c; line-height: 1.5; margin-top: 10px; }</pre>
+
+      <div class="feature-desc">
+        <strong>功能说明:</strong>本页验证 Layer 在 <code class="inline">icon</code> 传入 <code class="inline">svg:BallSorting</code> 时,
+        会自动加载 Lottie 库并渲染 <code class="inline">BallSorting.json</code>。
+      </div>
+    </div>
+
+    <div class="doc-nav" aria-label="Previous and next navigation">
+      <a href="#" id="prevLink" onclick="goPrev(); return false;">
+        <span><span class="nav-label">Previous</span><br><span class="nav-title" id="prevTitle">—</span></span>
+        <span aria-hidden="true">←</span>
+      </a>
+      <div class="nav-center" id="navCenter">Layer</div>
+      <a href="#" id="nextLink" onclick="goNext(); return false;">
+        <span><span class="nav-label">Next</span><br><span class="nav-title" id="nextTitle">—</span></span>
+        <span aria-hidden="true">→</span>
+      </a>
+    </div>
+  </div>
+
+  <script src="../highlight_css.js"></script>
+  <!-- Dev/test: load source files directly with cache-bust (avoid stale iframe cache) -->
+  <script>
+    (function () {
+      var b = Date.now();
+      document.write('<script src="../../animal.js?_=' + b + '"><\/script>');
+      document.write('<script src="../../layer.js?_=' + b + '"><\/script>');
+      document.write('<script>try{if(window.animal&&!window.xjs)window.xjs=window.animal;}catch(e){}<\/script>');
+    })();
+  </script>
+  <script>
+    const CURRENT = 'layer/test_custom_animation_ballsorting.html';
+
+    function switchTab(tab) {
+      document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
+      document.querySelectorAll('.code-view, .html-view, .css-view').forEach(v => v.classList.remove('active'));
+      if (tab === 'js') {
+        document.querySelector('.tabs .tab:nth-child(1)')?.classList.add('active');
+        document.getElementById('js-code')?.classList.add('active');
+      } else if (tab === 'html') {
+        document.querySelector('.tabs .tab:nth-child(2)')?.classList.add('active');
+        document.getElementById('html-code')?.classList.add('active');
+      } else {
+        document.querySelector('.tabs .tab:nth-child(3)')?.classList.add('active');
+        document.getElementById('css-code')?.classList.add('active');
+      }
+    }
+
+    function openDemo(name) {
+      if (typeof window.$ !== 'function') return;
+
+      const iconAnimation = !!document.getElementById('optIcon')?.checked;
+      const popupAnimation = !!document.getElementById('optPopup')?.checked;
+      const closeOnEsc = !!document.getElementById('optEsc')?.checked;
+
+      const titleCap = name ? (name[0].toUpperCase() + name.slice(1)) : '';
+
+      // Keep the displayed code consistent with what is executed.
+      const code = `$.layer({
+  title: '${titleCap}',
+  text: 'Lottie animation icon',
+  icon: 'svg:${name}',
+  iconAnimation: ${iconAnimation},
+  popupAnimation: ${popupAnimation},
+  closeOnEsc: ${closeOnEsc}
+});`;
+      const jsPre = document.getElementById('js-code');
+      if (jsPre) jsPre.textContent = code;
+
+      $.layer({
+        title: titleCap,
+        text: 'Lottie animation icon',
+        icon: 'svg:' + name,
+        iconAnimation,
+        popupAnimation,
+        closeOnEsc
+      });
+    }
+
+    function goPrev() { window.parent?.docNavigatePrev?.(CURRENT); }
+    function goNext() { window.parent?.docNavigateNext?.(CURRENT); }
+
+    function syncNavLabels() {
+      const api = window.parent?.docGetPrevNext;
+      if (typeof api !== 'function') return;
+      const { prev, next, current } = api(CURRENT) || {};
+      const prevLink = document.getElementById('prevLink');
+      const nextLink = document.getElementById('nextLink');
+      if (prev) document.getElementById('prevTitle').textContent = prev.title || prev.url;
+      else { prevLink.style.visibility = 'hidden'; }
+      if (next) document.getElementById('nextTitle').textContent = next.title || next.url;
+      else { nextLink.style.visibility = 'hidden'; }
+      document.getElementById('navCenter').textContent = (current && current.group) ? current.group : 'Layer';
+    }
+
+    try { window.parent?.setMiddleActive?.(CURRENT); } catch {}
+    syncNavLabels();
+  </script>
+</body>
+</html>

+ 259 - 0
doc/layer/test_custom_animation_banner.html

@@ -0,0 +1,259 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>Layer 自定义动画(Banner)- Animal.js</title>
+  <link rel="stylesheet" href="../demo.css">
+  <link rel="stylesheet" href="../../xjs.css">
+  <style>
+    /* Keep Layer above the doc UI */
+    .layer-overlay { z-index: 99999; }
+  </style>
+  <style>
+    .controls {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 10px 12px;
+      align-items: center;
+      font-size: 13px;
+      color: #b7b7b7;
+    }
+    .controls label {
+      display: inline-flex;
+      gap: 8px;
+      align-items: center;
+      cursor: pointer;
+      user-select: none;
+    }
+    .controls input { accent-color: var(--highlight-color); }
+    .row {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 10px;
+      align-items: center;
+    }
+    .btn {
+      appearance: none;
+      border: 1px solid rgba(255,255,255,0.12);
+      background: rgba(255,255,255,0.04);
+      color: #fff;
+      border-radius: 999px;
+      padding: 10px 14px;
+      font-weight: 650;
+      cursor: pointer;
+      transition: border-color 0.15s ease, background 0.15s ease;
+    }
+    .btn:hover { border-color: rgba(255,255,255,0.22); background: rgba(255,255,255,0.06); }
+    .demo-visual { padding: 22px 24px; }
+    .hint { font-size: 12px; color: #8c8c8c; line-height: 1.5; margin-top: 10px; }
+  </style>
+</head>
+<body>
+  <div class="container">
+    <div class="page-top">
+      <div class="crumb">UI › LAYER</div>
+      <div class="since">CUSTOM ANIMATION</div>
+    </div>
+
+    <h1>自定义动画(Banner)</h1>
+    <p class="description">
+      使用 <code class="inline">icon: "svg:banner"</code> 渲染 <code class="inline">svg/banner.json</code>。
+    </p>
+
+    <div class="box-container">
+      <div class="demo-visual">
+        <div class="controls" style="margin-bottom: 14px;">
+          <label><input id="optIcon" type="checkbox" checked> iconAnimation</label>
+          <label><input id="optPopup" type="checkbox" checked> popupAnimation</label>
+          <label><input id="optEsc" type="checkbox" checked> closeOnEsc</label>
+        </div>
+        <div class="row">
+          <button class="btn" type="button" onclick="openDemo('banner')">Open Banner</button>
+        </div>
+        <div class="hint">
+          Tip: <code class="inline">banner.json</code> 位于 <code class="inline">/svg</code> 目录。
+        </div>
+      </div>
+
+      <div class="box-header">
+        <div class="box-title">Controls</div>
+        <div class="tabs">
+          <div class="tab active" onclick="switchTab('js')">JavaScript</div>
+          <div class="tab" onclick="switchTab('html')">HTML</div>
+          <div class="tab" onclick="switchTab('css')">CSS</div>
+        </div>
+      </div>
+
+      <pre id="js-code" class="code-view active">function openDemo(name) {
+  if (typeof window.$ !== 'function') return;
+
+  const iconAnimation = !!document.getElementById('optIcon')?.checked;
+  const popupAnimation = !!document.getElementById('optPopup')?.checked;
+  const closeOnEsc = !!document.getElementById('optEsc')?.checked;
+
+  const titleCap = name ? (name[0].toUpperCase() + name.slice(1)) : '';
+
+  $.layer({
+    title: titleCap,
+    text: 'Lottie animation icon',
+    icon: 'svg:' + name,
+    iconAnimation,
+    popupAnimation,
+    closeOnEsc
+  });
+}</pre>
+
+      <pre id="html-code" class="html-view">&lt;div class="demo-visual"&gt;
+  &lt;div class="controls" style="margin-bottom: 14px;"&gt;
+  &lt;label&gt;&lt;input id="optIcon" type="checkbox" checked&gt; iconAnimation&lt;/label&gt;
+  &lt;label&gt;&lt;input id="optPopup" type="checkbox" checked&gt; popupAnimation&lt;/label&gt;
+  &lt;label&gt;&lt;input id="optEsc" type="checkbox" checked&gt; closeOnEsc&lt;/label&gt;
+  &lt;/div&gt;
+  &lt;div class="row"&gt;
+  &lt;button class="btn" type="button" onclick="openDemo('banner')"&gt;Open Banner&lt;/button&gt;
+  &lt;/div&gt;
+  &lt;div class="hint"&gt;
+    Tip: &lt;code class="inline"&gt;banner.json&lt;/code&gt; 位于 &lt;code class="inline"&gt;/svg&lt;/code&gt; 目录。
+  &lt;/div&gt;
+&lt;/div&gt;</pre>
+
+      <pre id="css-code" class="css-view">/* Keep Layer above the doc UI */
+.layer-overlay { z-index: 99999; }
+
+.controls {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px 12px;
+  align-items: center;
+  font-size: 13px;
+  color: #b7b7b7;
+}
+.controls label {
+  display: inline-flex;
+  gap: 8px;
+  align-items: center;
+  cursor: pointer;
+  user-select: none;
+}
+.controls input { accent-color: var(--highlight-color); }
+.row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+  align-items: center;
+}
+.btn {
+  appearance: none;
+  border: 1px solid rgba(255,255,255,0.12);
+  background: rgba(255,255,255,0.04);
+  color: #fff;
+  border-radius: 999px;
+  padding: 10px 14px;
+  font-weight: 650;
+  cursor: pointer;
+  transition: border-color 0.15s ease, background 0.15s ease;
+}
+.btn:hover { border-color: rgba(255,255,255,0.22); background: rgba(255,255,255,0.06); }
+.demo-visual { padding: 22px 24px; }
+.hint { font-size: 12px; color: #8c8c8c; line-height: 1.5; margin-top: 10px; }</pre>
+
+      <div class="feature-desc">
+        <strong>功能说明:</strong>本页验证 Layer 在 <code class="inline">icon</code> 传入 <code class="inline">svg:banner</code> 时,
+        会自动加载 Lottie 库并渲染 <code class="inline">banner.json</code>。
+      </div>
+    </div>
+
+    <div class="doc-nav" aria-label="Previous and next navigation">
+      <a href="#" id="prevLink" onclick="goPrev(); return false;">
+        <span><span class="nav-label">Previous</span><br><span class="nav-title" id="prevTitle">—</span></span>
+        <span aria-hidden="true">←</span>
+      </a>
+      <div class="nav-center" id="navCenter">Layer</div>
+      <a href="#" id="nextLink" onclick="goNext(); return false;">
+        <span><span class="nav-label">Next</span><br><span class="nav-title" id="nextTitle">—</span></span>
+        <span aria-hidden="true">→</span>
+      </a>
+    </div>
+  </div>
+
+  <script src="../highlight_css.js"></script>
+  <!-- Dev/test: load source files directly with cache-bust (avoid stale iframe cache) -->
+  <script>
+    (function () {
+      var b = Date.now();
+      document.write('<script src="../../animal.js?_=' + b + '"><\/script>');
+      document.write('<script src="../../layer.js?_=' + b + '"><\/script>');
+      document.write('<script>try{if(window.animal&&!window.xjs)window.xjs=window.animal;}catch(e){}<\/script>');
+    })();
+  </script>
+  <script>
+    const CURRENT = 'layer/test_custom_animation_banner.html';
+
+    function switchTab(tab) {
+      document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
+      document.querySelectorAll('.code-view, .html-view, .css-view').forEach(v => v.classList.remove('active'));
+      if (tab === 'js') {
+        document.querySelector('.tabs .tab:nth-child(1)')?.classList.add('active');
+        document.getElementById('js-code')?.classList.add('active');
+      } else if (tab === 'html') {
+        document.querySelector('.tabs .tab:nth-child(2)')?.classList.add('active');
+        document.getElementById('html-code')?.classList.add('active');
+      } else {
+        document.querySelector('.tabs .tab:nth-child(3)')?.classList.add('active');
+        document.getElementById('css-code')?.classList.add('active');
+      }
+    }
+
+    function openDemo(name) {
+      if (typeof window.$ !== 'function') return;
+
+      const iconAnimation = !!document.getElementById('optIcon')?.checked;
+      const popupAnimation = !!document.getElementById('optPopup')?.checked;
+      const closeOnEsc = !!document.getElementById('optEsc')?.checked;
+
+      const titleCap = name ? (name[0].toUpperCase() + name.slice(1)) : '';
+
+      // Keep the displayed code consistent with what is executed.
+      const code = `$.layer({
+  title: '${titleCap}',
+  text: 'Lottie animation icon',
+  icon: 'svg:${name}',
+  iconAnimation: ${iconAnimation},
+  popupAnimation: ${popupAnimation},
+  closeOnEsc: ${closeOnEsc}
+});`;
+      const jsPre = document.getElementById('js-code');
+      if (jsPre) jsPre.textContent = code;
+
+      $.layer({
+        title: titleCap,
+        text: 'Lottie animation icon',
+        icon: 'svg:' + name,
+        iconAnimation,
+        popupAnimation,
+        closeOnEsc
+      });
+    }
+
+    function goPrev() { window.parent?.docNavigatePrev?.(CURRENT); }
+    function goNext() { window.parent?.docNavigateNext?.(CURRENT); }
+
+    function syncNavLabels() {
+      const api = window.parent?.docGetPrevNext;
+      if (typeof api !== 'function') return;
+      const { prev, next, current } = api(CURRENT) || {};
+      const prevLink = document.getElementById('prevLink');
+      const nextLink = document.getElementById('nextLink');
+      if (prev) document.getElementById('prevTitle').textContent = prev.title || prev.url;
+      else { prevLink.style.visibility = 'hidden'; }
+      if (next) document.getElementById('nextTitle').textContent = next.title || next.url;
+      else { nextLink.style.visibility = 'hidden'; }
+      document.getElementById('navCenter').textContent = (current && current.group) ? current.group : 'Layer';
+    }
+
+    try { window.parent?.setMiddleActive?.(CURRENT); } catch {}
+    syncNavLabels();
+  </script>
+</body>
+</html>

+ 10 - 6
doc/layer/test_dom_steps.html

@@ -119,15 +119,14 @@ $('#openWizard').click(function () {
   $.layer({
     closeOnClickOutside: false,
     closeOnEsc: false,
-    popupAnimation: false,
-    confirmButtonColor: '#3085d6',
-    cancelButtonColor: '#444'
+    popupAnimation: false
   })
   .step({
     title: 'Step 1: Basic info',
     dom: '#wizard_step_1',
     showCancelButton: true,
     cancelButtonText: 'Cancel',
+    nextIcon: '&gt;',
     preConfirm(popup) {
       const name = popup.querySelector('input[name="name"]').value.trim();
       const err = popup.querySelector('[data-error]');
@@ -139,6 +138,8 @@ $('#openWizard').click(function () {
   .step({
     title: 'Step 2: Preferences',
     dom: '#wizard_step_2',
+    prevIcon: '&lt;',
+    nextIcon: '&gt;',
     preConfirm(popup) {
       const plan = popup.querySelector('select[name="plan"]').value;
       return { plan };
@@ -148,6 +149,7 @@ $('#openWizard').click(function () {
     title: 'Summary',
     icon: 'success',
     isSummary: true,
+    prevIcon: '&lt;',
     html: '&lt;div class="hint"&gt;Click OK to finish.&lt;/div&gt;'
   })
   .run()
@@ -393,15 +395,14 @@ then restore it back (and restore display/hidden state) on close. */</pre>
         $.layer({
           closeOnClickOutside: false,
           closeOnEsc: false,
-          popupAnimation: false,
-          confirmButtonColor: '#3085d6',
-          cancelButtonColor: '#444'
+          popupAnimation: false
         })
         .step({
           title: 'Step 1: Basic info',
           dom: '#wizard_step_1',
           showCancelButton: true,
           cancelButtonText: 'Cancel',
+          nextIcon: '>',
           preConfirm(popup) {
             const name = popup.querySelector('input[name="name"]').value.trim();
             const err = popup.querySelector('[data-error]');
@@ -413,6 +414,8 @@ then restore it back (and restore display/hidden state) on close. */</pre>
         .step({
           title: 'Step 2: Preferences',
           dom: '#wizard_step_2',
+          prevIcon: '<',
+          nextIcon: '>',
           preConfirm(popup) {
             const plan = popup.querySelector('select[name="plan"]').value;
             return { plan };
@@ -422,6 +425,7 @@ then restore it back (and restore display/hidden state) on close. */</pre>
           title: 'Summary',
           icon: 'success',
           isSummary: true,
+          prevIcon: '<',
           html: '<div class="hint">Click OK to finish.</div>'
         })
       .run()

+ 82 - 22
doc/layer/test_icons_svg_animation.html

@@ -93,26 +93,87 @@
         </div>
       </div>
 
-      <pre id="js-code" class="code-view active">xjs.layer({
-  title: 'Success',
-  text: 'SVG draw + spring entrance',
-  icon: 'success',
-  iconAnimation: true,
-  popupAnimation: true,
-  closeOnEsc: true
-});</pre>
+      <pre id="js-code" class="code-view active">function openDemo(type) {
+  if (typeof window.$ !== 'function') return;
+
+  const iconAnimation = !!document.getElementById('optIcon')?.checked;
+  const popupAnimation = !!document.getElementById('optPopup')?.checked;
+  const closeOnEsc = !!document.getElementById('optEsc')?.checked;
+
+  const title = String(type || '');
+  const titleCap = title ? (title[0].toUpperCase() + title.slice(1)) : '';
+
+  $.layer({
+    title: titleCap,
+    text: 'SVG draw + spring entrance',
+    icon: title,
+    iconAnimation,
+    popupAnimation,
+    closeOnEsc
+  });
+}</pre>
 
-      <pre id="html-code" class="html-view">&lt;label&gt;&lt;input id="optIcon" type="checkbox" checked&gt; iconAnimation&lt;/label&gt;
-&lt;label&gt;&lt;input id="optPopup" type="checkbox" checked&gt; popupAnimation&lt;/label&gt;
-&lt;label&gt;&lt;input id="optEsc" type="checkbox" checked&gt; closeOnEsc&lt;/label&gt;
+      <pre id="html-code" class="html-view">&lt;div class="demo-visual"&gt;
+  &lt;div class="controls" style="margin-bottom: 14px;"&gt;
+  &lt;label&gt;&lt;input id="optIcon" type="checkbox" checked&gt; iconAnimation&lt;/label&gt;
+  &lt;label&gt;&lt;input id="optPopup" type="checkbox" checked&gt; popupAnimation&lt;/label&gt;
+  &lt;label&gt;&lt;input id="optEsc" type="checkbox" checked&gt; closeOnEsc&lt;/label&gt;
+  &lt;/div&gt;
+  &lt;div class="row"&gt;
+  &lt;button class="btn success" type="button" onclick="openDemo('success')"&gt;Success&lt;/button&gt;
+  &lt;button class="btn error" type="button" onclick="openDemo('error')"&gt;Error&lt;/button&gt;
+  &lt;button class="btn warning" type="button" onclick="openDemo('warning')"&gt;Warning&lt;/button&gt;
+  &lt;button class="btn info" type="button" onclick="openDemo('info')"&gt;Info&lt;/button&gt;
+  &lt;button class="btn" type="button" onclick="openDemo('question')"&gt;Question&lt;/button&gt;
+  &lt;/div&gt;
+  &lt;div class="hint"&gt;
+    Tip: open “Error” to see the two stroke segments staggered.
+  &lt;/div&gt;
+&lt;/div&gt;</pre>
 
-&lt;button class="btn success" type="button" onclick="openDemo('success')"&gt;Success&lt;/button&gt;
-&lt;button class="btn error" type="button" onclick="openDemo('error')"&gt;Error&lt;/button&gt;
-&lt;button class="btn warning" type="button" onclick="openDemo('warning')"&gt;Warning&lt;/button&gt;
-&lt;button class="btn info" type="button" onclick="openDemo('info')"&gt;Info&lt;/button&gt;
-&lt;button class="btn" type="button" onclick="openDemo('question')"&gt;Question&lt;/button&gt;</pre>
+      <pre id="css-code" class="css-view">/* Keep Layer above the doc UI */
+.layer-overlay { z-index: 99999; }
 
-      <pre id="css-code" class="css-view">/* Icon styling lives inside layer.js */</pre>
+.controls {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px 16px;
+  align-items: center;
+  font-size: 13px;
+  color: #b7b7b7;
+}
+.controls label {
+  display: inline-flex;
+  gap: 8px;
+  align-items: center;
+  cursor: pointer;
+  user-select: none;
+}
+.controls input { accent-color: var(--highlight-color); }
+.row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+  align-items: center;
+}
+.btn {
+  appearance: none;
+  border: 1px solid rgba(255,255,255,0.12);
+  background: rgba(255,255,255,0.04);
+  color: #fff;
+  border-radius: 999px;
+  padding: 10px 14px;
+  font-weight: 650;
+  cursor: pointer;
+  transition: border-color 0.15s ease, background 0.15s ease;
+}
+.btn:hover { border-color: rgba(255,255,255,0.22); background: rgba(255,255,255,0.06); }
+.btn.success { border-color: rgba(165,220,134,0.55); background: rgba(165,220,134,0.12); }
+.btn.error { border-color: rgba(242,116,116,0.55); background: rgba(242,116,116,0.12); }
+.btn.warning { border-color: rgba(248,187,134,0.55); background: rgba(248,187,134,0.12); }
+.btn.info { border-color: rgba(63,195,238,0.55); background: rgba(63,195,238,0.12); }
+.demo-visual { padding: 22px 24px; }
+.hint { font-size: 12px; color: #8c8c8c; line-height: 1.5; margin-top: 10px; }</pre>
 
       <div class="feature-desc">
         <strong>功能说明:</strong>本页专门验证 Layer 的 icon 动画:描边(draw)+ 弹性缩放(spring)。你可以关闭其中任意一项来对比体验。
@@ -216,9 +277,8 @@
     }
 
     function openDemo(type) {
-      const X = window.xjs || window.animal;
-      if (typeof X !== 'function') {
-        __showDebug('xjs not ready', 'animal.js not loaded or xjs alias missing.');
+      if (typeof window.$ !== 'function') {
+        __showDebug('xjs not ready', '$ is not available on window.');
         return;
       }
 
@@ -230,7 +290,7 @@
       const titleCap = title ? (title[0].toUpperCase() + title.slice(1)) : '';
 
       // Keep the displayed code consistent with what is executed.
-      const code = `xjs.layer({
+      const code = `$.layer({
   title: '${titleCap}',
   text: 'SVG draw + spring entrance',
   icon: '${title}',
@@ -242,7 +302,7 @@
       if (jsPre) jsPre.textContent = code;
 
       try {
-        X.layer({
+        $.layer({
           title: titleCap,
           text: 'SVG draw + spring entrance',
           icon: title,

+ 287 - 24
layer.js

@@ -38,6 +38,7 @@
   const PREFIX = 'layer-';
   const RING_TYPES = new Set(['success', 'error', 'warning', 'info', 'question']);
   const RING_BG_PARTS_SELECTOR = `.${PREFIX}success-circular-line-left, .${PREFIX}success-circular-line-right, .${PREFIX}success-fix`;
+  const LOTTIE_PREFIX = 'svg:';
 
   const normalizeOptions = (options, text, icon) => {
     if (typeof options !== 'string') return options || {};
@@ -113,6 +114,93 @@
     }
   };
 
+  // Lottie (svg.js) lazy loader for "svg:*" icon type
+  let _lottieReady = null;
+  let _lottiePrefetchScheduled = false;
+  const isLottieIcon = (icon) => {
+    if (typeof icon !== 'string') return false;
+    const s = icon.trim();
+    return s.startsWith(LOTTIE_PREFIX);
+  };
+  const getLottieIconName = (icon) => {
+    if (!isLottieIcon(icon)) return '';
+    let name = String(icon || '').trim().slice(LOTTIE_PREFIX.length).trim();
+    if (name === 'loadding') name = 'loading';
+    return name;
+  };
+  const getLayerScriptBase = () => {
+    try {
+      if (typeof document === 'undefined') return '';
+      const scripts = Array.from(document.getElementsByTagName('script'));
+      const src = scripts
+        .map((s) => s && s.src)
+        .find((s) => /(^|\/)(xjs|layer)\.js(\?|#|$)/.test(String(s || '')));
+      if (!src) return '';
+      return String(src)
+        .replace(/[#?].*$/, '')
+        .replace(/\/[^\/]*$/, '/');
+    } catch {
+      return '';
+    }
+  };
+  const resolveLottieScriptUrl = () => {
+    const base = getLayerScriptBase();
+    return base ? (base + 'svg/svg.js') : 'svg/svg.js';
+  };
+  const resolveLottieJsonUrl = (name) => {
+    const raw = String(name || '').trim();
+    if (!raw) return '';
+    if (/^(https?:)?\/\//.test(raw)) return raw;
+    if (raw.startsWith('/') || raw.startsWith('./') || raw.startsWith('../')) return raw;
+    const base = getLayerScriptBase();
+    const file = raw.endsWith('.json') ? raw : (raw + '.json');
+    return (base ? base : '') + 'svg/' + file;
+  };
+  const ensureLottieLib = () => {
+    try {
+      if (typeof window === 'undefined') return Promise.resolve(null);
+      if (window.lottie) return Promise.resolve(window.lottie);
+      if (typeof document === 'undefined' || !document.head) return Promise.resolve(null);
+      if (_lottieReady) return _lottieReady;
+
+      const existing = document.getElementById('layer-lottie-lib');
+      _lottieReady = new Promise((resolve) => {
+        const finish = () => resolve(window.lottie || null);
+        if (existing) {
+          try { existing.addEventListener('load', finish, { once: true }); } catch {}
+          try { existing.addEventListener('error', finish, { once: true }); } catch {}
+          setTimeout(finish, 1200);
+          return;
+        }
+        const script = document.createElement('script');
+        script.id = 'layer-lottie-lib';
+        script.async = true;
+        script.src = resolveLottieScriptUrl();
+        try { script.addEventListener('load', finish, { once: true }); } catch {}
+        try { script.addEventListener('error', finish, { once: true }); } catch {}
+        setTimeout(finish, 1800);
+        document.head.appendChild(script);
+      });
+      return _lottieReady;
+    } catch {
+      return Promise.resolve(null);
+    }
+  };
+  const scheduleLottiePrefetch = () => {
+    if (_lottiePrefetchScheduled) return;
+    _lottiePrefetchScheduled = true;
+    try {
+      if (typeof window === 'undefined') return;
+      const start = () => {
+        setTimeout(() => {
+          try { ensureLottieLib(); } catch {}
+        }, 0);
+      };
+      if (document.readyState === 'complete') start();
+      else window.addEventListener('load', start, { once: true });
+    } catch {}
+  };
+
   class Layer {
     constructor() {
       this._cssReady = ensureXjsCss();
@@ -223,15 +311,16 @@
         text: '',
         icon: null,
         iconSize: null, // e.g. '6em' / '72px'
+        iconLoop: null, // lottie only: true/false/null (auto)
         confirmButtonText: 'OK',
         cancelButtonText: 'Cancel',
         showCancelButton: false,
-        confirmButtonColor: '#3085d6',
-        cancelButtonColor: '#aaa',
         closeOnClickOutside: true,
         closeOnEsc: true,
         iconAnimation: true,
         popupAnimation: true,
+        nextIcon: null, // flow-only: icon for Next button
+        prevIcon: null, // flow-only: icon for Back button
         // Content:
         // - text: plain text
         // - html: innerHTML
@@ -332,6 +421,7 @@
     }
 
     _render(meta = null) {
+      this._destroyLottieIcon();
       // Remove existing if any (but first, try to restore any mounted DOM from the previous instance)
       const existing = document.querySelector(`.${PREFIX}overlay`);
       const wantReplace = !!(this.params && this.params.replace);
@@ -426,15 +516,15 @@
       
       // Cancel Button
       if (this.params.showCancelButton) {
-        this.dom.cancelBtn = el('button', `${PREFIX}button ${PREFIX}cancel`, this.params.cancelButtonText);
-        this.dom.cancelBtn.style.backgroundColor = this.params.cancelButtonColor;
+        this.dom.cancelBtn = el('button', `${PREFIX}button ${PREFIX}cancel`, '');
+        this._setButtonContent(this.dom.cancelBtn, this.params.cancelButtonText);
         this.dom.cancelBtn.onclick = () => this._handleCancel();
         this.dom.actions.appendChild(this.dom.cancelBtn);
       }
 
       // Confirm Button
-      this.dom.confirmBtn = el('button', `${PREFIX}button ${PREFIX}confirm`, this.params.confirmButtonText);
-      this.dom.confirmBtn.style.backgroundColor = this.params.confirmButtonColor;
+      this.dom.confirmBtn = el('button', `${PREFIX}button ${PREFIX}confirm`, '');
+      this._setButtonContent(this.dom.confirmBtn, this.params.confirmButtonText);
       this.dom.confirmBtn.onclick = () => this._handleConfirm();
       this.dom.actions.appendChild(this.dom.confirmBtn);
 
@@ -482,6 +572,7 @@
     }
 
     _renderFlowUI() {
+      this._destroyLottieIcon();
       // Icon/title/content/actions are stable; steps are pre-mounted and toggled.
       const popup = this.dom.popup;
       if (!popup) return;
@@ -584,21 +675,27 @@
       while (actions.firstChild) actions.removeChild(actions.firstChild);
 
       this.dom.cancelBtn = null;
+      const total = (this._flowSteps && this._flowSteps.length) ? this._flowSteps.length : 0;
+      const isLast = total > 0 ? (this._flowIndex >= (total - 1)) : true;
+
       if (this.params.showCancelButton) {
-        this.dom.cancelBtn = el('button', `${PREFIX}button ${PREFIX}cancel`, this.params.cancelButtonText);
-        this.dom.cancelBtn.style.backgroundColor = this.params.cancelButtonColor;
+        const prevIcon = (this._flowIndex > 0) ? this.params.prevIcon : null;
+        this.dom.cancelBtn = el('button', `${PREFIX}button ${PREFIX}cancel`, '');
+        this._setButtonContent(this.dom.cancelBtn, this.params.cancelButtonText, prevIcon, 'start');
         this.dom.cancelBtn.onclick = () => this._handleCancel();
         actions.appendChild(this.dom.cancelBtn);
       }
 
-      this.dom.confirmBtn = el('button', `${PREFIX}button ${PREFIX}confirm`, this.params.confirmButtonText);
-      this.dom.confirmBtn.style.backgroundColor = this.params.confirmButtonColor;
+      const nextIcon = (!isLast) ? this.params.nextIcon : null;
+      this.dom.confirmBtn = el('button', `${PREFIX}button ${PREFIX}confirm`, '');
+      this._setButtonContent(this.dom.confirmBtn, this.params.confirmButtonText, nextIcon, 'end');
       this.dom.confirmBtn.onclick = () => this._handleConfirm();
       actions.appendChild(this.dom.confirmBtn);
     }
 
     _createIcon(type) {
-      const icon = el('div', `${PREFIX}icon ${type}`);
+      const rawType = String(type || '').trim();
+      const icon = el('div', `${PREFIX}icon ${rawType}`);
       const applyIconSize = (mode) => {
         if (!(this.params && this.params.iconSize)) return;
         try {
@@ -659,22 +756,35 @@
       };
       
       const appendBuiltInInnerMark = (svg) => {
-        if (type === 'error') {
+        if (rawType === 'error') {
           addMarkPath(svg, 'M28 28 L52 52', `${PREFIX}svg-error-left`);
           addMarkPath(svg, 'M52 28 L28 52', `${PREFIX}svg-error-right`);
-        } else if (type === 'warning') {
+        } else if (rawType === 'warning') {
           addMarkPath(svg, 'M40 20 L40 46', `${PREFIX}svg-warning-line`);
           addDot(svg, 40, 58);
-        } else if (type === 'info') {
+        } else if (rawType === 'info') {
           addMarkPath(svg, 'M40 34 L40 56', `${PREFIX}svg-info-line`);
           addDot(svg, 40, 25);
-        } else if (type === 'question') {
+        } else if (rawType === 'question') {
           addMarkPath(svg, '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`);
           addDot(svg, 42, 61);
         }
       };
       
-      if (type === 'success') {
+      if (isLottieIcon(rawType)) {
+        // Lottie animation icon: svg:<name>
+        const name = getLottieIconName(rawType);
+        icon.className = `${PREFIX}icon ${PREFIX}icon-lottie`;
+        icon.dataset.lottie = name;
+        const container = el('div', `${PREFIX}lottie`);
+        icon.appendChild(container);
+        applyIconSize('box');
+        scheduleLottiePrefetch();
+        this._initLottieIcon(icon, name);
+        return icon;
+      }
+
+      if (rawType === 'success') {
         // SweetAlert2-compatible DOM structure (circle + tick) for exact style parity
         //   <div class="...success-circular-line-left"></div>
         //   <div class="...success-mark"><span class="...success-line-tip"></span><span class="...success-line-long"></span></div>
@@ -694,7 +804,7 @@
         return icon;
       }
 
-      if (type === 'error' || type === 'warning' || type === 'info' || type === 'question') {
+      if (rawType === 'error' || rawType === 'warning' || rawType === 'info' || rawType === 'question') {
         // Use the same "success-like" ring parts for every icon
         appendRingParts();
 
@@ -847,6 +957,7 @@
       const icon = this.dom.icon;
       if (!icon) return;
       const type = (this.params && this.params.icon) || '';
+      if (isLottieIcon(type)) return;
 
       // Ring animation (same as success) for all built-in icons
       if (RING_TYPES.has(type)) this._adjustRingBackgroundColor();
@@ -902,6 +1013,7 @@
     }
 
     _forceDestroy(reason = 'replace') {
+      try { this._destroyLottieIcon(); } catch {}
       // Restore mounted DOM (if any) and cleanup listeners; also resolve the promise so it doesn't hang.
       try {
         if (this._flowSteps && this._flowSteps.length) this._unmountFlowMounted();
@@ -921,6 +1033,7 @@
     _close(isConfirmed, reason) {
       if (this._isClosing) return;
       this._isClosing = true;
+      try { this._destroyLottieIcon(); } catch {}
 
       const shouldDelayUnmount = !!(
         (this._mounted && this._mounted.kind === 'move') ||
@@ -1248,6 +1361,46 @@
       } catch {}
     }
 
+    _normalizeButtonIcon(icon) {
+      if (!icon) return null;
+      try {
+        if (icon && icon.nodeType) return icon.cloneNode(true);
+      } catch {}
+      if (typeof icon === 'string') {
+        const s = icon.trim();
+        if (!s) return null;
+        if (s.indexOf('<') !== -1 && s.indexOf('>') !== -1) {
+          const wrap = document.createElement('span');
+          wrap.innerHTML = s;
+          return wrap;
+        }
+        return document.createTextNode(s);
+      }
+      return null;
+    }
+
+    _setButtonContent(btn, text, icon, iconPosition) {
+      if (!btn) return;
+      const labelText = (text === undefined || text === null) ? '' : String(text);
+      if (!icon) {
+        try { btn.textContent = labelText; } catch {}
+        return;
+      }
+      while (btn.firstChild) btn.removeChild(btn.firstChild);
+      const iconNode = this._normalizeButtonIcon(icon);
+      const iconWrap = el('span', `${PREFIX}btn-icon`);
+      if (iconNode) iconWrap.appendChild(iconNode);
+      const label = el('span', `${PREFIX}btn-label`, labelText);
+      const atEnd = iconPosition === 'end';
+      if (atEnd) {
+        btn.appendChild(label);
+        btn.appendChild(iconWrap);
+      } else {
+        btn.appendChild(iconWrap);
+        btn.appendChild(label);
+      }
+    }
+
     async _handleConfirm() {
       // Flow next / finalize
       if (this._flowSteps && this._flowSteps.length) {
@@ -1430,12 +1583,16 @@
       try {
         const wantsIcon = !!this.params.icon;
         if (!wantsIcon && this.dom.icon) {
+          this._destroyLottieIcon();
           this.dom.icon.remove();
           this.dom.icon = null;
         } else if (wantsIcon) {
-          const curType = this.dom.icon ? (Array.from(this.dom.icon.classList).find(c => c !== `${PREFIX}icon`) || '') : '';
-          if (!this.dom.icon || !this.dom.icon.classList.contains(String(this.params.icon))) {
-            if (this.dom.icon) this.dom.icon.remove();
+          const same = this._isSameIconType(this.dom.icon, this.params.icon);
+          if (!this.dom.icon || !same) {
+            if (this.dom.icon) {
+              this._destroyLottieIcon();
+              this.dom.icon.remove();
+            }
             this.dom.icon = this._createIcon(this.params.icon);
             // icon should be on top (before title)
             popup.insertBefore(this.dom.icon, this.dom.title || popup.firstChild);
@@ -1534,7 +1691,113 @@
       try { this._adjustRingBackgroundColor(); } catch {}
     }
 
+    _isSameIconType(iconEl, type) {
+      if (!iconEl) return false;
+      const raw = String(type || '').trim();
+      if (isLottieIcon(raw)) {
+        const name = getLottieIconName(raw);
+        return iconEl.dataset && iconEl.dataset.lottie === name;
+      }
+      try {
+        return iconEl.classList && iconEl.classList.contains(raw);
+      } catch {
+        return false;
+      }
+    }
+
+    _destroyLottieIcon() {
+      const icon = this.dom && this.dom.icon;
+      if (!icon) return;
+      const inst = icon._lottieInstance;
+      if (inst && typeof inst.destroy === 'function') {
+        try { inst.destroy(); } catch {}
+      }
+      try { icon._lottieInstance = null; } catch {}
+    }
+
+    _initLottieIcon(iconEl, name) {
+      if (!iconEl) return;
+      const container = iconEl.querySelector(`.${PREFIX}lottie`);
+      if (!container) return;
+      const cleanName = String(name || '').trim();
+      if (!cleanName) return;
+
+      const url = resolveLottieJsonUrl(cleanName);
+      if (!url) return;
+
+      const wantsLoop = (this.params && typeof this.params.iconLoop === 'boolean')
+        ? this.params.iconLoop
+        : true;
+      const wantsAutoplay = !(this.params && this.params.iconAnimation === false);
+
+      const showError = (msg) => {
+        try {
+          container.textContent = String(msg || '');
+          container.classList.add(`${PREFIX}lottie-error`);
+        } catch {}
+      };
+
+      const loadData = () => new Promise((resolve, reject) => {
+        try {
+          if (typeof fetch === 'function') {
+            fetch(url).then((res) => {
+              if (!res || !res.ok) throw new Error('fetch failed');
+              return res.json();
+            }).then(resolve).catch(reject);
+            return;
+          }
+          const xhr = new XMLHttpRequest();
+          xhr.open('GET', url, true);
+          xhr.responseType = 'json';
+          xhr.onload = () => {
+            if (xhr.status >= 200 && xhr.status < 300) {
+              const data = xhr.response || (xhr.responseText ? JSON.parse(xhr.responseText) : null);
+              resolve(data);
+            } else {
+              reject(new Error('xhr failed'));
+            }
+          };
+          xhr.onerror = () => reject(new Error('xhr error'));
+          xhr.send();
+        } catch (e) {
+          reject(e);
+        }
+      });
+
+      (async () => {
+        const lottie = await ensureLottieLib();
+        if (!lottie) {
+          showError('Lottie lib not loaded.');
+          return;
+        }
+        if (!iconEl.isConnected) return;
+        let data = null;
+        try { data = await loadData(); } catch {}
+        if (!data) {
+          showError('Failed to load animation.');
+          return;
+        }
+        if (!iconEl.isConnected) return;
+        try {
+          const anim = lottie.loadAnimation({
+            container,
+            renderer: 'svg',
+            loop: wantsLoop,
+            autoplay: wantsAutoplay,
+            animationData: data
+          });
+          iconEl._lottieInstance = anim;
+          if (!wantsAutoplay && anim && typeof anim.goToAndStop === 'function') {
+            anim.goToAndStop(0, true);
+          }
+        } catch {
+          showError('Failed to init animation.');
+        }
+      })();
+    }
+
     _rerenderInside() {
+      this._destroyLottieIcon();
       // Update popup content without recreating overlay (used in flow transitions)
       const popup = this.dom && this.dom.popup;
       if (!popup) return;
@@ -1571,14 +1834,14 @@
       this.dom.actions = el('div', `${PREFIX}actions`);
       this.dom.cancelBtn = null;
       if (this.params.showCancelButton) {
-        this.dom.cancelBtn = el('button', `${PREFIX}button ${PREFIX}cancel`, this.params.cancelButtonText);
-        this.dom.cancelBtn.style.backgroundColor = this.params.cancelButtonColor;
+        this.dom.cancelBtn = el('button', `${PREFIX}button ${PREFIX}cancel`, '');
+        this._setButtonContent(this.dom.cancelBtn, this.params.cancelButtonText);
         this.dom.cancelBtn.onclick = () => this._handleCancel();
         this.dom.actions.appendChild(this.dom.cancelBtn);
       }
 
-      this.dom.confirmBtn = el('button', `${PREFIX}button ${PREFIX}confirm`, this.params.confirmButtonText);
-      this.dom.confirmBtn.style.backgroundColor = this.params.confirmButtonColor;
+      this.dom.confirmBtn = el('button', `${PREFIX}button ${PREFIX}confirm`, '');
+      this._setButtonContent(this.dom.confirmBtn, this.params.confirmButtonText);
       this.dom.confirmBtn.onclick = () => this._handleConfirm();
       this.dom.actions.appendChild(this.dom.confirmBtn);
 

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
svg/BallSorting.json


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
svg/banner.json


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
svg/loading.json


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
svg/svg.js


+ 0 - 2
test.html

@@ -83,8 +83,6 @@
                 text: "You won't be able to revert this!",
                 icon: 'warning',
                 showCancelButton: true,
-                confirmButtonColor: '#3085d6',
-                cancelButtonColor: '#d33',
                 confirmButtonText: 'Yes, delete it!'
             }).then((result) => {
                 if (result.isConfirmed) {

+ 43 - 4
xjs.css

@@ -39,6 +39,11 @@
   transform: scale(0.92);
   transition: transform 0.26s ease;
   font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+  --layer-confirm-bg: #3085d6;
+  --layer-confirm-hover: #2b77c0;
+  --layer-cancel-bg: #444;
+  --layer-cancel-hover: #333;
+  background-image: url("https://i0.wp.com/picjumbo.com/wp-content/uploads/crazy-iridescent-smoke-abstract-background-free-image.jpeg?w=600&quality=80");
 }
 .layer-overlay.show .layer-popup {
   transform: scale(1);
@@ -72,18 +77,30 @@
   cursor: pointer;
   transition: background-color 0.2s, box-shadow 0.2s;
   color: #fff;
+  display: inline-flex;
+  align-items: center;
+  gap: 0.45em;
 }
 .layer-confirm {
-  background-color: #3085d6;
+  background-color: var(--layer-confirm-bg);
 }
 .layer-confirm:hover {
-  background-color: #2b77c0;
+  background-color: var(--layer-confirm-hover);
 }
 .layer-cancel {
-  background-color: #aaa;
+  background-color: var(--layer-cancel-bg);
 }
 .layer-cancel:hover {
-  background-color: #999;
+  background-color: var(--layer-cancel-hover);
+}
+.layer-btn-icon {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  line-height: 1;
+}
+.layer-btn-label {
+  display: inline-block;
 }
 .layer-icon {
   position: relative;
@@ -102,6 +119,28 @@
   /* End is one full turn from start so it "sweeps then settles" */
   --layer-ring-end-rotate: -405deg;
 }
+.layer-icon.layer-icon-lottie {
+  border: none;
+  padding: 0;
+  display: grid;
+  place-items: center;
+}
+.layer-icon .layer-lottie {
+  width: 100%;
+  height: 100%;
+  display: block;
+}
+.layer-icon .layer-lottie svg {
+  width: 100%;
+  height: 100%;
+  display: block;
+}
+.layer-lottie-error {
+  font-size: 11px;
+  color: #9a9a9a;
+  text-align: center;
+  line-height: 1.2;
+}
 
 /* SVG mark content (kept), sits above the ring */
 .layer-icon svg {

Некоторые файлы не были показаны из-за большого количества измененных файлов