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
},
}
}