Files
hermes-webui/tests/test_css_tooltips.py
T
nesquena-hermes d41555cec6 fix(ux): polish CSS tooltips + clear native title + extend coverage
Stage 311 maintainer-side enhancements on top of @jasonjcwu's PR #1782,
addressing browser-verified issues + extending coverage to high-traffic
icon buttons:

(1) Clear native title when custom data-tooltip is present (the core bug fix):
    - static/i18n.js: when data-i18n-title runs against an element that has
      data-tooltip, sync data-tooltip AND removeAttribute('title'). Without
      this, the slow ~1.5s native browser tooltip co-fires alongside the
      fast custom CSS tooltip — exactly the bug #1775 reports.
    - static/ui.js _applyDashboardStatus: same treatment for the dashboard
      rail/mobile buttons (was setting btn.title=warning unconditionally).
    - static/boot.js: added _setButtonTooltip() helper, replaced 6 direct
      .title assignments (workspace toggle/collapse/clear, voice dictate,
      voice mode active/inactive) with calls through the helper.

(2) Extend coverage to high-traffic icon buttons in static/index.html:
    - Composer area (side tooltip): btnAttach, btnMic, btnVoiceMode,
      btnWorkspacePanelToggle, btnSend.
    - Workspace panel header (bottom tooltip): btnCollapseWorkspacePanel,
      btnUpDir, btnNewFile, btnNewFolder, btnRefreshPanel, btnClearPreview.
    - All 11 buttons gain has-tooltip[--bottom] class and data-tooltip,
      lose their native title=. Total covered surfaces: rail (12), sidebar
      nav-tabs (12), panel-head (31), composer/workspace icons (11) = 66.

(3) CSS polish (browser-verified visible improvement):
    - z-index 60 → 1500/1501 so the tooltip clears all sidebar/panel
      stacking contexts. Earlier verification showed the tooltip overlapping
      the Filter conversations search input.
    - background: var(--bg-strong, ...) → var(--surface) (solid #1A1A2E
      instead of falling back via undefined cascade).
    - color: var(--text, var(--accent-text)) → var(--text) (solid warm white
      #FFF8DC instead of gold which clashed at body-text size).
    - border: var(--accent-bg-strong) → var(--border) (#2A2A45 solid
      instead of gold at 0.15 alpha — the old border was barely visible
      and the arrow ::before triangle was invisible).
    - shadow: 4px/0.45 alpha → 6px/0.55 alpha + 0 0 0 1px ring fallback.
    - Added 150ms hover-onset delay (matches Cygnus's spec in #1775); 0s
      dismissal-delay so quick mouse-aways don't leave the tooltip behind.
    - Fixed has-tooltip--bottom arrow direction: was pointing down (wrong),
      now points up at the trigger (border-color order corrected).
    - Bumped offsets: side tooltip 10px → 12px (clearance from icon edge),
      bottom tooltip 8px → 10px.

(4) Test fixes (the 2 CI failures):
    - tests/test_cron_refresh_button_835.py: assertion accepts either
      title= or data-tooltip= per #1775 (was hardcoded title=).
    - tests/test_mobile_layout.py::test_profiles_sidebar_tab_present:
      regex tolerant to additional utility classes (has-tooltip).

(5) Regression tests added to tests/test_css_tooltips.py:
    - test_native_title_cleared_when_custom_tooltip_present: pins the
      removeAttribute('title') call so we don't regress to dual tooltips.
    - test_native_title_path_preserved_for_non_tooltip_elements: pins the
      el.title fallback for elements without data-tooltip.

Browser-verified: all 72 has-tooltip elements have zero native title at
runtime (was 94 with native, 2 stuck via dashboard JS path).

Co-authored-by: Jason Wu <jasonjcwu@users.noreply.github.com>
2026-05-07 04:00:40 +00:00

355 lines
14 KiB
Python

"""
Tests for CSS tooltip changes (issue #1775).
Verifies that custom data-tooltip / has-tooltip markup is applied correctly
across index.html, style.css, and i18n.js — replacing native title="" attributes
with a faster, CSS-driven tooltip system.
Run:
/root/hermes-agent/venv/bin/python -m pytest tests/test_css_tooltips.py -v
"""
import os
import re
import unittest
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
INDEX_HTML = os.path.join(BASE_DIR, "static", "index.html")
STYLE_CSS = os.path.join(BASE_DIR, "static", "style.css")
I18N_JS = os.path.join(BASE_DIR, "static", "i18n.js")
def _read(path):
with open(path, encoding="utf-8") as fh:
return fh.read()
# ---------------------------------------------------------------------------
# Lightweight HTML tag extractor (stdlib-only)
# ---------------------------------------------------------------------------
_TAG_RE = re.compile(r"<(\w+)([^>]*?)(?:/>|>)", re.DOTALL)
def _extract_tags(html, class_filter=None):
"""Return a list of dicts {tag, attrs_str, line} for tags whose class
attribute contains all tokens in *class_filter* (if given)."""
results = []
for m in _TAG_RE.finditer(html):
tag = m.group(1)
attrs_str = m.group(2)
if class_filter:
cls_match = re.search(r'class="([^"]*)"', attrs_str)
if not cls_match:
continue
classes = cls_match.group(1).split()
if not all(tok in classes for tok in class_filter):
continue
results.append({"tag": tag, "attrs": attrs_str, "match": m})
return results
def _has_attr(attrs_str, attr_name):
"""Check if a bare attribute name is present in the attrs string.
Handles both attr_name and attr_name="..."."""
return bool(re.search(r'\b' + re.escape(attr_name) + r'(?:=|\s|>)', attrs_str))
def _get_attr(attrs_str, attr_name):
"""Get the value of attr="..." from an attrs string, or None.
Uses a negative lookbehind to avoid matching 'title' inside
'data-i18n-title' or similar prefixed attributes.
"""
# Preceding char must be whitespace or start-of-string — not a letter/hyphen.
m = re.search(r'(?<![a-zA-Z\-])' + re.escape(attr_name) + r'="([^"]*)"', attrs_str)
return m.group(1) if m else None
# ===========================================================================
# 1. index.html — has-tooltip coverage
# ===========================================================================
class TestIndexHTMLTooltipCoverage(unittest.TestCase):
"""Parse static/index.html and verify tooltip class/attribute coverage."""
@classmethod
def setUpClass(cls):
cls.html = _read(INDEX_HTML)
# -- helpers -------------------------------------------------------------
def _find(self, *class_tokens):
return _extract_tags(self.html, class_filter=class_tokens)
# -- rail-btn ------------------------------------------------------------
def test_rail_btn_has_tooltip_class(self):
"""Every .rail-btn element must carry the has-tooltip class."""
rail_btns = self._find("rail-btn")
self.assertGreater(len(rail_btns), 0, "No .rail-btn elements found")
for btn in rail_btns:
cls_val = _get_attr(btn["attrs"], "class")
self.assertIn(
"has-tooltip", cls_val,
f".rail-btn missing has-tooltip class: ...{cls_val[:120]}",
)
def test_rail_btn_has_data_tooltip(self):
"""Every .rail-btn element must have data-tooltip attribute."""
for btn in self._find("rail-btn"):
self.assertIsNotNone(
_get_attr(btn["attrs"], "data-tooltip"),
".rail-btn missing data-tooltip attribute",
)
def test_rail_btn_no_native_title(self):
"""No .rail-btn element should use native title="" attribute."""
for btn in self._find("rail-btn"):
self.assertIsNone(
_get_attr(btn["attrs"], "title"),
".rail-btn still has native title=\"\" — should use data-tooltip",
)
# -- sidebar-nav .nav-tab ------------------------------------------------
def _get_sidebar_nav_section(self):
"""Extract the inner HTML of the <div class="sidebar-nav">...</div>."""
m = re.search(
r'<div\s+class="sidebar-nav"[^>]*>(.*?)</div>',
self.html,
re.DOTALL,
)
self.assertIsNotNone(m, "Could not find <div class=\"sidebar-nav\"> in index.html")
return m.group(1)
def test_sidebar_nav_tabs_have_tooltip_class(self):
"""Every .nav-tab inside sidebar-nav must carry has-tooltip class."""
section = self._get_sidebar_nav_section()
nav_tabs = _extract_tags(section, class_filter=["nav-tab"])
self.assertGreater(len(nav_tabs), 0, "No .nav-tab elements in sidebar-nav")
for tab in nav_tabs:
cls_val = _get_attr(tab["attrs"], "class")
self.assertIn(
"has-tooltip", cls_val,
f"sidebar-nav .nav-tab missing has-tooltip: ...{cls_val[:120]}",
)
def test_sidebar_nav_tabs_have_data_tooltip(self):
"""Every .nav-tab inside sidebar-nav must have data-tooltip attribute."""
section = self._get_sidebar_nav_section()
for tab in _extract_tags(section, class_filter=["nav-tab"]):
self.assertIsNotNone(
_get_attr(tab["attrs"], "data-tooltip"),
"sidebar-nav .nav-tab missing data-tooltip attribute",
)
def test_sidebar_nav_tabs_no_native_title(self):
"""No .nav-tab inside sidebar-nav should use native title=\"\"."""
section = self._get_sidebar_nav_section()
for tab in _extract_tags(section, class_filter=["nav-tab"]):
self.assertIsNone(
_get_attr(tab["attrs"], "title"),
"sidebar-nav .nav-tab still has native title=\"\" — should use data-tooltip",
)
# -- panel-head-btn ------------------------------------------------------
def test_panel_head_btn_has_tooltip_class(self):
"""Every .panel-head-btn element must carry has-tooltip class."""
btns = self._find("panel-head-btn")
self.assertGreater(len(btns), 0, "No .panel-head-btn elements found")
for btn in btns:
cls_val = _get_attr(btn["attrs"], "class")
self.assertIn(
"has-tooltip", cls_val,
f".panel-head-btn missing has-tooltip class: ...{cls_val[:120]}",
)
def test_panel_head_btn_has_data_tooltip(self):
"""Every .panel-head-btn element must have data-tooltip attribute."""
for btn in self._find("panel-head-btn"):
self.assertIsNotNone(
_get_attr(btn["attrs"], "data-tooltip"),
".panel-head-btn missing data-tooltip attribute",
)
def test_panel_head_btn_no_native_title(self):
"""No .panel-head-btn element should use native title=\"\"."""
for btn in self._find("panel-head-btn"):
self.assertIsNone(
_get_attr(btn["attrs"], "title"),
".panel-head-btn still has native title=\"\" — should use data-tooltip",
)
# -- has-tooltip ↔ data-tooltip consistency -----------------------------
def test_has_tooltip_also_has_data_tooltip(self):
"""Every element with has-tooltip class must also have data-tooltip."""
all_ht = _extract_tags(self.html, class_filter=["has-tooltip"])
self.assertGreater(len(all_ht), 0, "No .has-tooltip elements found at all")
for el in all_ht:
self.assertIsNotNone(
_get_attr(el["attrs"], "data-tooltip"),
"Element with has-tooltip is missing data-tooltip attribute",
)
# ===========================================================================
# 2. style.css — class definitions
# ===========================================================================
class TestStyleCSSTooltipClasses(unittest.TestCase):
"""Parse static/style.css and verify .has-tooltip CSS rules."""
@classmethod
def setUpClass(cls):
cls.css = _read(STYLE_CSS)
def test_has_tooltip_class_defined(self):
"""The .has-tooltip base class must be defined."""
self.assertRegex(
self.css, r'\.has-tooltip\s*\{',
".has-tooltip class not found in CSS",
)
def test_has_tooltip_after_uses_attr_data_tooltip(self):
""".has-tooltip::after must use content:attr(data-tooltip)."""
self.assertRegex(
self.css,
r'\.has-tooltip::after\s*\{[^}]*content:\s*attr\(data-tooltip\)',
".has-tooltip::after does not use content:attr(data-tooltip)",
)
def test_has_tooltip_bottom_defined(self):
"""The .has-tooltip--bottom modifier class must be defined."""
self.assertRegex(
self.css, r'\.has-tooltip--bottom\s*(?:::[\w-]+)?\s*\{',
".has-tooltip--bottom class not found in CSS",
)
def test_hover_and_focus_visible_trigger_opacity(self):
"""Both :hover and :focus-visible must trigger opacity on ::after."""
# Look for a rule that combines both selectors
hover_match = re.search(
r'\.has-tooltip:hover::after\s*\{[^}]*opacity',
self.css,
)
focus_match = re.search(
r'\.has-tooltip:focus-visible::after\s*\{[^}]*opacity',
self.css,
)
# Also accept combined selectors: .has-tooltip:hover::after,.has-tooltip:focus-visible::after
if not hover_match:
combined = re.search(
r'\.has-tooltip:hover::after\s*,\s*\.has-tooltip:focus-visible::after\s*\{[^}]*opacity',
self.css,
)
self.assertTrue(
combined,
":hover does not trigger opacity on .has-tooltip::after",
)
if not focus_match and not (hover_match and re.search(
r'\.has-tooltip:focus-visible::after', self.css,
)):
self.fail(
":focus-visible does not trigger opacity on .has-tooltip::after",
)
def test_prefers_reduced_motion_exists(self):
"""A prefers-reduced-motion media query must exist for .has-tooltip."""
self.assertRegex(
self.css,
r'@media\s*\(\s*prefers-reduced-motion\s*:\s*reduce\s*\)\s*\{[^}]*\.has-tooltip',
"No prefers-reduced-motion media query found for .has-tooltip",
)
# ===========================================================================
# 3. i18n.js — data-tooltip sync
# ===========================================================================
class TestI18NTooltipSync(unittest.TestCase):
"""Parse static/i18n.js and verify data-tooltip sync in data-i18n-title handler."""
@classmethod
def setUpClass(cls):
cls.js = _read(I18N_JS)
def test_data_tooltip_synced_in_i18n_title_handler(self):
"""The data-i18n-title handler must also sync data-tooltip attribute."""
# Find the data-i18n-title forEach block
block_match = re.search(
r"document\.querySelectorAll\(\s*'\[data-i18n-title\]'\s*\)"
r"\.forEach\s*\(\s*el\s*=>\s*\{(.*?)\}\s*\)",
self.js,
re.DOTALL,
)
self.assertIsNotNone(
block_match,
"Could not find data-i18n-title forEach handler in i18n.js",
)
block = block_match.group(1)
# Must reference setAttribute('data-tooltip', ...) or data-tooltip sync
self.assertRegex(
block,
r"setAttribute\s*\(\s*['\"]data-tooltip['\"]",
"data-i18n-title handler does not sync data-tooltip attribute",
)
def test_sync_only_fires_when_both_present(self):
"""The data-tooltip sync must guard on el.hasAttribute('data-tooltip')."""
block_match = re.search(
r"document\.querySelectorAll\(\s*'\[data-i18n-title\]'\s*\)"
r"\.forEach\s*\(\s*el\s*=>\s*\{(.*?)\}\s*\)",
self.js,
re.DOTALL,
)
self.assertIsNotNone(block_match, "Could not find data-i18n-title handler")
block = block_match.group(1)
# Must guard with hasAttribute('data-tooltip')
self.assertRegex(
block,
r"el\.hasAttribute\s*\(\s*['\"]data-tooltip['\"]\s*\)",
"data-tooltip sync does not guard on hasAttribute('data-tooltip')",
)
def test_native_title_cleared_when_custom_tooltip_present(self):
"""When the element has a custom data-tooltip, i18n.js must NOT also
set el.title (otherwise the slow ~1.5s native browser tooltip co-fires
alongside the fast custom CSS tooltip — exactly the bug #1775 reports).
It must explicitly removeAttribute('title') so any stale runtime
value gets dropped."""
block_match = re.search(
r"document\.querySelectorAll\(\s*'\[data-i18n-title\]'\s*\)"
r"\.forEach\s*\(\s*el\s*=>\s*\{(.*?)\}\s*\)",
self.js,
re.DOTALL,
)
self.assertIsNotNone(block_match, "Could not find data-i18n-title handler")
block = block_match.group(1)
self.assertRegex(
block,
r"removeAttribute\s*\(\s*['\"]title['\"]\s*\)",
"data-i18n-title handler must clear el.title when data-tooltip is "
"present so the native ~1.5s tooltip does not co-fire alongside "
"the fast custom CSS tooltip (#1775).",
)
def test_native_title_path_preserved_for_non_tooltip_elements(self):
"""Elements that opt OUT of custom tooltips (no data-tooltip attribute)
must still get el.title from data-i18n-title — falling back gracefully
to the native tooltip rather than rendering nothing."""
block_match = re.search(
r"document\.querySelectorAll\(\s*'\[data-i18n-title\]'\s*\)"
r"\.forEach\s*\(\s*el\s*=>\s*\{(.*?)\}\s*\)",
self.js,
re.DOTALL,
)
self.assertIsNotNone(block_match, "Could not find data-i18n-title handler")
block = block_match.group(1)
self.assertIn(
"el.title",
block,
"data-i18n-title handler must still assign el.title for "
"elements without data-tooltip (non-rail, non-nav surfaces).",
)
if __name__ == "__main__":
unittest.main()