fix(panels): mount the panel host inside the shell's framed content viewport
On DSH Desktop the plugin's panel layer covered host modals — the community
market dialog (dsh-community-market, which renders into the shell's own
`shell.overlay` slot) had its lower-left corner clipped by our workbench.
The fence is not a z-index mistake on either side. The shell's framed modes do:
const contentViewport = document.getElementById('root')
document.body.dataset.dshDesktopMode = environment.mode
/* …and tags #root with data-dsh-desktop-content-viewport */
with
body:is([data-dsh-desktop-mode="compatibility"], […"extended"]) [data-dsh-desktop-content-viewport] {
isolation: isolate;
}
`isolation: isolate` makes #root a stacking context (z-index: auto) wrapping the
whole app INCLUDING the shell's overlay layer (`.dshDesktopOverlay`,
z-index: 1000) that hosts plugin overlays. Our host was appended to
document.body — OUTSIDE that context — and because it is a positioned element
with z-index 25 it painted above the entire context, so the market's 1000 never
competed. The shell's overlay layer is the boundary plugins are meant to sit
under, so the fix is to join the layer, not to renumber ours: panelHostParent()
mounts the host into #root when the shell framed one (body otherwise, i.e. the
plain web deployment keeps today's behaviour), where the documented 25 keeps
its meaning — above the app frame (z-index auto), below the overlay layer.
The re-anchor guard now re-appends into the resolved parent instead of always
`document.body`, and also watches `data-dsh-desktop-mode` on <body> (the same
shell effect that stamps the viewport) so a shell that frames #root after our
mount still gets the host moved.
Verified: typecheck clean; build clean; 1367 passed / 9 skipped (131 files,
+3 parenting specs). Revert is one line per call site if the shell's overlay
layer turns out to sit below our panels for a reason not visible in the CSS.
This commit is contained in:
@@ -70,3 +70,24 @@ function parseTitlebarInset(raw: string | null): number {
|
||||
export function resetDesktopEnvForTests(): void {
|
||||
cached = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The element the sidebar's panel host belongs in.
|
||||
*
|
||||
* The desktop shell stamps the upstream `#root` with
|
||||
* `data-dsh-desktop-content-viewport` and gives it `isolation: isolate` — a
|
||||
* stacking context around the whole app INCLUDING the shell's own overlay
|
||||
* layer (`.dshDesktopOverlay`, z-index 1000), which is where a plugin's modal
|
||||
* renders (the community market's, for one). A host parented to
|
||||
* `document.body` sits OUTSIDE that context, so the panel layer's documented
|
||||
* 25 outranks everything inside it and covered host modals; inside the
|
||||
* viewport the same 25 keeps its meaning — above the app frame (z-index auto)
|
||||
* and below the overlay layer (1000).
|
||||
*
|
||||
* Every other deployment (plain web, an unframed shell) keeps the body mount.
|
||||
* @param doc - the document to resolve against.
|
||||
* @returns the content viewport when the shell framed one, else the body.
|
||||
*/
|
||||
export function panelHostParent(doc: Document = document): HTMLElement {
|
||||
return doc.querySelector<HTMLElement>('#root[data-dsh-desktop-content-viewport]') ?? doc.body
|
||||
}
|
||||
|
||||
+22
-9
@@ -27,6 +27,7 @@ import { registerLinkInterception } from './link-intercept.ts'
|
||||
import { registerImeGuard } from './ime-guard.ts'
|
||||
import { registerSettingsNavIcon } from './settings-nav-icon.ts'
|
||||
import { loadBootDecision } from './prefs.ts'
|
||||
import { panelHostParent } from './desktop-env.ts'
|
||||
import { SideCardSection } from './SideCardSection.tsx'
|
||||
import { api } from './api.ts'
|
||||
import { LOCALE_NS, attachLocale, attachBetterLocale, t, zh, en } from './locales.ts'
|
||||
@@ -259,17 +260,26 @@ export function apply(ctx: Context): void {
|
||||
host?.remove()
|
||||
host = undefined
|
||||
}
|
||||
/** Re-attach the host if the page (a desktop shell wrapper, SPA
|
||||
* navigation, …) ever removes it from <body>. Cheap: childList only,
|
||||
* no subtree, no attribute filtering. */
|
||||
/** Put the host in the layer it belongs to — re-attaching it if the
|
||||
* page (a desktop shell wrapper, SPA navigation, …) removed it, and
|
||||
* moving it if the shell framed the content viewport after our mount
|
||||
* (see panelHostParent). */
|
||||
const anchorHost = (): void => {
|
||||
if (host === undefined) return
|
||||
const parent = panelHostParent()
|
||||
if (host.parentElement !== parent) parent.appendChild(host)
|
||||
}
|
||||
/** Watch for the host being removed, and for the shell stamping its
|
||||
* framed mode onto <body> (the same effect tags #root as the content
|
||||
* viewport, which is where the host must live). */
|
||||
const guardAnchor = (): void => {
|
||||
if (bodyObserver !== undefined) return
|
||||
bodyObserver = new MutationObserver(() => {
|
||||
if (host !== undefined && !document.body.contains(host)) {
|
||||
document.body.appendChild(host)
|
||||
}
|
||||
bodyObserver = new MutationObserver(anchorHost)
|
||||
bodyObserver.observe(document.body, {
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['data-dsh-desktop-mode'],
|
||||
})
|
||||
bodyObserver.observe(document.body, { childList: true })
|
||||
}
|
||||
/** One-shot geometry self-check: if the host page transforms
|
||||
* <html>/<body> itself (exotic shells), a fixed panel host would
|
||||
@@ -323,7 +333,10 @@ export function apply(ctx: Context): void {
|
||||
try {
|
||||
host = document.createElement('div')
|
||||
host.setAttribute('data-dsh-apes-sidebar', '')
|
||||
document.body.appendChild(host)
|
||||
// The shell's framed content viewport when there is one (see
|
||||
// panelHostParent): it is a stacking context, so a body-level host
|
||||
// would paint above every overlay the shell hosts inside it.
|
||||
panelHostParent().appendChild(host)
|
||||
root = createRoot(host)
|
||||
root.render(createElement(RenderBoundary, { className: css.boundaryError }, createElement(Sidebar, { ctx, store: sidebarStore })))
|
||||
mounted = true
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Panel-host parenting tests — the layering half of a real defect: on DSH
|
||||
* Desktop's framed modes the shell tags the upstream `#root` with
|
||||
* `data-dsh-desktop-content-viewport` and gives it `isolation: isolate`, a
|
||||
* stacking context around the whole app INCLUDING the shell's overlay layer
|
||||
* (`.dshDesktopOverlay`, z-index 1000 — where a plugin's modal renders, e.g.
|
||||
* the community market's). A host parented to `document.body` sits outside
|
||||
* that context, so the panel layer's documented 25 outranked every overlay
|
||||
* inside it and covered host modals.
|
||||
*
|
||||
* jsdom: the resolution is a DOM query, so the stub-globals spec style used
|
||||
* by desktop-env.spec.ts cannot exercise it.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { panelHostParent } from '../src/client/desktop-env.ts'
|
||||
|
||||
beforeEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
describe('panelHostParent', () => {
|
||||
it('falls back to the body when the shell framed no content viewport', () => {
|
||||
document.body.innerHTML = '<div id="root"></div>'
|
||||
expect(panelHostParent(document)).toBe(document.body)
|
||||
})
|
||||
|
||||
it('falls back to the body when there is no root at all (plain web)', () => {
|
||||
expect(panelHostParent(document)).toBe(document.body)
|
||||
})
|
||||
|
||||
it('resolves the framed content viewport when the shell stamped one', () => {
|
||||
document.body.innerHTML = '<div id="root" data-dsh-desktop-content-viewport=""><div class="dshDesktopFrame"></div></div>'
|
||||
const root = document.getElementById('root')
|
||||
expect(panelHostParent(document)).toBe(root)
|
||||
// The host lands INSIDE the shell's stacking context — beside the overlay
|
||||
// layer the shell renders there, and never as a body-level sibling.
|
||||
const host = document.createElement('div')
|
||||
panelHostParent(document).appendChild(host)
|
||||
expect(root?.contains(host)).toBe(true)
|
||||
expect(document.body.lastElementChild).toBe(root)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user