Add hover/tap tooltip explaining what "limited" means in local AI mode
Browse filesHovering (desktop) or tapping (mobile) the word "limited" in the local-AI
banner now shows the 5 concrete capability gaps versus the server model:
smaller model, no images, no live data, no date awareness, and
device-dependent speed. Positioned via JS from the term's actual on-screen
rect and clamped to the viewport, rather than a fixed CSS offset, since
the term sits at an unpredictable horizontal spot in the sentence.
Fixed a real bug caught by testing on a touch-emulated device: the term
has tabindex=0, so a tap fires a native `focus` event before `click`,
which independently opened the tooltip β the click handler's toggle then
read "already open" and immediately closed it again on every tap. Fixed
by tracking the click-toggle's own open/closed state in a dedicated flag
instead of inferring it from the tooltip's current display state.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016b3u4n81zwJ6rpE9NeCokb
- index.html +91 -1
|
@@ -141,6 +141,17 @@
|
|
| 141 |
.local-mode-link{background:none;border:none;color:var(--accent);font-size:12px;font-weight:700;cursor:pointer;padding:6px 0 0;font-family:inherit;text-decoration:underline;min-height:auto;display:block;}
|
| 142 |
.local-progress-track{background:rgba(127,127,127,.25);border-radius:999px;height:8px;overflow:hidden;margin-top:8px;}
|
| 143 |
.local-progress-fill{background:var(--accent);height:100%;width:0%;transition:width .25s;}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
.local-mode-btn{background:transparent;border:1px solid var(--border);color:var(--text);border-radius:12px;padding:0 20px;font-size:12.5px;font-weight:700;cursor:pointer;font-family:inherit;min-height:40px;width:100%;margin-top:10px;}
|
| 145 |
.local-mode-btn:hover{border-color:var(--accent);}
|
| 146 |
.sidebar-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:900;}
|
|
@@ -289,7 +300,15 @@
|
|
| 289 |
<div class="local-progress-track"><div class="local-progress-fill" id="localProgressFill"></div></div>
|
| 290 |
</div>
|
| 291 |
<div class="cap-banner-active" id="capBannerActive" style="display:none;align-items:center;justify-content:space-between;gap:10px;">
|
| 292 |
-
<div class="cap-title">π₯οΈ Local AI mode β
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
<button type="button" class="local-mode-link" id="exitLocalModeBtn">Use server again</button>
|
| 294 |
</div>
|
| 295 |
</div>
|
|
@@ -1514,6 +1533,77 @@ if (aiModeLocalBtn) aiModeLocalBtn.addEventListener('click', enableLocalMode);
|
|
| 1514 |
if (aiModeServerBtn) aiModeServerBtn.addEventListener('click', () => { if (localModeActive) exitLocalMode(); });
|
| 1515 |
if (aiModeRowEl && HAS_WEBGPU) aiModeRowEl.style.display = 'flex';
|
| 1516 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1517 |
// Single source of truth for both the inline composer banner and the
|
| 1518 |
// centered modal popup, for either reset cause. Both show the SAME
|
| 1519 |
// UTC-midnight countdown β for the message cap (this app's own counter)
|
|
|
|
| 141 |
.local-mode-link{background:none;border:none;color:var(--accent);font-size:12px;font-weight:700;cursor:pointer;padding:6px 0 0;font-family:inherit;text-decoration:underline;min-height:auto;display:block;}
|
| 142 |
.local-progress-track{background:rgba(127,127,127,.25);border-radius:999px;height:8px;overflow:hidden;margin-top:8px;}
|
| 143 |
.local-progress-fill{background:var(--accent);height:100%;width:0%;transition:width .25s;}
|
| 144 |
+
.info-term{position:relative;display:inline-block;border-bottom:1px dotted currentColor;cursor:help;}
|
| 145 |
+
/* Positioned via JS (left/top computed from the term's actual on-screen
|
| 146 |
+
rect, clamped to the viewport) rather than pure CSS anchoring β the
|
| 147 |
+
term sits at an unpredictable horizontal spot in the sentence (varies
|
| 148 |
+
by language, font size), so a fixed left:0 offset would overflow the
|
| 149 |
+
right edge on a narrow phone screen. See showLocalLimitedTooltip(). */
|
| 150 |
+
.tooltip-bubble{display:none;position:fixed;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:12px 14px;box-shadow:0 12px 32px rgba(0,0,0,.4);z-index:1200;font-size:12px;line-height:1.5;font-weight:500;color:var(--text);white-space:normal;max-height:60vh;overflow-y:auto;}
|
| 151 |
+
.tooltip-bubble ol{margin:0;padding-left:18px;}
|
| 152 |
+
.tooltip-bubble li{margin-bottom:8px;}
|
| 153 |
+
.tooltip-bubble li:last-child{margin-bottom:0;}
|
| 154 |
+
.tooltip-bubble strong{color:var(--accent);}
|
| 155 |
.local-mode-btn{background:transparent;border:1px solid var(--border);color:var(--text);border-radius:12px;padding:0 20px;font-size:12.5px;font-weight:700;cursor:pointer;font-family:inherit;min-height:40px;width:100%;margin-top:10px;}
|
| 156 |
.local-mode-btn:hover{border-color:var(--accent);}
|
| 157 |
.sidebar-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:900;}
|
|
|
|
| 300 |
<div class="local-progress-track"><div class="local-progress-fill" id="localProgressFill"></div></div>
|
| 301 |
</div>
|
| 302 |
<div class="cap-banner-active" id="capBannerActive" style="display:none;align-items:center;justify-content:space-between;gap:10px;">
|
| 303 |
+
<div class="cap-title">π₯οΈ Local AI mode β <span class="info-term" id="localLimitedTerm" tabindex="0" role="button" aria-expanded="false" aria-describedby="localLimitedTooltip">limited<div class="tooltip-bubble" id="localLimitedTooltip" role="tooltip">
|
| 304 |
+
<ol>
|
| 305 |
+
<li><strong>Much smaller model</strong> β it's a 360-million-parameter model versus the 1B/3B/8B-class models Nox normally runs server-side. Noticeably less coherent, worse at reasoning, and more prone to getting facts wrong or losing the thread in longer replies.</li>
|
| 306 |
+
<li><strong>No images</strong> β it's text-only. Attaching a photo/document in local mode gets declined with a message telling you to switch back to the server.</li>
|
| 307 |
+
<li><strong>No live data</strong> β the server injects real-time weather, news, currency rates, holidays, and general web facts into its answers. Local mode has none of that β it's just the raw model guessing from its own training, so it'll be wrong or evasive on anything time-sensitive ("what's the weather," "who won yesterday," etc.).</li>
|
| 308 |
+
<li><strong>No date awareness</strong> β the server also tells the model today's actual date; local mode doesn't get that either, so it may not know what day it is.</li>
|
| 309 |
+
<li><strong>Speed/quality depend on your device</strong> β no server-grade GPU behind it, just whatever's in your laptop/phone.</li>
|
| 310 |
+
</ol>
|
| 311 |
+
</div></span>, runs on your device</div>
|
| 312 |
<button type="button" class="local-mode-link" id="exitLocalModeBtn">Use server again</button>
|
| 313 |
</div>
|
| 314 |
</div>
|
|
|
|
| 1533 |
if (aiModeServerBtn) aiModeServerBtn.addEventListener('click', () => { if (localModeActive) exitLocalMode(); });
|
| 1534 |
if (aiModeRowEl && HAS_WEBGPU) aiModeRowEl.style.display = 'flex';
|
| 1535 |
|
| 1536 |
+
// The ":hover"/":focus" CSS alone covers desktop and most keyboard nav,
|
| 1537 |
+
// but a tap on mobile doesn't reliably keep an element "hovered" long
|
| 1538 |
+
// enough to read a multi-paragraph tooltip β an explicit toggle (open on
|
| 1539 |
+
// tap, close on any tap elsewhere) makes it usable there too.
|
| 1540 |
+
const localLimitedTerm = document.getElementById('localLimitedTerm');
|
| 1541 |
+
const localLimitedTooltip = document.getElementById('localLimitedTooltip');
|
| 1542 |
+
// True hover only on devices that actually have a pointer capable of it β
|
| 1543 |
+
// on touch, "hover" fires on tap in a way that fights with the click
|
| 1544 |
+
// toggle below (tap would open-then-immediately-close), so touch relies
|
| 1545 |
+
// purely on the click handler instead.
|
| 1546 |
+
const supportsHover = !!(window.matchMedia && window.matchMedia('(hover: hover)').matches);
|
| 1547 |
+
|
| 1548 |
+
function showLocalLimitedTooltip() {
|
| 1549 |
+
if (!localLimitedTerm || !localLimitedTooltip) return;
|
| 1550 |
+
const margin = 12;
|
| 1551 |
+
const termRect = localLimitedTerm.getBoundingClientRect();
|
| 1552 |
+
const width = Math.min(300, window.innerWidth - margin * 2);
|
| 1553 |
+
localLimitedTooltip.style.width = width + 'px';
|
| 1554 |
+
localLimitedTooltip.style.display = 'block';
|
| 1555 |
+
const height = localLimitedTooltip.offsetHeight;
|
| 1556 |
+
let left = termRect.left;
|
| 1557 |
+
if (left + width > window.innerWidth - margin) left = window.innerWidth - margin - width;
|
| 1558 |
+
if (left < margin) left = margin;
|
| 1559 |
+
let top = termRect.top - height - 9;
|
| 1560 |
+
if (top < margin) top = termRect.bottom + 9; // flip below if there's no room above
|
| 1561 |
+
localLimitedTooltip.style.left = left + 'px';
|
| 1562 |
+
localLimitedTooltip.style.top = top + 'px';
|
| 1563 |
+
}
|
| 1564 |
+
|
| 1565 |
+
function hideLocalLimitedTooltip() {
|
| 1566 |
+
if (localLimitedTooltip) localLimitedTooltip.style.display = 'none';
|
| 1567 |
+
if (localLimitedTerm) localLimitedTerm.setAttribute('aria-expanded', 'false');
|
| 1568 |
+
}
|
| 1569 |
+
|
| 1570 |
+
// Tracked separately from the tooltip's own display state, not inferred
|
| 1571 |
+
// from it β the term has tabindex="0", so a real click/tap ALSO fires a
|
| 1572 |
+
// native `focus` event first (browsers focus a focusable element before
|
| 1573 |
+
// dispatching its click), which independently shows the tooltip. Reading
|
| 1574 |
+
// "is it currently visible" inside the click handler was therefore always
|
| 1575 |
+
// true by the time click ran, making every tap open-then-immediately-close
|
| 1576 |
+
// it. A dedicated flag that only the click handler itself flips sidesteps
|
| 1577 |
+
// that ordering entirely β confirmed with a real Playwright touch tap
|
| 1578 |
+
// after this was still broken with the naive "toggle by reading display" approach.
|
| 1579 |
+
let localTooltipPinnedOpen = false;
|
| 1580 |
+
|
| 1581 |
+
if (localLimitedTerm) {
|
| 1582 |
+
if (supportsHover) {
|
| 1583 |
+
localLimitedTerm.addEventListener('mouseenter', showLocalLimitedTooltip);
|
| 1584 |
+
localLimitedTerm.addEventListener('mouseleave', () => { if (!localTooltipPinnedOpen) hideLocalLimitedTooltip(); });
|
| 1585 |
+
}
|
| 1586 |
+
localLimitedTerm.addEventListener('focus', showLocalLimitedTooltip);
|
| 1587 |
+
localLimitedTerm.addEventListener('blur', () => { if (!localTooltipPinnedOpen) hideLocalLimitedTooltip(); });
|
| 1588 |
+
localLimitedTerm.addEventListener('click', (e) => {
|
| 1589 |
+
e.stopPropagation();
|
| 1590 |
+
localTooltipPinnedOpen = !localTooltipPinnedOpen;
|
| 1591 |
+
if (localTooltipPinnedOpen) {
|
| 1592 |
+
showLocalLimitedTooltip();
|
| 1593 |
+
localLimitedTerm.setAttribute('aria-expanded', 'true');
|
| 1594 |
+
} else {
|
| 1595 |
+
hideLocalLimitedTooltip();
|
| 1596 |
+
}
|
| 1597 |
+
});
|
| 1598 |
+
document.addEventListener('click', () => {
|
| 1599 |
+
localTooltipPinnedOpen = false;
|
| 1600 |
+
hideLocalLimitedTooltip();
|
| 1601 |
+
});
|
| 1602 |
+
window.addEventListener('resize', () => {
|
| 1603 |
+
if (localLimitedTooltip && localLimitedTooltip.style.display === 'block') showLocalLimitedTooltip();
|
| 1604 |
+
});
|
| 1605 |
+
}
|
| 1606 |
+
|
| 1607 |
// Single source of truth for both the inline composer banner and the
|
| 1608 |
// centered modal popup, for either reset cause. Both show the SAME
|
| 1609 |
// UTC-midnight countdown β for the message cap (this app's own counter)
|