@enigmax/dashboard 0.1.61 → 0.1.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/assets/index.html +367 -48
  2. package/package.json +1 -1
package/assets/index.html CHANGED
@@ -200,19 +200,92 @@
200
200
  background: var(--surface2); color: var(--text); border: 1px solid var(--border);
201
201
  border-radius: var(--radius-sm); padding: 6px 10px; font-size: 12px; font-family: var(--mono); cursor: pointer;
202
202
  }
203
- .set-note { color: var(--accent); font-size: 12px; min-height: 16px; margin-top: 10px; }
204
- /* A note must read as success vs. failure at a glance, not the same accent for both. */
203
+ /* Top-of-page "update available" banner: spans the content width, amber-tinted so it
204
+ reads as a notice without alarming. Hidden until the stats poll reports an update. */
205
+ .update-alert {
206
+ display: flex; align-items: center; gap: 12px;
207
+ max-width: 1040px; margin: 16px auto -8px; padding: 11px 16px;
208
+ background: rgba(215,135,95,0.10); border: 1px solid rgba(215,135,95,0.45);
209
+ border-radius: var(--radius-sm); color: var(--text); font-size: 13px;
210
+ }
211
+ .update-alert[hidden] { display: none; }
212
+ .update-alert .ua-ico { width: 18px; height: 18px; color: var(--accent); flex: none; }
213
+ .update-alert .ua-msg { flex: 1; }
214
+ .update-alert .ua-x { background: none; border: none; color: var(--muted); font-size: 20px; line-height: 1; cursor: pointer; padding: 0 4px; }
215
+ .update-alert .ua-x:hover { color: var(--text); }
216
+ /* Status notes now surface as toasts; the inline element stays for call compatibility but
217
+ collapses when empty so no leftover green/red line shows. */
218
+ .set-note { color: var(--accent); font-size: 12px; margin-top: 10px; }
219
+ .set-note:empty { display: none; margin: 0; }
205
220
  .set-note.ok { color: var(--good); }
206
221
  .set-note.err { color: var(--bad); }
207
- /* Toasts: failures are easy to miss in a small inline note, so every error is also
208
- raised here - fixed, coloured, and self-dismissing - so a failed action is unmissable. */
209
- #toasts { position: fixed; right: 18px; bottom: 18px; display: flex; flex-direction: column; gap: 8px; z-index: 9999; max-width: 360px; }
210
- .toast { padding: 11px 14px; border-radius: 10px; font-size: 13px; line-height: 1.35; color: var(--text);
211
- background: var(--surface2); border: 1px solid var(--border); box-shadow: var(--shadow-1); animation: toastIn 0.18s ease-out; }
212
- .toast.err { border-color: var(--bad); }
213
- .toast.ok { border-color: var(--good); }
214
- .toast.hide { opacity: 0; transition: opacity 0.3s; }
215
- @keyframes toastIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
222
+ /* Toasts (Sonner-style, ported to vanilla). Stacked bottom-right: the newest is in front,
223
+ older ones peek behind and fan out on hover. Rich-coloured per type, self-dismissing,
224
+ swipe- or click-to-close - so a result is never a cutre green line again. */
225
+ #toaster {
226
+ position: fixed; right: var(--t-offset); bottom: var(--t-offset); z-index: 999999;
227
+ width: var(--t-width); list-style: none; margin: 0; padding: 0; outline: none;
228
+ --t-offset: 24px; --t-width: 356px; --t-gap: 14px; --t-front: 0px;
229
+ font-family: var(--sans);
230
+ --t-bg: var(--surface); --t-border: var(--border); --t-text: var(--text);
231
+ --success-bg: hsl(150,38%,9%); --success-border: hsl(147,55%,18%); --success-text: hsl(150,80%,72%);
232
+ --error-bg: hsl(358,42%,12%); --error-border: hsl(357,55%,24%); --error-text: hsl(358,100%,82%);
233
+ --info-bg: hsl(215,45%,11%); --info-border: hsl(223,50%,24%); --info-text: hsl(216,87%,74%);
234
+ --warning-bg: hsl(40,55%,9%); --warning-border: hsl(40,55%,20%); --warning-text: hsl(46,87%,70%);
235
+ transition: transform .35s ease;
236
+ }
237
+ #toaster[data-expanded="true"] { transform: translateY(-6px); }
238
+ .etoast {
239
+ position: absolute; bottom: 0; right: 0; left: 0; width: 100%; box-sizing: border-box;
240
+ display: flex; align-items: center; gap: 10px; padding: 15px 16px;
241
+ background: var(--t-bg); border: 1px solid var(--t-border); color: var(--t-text);
242
+ border-radius: 12px; box-shadow: 0 6px 22px rgba(0,0,0,.34); font-size: 13px;
243
+ z-index: var(--z); opacity: 0; touch-action: none; overflow-wrap: anywhere;
244
+ transform: translateY(110%); transform-origin: bottom center;
245
+ transition: transform .42s cubic-bezier(.21,1.02,.73,1), opacity .35s, height .35s, box-shadow .2s;
246
+ }
247
+ .etoast[data-mounted="true"] { opacity: 1; transform: translateY(0); }
248
+ /* Collapsed (default): older toasts sit behind the front one, scaled + clipped to its height. */
249
+ .etoast[data-front="false"][data-expanded="false"] {
250
+ transform: translateY(calc(var(--toasts-before) * -14px)) scale(calc(1 - var(--toasts-before) * .06));
251
+ height: var(--t-front);
252
+ }
253
+ .etoast[data-front="false"][data-expanded="false"] > * { opacity: 0; }
254
+ /* Expanded (hover): fan out, each lifted by the summed height of the ones in front of it. */
255
+ .etoast[data-expanded="true"] { transform: translateY(calc(-1 * var(--offset))); }
256
+ .etoast[data-visible="false"] { opacity: 0; pointer-events: none; }
257
+ .etoast[data-removed="true"] { opacity: 0; }
258
+ .etoast[data-removed="true"][data-expanded="false"] { transform: translateY(150%) scale(.9); }
259
+ .etoast[data-removed="true"][data-expanded="true"] { transform: translateY(calc(-1 * var(--offset) + 110%)); }
260
+ .etoast[data-swiping="true"] { transition: opacity .2s; transform: translateX(var(--swipe-x, 0px)); }
261
+ .etoast > * { transition: opacity .35s; }
262
+ .etoast-icon { display: flex; align-items: center; flex-shrink: 0; width: 20px; height: 20px; }
263
+ .etoast-icon svg { width: 20px; height: 20px; display: block; }
264
+ .etoast-content { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1; }
265
+ .etoast-title { font-weight: 600; line-height: 1.4; }
266
+ .etoast-desc { font-size: 12px; line-height: 1.45; color: var(--muted); }
267
+ .etoast-close {
268
+ position: absolute; top: 0; left: 0; transform: translate(-35%,-35%);
269
+ width: 20px; height: 20px; display: flex; align-items: center; justify-content: center;
270
+ padding: 0; border-radius: 50%; cursor: pointer; opacity: 0; z-index: 1;
271
+ background: var(--t-bg); border: 1px solid var(--t-border); color: var(--t-text);
272
+ transition: opacity .15s, background .2s, border-color .2s;
273
+ }
274
+ .etoast:hover .etoast-close, .etoast-close:focus-visible { opacity: 1; }
275
+ .etoast-close:hover { background: var(--surface2); }
276
+ .etoast[data-type="success"] { background: var(--success-bg); border-color: var(--success-border); color: var(--success-text); }
277
+ .etoast[data-type="error"] { background: var(--error-bg); border-color: var(--error-border); color: var(--error-text); }
278
+ .etoast[data-type="info"] { background: var(--info-bg); border-color: var(--info-border); color: var(--info-text); }
279
+ .etoast[data-type="warning"] { background: var(--warning-bg); border-color: var(--warning-border); color: var(--warning-text); }
280
+ .etoast[data-type="success"] .etoast-desc, .etoast[data-type="error"] .etoast-desc,
281
+ .etoast[data-type="info"] .etoast-desc, .etoast[data-type="warning"] .etoast-desc { color: inherit; opacity: .82; }
282
+ .etoast[data-type] .etoast-close { background: inherit; border-color: currentColor; color: inherit; }
283
+ .etoast-spin { width: 18px; height: 18px; animation: etoast-spin 0.9s linear infinite; transform-origin: center; }
284
+ @keyframes etoast-spin { to { transform: rotate(360deg); } }
285
+ @media (max-width: 600px) {
286
+ #toaster { right: 14px; left: 14px; width: auto; --t-width: 100%; }
287
+ }
288
+ @media (prefers-reduced-motion) { .etoast, .etoast > * { transition: opacity .2s !important; } }
216
289
  /* Settings: two-column grid of rows on wide screens, so a long config list stays compact. */
217
290
  .set-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); gap: 0 32px; }
218
291
  .set-grid .set-row { padding: 10px 0; }
@@ -245,6 +318,9 @@
245
318
  .btn-danger.icon { display: inline-flex; align-items: center; justify-content: center; padding: 6px 9px; }
246
319
  .btn-danger.icon:hover { color: var(--bad); border-color: var(--bad); }
247
320
  .btn-danger svg { width: 15px; height: 15px; display: block; }
321
+ /* Per-memory delete in the Recall list: kept to the right and revealed on row hover/focus. */
322
+ .recall-del { margin-left: auto; opacity: 0; transition: opacity .12s; }
323
+ .set-row:hover .recall-del, .recall-del:focus-visible { opacity: 1; }
248
324
  /* Per-agent skill toggles: one pill per app (the app's icon), green when the skill is on for it; the app name is the tooltip. */
249
325
  .set-agents { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
250
326
  .achip { display: inline-flex; align-items: center; justify-content: center; padding: 5px 8px; border-radius: 999px; border: 1px solid var(--border); background: var(--surface2); color: var(--muted); cursor: pointer; text-decoration: none; transition: color .15s, background .15s, border-color .15s; }
@@ -394,6 +470,19 @@
394
470
  .dd-opt { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; font-size: 12px; color: var(--text); white-space: nowrap; cursor: pointer; }
395
471
  .dd-opt:hover, .dd-opt:focus { background: var(--surface2); outline: none; }
396
472
  .dd-opt.sel { color: var(--accent); }
473
+ .dd-btn:focus-visible { outline: none; border-color: var(--accent); }
474
+ /* Enhanced native <select>s (enhanceSelect): same look, but they hug their content instead
475
+ of the wider provider picker, and fill a flex slot when the original select did. */
476
+ .ddx { display: inline-flex; vertical-align: middle; }
477
+ .ddx .dd-btn { min-width: 120px; }
478
+ .ddx .dd-menu { max-height: 320px; overflow-y: auto; }
479
+ /* Native selects that stay native (inside modals) get a custom caret so they match the
480
+ styled dropdowns - no default OS chevron. */
481
+ select.linput, select.modal-input, select.scope-sel, select.choice {
482
+ -webkit-appearance: none; -moz-appearance: none; appearance: none;
483
+ background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='13' height='13' viewBox='0 0 24 24' fill='none' stroke='%23808a99' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>");
484
+ background-repeat: no-repeat; background-position: right 9px center; padding-right: 28px;
485
+ }
397
486
  /* Icon picker: clickable app cards (a native <select> can't render SVG in options). */
398
487
  .modal-picker { display: flex; flex-direction: column; gap: 8px; margin-top: 14px; }
399
488
  .pick-card {
@@ -445,6 +534,13 @@
445
534
  </div>
446
535
  </header>
447
536
 
537
+ <div id="updateAlert" class="update-alert" hidden>
538
+ <svg class="ua-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56"/><path d="M12 7v5l3 2"/></svg>
539
+ <span class="ua-msg" id="updateAlertMsg"></span>
540
+ <button type="button" id="updateAlertBtn" class="toggle on">Update now</button>
541
+ <button type="button" id="updateAlertDismiss" class="ua-x" title="Dismiss" aria-label="Dismiss">&times;</button>
542
+ </div>
543
+
448
544
  <main id="view-savings">
449
545
  <div class="grid">
450
546
  <div class="card hero"><div class="label">Money Saved</div><div id="money" class="value good">-</div><div id="moneyFoot" class="foot">Estimated, by source</div></div>
@@ -1995,23 +2091,28 @@ export {
1995
2091
  try { await fetch("/api/plan", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, value: Number(value) }) }); refreshAll(true); }
1996
2092
  catch { /* leave as-is */ }
1997
2093
  }
1998
- // Accessible icon dropdown (a native <select> can't render icons in its <option>s). The
1999
- // button shows the selected entry's icon + label; the listbox shows every entry with its
2000
- // icon. entries: [{ value, label, icon }]; getVal()/onPick(value) bind it to state.
2001
- function iconDropdown(rootId, entries, getVal, onPick) {
2002
- const root = $(rootId), btn = root.querySelector(".dd-btn"), menu = root.querySelector(".dd-menu");
2094
+ // Shared dropdown core. Renders one styled listbox over a `.dd` root (button + menu) from a
2095
+ // model: entries() -> [{ value, label, icon }], get() -> current value, set(value) -> commit.
2096
+ // Icons are optional per entry, so the SAME component drives the icon pickers (provider,
2097
+ // usage) AND every plain selector on the page (see enhanceSelect) - one look, one set of
2098
+ // keyboard semantics, with or without logos.
2099
+ function bindDropdown(root, model) {
2100
+ const btn = root.querySelector(".dd-btn"), menu = root.querySelector(".dd-menu");
2003
2101
  const btnIc = btn.querySelector(".dd-ic"), btnLabel = btn.querySelector(".dd-label");
2004
2102
  const opts = () => [...menu.querySelectorAll(".dd-opt")];
2103
+ const ic = (svg) => '<span class="dd-ic"' + (svg ? "" : ' style="display:none"') + ">" + (svg || "") + "</span>";
2005
2104
  const render = () => {
2006
- const cur = getVal(), e = entries.find((x) => x.value === cur) || entries[0];
2007
- btnIc.innerHTML = e.icon || ""; btnLabel.textContent = e.label;
2105
+ const entries = model.entries(), cur = model.get();
2106
+ const e = entries.find((x) => x.value === cur) || entries[0] || { label: "", icon: "" };
2107
+ btnIc.innerHTML = e.icon || ""; btnIc.style.display = e.icon ? "" : "none";
2108
+ btnLabel.textContent = e.label || "";
2008
2109
  menu.innerHTML = entries.map((x) => '<li class="dd-opt' + (x.value === cur ? " sel" : "")
2009
2110
  + '" role="option" tabindex="-1" data-val="' + esc(x.value) + '" aria-selected="' + (x.value === cur)
2010
- + '"><span class="dd-ic">' + (x.icon || "") + "</span><span>" + esc(x.label) + "</span></li>").join("");
2111
+ + '">' + ic(x.icon) + "<span>" + esc(x.label) + "</span></li>").join("");
2011
2112
  };
2012
2113
  const close = () => { menu.hidden = true; btn.setAttribute("aria-expanded", "false"); };
2013
- const open = () => { menu.hidden = false; btn.setAttribute("aria-expanded", "true"); (menu.querySelector(".sel") || opts()[0]) && (menu.querySelector(".sel") || opts()[0]).focus(); };
2014
- const pick = (li) => { close(); btn.focus(); if (li && li.dataset.val) onPick(li.dataset.val); };
2114
+ const open = () => { render(); menu.hidden = false; btn.setAttribute("aria-expanded", "true"); const f = menu.querySelector(".sel") || opts()[0]; if (f) f.focus(); };
2115
+ const pick = (li) => { close(); btn.focus(); if (li && li.dataset.val != null) { model.set(li.dataset.val); render(); } };
2015
2116
  btn.addEventListener("click", (e) => { e.stopPropagation(); menu.hidden ? open() : close(); });
2016
2117
  btn.addEventListener("keydown", (e) => { if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") { e.preventDefault(); open(); } });
2017
2118
  menu.addEventListener("click", (e) => pick(e.target.closest(".dd-opt")));
@@ -2021,11 +2122,65 @@ export {
2021
2122
  else if (e.key === "ArrowDown") { e.preventDefault(); (list[i + 1] || list[0]).focus(); }
2022
2123
  else if (e.key === "ArrowUp") { e.preventDefault(); (list[i - 1] || list[list.length - 1]).focus(); }
2023
2124
  else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); pick(document.activeElement); }
2125
+ else if (e.key === "Tab") close();
2024
2126
  });
2025
- document.addEventListener("click", close);
2026
2127
  render();
2027
2128
  return { render };
2028
2129
  }
2130
+ // One global outside-click closes any open dropdown - cheaper and leak-free vs a per-instance
2131
+ // listener (enhanceSelect can create/replace many over a session). A `.dd-btn` click stops
2132
+ // propagation, so opening one never trips this.
2133
+ document.addEventListener("click", () => {
2134
+ document.querySelectorAll(".dd-menu:not([hidden])").forEach((m) => {
2135
+ m.hidden = true;
2136
+ const b = m.parentElement && m.parentElement.querySelector(".dd-btn");
2137
+ if (b) b.setAttribute("aria-expanded", "false");
2138
+ });
2139
+ });
2140
+ // entries: [{ value, label, icon }] bound to a pre-rendered `.dd` root by id.
2141
+ function iconDropdown(rootId, entries, getVal, onPick) {
2142
+ return bindDropdown($(rootId), { entries: () => entries, get: getVal, set: onPick });
2143
+ }
2144
+ // Markup for an enhanced dropdown's button + menu, matching the hand-written provider picker.
2145
+ const DD_INNER = '<button type="button" class="dd-btn" aria-haspopup="listbox" aria-expanded="false"><span class="dd-ic"></span><span class="dd-label"></span><svg class="dd-caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg></button><ul class="dd-menu" role="listbox" hidden></ul>';
2146
+ // Progressive enhancement: replace a native <select> with the shared pretty dropdown while
2147
+ // keeping the <select> itself as the source of truth (hidden). Its .value and `change`
2148
+ // events are untouched - we set .value and dispatch a real `change` on pick - so every
2149
+ // existing reader and delegated handler keeps working. Icons are opt-in via
2150
+ // data-dd-icons="agent" (option value -> brand logo) or per-option data-icon="<key>".
2151
+ function enhanceSelect(select) {
2152
+ if (select.__dd || select.dataset.noDd != null) return;
2153
+ if (select.closest(".modal-back")) return; // a popup menu inside an animated modal would clip; left native (still styled)
2154
+ const iconMode = select.dataset.ddIcons || "";
2155
+ const iconFor = (opt) => { const k = opt.dataset.icon || (iconMode === "agent" ? opt.value : ""); return k ? (agentSvg(k) || "") : ""; };
2156
+ const root = document.createElement("span");
2157
+ root.className = "dd ddx";
2158
+ root.innerHTML = DD_INNER;
2159
+ select.style.display = "none";
2160
+ select.insertAdjacentElement("afterend", root);
2161
+ const api = bindDropdown(root, {
2162
+ entries: () => [...select.options].map((o) => ({ value: o.value, label: o.textContent, icon: iconFor(o) })),
2163
+ get: () => select.value,
2164
+ set: (v) => { if (v !== select.value) { select.value = v; select.dispatchEvent(new Event("change", { bubbles: true })); } api.render(); }
2165
+ });
2166
+ select.__dd = api;
2167
+ // Re-sync the button when options are rebuilt (innerHTML) or the value changes.
2168
+ new MutationObserver(() => api.render()).observe(select, { childList: true });
2169
+ select.addEventListener("change", () => api.render());
2170
+ return api;
2171
+ }
2172
+ // Push a programmatic `select.value = x` (no `change` event) into the enhanced dropdown.
2173
+ const syncDD = (el) => { if (el && el.__dd) el.__dd.render(); };
2174
+ function enhanceAll(root) { (root || document).querySelectorAll("select:not([data-no-dd])").forEach(enhanceSelect); }
2175
+ // Any selector added later (settings rows, project config, account profiles...) is enhanced
2176
+ // automatically, so there is nothing to wire per-view.
2177
+ new MutationObserver((muts) => {
2178
+ for (const m of muts) for (const n of m.addedNodes) {
2179
+ if (n.nodeType !== 1) continue;
2180
+ if (n.tagName === "SELECT") enhanceSelect(n);
2181
+ else if (n.querySelectorAll) n.querySelectorAll("select:not([data-no-dd])").forEach(enhanceSelect);
2182
+ }
2183
+ }).observe(document.body, { childList: true, subtree: true });
2029
2184
  let providerDD = null;
2030
2185
  function wireUsage() {
2031
2186
  // The provider picker is a custom icon dropdown; the account one stays a native select.
@@ -2102,6 +2257,7 @@ export {
2102
2257
  + names.map((n) => '<option value="' + esc(n) + '">' + esc(accountLabel(n)) + "</option>").join("");
2103
2258
  }
2104
2259
  sel.value = usageAccount;
2260
+ syncDD(sel);
2105
2261
  }
2106
2262
 
2107
2263
  // Fill the panel from one scope: the whole report (isAll) or one account's sub-report.
@@ -2330,6 +2486,7 @@ export {
2330
2486
  if (loadedUi === null) loadedUi = data.ui;
2331
2487
  else if (data.ui !== loadedUi) { location.reload(); return; }
2332
2488
  }
2489
+ renderUpdateAlert(data.updates);
2333
2490
  priceOverride = Number(data.priceOverride) || 0;
2334
2491
  speedOverride = Number(data.speedOverride) || 0;
2335
2492
  const s = data.stats || {};
@@ -2388,6 +2545,35 @@ export {
2388
2545
  }
2389
2546
  }
2390
2547
 
2548
+ // --- package update alert --------------------------------------------------
2549
+ // Server-side npm version check (CLI + dashboard + linter), surfaced in /api/stats and
2550
+ // refreshed every poll. Dismiss is sticky only for the exact versions shown - a later
2551
+ // release re-shows the banner. The "Update now" button runs `enigma update` behind the
2552
+ // scenes; the alert then clears on its own as the next polls see the new versions.
2553
+ let dismissedUpdateSig = "";
2554
+ function renderUpdateAlert(u) {
2555
+ const el = $("updateAlert");
2556
+ if (!el) return;
2557
+ const items = ((u && u.packages) || []).filter((p) => p.hasUpdate);
2558
+ const sig = items.map((p) => p.name + "@" + p.latest).join(",");
2559
+ if (!items.length || sig === dismissedUpdateSig) { el.hidden = true; return; }
2560
+ const list = items.map((p) => esc(p.label) + " " + esc(p.installed) + " -> " + esc(p.latest)).join(", ");
2561
+ $("updateAlertMsg").innerHTML = "<strong>Update available</strong> &nbsp;" + list;
2562
+ el.dataset.sig = sig;
2563
+ el.hidden = false;
2564
+ }
2565
+
2566
+ async function runFullUpdate() {
2567
+ const btn = $("updateAlertBtn");
2568
+ if (btn) { btn.disabled = true; btn.textContent = "Updating..."; }
2569
+ try {
2570
+ const res = await fetch("/api/run-update", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
2571
+ const out = await res.json();
2572
+ toast(out.note || (out.ok ? "Update started." : "Could not start the update."), out.ok ? "ok" : "err");
2573
+ } catch { toast("Could not reach the server to update.", "err"); }
2574
+ finally { if (btn) { btn.disabled = false; btn.textContent = "Update now"; } }
2575
+ }
2576
+
2391
2577
  // --- dashboard self-update -------------------------------------------------
2392
2578
  // Where the latest published dashboard version lives: the raw package.json on the
2393
2579
  // repo's main branch (no API, no auth, no rate limit). A repo rename 404s here and is
@@ -2435,24 +2621,153 @@ export {
2435
2621
  // --- settings subpage (mirrors the TUI registry over /api/settings) ---
2436
2622
  function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c])); }
2437
2623
 
2438
- // A self-dismissing notification so a failed action can't slip by unseen. Errors
2439
- // linger longer than confirmations; both auto-clear so they never pile up.
2440
- function toast(msg, kind) {
2441
- let wrap = $("toasts");
2442
- if (!wrap) { wrap = document.createElement("div"); wrap.id = "toasts"; document.body.appendChild(wrap); }
2443
- const el = document.createElement("div");
2444
- el.className = "toast " + (kind === "err" ? "err" : kind === "ok" ? "ok" : "");
2445
- el.setAttribute("role", kind === "err" ? "alert" : "status");
2446
- el.textContent = msg;
2447
- wrap.appendChild(el);
2448
- setTimeout(() => { el.classList.add("hide"); setTimeout(() => el.remove(), 320); }, kind === "err" ? 6000 : 3000);
2449
- }
2450
- // Set a status note's text and ok/err styling in one place. Errors are also toasted
2451
- // (the inline note alone is easy to miss), so callers just call setNote(el, msg, "err").
2624
+ // --- Toasts (vanilla port of Sonner) -------------------------------------------------
2625
+ // A stacked, self-dismissing notification system: the newest sits in front, older ones
2626
+ // peek behind and fan out on hover; rich-coloured per type; swipe- or click-to-close.
2627
+ // API: toast(msg, opts) and toast.success/error/info/warning/loading/message/dismiss.
2628
+ // opts: { description, duration, id }. A loading toast has Infinity duration; calling a
2629
+ // type with the same id UPDATES it in place (loading -> success/error), so an async flow
2630
+ // is a single morphing toast, not a pile.
2631
+ const toast = (() => {
2632
+ const ICONS = {
2633
+ success: '<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd"/></svg>',
2634
+ error: '<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/></svg>',
2635
+ info: '<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd"/></svg>',
2636
+ warning: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" clip-rule="evenodd"/></svg>',
2637
+ loading: '<svg class="etoast-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" opacity="0.85"/></svg>',
2638
+ close: '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>'
2639
+ };
2640
+ const VISIBLE = 3, DURATION = { default: 4000, success: 4000, info: 4000, warning: 5000, error: 6000, loading: Infinity };
2641
+ let list = null, counter = 1, expanded = false;
2642
+ const items = []; // newest-first; { id, el, type, duration, remaining, startedAt, timer, removed }
2643
+ const mount = () => { if (!list) { list = document.createElement("ol"); list.id = "toaster"; list.setAttribute("aria-live", "polite"); list.tabIndex = -1; document.body.appendChild(list); wireHover(); } return list; };
2644
+ const wireHover = () => {
2645
+ list.addEventListener("mouseenter", () => { expanded = true; list.dataset.expanded = "true"; pauseAll(); place(); });
2646
+ list.addEventListener("mouseleave", () => { expanded = false; list.dataset.expanded = "false"; resumeAll(); place(); });
2647
+ };
2648
+ const front = () => items.find((t) => !t.removed);
2649
+ const pause = (t) => { if (t.timer) { clearTimeout(t.timer); t.timer = null; t.remaining -= Date.now() - t.startedAt; } };
2650
+ const resume = (t) => { if (t.duration === Infinity || t.removed || t.timer) return; t.startedAt = Date.now(); t.timer = setTimeout(() => dismiss(t.id), Math.max(0, t.remaining)); };
2651
+ const pauseAll = () => items.forEach(pause);
2652
+ const resumeAll = () => items.forEach(resume);
2653
+ // Position every toast: front shows fully, the rest peek behind (collapsed) or fan out
2654
+ // (expanded). --offset is the summed height of the live toasts in front of this one.
2655
+ function place() {
2656
+ let acc = 0, vi = 0;
2657
+ for (const t of items) {
2658
+ if (t.removed) { t.el.style.setProperty("--z", "0"); continue; }
2659
+ // The front toast (and any toast while the stack is expanded) is never
2660
+ // height-clamped, so its offsetHeight is the true natural height - cache it.
2661
+ if (vi === 0 && !expanded) t.height = t.el.offsetHeight;
2662
+ if (vi === 0) list.style.setProperty("--t-front", (t.height || t.el.offsetHeight) + "px");
2663
+ t.el.dataset.front = vi === 0 ? "true" : "false";
2664
+ t.el.dataset.expanded = expanded ? "true" : "false";
2665
+ t.el.dataset.visible = vi < VISIBLE ? "true" : "false";
2666
+ t.el.style.setProperty("--toasts-before", String(vi));
2667
+ t.el.style.setProperty("--offset", acc + "px");
2668
+ t.el.style.setProperty("--z", String(1000 - vi));
2669
+ acc += (t.height || t.el.offsetHeight) + 14;
2670
+ vi++;
2671
+ }
2672
+ }
2673
+ function dismiss(id) {
2674
+ const t = items.find((x) => x.id === id);
2675
+ if (!t || t.removed) return id;
2676
+ t.removed = true; if (t.timer) clearTimeout(t.timer);
2677
+ t.el.dataset.removed = "true"; t.el.dataset.expanded = expanded ? "true" : "false";
2678
+ place();
2679
+ setTimeout(() => { const i = items.indexOf(t); if (i !== -1) items.splice(i, 1); t.el.remove(); place(); }, 420);
2680
+ return id;
2681
+ }
2682
+ function build(t, type, title, description) {
2683
+ t.el.dataset.type = type;
2684
+ t.el.setAttribute("role", type === "error" ? "alert" : "status");
2685
+ const icon = ICONS[type] ? '<div class="etoast-icon">' + ICONS[type] + "</div>" : "";
2686
+ t.el.innerHTML = icon
2687
+ + '<div class="etoast-content"><div class="etoast-title"></div>'
2688
+ + (description ? '<div class="etoast-desc"></div>' : "") + "</div>"
2689
+ + '<button type="button" class="etoast-close" aria-label="Close">' + ICONS.close + "</button>";
2690
+ t.el.querySelector(".etoast-title").textContent = title == null ? "" : String(title);
2691
+ if (description) t.el.querySelector(".etoast-desc").textContent = String(description);
2692
+ t.el.querySelector(".etoast-close").addEventListener("click", (e) => { e.stopPropagation(); dismiss(t.id); });
2693
+ }
2694
+ // Horizontal swipe-to-dismiss (pointer drag), matching Sonner's feel.
2695
+ function wireSwipe(t) {
2696
+ let startX = null;
2697
+ t.el.addEventListener("pointerdown", (e) => { if (e.target.closest(".etoast-close")) return; startX = e.clientX; t.el.setPointerCapture(e.pointerId); pause(t); });
2698
+ t.el.addEventListener("pointermove", (e) => {
2699
+ if (startX == null) return;
2700
+ const dx = e.clientX - startX;
2701
+ t.el.dataset.swiping = "true"; t.el.style.setProperty("--swipe-x", dx + "px");
2702
+ });
2703
+ const end = (e) => {
2704
+ if (startX == null) return;
2705
+ const dx = e.clientX - startX; startX = null; t.el.dataset.swiping = "false";
2706
+ if (Math.abs(dx) > 80) dismiss(t.id);
2707
+ else { t.el.style.removeProperty("--swipe-x"); if (!expanded) resume(t); }
2708
+ };
2709
+ t.el.addEventListener("pointerup", end);
2710
+ t.el.addEventListener("pointercancel", end);
2711
+ }
2712
+ function create(opts) {
2713
+ const type = opts.type || "default";
2714
+ const id = opts.id != null ? opts.id : counter++;
2715
+ const duration = opts.duration != null ? opts.duration : DURATION[type];
2716
+ const existing = items.find((x) => x.id === id && !x.removed);
2717
+ if (existing) { // update in place (e.g. loading -> success)
2718
+ build(existing, type, opts.title, opts.description);
2719
+ existing.duration = duration; existing.remaining = duration; existing.startedAt = Date.now();
2720
+ if (existing.timer) { clearTimeout(existing.timer); existing.timer = null; }
2721
+ if (!expanded) resume(existing);
2722
+ place();
2723
+ return id;
2724
+ }
2725
+ mount();
2726
+ const el = document.createElement("li");
2727
+ el.className = "etoast"; el.tabIndex = 0;
2728
+ const t = { id, el, type, duration, remaining: duration, startedAt: Date.now(), timer: null, removed: false };
2729
+ build(t, type, opts.title, opts.description);
2730
+ wireSwipe(t);
2731
+ items.unshift(t);
2732
+ list.prepend(el);
2733
+ place();
2734
+ requestAnimationFrame(() => { el.dataset.mounted = "true"; place(); if (!expanded) resume(t); });
2735
+ return id;
2736
+ }
2737
+ // Back-compat: a string 2nd arg is the legacy kind ("ok"/"err"); an object is opts.
2738
+ const KIND = { ok: "success", err: "error" };
2739
+ const api = (msg, o) => create(typeof o === "string" ? { type: KIND[o] || "default", title: msg } : { ...o, type: "default", title: msg });
2740
+ ["success", "error", "info", "warning", "loading", "message"].forEach((k) => {
2741
+ api[k] = (msg, o) => create({ ...o, type: k === "message" ? "default" : k, title: msg });
2742
+ });
2743
+ api.dismiss = (id) => { if (id == null) items.slice().forEach((t) => dismiss(t.id)); else dismiss(id); return id; };
2744
+ return api;
2745
+ })();
2746
+
2747
+ // Bridge the old inline-note API onto toasts so every existing call site upgrades at once.
2748
+ // A flow reuses ONE note target (e.g. "settingsNote") start to finish, so we key a single
2749
+ // toast per target: a progress message ("Saving...") opens a loading toast, and the ok/err
2750
+ // result morphs that same toast - no pile-up, no leftover green text. `restartNote`-style
2751
+ // long messages split into title + description at the first sentence break.
2752
+ const noteToastId = new Map();
2753
+ function noteKey(el) { return typeof el === "string" ? el : (el && el.id) || "__note__"; }
2754
+ function splitMsg(msg) {
2755
+ msg = String(msg);
2756
+ const m = msg.match(/^(.*?[.!])\s+(.+)$/s);
2757
+ return m && m[1].length <= 64 ? { title: m[1], description: m[2] } : { title: msg };
2758
+ }
2759
+ // Drop-in replacement for the former setNote(el, msg, kind). `el` is ignored for display
2760
+ // (kept for call compatibility); everything surfaces as a toast.
2452
2761
  function setNote(el, msg, kind) {
2453
- if (typeof el === "string") el = $(el);
2454
- if (el) { el.textContent = msg; el.classList.toggle("ok", kind === "ok"); el.classList.toggle("err", kind === "err"); }
2455
- if (kind === "err") toast(msg, "err");
2762
+ const key = noteKey(el);
2763
+ const prev = noteToastId.get(key);
2764
+ if (msg == null || msg === "") { if (prev != null) { toast.dismiss(prev); noteToastId.delete(key); } return; }
2765
+ const isProgress = !kind && /(\.\.\.|…)\s*$/.test(String(msg));
2766
+ const type = kind === "ok" ? "success" : kind === "err" ? "error" : isProgress ? "loading" : "info";
2767
+ const parts = splitMsg(msg);
2768
+ const id = toast[type](parts.title, { id: prev, description: parts.description });
2769
+ if (isProgress) noteToastId.set(key, id);
2770
+ else noteToastId.delete(key);
2456
2771
  }
2457
2772
 
2458
2773
  // Capitalize a choice value for display ("on-demand" -> "On demand"), keeping the raw
@@ -2599,7 +2914,7 @@ export {
2599
2914
  const want = isBool ? (value === true || value === 1) : String(value);
2600
2915
  const prevBool = ctl.dataset.on === "1";
2601
2916
  const prevChoice = ctl.dataset.prev != null ? ctl.dataset.prev : ctl.value; // <select> already changed natively
2602
- const rollback = () => { if (isBool) applyBool(ctl, prevBool); else ctl.value = prevChoice; };
2917
+ const rollback = () => { if (isBool) applyBool(ctl, prevBool); else { ctl.value = prevChoice; syncDD(ctl); } };
2603
2918
  if (isBool) applyBool(ctl, want);
2604
2919
  setNote(el, "Saving " + key + "...");
2605
2920
  try {
@@ -2608,7 +2923,7 @@ export {
2608
2923
  if (!out.ok) { rollback(); setNote(el, "Could not change " + key + ": " + (out.error || "error"), "err"); return; }
2609
2924
  const s = out.setting;
2610
2925
  if (s && isBool) applyBool(ctl, s.value);
2611
- else if (s) { ctl.value = s.choice; ctl.dataset.prev = s.choice; }
2926
+ else if (s) { ctl.value = s.choice; ctl.dataset.prev = s.choice; syncDD(ctl); }
2612
2927
  if (s && (isBool ? s.value !== want : String(s.choice) !== want)) { setNote(el, key + " did not change (the write had no effect).", "err"); return; }
2613
2928
  setNote(el, out.restartNote || ("Saved " + key + "."), "ok");
2614
2929
  } catch { rollback(); setNote(el, "Could not reach the server to change " + key + ".", "err"); }
@@ -3387,15 +3702,15 @@ export {
3387
3702
  if (!v.enabled) {
3388
3703
  $("recallBody").innerHTML = '<div class="panel"><div class="set-cat-h">Session memory is off</div>'
3389
3704
  + '<div class="set-hint" style="padding:8px 0">Build a searchable memory of your coding sessions from your own transcripts.</div>'
3390
- + '<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap"><button type="button" class="toggle" data-recall-on="1">Enable recall</button>'
3391
- + '<span class="set-hint">or run <code>enigma config recall on</code></span></div></div>';
3705
+ + '<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap"><button type="button" class="toggle" data-kind="bool" role="switch" aria-checked="false" data-recall-on="1" aria-label="Session memory" title="Turn session memory on">Enable recall</button>'
3706
+ + '<span class="set-hint">Turn on, or run <code>enigma config recall on</code></span></div></div>';
3392
3707
  $("recallSearch").style.display = "none"; $("recallProviderPanel").style.display = "none"; $("recallAddPanel").style.display = "none"; $("recallList").innerHTML = ""; return;
3393
3708
  }
3394
3709
  const s = v.stats || {};
3395
3710
  const types = Object.entries(s.byType || {}).map(([t, n]) => '<span class="tag">' + esc(t) + " " + n + "</span>").join(" ");
3396
3711
  const last = v.lastSync ? "last sync " + new Date(v.lastSync).toLocaleString() : "not synced yet";
3397
3712
  $("recallBody").innerHTML = '<div class="panel"><div style="display:flex;justify-content:space-between;align-items:center;gap:10px"><div class="set-cat-h" style="margin:0">Stored memory</div>'
3398
- + '<button type="button" class="toggle on" data-recall-on="0" title="Turn session memory off">Disable</button></div>'
3713
+ + '<button type="button" class="toggle on" data-kind="bool" role="switch" aria-checked="true" data-recall-on="0" aria-label="Session memory" title="Turn session memory off">Disable</button></div>'
3399
3714
  + '<div class="set-hint" style="padding:6px 0">' + (s.observations || 0) + " observations · " + (s.sessions || 0) + " sessions · "
3400
3715
  + (s.projects || 0) + " projects · " + Math.round((s.dbBytes || 0) / 1024) + " KB · " + esc(last) + "</div>"
3401
3716
  + (types ? '<div style="padding-top:6px">' + types + "</div>" : "") + "</div>";
@@ -3466,8 +3781,7 @@ export {
3466
3781
  + facts
3467
3782
  + (it.concepts && it.concepts.length ? '<div style="margin-top:6px">' + tags(it.concepts) + "</div>" : "")
3468
3783
  + list("Modified", it.files) + list("Read", it.filesRead)
3469
- + '<div style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap"><button type="button" class="toggle" data-tl="' + it.id + '">Show timeline</button>'
3470
- + (it.id ? '<button type="button" class="btn-danger" data-del="' + it.id + '" title="Delete this memory">Delete</button>' : "") + "</div>"
3784
+ + '<div style="margin-top:8px"><button type="button" class="toggle" data-tl="' + it.id + '">Show timeline</button></div>'
3471
3785
  + '<div class="rtl" id="rtl-' + it.id + '"></div></div>';
3472
3786
  }
3473
3787
  function renderRecallList(items) {
@@ -3475,7 +3789,8 @@ export {
3475
3789
  if (!items || !items.length) { $("recallList").innerHTML = '<div class="empty" style="padding:16px 0">No memories yet - run a sync, or adjust your search.</div>'; return; }
3476
3790
  $("recallList").innerHTML = '<div class="panel">' + items.map((it) => {
3477
3791
  if (it.id) recallItemsById[it.id] = it;
3478
- return '<div class="set-row" style="cursor:pointer" data-rid="' + (it.id || "") + '">' + recallRowHtml(it) + "</div>"
3792
+ const del = it.id ? '<button type="button" class="btn-danger icon recall-del" title="Delete memory" aria-label="Delete this memory" data-del="' + it.id + '">' + TRASH_ICON + "</button>" : "";
3793
+ return '<div class="set-row" style="cursor:pointer" data-rid="' + (it.id || "") + '">' + recallRowHtml(it) + del + "</div>"
3479
3794
  + '<div id="rd-' + (it.id || "") + '" style="display:none"></div>';
3480
3795
  }).join("") + "</div>";
3481
3796
  }
@@ -3611,6 +3926,7 @@ export {
3611
3926
  + projectsCache.map((p) => '<option value="' + esc(p.path) + '">' + esc(p.label) + "</option>").join("")
3612
3927
  + '<option value="__create__">+ Create new project</option>';
3613
3928
  sel.value = currentScope;
3929
+ syncDD(sel);
3614
3930
  }
3615
3931
  async function loadProjectsList() {
3616
3932
  try { projectsCache = ((await (await fetch("/api/projects", { cache: "no-store" })).json()).projects) || []; }
@@ -3690,7 +4006,7 @@ export {
3690
4006
  const sel = $("scopeSel");
3691
4007
  if (sel) sel.addEventListener("change", () => {
3692
4008
  const val = sel.value;
3693
- if (val === "__create__") { sel.value = currentScope; openCreateProject(); return; }
4009
+ if (val === "__create__") { sel.value = currentScope; syncDD(sel); openCreateProject(); return; }
3694
4010
  currentScope = val;
3695
4011
  try { localStorage.setItem("enigmaScope", val); } catch { /* */ }
3696
4012
  route();
@@ -3882,10 +4198,13 @@ export {
3882
4198
  wireResources();
3883
4199
  wireRecall();
3884
4200
  wireScope();
4201
+ enhanceAll(); // upgrade every static <select> into the shared pretty dropdown
3885
4202
  $("cfgExport").addEventListener("click", exportConfig);
3886
4203
  $("cfgImport").addEventListener("click", importConfig);
3887
4204
  $("updateBtn").addEventListener("click", runUpdate);
3888
4205
  $("fixPathBtn").addEventListener("click", runFixPath);
4206
+ $("updateAlertBtn").addEventListener("click", runFullUpdate);
4207
+ $("updateAlertDismiss").addEventListener("click", () => { dismissedUpdateSig = $("updateAlert").dataset.sig || ""; $("updateAlert").hidden = true; });
3889
4208
  $("refreshBtn").addEventListener("click", async () => {
3890
4209
  const b = $("refreshBtn");
3891
4210
  if (b.classList.contains("spinning")) return; // already refreshing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enigmax/dashboard",
3
- "version": "0.1.61",
3
+ "version": "0.1.63",
4
4
  "description": "Local browser dashboard UI for enigma: the static page and chart assets enigma serves on its loopback dashboard (savings, real tool usage, in-browser settings). Installed on demand by enigma-cli; not a runtime dependency.",
5
5
  "type": "module",
6
6
  "files": [