Files
apes-sidebar/src/client/desktop-env.ts
T
figmar 4422001c5e
CI / ci (push) Canceled after 0s
CI / ci-windows (push) Canceled after 0s
CI / plugin-mount (push) Canceled after 0s
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.
2026-10-04 00:08:13 +08:00

94 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Desktop-shell detection for the sidebar. Shells may stamp the render URL
* with `dsh-desktop-mode` / `dsh-desktop-platform` (the official Electron
* shell does) and expose `window.__DSH_DESKTOP_FILE_PATH__` through a
* preload. Parsed once per page and memoized (the URL never changes
* mid-session); `resetDesktopEnvForTests` clears the memo for unit tests.
*
* GEOMETRY POLICY: this module only REPORTS shell facts — it never decides
* how to adapt. The strip height comes from standard signals first (the
* Window Controls Overlay API, see wco.ts), then the documented contract
* parameter `dsh-desktop-titlebar-inset` (a shell may stamp the real pixels
* it reserves at the top), then the user's chosen scheme (preset / custom).
* The legacy win32-advanced 32px constant is gone from the core: it lives
* in the opt-in shell preset (shell-presets.ts) as a fallback for shells
* without the WCO API.
*/
export interface DesktopEnv {
/** Running inside a desktop shell (any URL stamp or preload marker). */
readonly desktop: boolean
/** `advanced` = frameless/custom-titlebar shell; `compatibility` = native frame. */
readonly mode: 'compatibility' | 'advanced' | null
/** Shell platform stamp ('darwin' | 'win32' | …), lowercased, or null. */
readonly platform: string | null
/**
* Contract parameter `dsh-desktop-titlebar-inset`: pixels the shell
* reserves at the top of the web content for its own chrome (0–120,
* clamped; 0 when absent). Standard WCO geometry takes precedence over
* this whenever the API is available.
*/
readonly titlebarInset: number
}
let cached: DesktopEnv | undefined
/** Read the shell's desktop stamps (memoized per page; SSR-safe). */
export function parseDesktopEnv(): DesktopEnv {
if (cached !== undefined) return cached
const hasWindow = typeof window !== 'undefined'
const hasPreloadMarker = hasWindow
&& typeof (window as { __DSH_DESKTOP_FILE_PATH__?: unknown }).__DSH_DESKTOP_FILE_PATH__ !== 'undefined'
// location.search includes the leading '?', which URLSearchParams does NOT
// strip (it would become part of the first key) — drop it explicitly.
// SSR (no window) resolves the empty environment: nothing desktop.
const params = hasWindow
? new URLSearchParams(window.location.search.replace(/^\?/, ''))
: new URLSearchParams()
const modeParam = params.get('dsh-desktop-mode')
const mode = modeParam === 'compatibility' || modeParam === 'advanced' ? modeParam : null
const platformParam = params.get('dsh-desktop-platform')
const platform = platformParam !== null && platformParam !== '' ? platformParam.toLowerCase() : null
const desktop = mode !== null || hasPreloadMarker
cached = {
desktop,
mode,
platform,
titlebarInset: parseTitlebarInset(params.get('dsh-desktop-titlebar-inset')),
}
return cached
}
/** Clamp the contract inset parameter into 0–120 (invalid/absent → 0). */
function parseTitlebarInset(raw: string | null): number {
if (raw === null) return 0
const parsed = Number(raw)
if (!Number.isFinite(parsed)) return 0
return Math.min(120, Math.max(0, Math.round(parsed)))
}
/** Test hook: drop the memo so the next parse re-reads the URL/globals. */
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
}