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>
This commit is contained in:
nesquena-hermesandwebtecnica committed 2026-07-16 00:27:47 +00:00
1 parent 7a862c9f6d
commit fca5056589
3 files changed
+395 -2

No files matched your search

+8
View File
@@ -3247,6 +3247,14 @@
.ws-dropdown-footer{left:0;right:auto;bottom:calc(100% + 4px);min-width:280px;max-width:min(420px,calc(100vw - 32px));}
.model-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;min-width:280px;max-width:min(420px,calc(100vw - 32px));background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:min(70vh,640px);overflow-y:auto;}
.model-dropdown.open{display:block;}
/* Phone path (#6080): the model picker is reparented to <body> and switched to
position:fixed by _positionModelDropdown() so it escapes the .composer-footer
containing block (container-type:inline-size, plus a backdrop-filter under the
Geist Contrast skin — both establish a fixed containing block that would
otherwise resolve `fixed` against the FOOTER, dropping the menu below the
fold). Same idiom as #profileDropdown. JS owns left/top/width/maxHeight; this
rule only flips positioning and relaxes the desktop width clamps. */
.model-dropdown.model-dropdown--floating{position:fixed;left:0;bottom:auto;min-width:0;}
.model-scope-note{position:sticky;top:0;z-index:2;padding:9px 14px;border-bottom:1px solid var(--border);color:var(--text);font-size:11px;font-weight:650;line-height:1.4;background:color-mix(in srgb,var(--surface) 82%,var(--accent-bg));box-shadow:0 1px 0 rgba(0,0,0,.12);}
.model-group{padding:8px 14px 4px;font-size:10px;font-weight:700;letter-spacing:.04em;color:var(--muted);text-transform:uppercase;border-top:1px solid var(--border2);margin-top:2px;}.model-group.collapsible{cursor:pointer;user-select:none}.model-group.collapsible:before{content:'\25b6';display:inline-block;font-size:8px;margin-right:5px;transition:transform .15s ease}.model-group.collapsible.open:before{transform:rotate(90deg)}.model-group-body:empty{display:none}
.model-group.sub{padding:4px 14px 2px 22px;font-size:9px;border-top:none;margin-top:0;letter-spacing:.03em;color:var(--muted);background:transparent;}.model-group.sub.collapsible:before{content:'\25b6';display:inline-block;font-size:7px;margin-right:4px;transition:transform .15s ease}.model-group.sub.collapsible.open:before{transform:rotate(90deg)}.model-group-body.sub{padding-left:8px;border-left:1px solid var(--border);}
+108 -2
View File
@@ -3714,6 +3714,35 @@ function syncModelChip(){
if(mobileAction) mobileAction.classList.toggle('active',!!(dd&&dd.classList.contains('open')));
}
// Remembers where #composerModelDropdown lives in the composer-footer so the
// phone path can move it to <body> and put it back exactly. Captured lazily on
// the first reparent (see _positionModelDropdown phone branch).
let _modelDropdownHome=null;
// Return the model dropdown into its original .composer-footer slot and clear
// every inline style the phone path wrote, so the desktop CSS (position:absolute
// anchored on the relatively-positioned .composer-footer) fully governs again.
// Safe to call when the element never moved — it just no-ops the reinsert.
function _restoreModelDropdownHome(){
const dd=document.getElementById('composerModelDropdown');
if(!dd) return;
dd.classList.remove('model-dropdown--floating');
dd.style.left='';
dd.style.top='';
dd.style.bottom='';
dd.style.width='';
dd.style.maxWidth='';
dd.style.maxHeight='';
if(_modelDropdownHome&&_modelDropdownHome.parent&&dd.parentNode!==_modelDropdownHome.parent){
const ref=_modelDropdownHome.nextSibling;
if(ref&&ref.parentNode===_modelDropdownHome.parent){
_modelDropdownHome.parent.insertBefore(dd,ref);
}else{
_modelDropdownHome.parent.appendChild(dd);
}
}
}
function _positionModelDropdown(){
const dd=$('composerModelDropdown');
const chip=$('composerModelChip');
@@ -3723,9 +3752,62 @@ function _positionModelDropdown(){
const panel=$('composerMobileConfigPanel');
const anchor=(panel&&panel.classList.contains('open')&&mobileAction)?mobileAction:(chip&&chip.offsetParent?chip:mobileAction);
if(!anchor) return;
const chipRect=anchor.getBoundingClientRect();
const isPhone=typeof window.matchMedia==='function'&&window.matchMedia('(max-width:640px)').matches;
if(isPhone){
// #6080: .composer-footer sets container-type:inline-size (and a
// backdrop-filter under the Geist Contrast skin) — both establish a fixed
// containing block, so a position:fixed dropdown left inside the footer
// resolves against the FOOTER (bottom of screen) instead of the viewport
// and lands below the fold. Reparent to <body> — exactly the working
// #profileDropdown idiom — so position:fixed is viewport-relative on ALL
// skins, then compute coordinates against the visual viewport.
if(!_modelDropdownHome){
_modelDropdownHome={parent:dd.parentNode,nextSibling:dd.nextSibling};
}
if(dd.parentNode!==document.body) document.body.appendChild(dd);
dd.classList.add('model-dropdown--floating');
const anchorRect=anchor.getBoundingClientRect();
const visualViewport=window.visualViewport;
const viewportWidth=Math.max(1,Number(visualViewport&&visualViewport.width)||window.innerWidth||1);
const viewportHeight=Math.max(1,Number(visualViewport&&visualViewport.height)||window.innerHeight||1);
const viewportTop=Math.max(0,Number(visualViewport&&visualViewport.offsetTop)||0);
const viewportBottom=viewportTop+viewportHeight;
const margin=8;
const gap=6;
const viewportLeft=Math.max(0,Number(visualViewport&&visualViewport.offsetLeft)||0);
const viewportRight=viewportLeft+viewportWidth;
const titlebar=document.querySelector('.app-titlebar');
const titlebarBottom=titlebar&&typeof titlebar.getBoundingClientRect==='function'
? Number(titlebar.getBoundingClientRect().bottom)||0
: 0;
const contentTop=Math.max(viewportTop+margin,titlebarBottom+margin);
const menuWidth=Math.max(1,viewportWidth-margin*2);
const left=Math.max(viewportLeft+margin,Math.min(anchorRect.left,viewportRight-menuWidth-margin));
dd.style.left=`${left}px`;
dd.style.width=`${menuWidth}px`;
dd.style.maxWidth=`${menuWidth}px`;
dd.style.bottom='auto';
const menuHeight=Math.max(dd.scrollHeight,dd.offsetHeight);
const aboveSpace=Math.max(0,anchorRect.top-contentTop-gap-margin);
const belowSpace=Math.max(0,viewportBottom-anchorRect.bottom-gap-margin);
const openAbove=aboveSpace>=Math.min(menuHeight,belowSpace)||aboveSpace>=belowSpace;
const availableHeight=Math.max(1,openAbove?aboveSpace:belowSpace);
dd.style.maxHeight=`${availableHeight}px`;
const visibleHeight=Math.min(menuHeight||availableHeight,availableHeight);
const top=openAbove
? anchorRect.top-gap-visibleHeight
: anchorRect.bottom+gap;
dd.style.top=`${Math.max(contentTop,Math.min(top,viewportBottom-margin-visibleHeight))}px`;
return;
}
// Desktop (>640px): keep the current master behaviour — an absolutely
// positioned .composer-footer child. Restore the element into the footer (in
// case a prior phone open moved it to <body>) and clear the phone inline
// styles so the desktop CSS anchor is byte-for-byte identical to master.
_restoreModelDropdownHome();
const anchorRect=anchor.getBoundingClientRect();
const footerRect=footer.getBoundingClientRect();
let left=chipRect.left-footerRect.left;
let left=anchorRect.left-footerRect.left;
const maxLeft=Math.max(0, footer.clientWidth-dd.offsetWidth);
left=Math.max(0, Math.min(left, maxLeft));
dd.style.left=`${left}px`;
@@ -4532,6 +4614,10 @@ function closeModelDropdown(){
if(dd) dd.classList.remove('open');
if(chip) chip.classList.remove('active');
if(mobileAction) mobileAction.classList.remove('active');
// If the phone path reparented the menu onto <body>, put it back in the
// footer and clear the fixed-position inline styles so the DOM returns to its
// baseline shape and the next desktop open anchors correctly (#6080).
if(typeof _restoreModelDropdownHome==='function') _restoreModelDropdownHome();
}
function closeSettingsModelDropdown(){
@@ -4653,6 +4739,26 @@ window.addEventListener('resize',()=>{
}
});
// visualViewport resize/scroll fire on mobile when the on-screen keyboard opens
// or the URL bar collapses/expands — the phone dropdown is fixed to the visual
// viewport, so it must be re-measured against the new offsets. Coalesce with rAF
// so a burst of scroll/resize events triggers at most one reposition per frame.
let _modelDropdownRepositionScheduled=false;
function _repositionOpenModelDropdown(){
const dd=$('composerModelDropdown');
if(!(dd&&dd.classList.contains('open'))||_modelDropdownRepositionScheduled) return;
_modelDropdownRepositionScheduled=true;
requestAnimationFrame(()=>{
_modelDropdownRepositionScheduled=false;
const openDd=$('composerModelDropdown');
if(openDd&&openDd.classList.contains('open')) _positionModelDropdown();
});
}
if(window.visualViewport){
window.visualViewport.addEventListener('resize',_repositionOpenModelDropdown);
window.visualViewport.addEventListener('scroll',_repositionOpenModelDropdown);
}
// ── Fit-based composer footer collapse ──────────────────────────────────────
// Stage classes on .composer-footer:
// (none) full labels · .cf-icons icon chips · .cf-icons.cf-burger hamburger.
@@ -0,0 +1,279 @@
"""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