← Commit history

2.15.2: durable recall; module 2.1.2

John Lauer ·7cccc95b99 ·1mo ago ·parent e220771
5 files changed +114−31
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.15.0"+version = "2.15.2" edition = "2021" description = "adom-lbr generator / EDA translator: the canonical adom-lbr JSON is the hub, with native converters to Altium (.SchLib/.PcbLib/.IntLib), EAGLE/Fusion (.lbr), and KiCad. Altium output is written natively — no Altium needed at conversion time." 
VERSION+1−1
@@ -1 +1 @@-2.15.0+2.15.2
src/manage.html+26−14
@@ -811,21 +811,26 @@ function gradAiDriven(){ if(!gradState().aiDriven){gradSave({aiDriven:true});app function panelOpen(){   const g=gradState(),n=g.launches||0,dism=(g.dismissed||{}),ai=!!g.aiDriven;   const allowed=settings?settings.show_say_strip!==false:true;-  return gradRecall||(allowed&&!dism.say&&!ai&&n<5);+  if(gradRecall)return true;         // brought back in THIS session+  if(!allowed)return false;          // switched off in settings+  if(dism.say)return false;          // the user closed it, and that is durable+  if(g.recalled)return true;         // the user brought it back BEFORE, which also has to be+                                     // durable: graduation must not re-fold a panel someone+                                     // deliberately reopened, or the ? button looks broken+                                     // one reload later.+  return !ai&&n<5;                   // otherwise the policy decides } // The module owns the panel's open/closed rendering, its X, and the header toggle's // aria-pressed and label. What stays here is GRADUATION, which is this app's policy: after // enough green launches, or once the user drives by AI, the panel folds on its own. So this // function no longer touches the panel's DOM, it asks the handle. let npanel=null;-// Programmatic open/close must NOT look like a user decision. applyGraduation() drives the-// module, the module calls onToggle, and onToggle records intent: without this guard,-// graduation opening the panel would set gradRecall and pin it open forever, and graduation-// closing it would mark it user-dismissed. A feedback loop, not a toggle.-let npApplying=false; function applyGraduation(){   const open=panelOpen();-  if(npanel){ npApplying=true; try{ open?npanel.open():npanel.close(); } finally { npApplying=false; } }+  // remember:false, because graduation deciding to fold the panel is not the user choosing+  // to dismiss it, and writing it to the stored preference would make a policy decision+  // permanent. The module tells onToggle which of us moved it (newbie-panel 2.1.0).+  if(npanel) open?npanel.open({remember:false}):npanel.close({remember:false});   // aria-pressed comes from the module; the VISIBLE difference is the app's own affordance,   // because a toggle that looks identical in both states is a lie (input-feedback skill).   document.getElementById('btnHelp').classList.toggle('on',open);@@ -857,14 +862,21 @@ async function mountNewbie(){     toggleButton:'#btnHelp',     demo:{label:'Play the demo', meta:'13 steps, about 2 minutes', onPlay:()=>{tourPlaying=true;tourShow(0);}},     phrases:phrases,-    onToggle:open=>{-      // The module's X and the header toggle drive the same state; graduation has to hear-      // about a USER close or the panel comes back on the next reload. Programmatic changes-      // are skipped (see npApplying), because they are graduation talking to itself.+    onToggle:(open,source)=>{+      // aria-pressed, aria-label and the tooltip are the module's; the visible on-state is+      // this app's own affordance, and it tracks BOTH sources.       document.getElementById('btnHelp').classList.toggle('on',open);-      if(npApplying)return;-      if(!open){ const g=gradState(); gradSave({dismissed:Object.assign(g.dismissed||{},{say:true})}); gradRecall=false; }-      else { gradRecall=true; }+      // Only a HUMAN closing the panel counts as dismissing it. 'api' means graduation is+      // talking to itself, and recording that as intent pins the panel open (or shut)+      // forever. The module reports the source so this no longer needs a flag app-side.+      if(source!=='user')return;+      // A user decision is DURABLE, both ways, and it is written where panelOpen() reads it+      // so the module's stored preference and this app's policy cannot disagree after a+      // reload. gradRecall alone was session-only: reopening the panel worked, then+      // graduation folded it again on the next load and the control looked broken.+      const g=gradState();+      if(!open){ gradSave({dismissed:Object.assign(g.dismissed||{},{say:true}),recalled:false}); gradRecall=false; }+      else { gradSave({dismissed:Object.assign(g.dismissed||{},{say:false}),recalled:true}); gradRecall=true; }     }   });   applyGraduation();
src/newbie-panel.js+44−15
@@ -154,6 +154,7 @@   function tipFor(node, text) {     node.setAttribute('aria-label', text);     node.removeAttribute('title');          // two tooltips on one control is a bug+    node.__anpSetTip = function (t) { text = t; if (tip && tip.classList.contains('on')) tip.textContent = t; };     node.addEventListener('mouseenter', function (e) {       clearTimeout(tipT);       tipT = setTimeout(function () {@@ -307,21 +308,32 @@     /* the header ? is a real TOGGLE, both ways, with aria-pressed and a label that swaps */     var toggleBtn = typeof cfg.toggleButton === 'string'       ? document.querySelector(cfg.toggleButton) : cfg.toggleButton;-    function setOpen(open, remember) {+    /* setOpen(open, remember, source)+     * source is 'user' when a person clicked the X or the header toggle, and 'api' when the+     * app called open()/close() itself. It is passed to onToggle, because an app with a+     * graduation policy calls open()/close() from inside its own onToggle handler: without a+     * source the callback cannot tell its own action from a person's, so graduating the panel+     * open pins it open forever and graduating it closed marks it user-dismissed. It looks+     * like "the panel sometimes ignores graduation". (lbr, 2026-08-25) */+    function setOpen(open, remember, source) {       panel.hidden = !open;       wrap.style.display = open ? '' : 'none';   /* no dead margin when collapsed */       if (toggleBtn) {+        var label = open ? 'Hide the sample prompts' : 'Show the sample prompts';         toggleBtn.setAttribute('aria-pressed', open ? 'true' : 'false');-        toggleBtn.setAttribute('aria-label', open ? 'Hide the sample prompts' : 'Show the sample prompts');+        toggleBtn.setAttribute('aria-label', label);+        /* the TOOLTIP has to swap with the state too, or a person with the panel already in+         * front of them hovers the control and is told it will show them the panel */+        if (toggleBtn.__anpSetTip) toggleBtn.__anpSetTip(label);       }       if (remember !== false) { try { localStorage.setItem(key, open ? '1' : '0'); } catch (e) {} }-      if (typeof cfg.onToggle === 'function') cfg.onToggle(open);+      if (typeof cfg.onToggle === 'function') cfg.onToggle(open, source || 'api');     }-    x.addEventListener('click', function () { setOpen(false); });+    x.addEventListener('click', function () { setOpen(false, true, 'user'); });     if (toggleBtn) {       if (!toggleBtn.innerHTML.trim()) toggleBtn.innerHTML = ICON.help;       tipFor(toggleBtn, 'Show the sample prompts');-      toggleBtn.addEventListener('click', function () { setOpen(panel.hidden); });+      toggleBtn.addEventListener('click', function () { setOpen(panel.hidden, true, 'user'); });     }     var saved = null;     try { saved = localStorage.getItem(key); } catch (e) {}@@ -345,8 +357,11 @@     }      return {-      panel: panel, open: function () { setOpen(true); }, close: function () { setOpen(false); },-      toggle: function () { setOpen(panel.hidden); }, isOpen: function () { return !panel.hidden; },+      panel: panel,+      open:   function (o) { setOpen(true,  !(o && o.remember === false), 'api'); },+      close:  function (o) { setOpen(false, !(o && o.remember === false), 'api'); },+      toggle: function () { setOpen(panel.hidden, true, 'api'); },+      isOpen: function () { return !panel.hidden; },       overBudget: over, toast: toast, selfCheck: function () { return selfCheck(panel); }     };   }@@ -361,6 +376,10 @@     'card padding':         ['padding',         '18px 20px 20px'],     'column gap':           ['columnGap',       '24px']   };+  /* selfCheck(panel) - pass the panel explicitly when the page mounts more than one, or+   * mounts into a shadow root. The no-arg form takes the FIRST .anp in the light DOM, which+   * is right for the single-panel case and silently wrong for the others. Returns+   * {ok:false, error} rather than throwing when there is no panel. (chip-fetcher, 2026-08-25) */   function selfCheck(panel) {     panel = panel || document.querySelector('.anp');     if (!panel) return { ok: false, error: 'no .anp panel on the page' };@@ -374,13 +393,23 @@     }     Object.keys(CANON).forEach(function (k) {       var want = CANON[k][1];-      if (want.indexOf('snap:') === 0) want = snap(parseFloat(want.slice(5)));-      var got = cs[CANON[k][0]];-      /* compare numerically with a sub-pixel tolerance: Chrome prints 0.666667px where the-       * arithmetic gives 0.6666666666, and a string compare fails a panel that is correct */-      var pass = (parseFloat(want) && parseFloat(got))-        ? Math.abs(parseFloat(got) - parseFloat(want)) < 0.01-        : got === want;+      var got = cs[CANON[k][0]], declared = CANON[k][1];+      /* Border widths need BOTH candidates accepted. Chrome reports the USED width, snapped+       * to whole device pixels, on some surfaces (ah's webview and pup on a dpr-1.5 laptop+       * both return 0.666667px for a declared 1px) and reports the DECLARED width on others+       * (an emulated dpr returns 1px at every ratio). Accepting only one of the two fails a+       * correct panel in the other environment, and at dpr 1.5 specifically, which is the+       * machine this fleet is reviewed on. Found by nb, 2026-08-25. */+      var pass;+      if (declared.indexOf('snap:') === 0) {+        var d = parseFloat(declared.slice(5)), g = parseFloat(got);+        pass = Math.abs(g - d) < 0.01 || Math.abs(g - parseFloat(snap(d))) < 0.01;+        want = d + 'px or ' + snap(d);+      } else {+        pass = (parseFloat(want) && parseFloat(got))+          ? Math.abs(parseFloat(got) - parseFloat(want)) < 0.01+          : got === want;+      }       if (!pass) ok = false;       rows.push({ what: k, want: want, got: got, pass: pass });     });@@ -399,6 +428,6 @@     return { ok: ok, rows: rows };   } -  root.AdomNewbiePanel = { mount: mount, toast: toast, selfCheck: selfCheck, version: '1.0.0',+  root.AdomNewbiePanel = { mount: mount, toast: toast, selfCheck: selfCheck, version: '2.1.2',                            TWO_COL_MIN: TWO_COL_MIN, PHRASE_BUDGET: PHRASE_BUDGET }; })(typeof window !== 'undefined' ? window : this);
tools/page-smoke.js+42
@@ -88,6 +88,48 @@ const EXPECT = {     console.log('[smoke] FAIL javascript errors on load (including ones the page swallows):');     [...new Set(real)].slice(0, 6).forEach(e => console.log('        ' + e.slice(0, 160)));   }+  // GRADUATION IS NOT DISMISSAL. The panel folding on its own after enough launches must+  // not be recorded as the user dismissing it, or the fold becomes permanent. The module+  // reports who moved the panel (newbie-panel 2.1.0 onToggle(open, source)); this asserts+  // this app reads it correctly, because the failure is silent and only shows up as a panel+  // that never comes back.+  try {+    const w = dom.window;+    const gradKey = Object.keys(w.localStorage).find(k => /graduation/.test(k));+    const readGrad = () => gradKey ? w.localStorage.getItem(gradKey) : '';+    // `let npanel` at script top level is NOT a window property, so w.npanel is undefined+    // and an `if (w.npanel)` guard skips this check in silence, which is the same silent+    // pass this whole file exists to abolish. Reach it through eval, in script scope.+    const hasHandle = w.eval("typeof npanel !== 'undefined' && !!npanel");+    if (!hasHandle) {+      bad++;+      console.log('[smoke] FAIL no panel handle in script scope: cannot verify graduation vs dismissal');+    } else {+      w.eval("npanel.close({remember:false})");+      const afterApi = readGrad();+      w.eval("npanel.open({remember:false})");+      const xBtn = d.querySelector('.anp-x');+      if (xBtn) xBtn.dispatchEvent(new w.MouseEvent('click', { bubbles: true }));+      const afterUser = readGrad();+      const apiClean = !/dismissed/.test(afterApi || '');+      const userRecorded = /dismissed/.test(afterUser || '');+      if (!apiClean) { bad++; console.log('[smoke] FAIL a programmatic close was recorded as a user dismissal'); }+      if (!userRecorded) { bad++; console.log('[smoke] FAIL a user close was NOT recorded, so the panel returns after dismissal'); }+      if (apiClean && userRecorded) console.log('[smoke] ok   graduation close vs user dismissal are distinguished');+      // ...and a user REOPEN has to survive a reload. The setup matters: with a FRESH+      // profile the policy would keep the panel open anyway, so the check passes for the+      // wrong reason and cannot fail. (It did exactly that on its first run, and only the+      // negative control exposed it.) Graduate the user first, so the policy actively wants+      // the panel folded and only a durable recall can keep it open.+      w.eval("gradSave({launches:5})");+      const tgl = d.querySelector('#btnHelp');+      if (tgl) tgl.dispatchEvent(new w.MouseEvent('click', { bubbles: true }));+      const survives = w.eval("(function(){gradRecall=false;return panelOpen();})()");+      if (!survives) { bad++; console.log('[smoke] FAIL a user reopen does not survive a reload: graduation re-folds it'); }+      else console.log('[smoke] ok   a user reopen survives a reload');+    }+  } catch (e) { console.log('[smoke] note: could not exercise the toggle: ' + e.message); }+   // The module proves its own conformance. jsdom has no layout engine, so the geometric   // rows are meaningless here and only the structural verdict is worth reading; the real   // selfCheck has to be run in ah's webview. Reported, never used as the pass condition.