diff --git a/src/client/desktop-env.ts b/src/client/desktop-env.ts index 49ffdcb..3b61339 100644 --- a/src/client/desktop-env.ts +++ b/src/client/desktop-env.ts @@ -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('#root[data-dsh-desktop-content-viewport]') ?? doc.body +} diff --git a/src/client/index.tsx b/src/client/index.tsx index ee7fc73..8cf08ed 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -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 . 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 (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 * / 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 diff --git a/tests/panel-host-parent.spec.ts b/tests/panel-host-parent.spec.ts new file mode 100644 index 0000000..583e014 --- /dev/null +++ b/tests/panel-host-parent.spec.ts @@ -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 = '
' + 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 = '
' + 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) + }) +})