Files
hermes-webui/tests/test_issue6080_model_picker_reparent.py
nesquena-hermesandwebtecnica fca5056589 fix: reparent mobile model picker to body so fixed positioning escapes footer (#6080)
The composer model dropdown (#composerModelDropdown) is a child of
.composer-footer, which sets container-type:inline-size (and, under the Geist
Contrast skin, a backdrop-filter). Both establish a fixed containing block, so
the phone position:fixed dropdown resolved against the FOOTER (pinned to the
bottom of the screen) instead of the visual viewport — dropping the menu below
the fold on all skins (the "tapping the model picker does nothing / opens
off-screen" symptom in #6080).

Fix (Fable UX gate recommendation on PR #6105): on the phone path
(<=640px) reparent the dropdown to <body> and switch it to position:fixed via a
.model-dropdown--floating class, exactly the working #profileDropdown idiom, so
fixed positioning is viewport-relative on every skin. The visualViewport math
from PR #6105 is preserved (viewport-relative coordinates, above/below flip
preferring the larger space, width = viewport - 16px, titlebar-bottom clamp) and
so are the rAF-coalesced visualViewport resize/scroll listeners.

Desktop (>640px) is unchanged: closeModelDropdown() and the desktop branch of
_positionModelDropdown() restore the element into its original .composer-footer
slot and clear the phone inline styles, so the desktop popover stays an
absolutely positioned footer child, byte-for-byte identical to master. Because
the menu now leaves the footer on phones, no per-skin backdrop-filter override
is needed.

Adds tests/test_issue6080_model_picker_reparent.py, including a node-executed
DOM-parentage guard that runs _positionModelDropdown()/closeModelDropdown()
against a stub DOM and asserts the open phone dropdown has NO .composer-footer
ancestor (escaping the container-type trap) and is restored into the footer on
close and on desktop.

Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
2026-07-16 00:27:47 +00:00

280 lines
13 KiB
Python

"""Regression guards for #6080 — mobile composer model picker lands off-screen.
Root cause: #composerModelDropdown is a child of .composer-footer, which sets
`container-type:inline-size` (and, under the Geist Contrast skin, a
`backdrop-filter`). Both establish a fixed containing block, so a phone
`position:fixed` dropdown resolves against the FOOTER (pinned to the bottom of
the screen) instead of the visual viewport, dropping the menu below the fold —
the "tapping the model picker does nothing / opens off-screen" symptom.
Fix (PR #6105 rework, credit @webtecnica): on the phone path the dropdown is
reparented to <body> and switched to position:fixed via a `--floating` class,
exactly the working #profileDropdown idiom, so `fixed` is viewport-relative on
ALL skins. Desktop (>640px) is unchanged: the menu stays an absolutely
positioned .composer-footer child.
The node-executed test below is the load-bearing one: it runs the real
`_positionModelDropdown` / `closeModelDropdown` against a stub DOM and asserts
DOM parentage, i.e. that the dropdown is NOT a descendant of the footer while
open on a phone (so no container-type ancestor can trap its fixed positioning),
and that it is restored into the footer on close and on desktop.
"""
import json
import re
import subprocess
import textwrap
from pathlib import Path
REPO_ROOT = Path(__file__).parent.parent.resolve()
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
def _function_body(src: str, marker: str) -> str:
start = src.index(marker)
depth = 0
opened = False
for idx, ch in enumerate(src[start:], start):
if ch == "{":
depth += 1
opened = True
elif ch == "}":
depth -= 1
if opened and depth == 0:
return src[start : idx + 1]
raise AssertionError(f"Could not extract function body for {marker}")
# ── Static structure guards ──────────────────────────────────────────────────
def test_css_has_floating_fixed_rule():
"""The reparented phone menu needs a body-level position:fixed rule."""
m = re.search(r"\.model-dropdown\.model-dropdown--floating\{([^}]*)\}", CSS)
assert m, ".model-dropdown--floating rule missing (phone reparent CSS)"
body = m.group(1)
assert "position:fixed" in body, (
"floating model dropdown must be position:fixed so it anchors to the "
"viewport once reparented to <body> (#6080)"
)
def test_no_geist_backdrop_override_added_for_model_dropdown():
"""Because the menu leaves the footer, the Geist backdrop-filter workaround
must NOT be introduced (the reparent is the fix, not a per-skin override)."""
assert "container-type:normal" not in CSS.replace(" ", ""), (
"do not neutralise container-type on the footer — reparent instead"
)
def test_position_fn_has_phone_reparent_branch():
body = _function_body(UI_JS, "function _positionModelDropdown(")
assert "matchMedia('(max-width:640px)')" in body, "phone breakpoint check missing"
assert "document.body.appendChild(dd)" in body, (
"phone path must reparent the dropdown to <body> to escape the "
".composer-footer containing block (#6080)"
)
assert "model-dropdown--floating" in body, "phone path must apply the floating class"
# PR #6105 visual-viewport math must be preserved.
assert "window.visualViewport" in body
assert "titlebar" in body and "app-titlebar" in body, "titlebar clamp preserved"
assert "openAbove" in body, "above/below flip preserved"
assert "menuWidth" in body and "margin*2" in body, "width=viewport-16px preserved"
def test_close_restores_home_parent():
body = _function_body(UI_JS, "function closeModelDropdown(")
assert "_restoreModelDropdownHome" in body, (
"closeModelDropdown must return the reparented menu to the footer"
)
def test_raf_coalesced_visualviewport_listeners_present():
assert "_repositionOpenModelDropdown" in UI_JS
assert "requestAnimationFrame" in _function_body(UI_JS, "function _repositionOpenModelDropdown(")
assert "window.visualViewport.addEventListener('resize',_repositionOpenModelDropdown)" in UI_JS
assert "window.visualViewport.addEventListener('scroll',_repositionOpenModelDropdown)" in UI_JS
# ── Load-bearing DOM-parentage guard (executed in node) ──────────────────────
def test_dropdown_escapes_footer_containing_block_when_open_on_phone():
"""Prove via real execution that the open phone dropdown is reparented to
<body> and is NOT a descendant of .composer-footer (the container-type
ancestor), and that it is restored into the footer on close / on desktop."""
block_start = UI_JS.index("let _modelDropdownHome=null;")
block_end = UI_JS.index("function _readModelOverflowData(")
position_block = UI_JS[block_start:block_end]
close_block = _function_body(UI_JS, "function closeModelDropdown(")
snippets = [position_block, close_block]
script = textwrap.dedent(
f"""
const assert = require('assert');
const snippets = {json.dumps(snippets)};
class ClassList {{
constructor(el) {{ this.el = el; this.set = new Set(); }}
add(n) {{ this.set.add(n); this._sync(); }}
remove(n) {{ this.set.delete(n); this._sync(); }}
contains(n) {{ return this.set.has(n); }}
toggle(n, f) {{
if (f === undefined) f = !this.set.has(n);
if (f) this.add(n); else this.remove(n);
return f;
}}
_sync() {{ this.el.className = [...this.set].join(' '); }}
}}
class Node {{
constructor(tag, id) {{
this.tagName = (tag || 'div').toUpperCase();
this.id = id || '';
this.className = '';
this.classList = new ClassList(this);
this.style = {{}};
this.children = [];
this.parentNode = null;
this.dataset = {{}};
this._rect = {{left:0, top:0, right:0, bottom:0, width:0, height:0}};
this.scrollHeight = 0; this.offsetHeight = 0;
this.offsetWidth = 0; this.clientWidth = 0;
this.offsetParent = null;
}}
_detach(child) {{
const i = this.children.indexOf(child);
if (i >= 0) this.children.splice(i, 1);
child.parentNode = null;
}}
appendChild(child) {{
if (child.parentNode) child.parentNode._detach(child);
child.parentNode = this; this.children.push(child); return child;
}}
insertBefore(child, ref) {{
if (child.parentNode) child.parentNode._detach(child);
child.parentNode = this;
const i = ref ? this.children.indexOf(ref) : -1;
if (i >= 0) this.children.splice(i, 0, child);
else this.children.push(child);
return child;
}}
get nextSibling() {{
if (!this.parentNode) return null;
const i = this.parentNode.children.indexOf(this);
return this.parentNode.children[i + 1] || null;
}}
getBoundingClientRect() {{ return this._rect; }}
}}
const body = new Node('body', 'body');
const footer = new Node('div'); // .composer-footer (container-type ancestor)
footer.className = 'composer-footer';
footer.clientWidth = 390;
footer._rect = {{left:0, top:560, right:390, bottom:600, width:390, height:40}};
const titlebar = new Node('div');
titlebar.className = 'app-titlebar';
titlebar._rect = {{left:0, top:0, right:390, bottom:44, width:390, height:44}};
const dd = new Node('div', 'composerModelDropdown');
dd.scrollHeight = 300; dd.offsetHeight = 300; dd.offsetWidth = 280;
const chip = new Node('button', 'composerModelChip');
chip.offsetParent = footer; // visible on the desktop composer
chip._rect = {{left:100, top:560, right:160, bottom:600, width:60, height:40}};
const mobileAction = new Node('button', 'composerMobileModelAction');
mobileAction.offsetParent = footer;
mobileAction._rect = {{left:20, top:560, right:120, bottom:600, width:100, height:40}};
const panel = new Node('div', 'composerMobileConfigPanel');
// dd starts life inside the footer, followed by a sibling — proves the
// insertBefore restore path returns it to the exact original slot.
const trailingSibling = new Node('div', 'uploadBarWrap');
footer.appendChild(dd);
footer.appendChild(trailingSibling);
const elements = new Map([
['composerModelDropdown', dd],
['composerModelChip', chip],
['composerMobileModelAction', mobileAction],
['composerMobileConfigPanel', panel],
]);
let PHONE = true;
globalThis.document = {{
body,
getElementById: (id) => elements.get(id) || null,
querySelector: (sel) => {{
if (sel === '.composer-footer') return footer;
if (sel === '.app-titlebar') return titlebar;
return null;
}},
}};
globalThis.window = {{
matchMedia: (q) => ({{ matches: q.indexOf('max-width:640px') !== -1 ? PHONE : false }}),
innerWidth: 390, innerHeight: 800,
visualViewport: {{ width: 390, height: 600, offsetTop: 0, offsetLeft: 0 }},
}};
globalThis.$ = (id) => elements.get(id) || null;
eval(snippets.join(String.fromCharCode(10)) + String.fromCharCode(10) + `;globalThis.__t = {{
position: _positionModelDropdown,
close: closeModelDropdown,
restore: _restoreModelDropdownHome,
}};`);
function ancestorIsFooter(el) {{
let p = el.parentNode;
while (p) {{ if (p === footer) return true; p = p.parentNode; }}
return false;
}}
// ── Phone: open ──────────────────────────────────────────────────────
PHONE = true;
dd.classList.add('open');
__t.position();
assert.strictEqual(dd.parentNode, body,
'open phone dropdown must be reparented to <body>');
assert.notStrictEqual(dd.parentNode, footer,
'open phone dropdown must NOT remain a footer child');
assert.strictEqual(ancestorIsFooter(dd), false,
'open phone dropdown must have NO .composer-footer ancestor — this is ' +
'what escapes the container-type/backdrop-filter fixed-containing-block trap (#6080)');
assert.strictEqual(dd.classList.contains('model-dropdown--floating'), true,
'phone dropdown must carry the floating (position:fixed) class');
assert.ok(dd.style.top && dd.style.top.endsWith('px'), 'phone path sets a px top');
assert.ok(dd.style.width && dd.style.width.endsWith('px'), 'phone path sets a px width');
// ── Phone: close restores it to the exact original footer slot ───────
__t.close();
assert.strictEqual(dd.parentNode, footer,
'closed dropdown must be restored into .composer-footer');
assert.strictEqual(dd.nextSibling, trailingSibling,
'restore must reinsert the dropdown before its original next sibling');
assert.strictEqual(dd.classList.contains('model-dropdown--floating'), false,
'close must drop the floating class');
assert.strictEqual(dd.style.top, '', 'close must clear the fixed inline top');
assert.strictEqual(dd.style.width, '', 'close must clear the inline width');
// ── Desktop: never reparents, stays an absolutely positioned footer child ──
PHONE = false;
dd.classList.add('open');
__t.position();
assert.strictEqual(dd.parentNode, footer,
'desktop dropdown must stay inside .composer-footer (behaviour identical to master)');
assert.strictEqual(dd.classList.contains('model-dropdown--floating'), false,
'desktop dropdown must not use the floating class');
assert.ok(dd.style.top === '' || dd.style.top === undefined || dd.style.top === null,
'desktop path must not write a fixed top');
console.log('OK');
"""
)
proc = subprocess.run(
["node", "-e", script],
cwd=REPO_ROOT,
check=False,
text=True,
capture_output=True,
)
assert proc.returncode == 0, (
f"node DOM-parentage assertions failed:\n{proc.stdout}\n{proc.stderr}"
)
assert "OK" in proc.stdout