Browse Source

slidedown解决

robert 16 hours ago
parent
commit
53441dde6a

+ 9 - 4
doc/layer/overview.html

@@ -144,10 +144,15 @@ xjs('.btn.confirm').forEach((el) => {
   </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;}catch(e){}<\/script>');
+    })();
+  </script>
   <script>
     const CURRENT = 'layer/overview.html';
 

+ 11 - 5
doc/layer/test_confirm_flow.html

@@ -99,10 +99,15 @@
   </div>
 
   <script src="../highlight_css.js"></script>
-  <!-- NOTE: xjs.js bundles an older Layer in some builds.
-       For local testing, load standalone layer.js last to ensure this page uses the edited Layer implementation. -->
-  <script src="../../xjs.js?v=dev"></script>
-  <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;}catch(e){}<\/script>');
+    })();
+  </script>
   <script>
     const CURRENT = 'layer/test_confirm_flow.html';
 
@@ -126,6 +131,7 @@
         title: 'Delete item?',
         text: 'This action cannot be undone.',
         icon: 'question',
+        popupAnimation: true,
         showCancelButton: true,
         confirmButtonText: 'Delete',
         cancelButtonText: 'Cancel',
@@ -143,7 +149,7 @@
     }
 
     function openError() {
-      xjs.layer({ title: 'Error', text: 'Example error popup', icon: 'error' });
+      xjs.layer({ title: 'Error', text: 'Example error popup', icon: 'error', popupAnimation: true });
     }
 
     function goPrev() { window.parent?.docNavigatePrev?.(CURRENT); }

+ 47 - 7
doc/layer/test_dom_steps.html

@@ -100,9 +100,8 @@ xjs.layer({
   cancelButtonText: 'Close'
 });
 
-// 2) Step flow (wizard)
+// 2) Step flow (wizard) - no icon during steps; final summary is allowed
 xjs.Layer.$({
-  icon: 'info',
   closeOnClickOutside: false,
   closeOnEsc: false,
   confirmButtonColor: '#3085d6',
@@ -129,6 +128,12 @@ xjs.Layer.$({
     return { plan };
   }
 })
+.step({
+  title: 'Summary',
+  icon: 'success',
+  isSummary: true,
+  html: '&lt;div class="hint"&gt;Click OK to finish.&lt;/div&gt;'
+})
 .fire()
 .then((res) =&gt; {
   if (res.isConfirmed) {
@@ -221,12 +226,24 @@ then restore it back (and restore display/hidden state) on close. */</pre>
   </div>
 
   <script src="../highlight_css.js"></script>
-  <!-- NOTE: xjs.js bundles an older Layer in some builds.
-       For local testing, load standalone layer.js last to ensure this page uses the edited Layer implementation. -->
-  <script src="../../xjs.js?v=dev"></script>
-  <script src="../../layer.js?v=dev"></script>
   <script>
     const CURRENT = 'layer/test_dom_steps.html';
+    // =========================================================
+    // Dev loader (cache-bust) for local debugging
+    // - Ensure this page always uses the latest workspace `xjs.js` + `layer.js`
+    // - Avoid "no change" caused by browser caching `?v=dev`
+    // =========================================================
+    const DEV_CACHE_BUST = Date.now();
+    function loadScript(src) {
+      return new Promise((resolve, reject) => {
+        const s = document.createElement('script');
+        s.src = src;
+        s.async = false; // preserve order
+        s.onload = () => resolve();
+        s.onerror = () => reject(new Error('Failed to load: ' + src));
+        (document.head || document.documentElement).appendChild(s);
+      });
+    }
 
     function switchTab(tab) {
       document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
@@ -247,6 +264,7 @@ then restore it back (and restore display/hidden state) on close. */</pre>
       xjs.layer({
         title: 'DOM content',
         dom: '#demo_dom_block',
+        popupAnimation: true,
         showCancelButton: true,
         confirmButtonText: 'OK',
         cancelButtonText: 'Close'
@@ -255,9 +273,9 @@ then restore it back (and restore display/hidden state) on close. */</pre>
 
     function openWizard() {
       xjs.Layer.$({
-        icon: 'info',
         closeOnClickOutside: false,
         closeOnEsc: false,
+        popupAnimation: true,
         confirmButtonColor: '#3085d6',
         cancelButtonColor: '#444'
       })
@@ -282,6 +300,12 @@ then restore it back (and restore display/hidden state) on close. */</pre>
           return { plan };
         }
       })
+      .step({
+        title: 'Summary',
+        icon: 'success',
+        isSummary: true,
+        html: '<div class="hint">Click OK to finish.</div>'
+      })
       .fire()
       .then((res) => {
         if (res.isConfirmed) {
@@ -315,6 +339,22 @@ then restore it back (and restore display/hidden state) on close. */</pre>
     try { window.parent?.setMiddleActive?.(CURRENT); } catch {}
     syncNavLabels();
   </script>
+  <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.
+    (async () => {
+      const base = '../../';
+      // Important:
+      // - Do NOT load `xjs.js` dev-loader here, because it internally loads `layer.js` again
+      //   (without cache-busting) and may overwrite the fresh one after our load finishes.
+      // - Load sources directly with cache-bust to guarantee we use the current workspace code.
+      await loadScript(base + 'animal.js?_=' + DEV_CACHE_BUST);
+      try { if (window.animal && !window.xjs) window.xjs = window.animal; } catch {}
+      await loadScript(base + 'layer.js?_=' + DEV_CACHE_BUST);
+    })().catch((e) => {
+      try { console.error(e); } catch {}
+    });
+  </script>
 </body>
 </html>
 

+ 7 - 5
doc/layer/test_icons_svg_animation.html

@@ -133,12 +133,14 @@
   </div>
 
   <script src="../highlight_css.js"></script>
-  <!-- Dev/test: load source files directly (sync, avoids async loader timing) -->
-  <script src="../../animal.js?v=dev"></script>
-  <script src="../../layer.js?v=dev"></script>
+  <!-- Dev/test: load source files directly with cache-bust (avoid stale iframe cache) -->
   <script>
-    // Ensure global alias
-    if (window.animal && !window.xjs) window.xjs = window.animal;
+    (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_icons_svg_animation.html';

+ 10 - 4
doc/layer/test_selection_layer_dataset.html

@@ -130,10 +130,15 @@ xjs('.btn-data').layer();</pre>
   </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;}catch(e){}<\/script>');
+    })();
+  </script>
   <script>
     const CURRENT = 'layer/test_selection_layer_dataset.html';
 
@@ -157,6 +162,7 @@ xjs('.btn-data').layer();</pre>
         title: 'Delete item?',
         text: 'This action cannot be undone.',
         icon: 'warning',
+        popupAnimation: true,
         showCancelButton: true
       });
       xjs('.btn-data').layer(); // reads data-layer-* attributes

+ 12 - 6
doc/layer/test_static_fire.html

@@ -96,10 +96,15 @@ Layer.$()
   </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;}catch(e){}<\/script>');
+    })();
+  </script>
   <script>
     const CURRENT = 'layer/test_static_fire.html';
 
@@ -119,7 +124,7 @@ Layer.$()
     }
 
     function openFire() {
-      Layer.fire({ title: 'Hello', text: 'Layer.fire(...)', icon: 'info' })
+      Layer.fire({ title: 'Hello', text: 'Layer.fire(...)', icon: 'info', popupAnimation: true })
         .then((res) => console.log('[Layer.fire] result:', res));
     }
 
@@ -129,12 +134,13 @@ Layer.$()
           title: 'Confirm?',
           text: 'Builder style API',
           icon: 'warning',
+          popupAnimation: true,
           showCancelButton: true
         })
         .fire()
         .then((res) => {
           console.log('[Layer.builder] result:', res);
-          if (res.isConfirmed) xjs.layer({ title: 'Confirmed', icon: 'success' });
+          if (res.isConfirmed) xjs.layer({ title: 'Confirmed', icon: 'success', popupAnimation: true });
         });
     }
 

+ 390 - 52
layer.js

@@ -59,18 +59,34 @@
   // Ensure library CSS is loaded (xjs.css)
   // - CSS is centralized in xjs.css (no runtime <style> injection).
   // - We still auto-load it for convenience/compat, since consumers may forget the <link>.
+  let _xjsCssReady = null; // Promise<void>
+  const waitForStylesheet = (link) => new Promise((resolve) => {
+    if (!link) return resolve();
+    if (link.sheet) return resolve();
+    let done = false;
+    const finish = () => {
+      if (done) return;
+      done = true;
+      resolve();
+    };
+    try { link.addEventListener('load', finish, { once: true }); } catch {}
+    try { link.addEventListener('error', finish, { once: true }); } catch {}
+    // Fallback timeout: avoid blocking forever if the load event is missed.
+    setTimeout(finish, 200);
+  });
   const ensureXjsCss = () => {
     try {
-      if (typeof document === 'undefined') return;
-      if (!document.head) return;
+      if (_xjsCssReady) return _xjsCssReady;
+      if (typeof document === 'undefined') return Promise.resolve();
+      if (!document.head) return Promise.resolve();
 
-      const existingHref = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
-        .map((l) => (l.getAttribute('href') || '').trim())
-        .find((h) => /(^|\/)xjs\.css(\?|#|$)/.test(h));
-      if (existingHref) return;
+      const existingLink = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
+        .find((l) => /(^|\/)xjs\.css(\?|#|$)/.test((l.getAttribute('href') || '').trim()));
+      if (existingLink) return (_xjsCssReady = waitForStylesheet(existingLink));
 
       const id = 'xjs-css';
-      if (document.getElementById(id)) return;
+      const existing = document.getElementById(id);
+      if (existing) return (_xjsCssReady = waitForStylesheet(existing));
 
       const scripts = Array.from(document.getElementsByTagName('script'));
       const scriptSrc = scripts
@@ -88,15 +104,18 @@
       link.id = id;
       link.rel = 'stylesheet';
       link.href = href;
+      _xjsCssReady = waitForStylesheet(link);
       document.head.appendChild(link);
+      return _xjsCssReady;
     } catch {
       // ignore
+      return Promise.resolve();
     }
   };
 
   class Layer {
     constructor() {
-      ensureXjsCss();
+      this._cssReady = ensureXjsCss();
       this.params = {};
       this.dom = {};
       this.promise = null;
@@ -111,6 +130,8 @@
       this._flowIndex = 0;
       this._flowValues = [];
       this._flowBase = null; // base options merged into each step
+      this._flowResolved = null; // resolved merged steps
+      this._flowMountedList = []; // mounted DOM records for move-mode steps
     }
 
     // Constructor helper when called as function: const popup = Layer({...})
@@ -221,14 +242,21 @@
     _fireFlow() {
       this._flowIndex = 0;
       this._flowValues = [];
-      this._flowBase = { ...(this.params || {}) };
+      // In flow mode:
+      // - keep iconAnimation off by default (avoids jitter)
+      // - BUT allow popupAnimation by default so the first step has the same entrance feel
+      const base = { ...(this.params || {}) };
+      if (!('popupAnimation' in base)) base.popupAnimation = true;
+      if (!('iconAnimation' in base)) base.iconAnimation = false;
+      this._flowBase = base;
 
       this.promise = new Promise((resolve, reject) => {
         this.resolve = resolve;
         this.reject = reject;
       });
 
-      const first = this._getFlowStepOptions(0);
+      this._flowResolved = (this._flowSteps || []).map((_, i) => this._getFlowStepOptions(i));
+      const first = this._flowResolved[0] || this._getFlowStepOptions(0);
       this.params = first;
       this._render({ flow: true });
       return this.promise;
@@ -261,6 +289,12 @@
       this.dom.popup = el('div', `${PREFIX}popup`);
       this.dom.overlay.appendChild(this.dom.popup);
 
+      // Flow mode: pre-mount all steps and switch by hide/show (DOM continuity, less jitter)
+      if (meta && meta.flow) {
+        this._renderFlowUI();
+        return;
+      }
+
       // Icon
       if (this.params.icon) {
         this.dom.icon = this._createIcon(this.params.icon);
@@ -318,15 +352,126 @@
         });
       }
 
-      document.body.appendChild(this.dom.overlay);
+      // Avoid first-open overlay "flash": wait for CSS before inserting into DOM,
+      // then show on a clean frame so opacity transitions are smooth.
+      const ready = this._cssReady && typeof this._cssReady.then === 'function' ? this._cssReady : Promise.resolve();
+      ready.then(() => {
+        try { this.dom.overlay.style.visibility = ''; } catch {}
+        if (!this.dom.overlay.parentNode) {
+          document.body.appendChild(this.dom.overlay);
+        }
+        // Double-rAF gives the browser a chance to apply styles before animating.
+        requestAnimationFrame(() => {
+          requestAnimationFrame(() => {
+            this.dom.overlay.classList.add('show');
+            this._didOpen();
+          });
+        });
+      });
+    }
+
+    _renderFlowUI() {
+      // Icon/title/content/actions are stable; steps are pre-mounted and toggled.
+      const popup = this.dom.popup;
+      if (!popup) return;
+
+      // Clear popup
+      while (popup.firstChild) popup.removeChild(popup.firstChild);
 
-      // Animation
-      requestAnimationFrame(() => {
-        this.dom.overlay.classList.add('show');
-        this._didOpen();
+      // Icon (in flow: suppressed by default unless question or summary)
+      this.dom.icon = null;
+      if (this.params.icon) {
+        this.dom.icon = this._createIcon(this.params.icon);
+        popup.appendChild(this.dom.icon);
+      }
+
+      // Title (always present for flow)
+      this.dom.title = el('h2', `${PREFIX}title`, this.params.title || '');
+      popup.appendChild(this.dom.title);
+
+      // Content container
+      this.dom.content = el('div', `${PREFIX}content`);
+      // Stack container: keep panes absolute so we can cross-fade without layout thrash
+      this.dom.stepStack = el('div', `${PREFIX}step-stack`);
+      this.dom.stepStack.style.position = 'relative';
+      this.dom.stepStack.style.width = '100%';
+      this.dom.stepPanes = [];
+      this._flowMountedList = [];
+
+      const steps = this._flowResolved || (this._flowSteps || []).map((_, i) => this._getFlowStepOptions(i));
+      steps.forEach((opt, i) => {
+        const pane = el('div', `${PREFIX}step-pane`);
+        pane.style.width = '100%';
+        pane.style.boxSizing = 'border-box';
+        pane.style.display = (i === this._flowIndex) ? '' : 'none';
+
+        // Fill pane: dom/html/text
+        const domSpec = this._normalizeDomSpec(opt);
+        if (domSpec) {
+          this._mountDomContentInto(pane, domSpec, { collectFlow: true });
+        } else if (opt.html) {
+          pane.innerHTML = opt.html;
+        } else if (opt.text) {
+          pane.textContent = opt.text;
+        }
+
+        this.dom.stepStack.appendChild(pane);
+        this.dom.stepPanes.push(pane);
+      });
+
+      this.dom.content.appendChild(this.dom.stepStack);
+      popup.appendChild(this.dom.content);
+
+      // Actions
+      this.dom.actions = el('div', `${PREFIX}actions`);
+      popup.appendChild(this.dom.actions);
+      this._updateFlowActions();
+
+      // Event Listeners
+      if (this.params.closeOnClickOutside) {
+        this.dom.overlay.addEventListener('click', (e) => {
+          if (e.target === this.dom.overlay) {
+            this._close(null, 'backdrop');
+          }
+        });
+      }
+
+      // Avoid first-open overlay "flash": wait for CSS before inserting into DOM,
+      // then show on a clean frame so opacity transitions are smooth.
+      const ready = this._cssReady && typeof this._cssReady.then === 'function' ? this._cssReady : Promise.resolve();
+      ready.then(() => {
+        try { this.dom.overlay.style.visibility = ''; } catch {}
+        if (!this.dom.overlay.parentNode) {
+          document.body.appendChild(this.dom.overlay);
+        }
+        requestAnimationFrame(() => {
+          requestAnimationFrame(() => {
+            this.dom.overlay.classList.add('show');
+            this._didOpen();
+          });
+        });
       });
     }
 
+    _updateFlowActions() {
+      const actions = this.dom && this.dom.actions;
+      if (!actions) return;
+      while (actions.firstChild) actions.removeChild(actions.firstChild);
+
+      this.dom.cancelBtn = null;
+      if (this.params.showCancelButton) {
+        this.dom.cancelBtn = el('button', `${PREFIX}button ${PREFIX}cancel`, this.params.cancelButtonText);
+        this.dom.cancelBtn.style.backgroundColor = this.params.cancelButtonColor;
+        this.dom.cancelBtn.onclick = () => this._handleCancel();
+        actions.appendChild(this.dom.cancelBtn);
+      }
+
+      this.dom.confirmBtn = el('button', `${PREFIX}button ${PREFIX}confirm`, this.params.confirmButtonText);
+      this.dom.confirmBtn.style.backgroundColor = this.params.confirmButtonColor;
+      this.dom.confirmBtn.onclick = () => this._handleConfirm();
+      actions.appendChild(this.dom.confirmBtn);
+    }
+
     _createIcon(type) {
       const icon = el('div', `${PREFIX}icon ${type}`);
       const applyIconSize = (mode) => {
@@ -485,18 +630,60 @@
 
       // 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.
+        if (this.dom.popup) {
+          // Use WAAPI directly to guarantee the slide+fade effect is visible,
+          // independent from xjs.animate implementation details.
           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 }
-            });
+            const popup = this.dom.popup;
+            try { popup.classList.add(`${PREFIX}popup-anim-slide`); } catch {}
+
+            const rect = popup.getBoundingClientRect();
+            // Default entrance: from above center by "more than half" of popup height.
+            // Clamp to viewport so very tall popups don't start far off-screen.
+            const vh = (typeof window !== 'undefined' && window && window.innerHeight) ? window.innerHeight : rect.height;
+            const baseH = Math.min(rect.height, vh);
+            const y0 = -Math.round(Math.max(18, baseH * 0.75));
+
+            // Disable CSS transform transition so it won't fight with WAAPI.
+            popup.style.transition = 'none';
+            popup.style.willChange = 'transform, opacity';
+
+            // If WAAPI is available, prefer it (most consistent).
+            if (popup.animate) {
+              const anim = popup.animate(
+                [
+                  { transform: `translateY(${y0}px) scale(0.92)`, opacity: 0 },
+                  { transform: 'translateY(0px) scale(1)', opacity: 1 }
+                ],
+                {
+                  duration: 520,
+                  easing: 'cubic-bezier(0.2, 0.9, 0.2, 1)',
+                  fill: 'forwards'
+                }
+              );
+              anim.finished
+                .catch(() => {})
+                .finally(() => {
+                  try { popup.style.willChange = ''; } catch {}
+                });
+            } else {
+              // Fallback: try xjs.animate if WAAPI isn't available.
+              const X = Layer._getXjs();
+              if (X) {
+                popup.style.opacity = '0';
+                popup.style.transform = `translateY(${y0}px) scale(0.92)`;
+                X(popup).animate({
+                  y: [y0, 0],
+                  scale: [0.92, 1],
+                  opacity: [0, 1],
+                  duration: 520,
+                  easing: 'ease-out'
+                });
+              }
+              setTimeout(() => {
+                try { popup.style.willChange = ''; } catch {}
+              }, 560);
+            }
           } catch {}
         }
       }
@@ -589,7 +776,10 @@
       this._isClosing = true;
 
       // Restore mounted DOM (moved into popup) before removing overlay
-      try { this._unmountDomContent(); } catch {}
+      try {
+        if (this._flowSteps && this._flowSteps.length) this._unmountFlowMounted();
+        else this._unmountDomContent();
+      } catch {}
 
       this.dom.overlay.classList.remove('show');
       setTimeout(() => {
@@ -643,6 +833,42 @@
       return { dom, mode };
     }
 
+    _mountDomContentInto(target, domSpec, opts = null) {
+      // Like _mountDomContent, but mounts into the provided container.
+      const collectFlow = !!(opts && opts.collectFlow);
+      const originalContent = this.dom.content;
+      // Temporarily redirect this.dom.content for reuse of internal logic.
+      try { this.dom.content = target; } catch {}
+      try {
+        const before = this._mounted;
+        this._mountDomContent(domSpec);
+        const rec = this._mounted;
+        // If we mounted in move-mode, _mounted holds record; detach it from single-mode tracking.
+        if (collectFlow && rec && rec.kind === 'move') {
+          this._flowMountedList.push(rec);
+          this._mounted = before; // restore previous single record (usually null)
+        }
+      } finally {
+        try { this.dom.content = originalContent; } catch {}
+      }
+    }
+
+    _unmountFlowMounted() {
+      // Restore all moved DOM nodes for flow steps
+      const list = Array.isArray(this._flowMountedList) ? this._flowMountedList : [];
+      this._flowMountedList = [];
+      list.forEach((m) => {
+        try {
+          if (!m || m.kind !== 'move') return;
+          // Reuse single unmount logic by swapping _mounted
+          const prev = this._mounted;
+          this._mounted = m;
+          this._unmountDomContent();
+          this._mounted = prev;
+        } catch {}
+      });
+    }
+
     _mountDomContent(domSpec) {
       try {
         if (!this.dom.content) return;
@@ -813,6 +1039,26 @@
         if (!('cancelButtonText' in step) && merged.showCancelButton) merged.cancelButtonText = merged.cancelButtonText || 'Cancel';
       }
 
+      // Icon/animation policy for flow:
+      // - During steps: no icon/animations by default (avoids distraction + layout jitter)
+      // - Allow icon only if step explicitly uses `icon:'question'`, or step is marked as summary.
+      const isSummary = !!(step && (step.summary === true || step.isSummary === true));
+      const explicitIcon = ('icon' in step) ? step.icon : undefined;
+      const baseIcon = ('icon' in base) ? base.icon : undefined;
+      const chosenIcon = (explicitIcon !== undefined) ? explicitIcon : baseIcon;
+
+      if (!isSummary && !isLast) {
+        merged.icon = (chosenIcon === 'question') ? 'question' : null;
+        merged.iconAnimation = false;
+      } else if (!isSummary && isLast) {
+        // last step: still suppress unless summary or question
+        merged.icon = (chosenIcon === 'question') ? 'question' : null;
+        merged.iconAnimation = false;
+      } else {
+        // summary step: allow icon; animation follows explicit config (default true only if provided elsewhere)
+        if (!('icon' in step) && chosenIcon === undefined) merged.icon = null;
+      }
+
       return merged;
     }
 
@@ -856,47 +1102,139 @@
     }
 
     async _flowGo(index, direction) {
-      const next = this._getFlowStepOptions(index);
-      this._flowIndex = index;
-      await this._transitionTo(next, direction);
+      const next = (this._flowResolved && this._flowResolved[index]) ? this._flowResolved[index] : this._getFlowStepOptions(index);
+      await this._transitionToFlow(index, next, direction);
     }
 
-    async _transitionTo(nextOptions, direction) {
-      // Animate popup swap (keep overlay)
+    async _transitionToFlow(nextIndex, nextOptions, direction) {
       const popup = this.dom && this.dom.popup;
-      if (!popup) {
+      const content = this.dom && this.dom.content;
+      const panes = this.dom && this.dom.stepPanes;
+      if (!popup || !content || !panes || !panes.length) {
+        this._flowIndex = nextIndex;
         this.params = nextOptions;
-        this._rerenderInside();
+        this._render({ flow: true });
         return;
       }
 
-      // First, unmount moved DOM from current step so it can be remounted later
-      try { this._unmountDomContent(); } catch {}
+      const fromIndex = this._flowIndex;
+      const fromPane = panes[fromIndex];
+      const toPane = panes[nextIndex];
+      if (!fromPane || !toPane) {
+        this._flowIndex = nextIndex;
+        this.params = nextOptions;
+        this._render({ flow: true });
+        return;
+      }
 
-      const outKeyframes = [
-        { opacity: 1, transform: 'translateY(0px)' },
-        { opacity: 0.55, transform: `translateY(${direction === 'prev' ? '8px' : '-8px'})` }
-      ];
-      const inKeyframes = [
-        { opacity: 0.55, transform: `translateY(${direction === 'prev' ? '-8px' : '8px'})` },
-        { opacity: 1, transform: 'translateY(0px)' }
-      ];
+      // Measure current content height
+      const oldH = content.getBoundingClientRect().height;
+
+      // Prepare target pane for measurement without affecting layout
+      const prevDisplay = toPane.style.display;
+      const prevPos = toPane.style.position;
+      const prevVis = toPane.style.visibility;
+      const prevPointer = toPane.style.pointerEvents;
+      toPane.style.display = '';
+      toPane.style.position = 'absolute';
+      toPane.style.visibility = 'hidden';
+      toPane.style.pointerEvents = 'none';
+      toPane.style.left = '0';
+      toPane.style.right = '0';
+
+      const newH = toPane.getBoundingClientRect().height;
+
+      // Restore pane styles (keep hidden until animation starts)
+      toPane.style.position = prevPos;
+      toPane.style.visibility = prevVis;
+      toPane.style.pointerEvents = prevPointer;
+      toPane.style.display = prevDisplay; // usually 'none'
+
+      // Apply new options (title/buttons/icon policy) before showing the pane
+      this._flowIndex = nextIndex;
+      this.params = nextOptions;
 
+      // Update icon/title/buttons without recreating DOM
       try {
-        const a = popup.animate(outKeyframes, { duration: 140, easing: 'ease-out', fill: 'forwards' });
-        await (a && a.finished ? a.finished.catch(() => {}) : Promise.resolve());
+        if (this.dom.title) this.dom.title.textContent = this.params.title || '';
       } catch {}
 
-      // Apply new options
-      this.params = nextOptions;
-      this._rerenderInside();
+      // Icon updates (only if needed)
+      try {
+        const wantsIcon = !!this.params.icon;
+        if (!wantsIcon && this.dom.icon) {
+          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();
+            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);
+          }
+        }
+      } catch {}
+
+      this._updateFlowActions();
+
+      // Switch panes with smooth opacity/translate; animate content height to reduce jitter
+      const dy = (direction === 'prev') ? -8 : 8;
+
+      // Show both panes during animation (stacked)
+      toPane.style.display = '';
+      toPane.style.position = 'relative';
+      toPane.style.opacity = '0';
+      toPane.style.transform = `translateY(${dy}px)`;
+      toPane.style.willChange = 'opacity, transform';
+      fromPane.style.willChange = 'opacity, transform';
 
+      // Lock content height during transition
+      content.style.height = oldH + 'px';
+      content.style.overflow = 'hidden';
+
+      // Animate height
+      let heightAnim = null;
+      try {
+        heightAnim = content.animate(
+          [{ height: oldH + 'px' }, { height: newH + 'px' }],
+          { duration: 220, easing: 'cubic-bezier(0.2, 0.9, 0.2, 1)', fill: 'forwards' }
+        );
+      } catch {}
+
+      // Animate panes
       try {
-        const b = popup.animate(inKeyframes, { duration: 200, easing: 'cubic-bezier(0.2, 0.9, 0.2, 1)', fill: 'forwards' });
-        await (b && b.finished ? b.finished.catch(() => {}) : Promise.resolve());
+        fromPane.animate(
+          [{ opacity: 1, transform: 'translateY(0px)' }, { opacity: 0, transform: `translateY(${-dy}px)` }],
+          { duration: 160, easing: 'ease-out', fill: 'forwards' }
+        );
       } catch {}
 
-      // Re-adjust icon ring bg in case popup background differs
+      let paneIn = null;
+      try {
+        paneIn = toPane.animate(
+          [{ opacity: 0, transform: `translateY(${dy}px)` }, { opacity: 1, transform: 'translateY(0px)' }],
+          { duration: 220, easing: 'cubic-bezier(0.2, 0.9, 0.2, 1)', fill: 'forwards' }
+        );
+      } catch {}
+
+      try {
+        await Promise.all([
+          heightAnim && heightAnim.finished ? heightAnim.finished.catch(() => {}) : Promise.resolve(),
+          paneIn && paneIn.finished ? paneIn.finished.catch(() => {}) : Promise.resolve()
+        ]);
+      } catch {}
+
+      // Cleanup styles and hide old pane
+      fromPane.style.display = 'none';
+      fromPane.style.willChange = '';
+      toPane.style.willChange = '';
+      toPane.style.opacity = '';
+      toPane.style.transform = '';
+      content.style.height = '';
+      content.style.overflow = '';
+
+      // Re-adjust ring background if icon exists (rare in flow)
       try { this._adjustRingBackgroundColor(); } catch {}
     }
 

+ 10 - 1
main.go

@@ -38,7 +38,16 @@ func main() {
 func serve() {
 	// Serve files from the current directory
 	fs := http.FileServer(http.Dir("."))
-	http.Handle("/", fs)
+	noCache := func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			// Dev server: disable cache to avoid stale JS/CSS/HTML in iframes.
+			w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
+			w.Header().Set("Pragma", "no-cache")
+			w.Header().Set("Expires", "0")
+			next.ServeHTTP(w, r)
+		})
+	}
+	http.Handle("/", noCache(fs))
 
 	log.Println("Listening on :8080...")
 	if err := http.ListenAndServe(":8080", nil); err != nil {

+ 1 - 0
xjs.css

@@ -56,6 +56,7 @@
   margin-bottom: 1.5em;
   text-align: center;
   line-height: 1.5;
+  width: 100%;
 }
 .layer-actions {
   display: flex;