import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type { ReconcilerTask } from '../core/reconciler-core.ts' import { NS } from '../i18n/locales.ts' import type { PanelExit } from './panel-exit.ts' import { getFrame } from '../platform/frame.ts' /** Fade the CURRENT backdrop out (pointer-events off + opacity 0). Called by * the gesture layer when a close commit starts animating, so the dimming * fades WITH the drawer's slide-out instead of vanishing after it. The * element itself is removed later by the task's normal remove path (the * marker flip schedules it). */ export function fadeOverlayOut(): void { fadeHook?.() } let fadeHook: (() => void) | null = null /** Removal is deferred by one fade so the dimming eases out instead of * snapping (user request 2026-08-29 「背景黑色遮罩进行渐变动画」; the fade-IN * already existed as a mount animation). */ const BACKDROP_FADE_MS = 200 /** The FAB's second face: an arrow, shown while a sidebar panel owns the main * area — both the subpage 「返回」 and the panel-root 「返回会话」 modes. */ const FAB_BACK_ICON = '' const FAB_DRAWER_ICON = '' /** * @param t - our dictionary (the `NS` locale namespace, i18n/locales.ts). * @param toggleSidebar - opens/closes the drawer. * @param panelExit - the sidebar-panel exit face (panel-exit.ts). The FAB is the * screen's only full-size control while a panel owns the main area — the * header toggle does not render there — so it also steps back: one host view * level on a panel subpage, out of the panel at its root. Null on a host that * cannot select panels (rc.6), where it stays a plain drawer button. */ export function createOverlayTask( t: TranslateNS, toggleSidebar: () => void, panelExit: PanelExit | null, ): ReconcilerTask { let backdrop: HTMLDivElement | null = null let fab: HTMLButtonElement | null = null let backdropRemoveTimer: number | null = null /** True while the backdrop carries our inline faded state. Guards the * restore branch: while a late close commit is animating, the marker is * STILL open, so a plain open-branch restore would undo the pre-fade. */ let faded = false const drawerOpen = (): boolean => { const frame = getFrame() if (frame === null) return false // The user's call (2026-09-13): our drawer is the one users get, even on // hosts that ship their own overlay drawer. The host's version measures // 321px wide with z-index:1100 and, notably, NO full-screen backdrop at all // (measured: the conversation stays hit-testable beside it), which is the // behaviour the phone owner rejected as unusable. So the legacy column // rules stay armed and this backdrop keeps being created. return !frame.hasAttribute('data-sidebar-collapsed') } const heroPhase = (): boolean => document.querySelector('[data-phase="active"]') === null /** * The FAB does one job per view. While a sidebar panel owns the main area it * steps back through the panel and everywhere else it opens the drawer. * * It matters because the FAB is the ONLY full-size control on screen there: * the panel replaces the conversation, so the header (and with it the drawer * toggle) does not render. A panel subpage does ship the host's own * 「返回上一级」 control (moreBack / crumb), but it sits in the same top-left * corner and is geometrically covered by the FAB — so the FAB has to be the * one that climbs a level. */ const onFabClick = (event: MouseEvent): void => { if (panelExit !== null && panelExit.panelOpen()) { event.preventDefault() event.stopPropagation() // One level, not the whole panel: on a plugin subpage this clicks the // host's own 「返回上一级」 (see back in panel-exit.ts). panelExit.back() return } toggleSidebar() } /** * Icon and accessible name follow the view so the button never reads as a * mystery control. Three states, two icons: open-drawer (no panel), * back-level (panel subpage — same arrow, named 「返回」, because it climbs a * host view level and does not leave the panel) and exit-panel (panel root — * named 「返回会话」). Idempotent: ensure() runs on every mutation burst. */ const syncFab = (): void => { if (fab === null) return const onPanel = panelExit !== null && panelExit.panelOpen() const onSubpage = onPanel && panelExit !== null && panelExit.subpageOpen() const mode = onSubpage ? 'back-level' : onPanel ? 'exit-panel' : 'open-drawer' if (fab.dataset.mobileNavFabMode === mode) return fab.dataset.mobileNavFabMode = mode const label = onSubpage ? t('back') : onPanel ? t('backToConversation') : t('open') fab.setAttribute('aria-label', label) fab.title = label fab.innerHTML = onPanel ? FAB_BACK_ICON : FAB_DRAWER_ICON } return { name: 'overlay-backdrop-fab', scopes: ['*', 'data-sidebar-collapsed', 'data-phase'], ensure: () => { // Re-armed on every ensure, not once in the factory: core.deactivate() // runs dispose() on every MOBILE_QUERY flip (reconciler-core.ts), and // reactivation only re-runs ensure() - a hook assigned in the factory // body stayed null from the first breakpoint flip onwards, silently // killing the backdrop fade. fadeHook = (): void => { if (backdrop === null) return faded = true backdrop.style.pointerEvents = 'none' backdrop.style.opacity = '0' } const frame = getFrame() if (frame === null) return if (drawerOpen()) { if (backdrop === null) { backdrop = document.createElement('div') backdrop.dataset.mobileNav = 'backdrop' backdrop.setAttribute('role', 'button') backdrop.setAttribute('aria-label', t('backdrop')) // No element-level click listener: phone-chrome's capture-phase click // handler owns the backdrop tap. The third-party mobile shim stops // click propagation at the frame for anything outside the drawer, so // a listener down here never sees the tap (2026-09-14). frame.appendChild(backdrop) faded = false } else if (faded && backdropRemoveTimer !== null) { // Quick close→reopen inside the fade window: cancel the pending // removal and let the CSS transition ease the dimming back in. window.clearTimeout(backdropRemoveTimer) backdropRemoveTimer = null faded = false backdrop.style.removeProperty('pointer-events') backdrop.style.removeProperty('opacity') } } else if (backdrop !== null) { backdrop.style.pointerEvents = 'none' backdrop.style.opacity = '0' faded = true if (backdropRemoveTimer === null) { backdropRemoveTimer = window.setTimeout(() => { backdropRemoveTimer = null backdrop?.remove() backdrop = null }, BACKDROP_FADE_MS + 60) } } if (heroPhase() && !drawerOpen() && fab === null) { fab = document.createElement('button') fab.type = 'button' fab.dataset.mobileNav = 'fab' fab.setAttribute('aria-label', t('open')) fab.title = t('open') fab.addEventListener('click', onFabClick) frame.appendChild(fab) } else if ((!heroPhase() || drawerOpen()) && fab !== null) { fab.remove() fab = null } syncFab() }, dispose: () => { if (backdropRemoveTimer !== null) { window.clearTimeout(backdropRemoveTimer) backdropRemoveTimer = null } fadeHook = null backdrop?.remove() backdrop = null fab?.remove() fab = null }, } }