PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.5
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.5
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / assets / js / window-system.js

window-system.js in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.5, at assets/js/window-system.js

7,799 lines 278.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 (function() {
2 "use strict";
3 function getWpHooks() {
4 const hooks = window.wp?.hooks;
5 if (!hooks) {
6 throw new Error(
7 "[desktop-mode] `window.wp.hooks` is not available. The plugin declares `wp-hooks` as a script dependency; if you are seeing this error, verify the enqueue order."
8 );
9 }
10 return hooks;
11 }
12 function addAction(hookName2, namespace, callback, priority) {
13 getWpHooks().addAction(
14 hookName2,
15 namespace,
16 callback,
17 priority
18 );
19 }
20 function removeAction(hookName2, namespace) {
21 return getWpHooks().removeAction(hookName2, namespace);
22 }
23 function applyFilters(hookName2, value, ...args) {
24 return getWpHooks().applyFilters(hookName2, value, ...args);
25 }
26 function doAction(hookName2, ...args) {
27 getWpHooks().doAction(hookName2, ...args);
28 }
29 const HOOKS = {
30 /** Action, fires once after shell boot; plugins register here. */
31 INIT: "desktop-mode.init",
32 /** Filter, receives the wallpaper registry array. */
33 WALLPAPERS: "desktop-mode.wallpapers",
34 /** Filter, receives the unfocused-window effect registry array. */
35 UNFOCUS_EFFECTS: "desktop-mode.unfocus-effects",
36 /** Action before a canvas wallpaper mounts. */
37 WALLPAPER_MOUNTING: "desktop-mode.wallpaper.mounting",
38 /** Action after a canvas wallpaper mounts successfully. */
39 WALLPAPER_MOUNTED: "desktop-mode.wallpaper.mounted",
40 /** Action before a canvas wallpaper tears down. */
41 WALLPAPER_UNMOUNTING: "desktop-mode.wallpaper.unmounting",
42 /** Action when a canvas wallpaper's mount throws / rejects. */
43 WALLPAPER_MOUNT_FAILED: "desktop-mode.wallpaper.mount-failed",
44 /** Action mirroring document.visibilitychange for active canvas wallpapers. */
45 WALLPAPER_VISIBILITY: "desktop-mode.wallpaper.visibility",
46 /**
47 * Filter, receives a wallpaper's preview params (seeded from the
48 * def's `previewParams`) before its `renderPreview` runs in the OS
49 * Settings picker. Args: `( params, wallpaperId )`.
50 */
51 WALLPAPER_PREVIEW_PARAMS: "desktop-mode.wallpaper.preview-params",
52 /**
53 * Action, fires after a wallpaper's persisted settings change (the
54 * user edited them through the wallpaper's config dialog in OS
55 * Settings). Payload: `{ id, settings }` — the wallpaper id and the
56 * full post-merge settings object. A mounted wallpaper subscribes to
57 * live-apply changes without a remount.
58 *
59 * @since 0.9.5
60 */
61 WALLPAPER_SETTINGS_CHANGED: "desktop-mode.wallpaper.settings-changed",
62 // ------------------------------------------------------------------
63 // Observability — iframe errors, iframe network, shell-side errors,
64 // monitor entry aggregation. Designed for dashboard / debug widget
65 // plugins that want genuine admin observability (Gutenberg save
66 // failures, admin-ajax 500s, plugin exceptions) rather than just the
67 // shell's own console-error surface.
68 // ------------------------------------------------------------------
69 /**
70 * Action, fires once per iframe when the chromeless bridge
71 * script has finished wiring its message listeners. Payload:
72 * `{ windowId: string }`. Subscribers get a reliable "safe to
73 * talk to this iframe" signal — the browser's native `load`
74 * event fires before our bridge attaches, so messages sent on
75 * `load` can be dropped on the floor. Use this instead when
76 * timing matters (first-focus dispatch, auto-fill handshakes).
77 *
78 * @since 0.5.0
79 */
80 IFRAME_READY: "desktop-mode.iframe.ready",
81 /**
82 * Action, fires when a chromeless iframe's `error` or
83 * `unhandledrejection` handler catches an exception. Payload: `{
84 * windowId: string, kind: 'error' | 'unhandledrejection', message:
85 * string, filename: string | null, lineno: number | null, colno:
86 * number | null, stack: string | null }`. Origin-filtered at the
87 * parent shell; cross-origin iframe errors never reach here.
88 */
89 IFRAME_ERROR: "desktop-mode.iframe.error",
90 /**
91 * Action, fires when a `fetch` or `XMLHttpRequest` inside a
92 * chromeless iframe completes (success OR failure). Payload: `{
93 * windowId: string, method: string, url: string, status: number,
94 * duration: number, failed: boolean }`. Subscribers get a faithful
95 * view of admin-ajax + REST calls that previously never left the
96 * iframe boundary. `status === 0` indicates a network failure with
97 * no response received.
98 */
99 IFRAME_NETWORK_COMPLETED: "desktop-mode.iframe.network-completed",
100 /**
101 * Action, fires when one of the shell's own try/catch barriers
102 * catches an exception. Payload: `{ scope:
103 * 'widget-mount' | 'widget-teardown' | 'window-open' | 'wallpaper-mount' |
104 * 'wallpaper-teardown' | 'session-save' | 'menu-refresh' | string,
105 * id?: string, error: unknown }`. Paired with the existing
106 * `console.error` calls — a monitor widget can surface these as
107 * first-class entries.
108 */
109 SHELL_ERROR: "desktop-mode.shell.error",
110 /**
111 * Action, fires once per `wp.desktop.broadcast()` call with the
112 * fully-resolved `{ topic, payload }` detail. Lets plugins log,
113 * mirror, or augment broadcast traffic without subscribing for
114 * every individual topic.
115 */
116 BROADCAST: "desktop-mode.broadcast",
117 /**
118 * Filter, applies to a `MonitorEntry` before a monitor widget
119 * renders it. Plugins can mutate the entry (rewrite the message,
120 * add `extra` fields) or return `null` to suppress it. Used by
121 * monitor widgets to converge every plugin on the same shape —
122 * see `MonitorEntry` in `src/types.ts`.
123 */
124 MONITOR_ENTRY: "desktop-mode.monitor.entry",
125 /**
126 * Filter, applies to the list of "solid" surfaces wallpapers
127 * should consider for collision / accumulation effects (snow
128 * piling, leaves settling, rain splash). Seeded by the shell
129 * with: every visible (non-minimized) window's top edge; the
130 * desktop-area floor; the dock's outward-facing edge; and every
131 * mounted widget card's top edge.
132 *
133 * Plugins that own their own DOM (e.g. floating pickers,
134 * custom overlays) can push additional surfaces so snow
135 * accumulates on them too.
136 *
137 * Each entry is a `WallpaperSurface` — see
138 * `src/wallpapers/surfaces.ts` for the shape. Rects are in
139 * viewport coordinates (clientX / clientY), matching what a
140 * canvas mounted inside `#desktop-mode-wallpaper` reads.
141 */
142 WALLPAPER_SURFACES: "desktop-mode.wallpaper.surfaces",
143 // ------------------------------------------------------------------
144 // Window lifecycle actions. All payloads share a `windowId: string`
145 // field; additional fields are documented per-hook in the JS
146 // reference. These mirror the existing `desktop-mode-window-*`
147 // CustomEvents but ship under the hook bus so plugins can use one
148 // idiomatic API for everything the shell emits.
149 // ------------------------------------------------------------------
150 /**
151 * Filter, last call before a window's resolved geometry (x, y,
152 * width, height, initialState) is baked into the `WindowConfig`
153 * passed to the `Window` constructor. Lets a plugin override
154 * default placement for windows it owns, snap restored bounds to
155 * a different region, or force a particular initial state.
156 *
157 * Signature:
158 *
159 * ( geometry: ResolvedWindowGeometry, ctx: WindowGeometryContext )
160 * => ResolvedWindowGeometry
161 *
162 * Where `ResolvedWindowGeometry = { x, y, width, height, state? }`
163 * and `ctx = { windowId, baseId, hasSavedGeometry, callerPinned,
164 * desktopRect }`.
165 *
166 * - `hasSavedGeometry` is `true` when the user previously
167 * dragged or resized this window and the resolved geometry
168 * includes those restored values. Plugins that want to
169 * "leave the user's saved layout alone" should bail when
170 * this is true.
171 * - `callerPinned` is `true` when the caller of `manager.open()`
172 * passed at least one of `{ x, y, width, height, initialState }`
173 * explicitly. For NATIVE windows this is usually true (the
174 * framework's native-window opener passes the registry's
175 * declared dimensions); for admin-page iframe windows opened
176 * from the dock this is usually false. The filter is free to
177 * override registry defaults — `callerPinned: true` does NOT
178 * mean "leave it alone."
179 *
180 * The shell re-clamps `width`/`height` to the registered
181 * `minWidth`/`minHeight` after the filter returns — a buggy
182 * filter cannot ship a sub-minimum window. `x` and `y` are
183 * NOT re-clamped to the desktop rect after the filter (plugins
184 * sometimes want to place windows partially off-screen for
185 * deliberate stylistic reasons); the filter is responsible for
186 * its own viewport math when it cares.
187 *
188 * Companion of `desktop_mode_register_window` server-side
189 * defaults — runs every time a window opens, not just at
190 * registration.
191 *
192 * @since 0.8.6
193 */
194 WINDOW_GEOMETRY: "desktop-mode.window.geometry",
195 /** Action, fires when a window is added to the stack. */
196 WINDOW_OPENED: "desktop-mode.window.opened",
197 /**
198 * Action, fires when a window's body enters the loading state — at
199 * construction (every window starts loading) and whenever a plugin
200 * calls {@link NativeRenderContext.window.markLoading} or
201 * `Window.markContentLoading()` mid-life. Payload: `{ windowId }`.
202 *
203 * The shell shows a `<wpd-spinner>` overlay while the window is in
204 * the loading state and fades content in on the loaded transition.
205 * Subscribe to this hook (or to {@link WINDOW_CONTENT_LOADED}) when
206 * you need to react to either edge — analytics, instrumentation,
207 * decorating the spinner with a per-window message.
208 *
209 * Edge-triggered: idempotent calls don't re-fire. The matching
210 * `desktop-mode-window-content-loading` CustomEvent dispatches on
211 * `document` with the same payload.
212 *
213 * @since 0.6.0
214 */
215 WINDOW_CONTENT_LOADING: "desktop-mode.window.content-loading",
216 /**
217 * Action, fires when a window's body content becomes ready — for
218 * iframe windows the moment the chromeless bridge announces
219 * `desktop-mode-ready`, for native windows after the user's
220 * `render( body )` callback (or its returned promise) resolves, and
221 * whenever a plugin calls {@link NativeRenderContext.window.markReady}
222 * or `Window.markContentLoaded()` mid-life. Payload: `{ windowId }`.
223 *
224 * The unified "window content is ready" signal across both render
225 * strategies — use this instead of branching on iframe vs. native.
226 * Iframe-only consumers can still subscribe to {@link IFRAME_READY},
227 * which fires alongside this hook for iframe windows. The shell
228 * removes the loading overlay and fades the content in on this
229 * transition.
230 *
231 * Edge-triggered: only fires on a loading → ready transition.
232 * The matching `desktop-mode-window-content-loaded` CustomEvent
233 * dispatches on `document` with the same payload.
234 *
235 * @since 0.6.0
236 */
237 WINDOW_CONTENT_LOADED: "desktop-mode.window.content-loaded",
238 /**
239 * Filter, applied to the loading-overlay HTMLElement just after
240 * the shell paints its default `<wpd-spinner>` and after any
241 * per-window inline customization (`config.loading.render`)
242 * runs. Receives the overlay element; context: `{ windowId,
243 * config }`. Plugins may mutate the element (e.g.
244 * `host.replaceChildren( myBrandedLoader )` to swap out the
245 * default entirely, or `host.querySelector('wpd-spinner')!.
246 * setAttribute('preset', 'comet')` to retune the spinner) or
247 * return a different element to replace the overlay wholesale.
248 *
249 * Use cases: a brand-skin plugin that overrides every window's
250 * spinner with its own logo; a status-bar plugin that adds
251 * "Loading… 47% — fetching posts" text; an A/B-test framework
252 * that swaps the loader during an experiment.
253 *
254 * Resolution order for the loading overlay:
255 * 1. Default content (`<wpd-spinner>`) is painted.
256 * 2. Per-window `config.loading.render( host, ctx )` runs.
257 * 3. This filter runs.
258 * 4. The result is appended to the window body.
259 *
260 * @since 0.6.0
261 */
262 WINDOW_LOADING_OVERLAY: "desktop-mode.window.loading-overlay",
263 /**
264 * Action, fires when `manager.open(...)` is called for a baseId
265 * whose window already exists on the active desktop. This is the
266 * unambiguous "user requested to open this window again" signal
267 * — distinct from focus changes (which double-fire on alt-tab and
268 * skip when already focused) and from `WINDOW_OPENED` (which only
269 * fires on first creation). Payload:
270 * `{ windowId: string, baseId: string, wasMinimized: boolean }`.
271 *
272 * Plugins that hold per-window state (e.g. the code-editor's
273 * active file) should listen here to re-orient the existing
274 * window's content to whatever the caller wants to show — the
275 * open-window call is synchronous, so any state the caller sets
276 * BEFORE invoking `openWindow` is already in place when this
277 * fires.
278 */
279 WINDOW_REOPENED: "desktop-mode.window.reopened",
280 /**
281 * Action, fires BEFORE the window's element is detached from the
282 * DOM but AFTER the manager has already removed it from the stack.
283 * Payload: `{ windowId: string, element: HTMLElement }`.
284 *
285 * Use this for cleanup that needs a reference to the live
286 * element (removing anchored snow, wallpaper particles pinned to
287 * window tops, measurement caches keyed by element). `WINDOW_CLOSED`
288 * fires immediately after and only carries the id, which means
289 * subscribers would otherwise have to re-query the DOM — by then
290 * the element is gone, so they can't match at all.
291 */
292 WINDOW_CLOSING: "desktop-mode.window.closing",
293 /** Action, fires when a window is removed from the stack. */
294 WINDOW_CLOSED: "desktop-mode.window.closed",
295 /** Action, fires when focus changes to a different window. */
296 WINDOW_FOCUSED: "desktop-mode.window.focused",
297 /**
298 * Action, fires for the window that LOST focus when another
299 * window takes over. Symmetric counterpart to
300 * `WINDOW_FOCUSED`. Payload: `{ windowId: string, focusedTo:
301 * string | null }` — `focusedTo` identifies the new top of
302 * the stack so blur subscribers can ignore alt-tabs to a
303 * sibling they own.
304 *
305 * No-op when there's no previously-focused window (initial
306 * boot, all-windows-closed). Manager fires this BEFORE
307 * `WINDOW_FOCUSED` so subscribers see "blur old, focus new"
308 * in deterministic order.
309 *
310 * @since 0.5.5
311 */
312 WINDOW_BLURRED: "desktop-mode.window.blurred",
313 /**
314 * Action, fires when a window is minimized. Payload:
315 * `{ windowId: string, element: HTMLElement }`.
316 *
317 * The element ride-along matches {@link WINDOW_CLOSING}'s shape so
318 * wallpaper plugins anchored to window tops (snow, leaves, rain
319 * splash) can match stuck particles by element identity and run
320 * their teardown — minimized windows render at `opacity: 0` so
321 * `offsetParent === null` checks miss them.
322 */
323 WINDOW_MINIMIZED: "desktop-mode.window.minimized",
324 /**
325 * Action, fires when a window is restored from minimized. Payload:
326 * `{ windowId: string, element: HTMLElement }`.
327 */
328 WINDOW_RESTORED: "desktop-mode.window.restored",
329 /**
330 * Action, fires when a window is maximized (fills desktop area).
331 * Payload: `{ windowId: string, element: HTMLElement }`.
332 */
333 WINDOW_MAXIMIZED: "desktop-mode.window.maximized",
334 /**
335 * Action, fires when a window exits maximized state. Payload:
336 * `{ windowId: string, element: HTMLElement }`.
337 */
338 WINDOW_UNMAXIMIZED: "desktop-mode.window.unmaximized",
339 /**
340 * Action, fires when a window enters fullscreen / focus mode.
341 * Payload: `{ windowId: string, element: HTMLElement }`.
342 */
343 WINDOW_FULLSCREEN_ENTERED: "desktop-mode.window.fullscreen-entered",
344 /**
345 * Action, fires when a window exits fullscreen / focus mode.
346 * Payload: `{ windowId: string, element: HTMLElement }`.
347 */
348 WINDOW_FULLSCREEN_EXITED: "desktop-mode.window.fullscreen-exited",
349 /**
350 * Filter, decides whether a fullscreen ("focus mode") window
351 * should auto-exit when focus moves to a different window.
352 *
353 * Default is `true` so a newly-focused window is never silently
354 * occluded by a fullscreen one (its `z-index` sits above all
355 * other windows). Plugins whose fullscreen surface is meant to
356 * persist across focus changes — slideshows, video players,
357 * immersive games — can return `false` to keep their window
358 * fullscreen.
359 *
360 * Signature:
361 *
362 * ( shouldExit: boolean, ctx: {
363 * windowId: string, // the fullscreen window
364 * focusedTo: string, // the window gaining focus
365 * } ) => boolean
366 *
367 * @since 0.8.6
368 */
369 WINDOW_AUTO_EXIT_FULLSCREEN: "desktop-mode.window.auto-exit-fullscreen",
370 /**
371 * Filter, decides whether the window under the cursor is raised
372 * (focused) after a short hover dwell during a drag — any drag,
373 * whatever its source: a shell DragManager session, a
374 * cross-iframe bridge drag, an OS file, or an arbitrary native
375 * HTML5 drag.
376 *
377 * Default is `true`: dragging a payload over a background window
378 * and resting there for ~250 ms brings it forward, so the user
379 * can see the drop target they're aiming at (macOS spring-loading
380 * style). Plugins whose windows must never steal z-order during a
381 * drag — pinned reference panels, HUD/palette windows — can
382 * return `false` for their window id.
383 *
384 * Signature:
385 *
386 * ( shouldFocus: boolean, ctx: {
387 * windowId: string, // the hovered window
388 * payloadType: string, // DragManager payload `type`,
389 * // bridge payload `kind`,
390 * // 'os-file', or 'external'
391 * } ) => boolean
392 *
393 * @since 0.9.4
394 */
395 WINDOW_FOCUS_ON_DRAG_HOVER: "desktop-mode.window.focus-on-drag-hover",
396 /**
397 * Action, fires at most once per animation frame during an
398 * active drag or resize with the live geometry. Payload: `{
399 * windowId: string, x: number, y: number, width: number,
400 * height: number, state: WindowState, phase: 'drag' | 'resize' }`.
401 *
402 * Intended for per-frame collision-aware wallpapers (snow piling
403 * on window tops, rain splash on edges) that would otherwise
404 * poll `getBoundingClientRect` every rAF. Coalesced via
405 * `requestAnimationFrame` so a pointermove storm collapses to
406 * one fire per paint — matches the cadence a wallpaper's own
407 * ticker runs at.
408 *
409 * NOT fired at drag/resize end — `WINDOW_DRAG_END` /
410 * `WINDOW_RESIZE_END` handle the settled geometry. Subscribers
411 * that only want the final position should listen to those
412 * instead.
413 */
414 WINDOW_BOUNDS_CHANGED: "desktop-mode.window.bounds-changed",
415 /** Action, fires at drag-end with the final `{ x, y }` position. */
416 WINDOW_MOVED: "desktop-mode.window.moved",
417 /** Action, fires at resize-end with the final `{ width, height }`. */
418 WINDOW_RESIZED: "desktop-mode.window.resized",
419 /** Action, fires when title-bar drag begins. */
420 WINDOW_DRAG_START: "desktop-mode.window.drag-start",
421 /** Action, fires when title-bar drag ends. Payload mirrors WINDOW_MOVED. */
422 WINDOW_DRAG_END: "desktop-mode.window.drag-end",
423 /** Action, fires when the resize handle is first pressed. */
424 WINDOW_RESIZE_START: "desktop-mode.window.resize-start",
425 /** Action, fires when resize completes. Payload mirrors WINDOW_RESIZED. */
426 WINDOW_RESIZE_END: "desktop-mode.window.resize-end",
427 /** Action, fires when the user "detaches" a window to a classic tab. */
428 WINDOW_DETACHED: "desktop-mode.window.detached",
429 /**
430 * Action, fires when the user clicks the title-bar reload button
431 * on an iframe-backed window. Payload: `{ windowId: string, url:
432 * string }` where `url` is the URL being reloaded (the active
433 * primary or external sub-tab). Subscribers can use this to
434 * invalidate their own cache, force a save before navigation,
435 * track usage as a UX signal, or sync state across companion
436 * surfaces. Native windows do not fire this — they own their
437 * DOM directly and the reload button doesn't apply.
438 */
439 WINDOW_RELOADED: "desktop-mode.window.reloaded",
440 /** Action, fires when iframe title updates change the window title. */
441 WINDOW_TITLE_CHANGED: "desktop-mode.window.title-changed",
442 /**
443 * Action, fires when a window's `setHighlight()` mode changes.
444 * Payload: `{ windowId: string, mode: 'preview' | 'persistent' | null,
445 * color?: string }`. Lets onboarding / guidance / drag-bridge
446 * plugins react when another module flagged one of their
447 * windows as the focus of a multi-step interaction without
448 * having to observe DOM mutations.
449 *
450 * @since 0.6.0
451 */
452 WINDOW_HIGHLIGHT_CHANGED: "desktop-mode.window.highlight-changed",
453 /**
454 * Action, fires when a window's body element's dimensions
455 * change — mount, user resize, viewport reflow. Payload: `{
456 * windowId: string, width: number, height: number }`. Body
457 * dimensions exclude the title bar + tab strip, matching what a
458 * canvas or layout engine inside the body would measure.
459 */
460 WINDOW_BODY_RESIZED: "desktop-mode.window.body-resized",
461 // ------------------------------------------------------------------
462 // Native-window lifecycle. These fire ONLY for windows constructed
463 // with `native: true` — iframe windows have no render phase to
464 // intercept. Use them to wrap / instrument / cancel the paint of
465 // plugin-contributed native windows (the Calculator, Jorvy, custom
466 // native launchers).
467 // ------------------------------------------------------------------
468 /**
469 * Filter, applied to the body element a native window will render
470 * into, just BEFORE the user's `render( body )` callback runs.
471 * Payload: the `HTMLElement`; context: `{ windowId, config }`.
472 *
473 * Return the same element (or a wrapper) to intercept. Subscribers
474 * commonly use this to inject a consistent shell (padding,
475 * background, decorative chrome) around every native window
476 * without every plugin re-implementing the pattern.
477 */
478 NATIVE_WINDOW_BEFORE_RENDER: "desktop-mode.native-window.before-render",
479 /**
480 * Action, fires AFTER a native window's `render( body )` callback
481 * returns. Payload: `{ windowId, body, config }`. Observability
482 * hook — analytics / auto-focus / post-render measurement.
483 */
484 NATIVE_WINDOW_AFTER_RENDER: "desktop-mode.native-window.after-render",
485 /**
486 * Filter, applied when a native window is about to start its
487 * close animation. Return `false` to CANCEL the close — the
488 * window stays open. Payload: `true`; context: `{ windowId,
489 * config }`. Any non-`false` return (including `undefined`) lets
490 * the close proceed.
491 *
492 * Intended for "unsaved changes" guards: a calculator with a
493 * pending operation can prompt the user and abort the close
494 * mid-flight. Does NOT apply to iframe windows — their close is
495 * driven by browser navigation patterns the shell doesn't own.
496 */
497 NATIVE_WINDOW_BEFORE_CLOSE: "desktop-mode.native-window.before-close",
498 // ------------------------------------------------------------------
499 // Window-chrome customization framework. Plugins drive per-window
500 // appearance (theme, controls, slots, full chrome render) through
501 // the `wp.desktop.registerWindow*` registries; these hooks expose
502 // every resolution step so plugins can mutate or observe the
503 // chrome pipeline without owning a registration.
504 //
505 // Layers 1-3 (theme, controls, slots) are Stable. Layer 4 (chrome
506 // render) is Experimental — `WINDOW_CHROME_RENDER` may change.
507 // ------------------------------------------------------------------
508 /**
509 * Filter, applied to the resolved CSS-variable map for a window.
510 * Receives `Record< string, string >`; context: `{ windowId,
511 * config }`. Plugins return a mutated map to override or augment
512 * the per-window theme tokens — e.g. tint every Gutenberg
513 * window's title bar to brand colour.
514 *
515 * Stable since 0.6.0.
516 */
517 WINDOW_CHROME_THEME: "desktop-mode.window.chrome.theme",
518 /**
519 * Filter, applied to the resolved control list for a window.
520 * Receives `WindowControlDef[]`; context: `{ windowId, config,
521 * placement: 'left' | 'right' | 'controls' }`. Plugins return a
522 * mutated array to reorder, hide, or inject controls per-window.
523 *
524 * Stable since 0.6.0.
525 */
526 WINDOW_CHROME_CONTROLS: "desktop-mode.window.chrome.controls",
527 /**
528 * Filter, applied per slot when the chrome paints. Receives the
529 * slot host element; context: `{ windowId, slot, config }`.
530 * Plugins can mutate `host` (append decorative children, set
531 * inline styles) without owning a `WindowSlotDef` registration.
532 * The shell never reads the return value — this is an action-
533 * shaped filter so existing `addFilter` plumbing applies.
534 *
535 * Stable since 0.6.0.
536 */
537 WINDOW_CHROME_SLOT: "desktop-mode.window.chrome.slot",
538 /**
539 * Filter, applied to the chrome id selected for a window.
540 * Receives the resolved id (defaults to `'core/standard'`);
541 * context: `{ windowId, config }`. Returning a different id
542 * swaps the chrome registration. **Experimental** — chrome
543 * render contract may change.
544 *
545 * @since 0.6.0
546 */
547 WINDOW_CHROME_RENDER: "desktop-mode.window.chrome.render",
548 /**
549 * Action, fires after a window chrome layer has been mounted /
550 * remounted. Payload: `{ windowId, layer: 'chrome' | 'controls'
551 * | 'slots', chromeId? }` — `chromeId` is present only when
552 * `layer` is `'chrome'`. Subscribers can post-decorate the
553 * chrome (attach observers, anchor pickers).
554 *
555 * @since 0.6.0
556 */
557 WINDOW_CHROME_APPLIED: "desktop-mode.window.chrome.applied",
558 /**
559 * Action, fires after a window's theme tokens are applied to its
560 * outer element. Payload: `{ windowId, themeId, tokens }`. Lets
561 * plugins react to theme changes without diffing CSS variables.
562 *
563 * @since 0.6.0
564 */
565 WINDOW_CHROME_THEME_CHANGED: "desktop-mode.window.chrome.theme-changed",
566 /**
567 * Action, fires when a user clicks a desktop icon (a shortcut
568 * tile registered server-side via `desktop_mode_register_icon()`
569 * and rendered on the wallpaper). Payload: `{ id: string,
570 * target: 'window' | 'url' }`. Fires BEFORE the default open
571 * action — plugins cannot cancel the open from this hook, but
572 * can use it to track click-throughs or augment behaviour (e.g.
573 * play a sound, surface a confirmation toast).
574 *
575 * @since 0.5.0
576 */
577 DESKTOP_ICON_CLICKED: "desktop-mode.desktop-icon.clicked",
578 /**
579 * Action, fires after the wallpaper icon grid is rendered or
580 * re-rendered. Payload:
581 *
582 * {
583 * ids: string[]; // paint order
584 * container: HTMLElement; // <div class="desktop-mode-icons">
585 * tiles: ReadonlyMap<string, HTMLElement>; // id → tile <button>
586 * }
587 *
588 * Plugins that decorate icons with surfaces the framework doesn't
589 * natively expose (drag handles, status dots, cursor adornments)
590 * subscribe here so their decorations survive a live menu refresh
591 * that legitimately rebuilds the grid. The `container` and
592 * `tiles` map mirror the {@link DOCK_AFTER_RENDER}
593 * `tileElements` contract — reach into them directly instead of
594 * re-`querySelector`ing the rendered DOM.
595 *
596 * Notification badges have a first-class API since 0.6.0 —
597 * use `wp.desktop.icons.setBadge( id, count )` (and subscribe
598 * to {@link ICON_BADGE_CHANGED}) instead of decorating from
599 * here. The framework persists badge state across rebuilds, so
600 * a plugin that uses the API doesn't need to re-decorate on
601 * every render.
602 *
603 * Suppressed entirely when the rendered DOM is unchanged from
604 * the previous call (the fingerprint short-circuit upstream
605 * skips both the rebuild and this signal). When the icon list
606 * is empty the hook does not fire at all — the previous
607 * container is removed and no new one is appended.
608 *
609 * @since 0.6.0
610 * @since 0.8.6 — `container` + `tiles` added to the payload
611 * (`ids` retained for back-compat).
612 */
613 DESKTOP_ICONS_RENDERED: "desktop-mode.desktop-icons.rendered",
614 /**
615 * Action, fires whenever the badge count on a desktop icon
616 * changes. Payload: `{ iconId: string, count: number,
617 * previousCount: number }`. Symmetric to {@link DOCK_ITEM_APPENDED}
618 * and the dock/taskbar `wpd-dock-item-badge-changed` CustomEvent
619 * — the icon rail's lifecycle hook for badge transitions.
620 *
621 * Mirrors `desktop-mode/badge-changed` on the activity bus with
622 * `rail: 'icon'`. Subscribe to whichever surface fits — the
623 * activity channel composes across rails for global widgets,
624 * this hook fires only for icon-rail badges with the previous
625 * count carried alongside for delta-aware consumers.
626 *
627 * @since 0.6.0
628 */
629 ICON_BADGE_CHANGED: "desktop-mode.icon.badge-changed",
630 // ------------------------------------------------------------------
631 // Cross-plugin composition.
632 // ------------------------------------------------------------------
633 /**
634 * Action, fires ONCE after every shell-shipped `<wpd-*>` custom
635 * element has registered with `customElements`. Payload: `{
636 * tags: string[] }` — the list of registered tag names. Plugins
637 * that need to defer work until the component registry is
638 * complete (e.g. hydrate user content that uses these tags)
639 * subscribe here instead of polling `customElements.get()`.
640 */
641 COMPONENTS_REGISTERED: "desktop-mode.components.registered",
642 /**
643 * Action, fires after `wp.desktop.registerSystemTile()` inserts
644 * a tile into the unified dock. Payload: `{ id: string }`. Useful
645 * for plugins that want to decorate tiles they didn't register
646 * themselves — analytics, theming, per-tile badges.
647 */
648 DOCK_ITEM_APPENDED: "desktop-mode.dock.item-appended",
649 /**
650 * Action, fires after a system tile is removed from a rail
651 * via `Dock.removeSystemItem()` (typically the server-driven
652 * native-window-sync path on plugin deactivation). Payload:
653 * `{ id: string, placement: 'dock' | 'taskbar' }`. Symmetric
654 * to {@link DOCK_ITEM_APPENDED}; lets analytics / decorators /
655 * cleanup hooks see the full lifecycle without polling the DOM.
656 *
657 * @since 0.6.0
658 */
659 DOCK_ITEM_REMOVED: "desktop-mode.dock.item-removed",
660 // ------------------------------------------------------------------
661 // Dock decoration hooks — render-pipeline filters and actions the
662 // default `Dock` renderer fires while painting tiles. Plugins
663 // compose decoration (animations, classNames, wrappers, tooltips)
664 // without forking the renderer. Custom rail renderers SHOULD fire
665 // the same hooks for ecosystem compatibility — see
666 // `docs/examples/dock-decoration-hooks.md` for the contract.
667 //
668 // Every detail object carries `{ rail, orientation, dockId,
669 // container }` so a single subscriber can disambiguate when two
670 // rails coexist (Classic layout's left side bar + bottom dock).
671 // `dockId` matches the host element's `id` (e.g. `'desktop-mode-dock'`
672 // or `'desktop-mode-side-dock'`) and is the stable
673 // disambiguator — `rail` and `orientation` are convenience
674 // projections of where the renderer is painting.
675 // ------------------------------------------------------------------
676 /**
677 * Action, fires at the start of every dock paint pass — both the
678 * initial mount and every `replaceItems()` that follows on the
679 * live menu-refresh path. Payload `DockRenderContext`. Use this
680 * to invalidate cached per-render decoration state before the
681 * tiles repopulate.
682 *
683 * @since 0.5.2
684 */
685 DOCK_BEFORE_RENDER: "desktop-mode.dock.before-render",
686 /**
687 * Action, fires once every menu and system tile has landed in
688 * the DOM for a paint pass. Payload `DockRenderContext` plus a
689 * frozen `tileElements: ReadonlyMap<string, HTMLElement>` so a
690 * plugin can decorate every tile in one sweep. Symmetric to
691 * {@link DOCK_BEFORE_RENDER}.
692 *
693 * @since 0.5.2
694 */
695 DOCK_AFTER_RENDER: "desktop-mode.dock.after-render",
696 /**
697 * Filter, runs once per tile while the renderer is composing the
698 * className list. Plugins may add, remove, or reorder classes.
699 * Signature: `( classes: string[], detail: DockTileContext ) =>
700 * string[]`. Order is preserved.
701 *
702 * @since 0.5.2
703 */
704 DOCK_TILE_CLASS: "desktop-mode.dock.tile-class",
705 /**
706 * Filter, runs once per tile after the renderer finishes building
707 * the element but before it lands in the DOM. Return the same
708 * element with mutations, or replace with a wrapper — the shell
709 * inserts whatever you return. Signature:
710 * `( el: HTMLElement, detail: DockTileContext ) => HTMLElement`.
711 *
712 * Returning a different node still has to expose a stable
713 * `[data-menu-slug="<id>"]` (or `[data-system-id="<id>"]`)
714 * descendant for active-state / badge updates to find the tile;
715 * wrap, don't replace.
716 *
717 * @since 0.5.2
718 */
719 DOCK_TILE_ELEMENT: "desktop-mode.dock.tile-element",
720 /**
721 * Action, fires once per tile after it has been inserted into
722 * the DOM. Payload `DockTileContext` plus the resolved `el`. Use
723 * for post-insertion decoration where computed layout matters
724 * (measurements, IntersectionObserver bindings, etc.).
725 *
726 * @since 0.5.2
727 */
728 DOCK_TILE_RENDERED: "desktop-mode.dock.tile-rendered",
729 /**
730 * Filter, resolves the tooltip text for a tile. Runs once at
731 * bind time so the dock doesn't re-filter on every pointerenter.
732 * Signature: `( label: string, detail: DockTileContext ) =>
733 * string`. Return an empty string to suppress the tooltip.
734 *
735 * @since 0.5.2
736 */
737 DOCK_TILE_TOOLTIP: "desktop-mode.dock.tile-tooltip",
738 /**
739 * Filter, resolves the body content of a single hover-peek card.
740 * Runs once per card build (i.e., on every show of the peek for
741 * a multi-instance dock tile that has ≥1 open window). Lets a
742 * plugin render a custom thumbnail, status block, or any other
743 * markup inside the card in place of (or alongside) the default
744 * mini-window styling.
745 *
746 * Signature:
747 * ( body: HTMLElement, detail: DockPeekCardContext ) => HTMLElement
748 *
749 * Where `body` is the `<span class="desktop-mode-dock-peek__card-body">`
750 * element that the peek would otherwise populate with ghosted
751 * content lines. The filter may:
752 * - Mutate `body` in place (e.g., append a custom child) and
753 * return it.
754 * - Empty `body` and append plugin-owned children.
755 * - Return an entirely different element to replace `body`.
756 *
757 * `detail.window` is the live `Window` instance the card represents
758 * — plugins can read `window.config`, call `window.getCurrentUrl()`,
759 * subscribe to lifecycle events, etc. `detail.item` is the dock
760 * item descriptor (id / title / icon / url).
761 *
762 * The filter is invoked under the `applyFilters` namespace
763 * `desktop-mode.dock.peek-card-content`.
764 *
765 * @since 0.6.2
766 */
767 DOCK_PEEK_CARD_CONTENT: "desktop-mode.dock.peek-card-content",
768 /**
769 * Filter, runs once per peek card right before it's appended to
770 * the popover. Receives the fully-built default card (with its
771 * mini-window chrome already populated) and can return either
772 * the same node, a mutated version, or an entirely different
773 * element to replace the card outright. Use this when the
774 * `peek-card-content` body filter isn't enough — e.g., when a
775 * plugin wants to swap the whole card chrome (custom titlebar,
776 * different shape) or wrap the card in a third-party component.
777 *
778 * Signature:
779 * ( card: HTMLElement, detail: DockPeekCardContext ) => HTMLElement
780 *
781 * If a plugin returns a brand-new node, it is responsible for
782 * preserving anything the peek relies on:
783 * - The `desktop-mode-dock-peek__card` class (used by the
784 * fan-out animation timing + hover styles).
785 * - A `click` handler if the card should still focus the
786 * window. The default click handler lives on the original
787 * node — replacing the node loses it.
788 *
789 * @since 0.6.2
790 */
791 DOCK_PEEK_CARD_ELEMENT: "desktop-mode.dock.peek-card-element",
792 // ------------------------------------------------------------------
793 // Overview / Arrange lifecycle actions.
794 //
795 // The "Arrange" admin-bar menu drives two layout algorithms —
796 // Cascade (instantly reposition every window in a staggered
797 // stack) and Overview (zoom-out grid view with click-to-focus).
798 // These hooks surface the state transitions so plugins can
799 // instrument analytics, apply custom transitions, override
800 // thumbnail decorations, etc. All actions; a filter for
801 // mutating the overview layout may be added later if plugins
802 // want to reorder or group thumbnails.
803 // ------------------------------------------------------------------
804 /** Action, fires before the overview enter animation starts. */
805 OVERVIEW_ENTERING: "desktop-mode.overview.entering",
806 /** Action, fires once the overview enter animation has completed. */
807 OVERVIEW_ENTERED: "desktop-mode.overview.entered",
808 /**
809 * Action, fires at the start of the overview-exit animation.
810 * Payload: `{ windowId?: string, reason: 'select' | 'cancel' }` —
811 * `windowId` set when the user clicked a thumbnail (reason
812 * 'select'); omitted when the user pressed Escape or clicked
813 * the backdrop (reason 'cancel').
814 */
815 OVERVIEW_EXITING: "desktop-mode.overview.exiting",
816 /** Action, fires once the overview-exit animation has settled. */
817 OVERVIEW_EXITED: "desktop-mode.overview.exited",
818 /** Action, fires when the cursor enters a thumbnail. Payload `{ windowId }`. */
819 OVERVIEW_WINDOW_HOVER: "desktop-mode.overview.window-hover",
820 /** Action, fires when the cursor leaves a thumbnail. Payload `{ windowId }`. */
821 OVERVIEW_WINDOW_UNHOVER: "desktop-mode.overview.window-unhover",
822 /** Action, fires the instant a thumbnail click is registered (before exit + maximize kick in). Payload `{ windowId }`. */
823 OVERVIEW_WINDOW_CLICK: "desktop-mode.overview.window-click",
824 /** Action, fires before cascade computes + applies new positions. Payload `{ windowCount }`. */
825 ARRANGE_CASCADE_STARTING: "desktop-mode.arrange.cascade.starting",
826 /** Action, fires after cascade has positioned every window. Payload `{ windowCount }`. */
827 ARRANGE_CASCADE_APPLIED: "desktop-mode.arrange.cascade.applied",
828 /** Action, fires before tile computes + applies new positions. Payload `{ windowCount, cols, rows }`. */
829 ARRANGE_TILE_STARTING: "desktop-mode.arrange.tile.starting",
830 /** Action, fires after tile has positioned every window. Payload `{ windowCount, cols, rows }`. */
831 ARRANGE_TILE_APPLIED: "desktop-mode.arrange.tile.applied",
832 /**
833 * Filter on the tile-grid dimensions chosen by the built-in
834 * algorithm. Receives `{ cols, rows }` plus a context arg
835 * `{ windowCount, areaWidth, areaHeight }`. Plugins can return
836 * a different `{ cols, rows }` to enforce a custom layout
837 * (fixed-column newsroom, golden-ratio cells, etc.). Returned
838 * values are validated — non-positive integers, or a product
839 * smaller than `windowCount`, fall back to the original.
840 */
841 ARRANGE_TILE_DIMENSIONS: "desktop-mode.arrange.tile.dimensions",
842 /** Action, fires when snap-to-grid is toggled. Payload `{ enabled }`. */
843 ARRANGE_SNAP_CHANGED: "desktop-mode.arrange.snap.changed",
844 /**
845 * Filter on the snap-grid cell size. Receives
846 * `{ cellWidth, cellHeight }` plus a context arg
847 * `{ areaWidth, areaHeight }`. Plugins can return different
848 * dimensions to enforce a Tetris-style fixed grid, a musical
849 * staff aspect, etc. Non-positive returns fall back to the
850 * original.
851 */
852 ARRANGE_SNAP_CELL_SIZE: "desktop-mode.arrange.snap.cell-size",
853 /**
854 * Action, fires when the user clicks a plugin-registered entry in
855 * the Arrange admin-bar submenu (items added via the
856 * `desktop_mode_arrange_menu_items` PHP filter). Payload `{ id }`
857 * where `id` is the item's `id` field as registered. Plugins
858 * subscribe here to run their custom arrangement logic.
859 */
860 ARRANGE_CUSTOM_ACTION: "desktop-mode.arrange.custom-action",
861 // ------------------------------------------------------------------
862 // Snap-zones — Windows-style edge snapping with a split-overview
863 // picker to fill the opposite half after commit.
864 // ------------------------------------------------------------------
865 /**
866 * Action, fires when the drag cursor enters a snap zone and the
867 * shell shows the target-position preview. Payload
868 * `{ windowId, zone: 'left' | 'right' }`.
869 */
870 SNAP_ZONE_PENDING: "desktop-mode.snap.zone-pending",
871 /**
872 * Action, fires when the drag cursor leaves the snap zone without
873 * releasing — the preview disappears. Payload `{ windowId }`.
874 */
875 SNAP_ZONE_CANCELED: "desktop-mode.snap.zone-canceled",
876 /**
877 * Action, fires once the window has animated into its snapped
878 * bounds. Payload `{ windowId, zone: 'left' | 'right' }`.
879 */
880 SNAP_ZONE_COMMITTED: "desktop-mode.snap.zone-committed",
881 /**
882 * Action, fires when a user picks a thumbnail from the split
883 * overview to fill the opposite half. Payload
884 * `{ windowId, zone: 'left' | 'right' }`.
885 */
886 SNAP_SPLIT_FILLED: "desktop-mode.snap.split-filled",
887 // ------------------------------------------------------------------
888 // Widgets — the right-side column. Widgets paint above the
889 // wallpaper but beneath windows. Lifecycle mirrors canvas
890 // wallpapers: register via filter, mount/unmount actions bracket
891 // each paint, mount-failed fires on sync throws / async rejects.
892 // ------------------------------------------------------------------
893 /** Filter, receives the widget registry array. */
894 WIDGETS: "desktop-mode.widgets",
895 /** Action before a widget mounts. Payload `{ id, container, ctx }`. */
896 WIDGET_MOUNTING: "desktop-mode.widget.mounting",
897 /** Action after a widget mounts successfully. Payload `{ id, container, ctx }`. */
898 WIDGET_MOUNTED: "desktop-mode.widget.mounted",
899 /** Action before a widget tears down. Payload `{ id }`. */
900 WIDGET_UNMOUNTING: "desktop-mode.widget.unmounting",
901 /** Action when a widget's mount throws / rejects. Payload `{ id, error }`. */
902 WIDGET_MOUNT_FAILED: "desktop-mode.widget.mount-failed",
903 /** Action when the user adds a widget via the picker. Payload `{ id }`. */
904 WIDGET_ADDED: "desktop-mode.widget.added",
905 /** Action when the user removes a widget via the card's × button. Payload `{ id }`. */
906 WIDGET_REMOVED: "desktop-mode.widget.removed",
907 // ------------------------------------------------------------------
908 // Virtual-desktop ("Spaces") lifecycle actions.
909 //
910 // Spaces let users group windows into separate workspaces and flip
911 // between them from the overview top bar. These hooks expose every
912 // state change so plugins can persist per-space state, sync custom
913 // indicators, or react to the user's workspace context.
914 // ------------------------------------------------------------------
915 /** Action, fires when a new desktop is created. Payload `{ desktopId }`. */
916 DESKTOP_CREATED: "desktop-mode.desktop.created",
917 /** Action, fires when a desktop is closed. Payload `{ desktopId, migratedTo }`. */
918 DESKTOP_CLOSED: "desktop-mode.desktop.closed",
919 /** Action, fires when the active desktop changes. Payload `{ from, to }`. */
920 DESKTOP_SWITCHED: "desktop-mode.desktop.switched",
921 /**
922 * Filter. Returns the id of the "primary" desktop — the one the
923 * shell treats as canonical for batch operations. Receives the
924 * default (first desktop's id) and the full `Desktop[]` list.
925 * @since 0.5.0
926 */
927 PRIMARY_DESKTOP_ID: "desktop-mode.primary-desktop-id",
928 // ------------------------------------------------------------------
929 // Batch window operations.
930 // ------------------------------------------------------------------
931 /**
932 * Action, fires before {@link WindowManager.closeAll} starts
933 * iterating. Payload `{ candidates: Window[] }` — every window the
934 * shell is about to close (after `exceptIds` was applied).
935 * @since 0.5.0
936 */
937 WINDOWS_BEFORE_CLOSE_ALL: "desktop-mode.windows.before-close-all",
938 /**
939 * Filter, runs inside {@link WindowManager.closeAll}. Receives the
940 * candidate `Window[]` list and returns the (possibly trimmed) list
941 * that will actually be closed. Plugins use this to PROTECT specific
942 * windows from a bulk close — e.g. keep the active draft open.
943 * Returning an empty array cancels the close entirely.
944 * @since 0.5.0
945 */
946 WINDOWS_CLOSE_ALL: "desktop-mode.windows.close-all",
947 /**
948 * Action, fires after {@link WindowManager.closeAll} has finished.
949 * Payload `{ closed: number, skipped: Window[] }`.
950 * @since 0.5.0
951 */
952 WINDOWS_AFTER_CLOSE_ALL: "desktop-mode.windows.after-close-all",
953 // ------------------------------------------------------------------
954 // Slash-command lifecycle.
955 // ------------------------------------------------------------------
956 /**
957 * Filter. Runs immediately before a command's `run()` is invoked.
958 * Receives `{ proceed: true, slug, args, command }` and may return
959 * the same shape with `proceed: false` to cancel the run.
960 * @since 0.5.0
961 */
962 COMMAND_BEFORE_RUN: "desktop-mode.command.before-run",
963 /**
964 * Action, fires after a command's `run()` resolves successfully.
965 * Payload `{ slug, args, command, result }`.
966 * @since 0.5.0
967 */
968 COMMAND_AFTER_RUN: "desktop-mode.command.after-run",
969 /**
970 * Action, fires when a command's `run()` throws. Payload
971 * `{ slug, args, command, error }`.
972 * @since 0.5.0
973 */
974 COMMAND_ERROR: "desktop-mode.command.error",
975 // ------------------------------------------------------------------
976 // Shell-level lifecycle actions.
977 // ------------------------------------------------------------------
978 /**
979 * Action, fires (debounced) after the browser viewport stops
980 * resizing. Payload `{ width, height }` describes the shell's
981 * bounding rect — plugins that render canvas-driven UIs hook here
982 * to adjust their render surface.
983 */
984 SHELL_RESIZED: "desktop-mode.shell.resized",
985 /**
986 * Action mirroring `document.visibilitychange` for the shell as a
987 * whole. Payload `{ state: 'visible' | 'hidden' }`. Different from
988 * the wallpaper-specific visibility action in that it fires
989 * regardless of which wallpaper (if any) is active.
990 */
991 SHELL_VISIBILITY: "desktop-mode.shell.visibility",
992 /**
993 * Action — fires when a `wp.desktop.connect()` connection
994 * completes its iframe handshake. Payload:
995 * `{ connectionId, targetWindowId, topics }`.
996 *
997 * @since 0.5.2
998 */
999 CONNECTION_OPENED: "desktop-mode.connection.opened",
1000 /**
1001 * Action — fires when a connection tears down. Payload:
1002 * `{ connectionId, reason: 'disconnect' | 'window-closed' | 'navigated' }`.
1003 *
1004 * @since 0.5.2
1005 */
1006 CONNECTION_CLOSED: "desktop-mode.connection.closed",
1007 /**
1008 * Action — fires for every message routed through a connection.
1009 * Payload: `{ connectionId, topic, direction: 'in' | 'out' }`.
1010 * Used for debug consoles + traffic auditing; high-volume topics
1011 * fire this many times per second, so subscribers should be
1012 * cheap.
1013 *
1014 * @since 0.5.2
1015 */
1016 CONNECTION_MESSAGE: "desktop-mode.connection.message",
1017 /**
1018 * Filter — fires when an iframe calls
1019 * `wp.desktop.iframe.requestConnection()`. Default value is
1020 * `true` (accept). Return `false` to reject, or an object
1021 * `{ topics: string[] }` to accept while narrowing the topic
1022 * list. `$context` carries `{ windowId, requestId, topics }`.
1023 *
1024 * @since 0.5.2
1025 */
1026 IFRAME_CONNECTION_REQUEST: "desktop-mode.iframe.connection-request",
1027 // ------------------------------------------------------------------
1028 // Window content relations & link renderers (since 0.9.4). A window
1029 // may carry a content identity ("I am comment 45 of post 123");
1030 // windows resolving to the same root form a relation group, and a
1031 // pluggable renderer draws the ties on the desktop. Engine:
1032 // `src/window-links/engine.ts`; registry:
1033 // `src/window-links/renderer-registry.ts`. See
1034 // `docs/examples/window-links.md`.
1035 // ------------------------------------------------------------------
1036 /**
1037 * Action — fires when a window's content identity is set, replaced,
1038 * or cleared. Payload: `{ windowId: string, content:
1039 * WindowContentRef | null, previous: WindowContentRef | null,
1040 * source: 'config' | 'bridge' | 'api' }`. The matching
1041 * `desktop-mode-window-content-changed` CustomEvent dispatches on
1042 * `document` with the same payload.
1043 *
1044 * @since 0.9.4
1045 */
1046 WINDOW_CONTENT_CHANGED: "desktop-mode.window-links.content-changed",
1047 /**
1048 * Action — fires when relation-group MEMBERSHIP changes (a window
1049 * gained/lost an identity, or a member window opened/closed).
1050 * Payload: `{ groups: WindowLinkGroup[] }`. Deliberately NOT fired
1051 * on move/resize (renderers get live geometry through their frame
1052 * subscription) nor on focus-recency reordering. The matching
1053 * `desktop-mode-window-link-groups-changed` CustomEvent dispatches
1054 * on `document` with the same payload.
1055 *
1056 * @since 0.9.4
1057 */
1058 WINDOW_LINK_GROUPS_CHANGED: "desktop-mode.window-links.groups-changed",
1059 /**
1060 * Filter — applied to every content identity as it is set, before
1061 * storage. Signature: `( ref: WindowContentRef | null, ctx: {
1062 * windowId: string, source: 'config' | 'bridge' | 'api' } ) =>
1063 * WindowContentRef | null`. Return `null` to suppress the identity,
1064 * or a rewritten ref to remap it (e.g. point a custom object type
1065 * at your own root scheme).
1066 *
1067 * @since 0.9.4
1068 */
1069 WINDOW_LINKS_CONTENT: "desktop-mode.window-links.content",
1070 /**
1071 * Filter — applied to the computed relation-group list on every
1072 * read (`wp.desktop.relations.groups()`). Signature:
1073 * `( groups: WindowLinkGroup[] ) => WindowLinkGroup[]`. Merge,
1074 * split, or inject groups here.
1075 *
1076 * @since 0.9.4
1077 */
1078 WINDOW_LINK_GROUPS: "desktop-mode.window-links.groups",
1079 /**
1080 * Filter — applied to the derived directed-edge list on every read
1081 * (`wp.desktop.relations.edges()`). Signature: `( edges:
1082 * WindowLinkEdge[] ) => WindowLinkEdge[]` where each edge is
1083 * `{ fromWindowId, toWindowId, kind: 'child-root' | 'reference',
1084 * bidirectional }`. Add, drop, or redirect ties here — this is
1085 * what the render host feeds to the active renderer.
1086 *
1087 * @since 0.9.4
1088 */
1089 WINDOW_LINK_EDGES: "desktop-mode.window-links.edges",
1090 /**
1091 * Filter — applied to the registered window-link renderer list on
1092 * every read (`wp.desktop.listWindowLinkRenderers()`). Signature:
1093 * `( defs: WindowLinkRendererDef[] ) => WindowLinkRendererDef[]`.
1094 *
1095 * @since 0.9.4
1096 */
1097 WINDOW_LINK_RENDERERS: "desktop-mode.window-links.renderers",
1098 /**
1099 * Filter — applied to the resolved ACTIVE renderer id after the OS
1100 * Settings selection is read, before the registry lookup.
1101 * Signature: `( id: string ) => string`. Return a different
1102 * registered id (or `'none'`) to force-swap the renderer without
1103 * touching the user's setting.
1104 *
1105 * @since 0.9.4
1106 */
1107 WINDOW_LINK_RENDERER: "desktop-mode.window-links.renderer",
1108 // ------------------------------------------------------------------
1109 // OS-file drop manager (since 0.30.0). Catches files dragged from
1110 // the user's host OS (Finder / Explorer / Nautilus) onto any
1111 // desktop-mode surface and routes them through a confirmation
1112 // dialog before uploading to the Media Library. Authoritative
1113 // constants live in `src/os-file-drop/hooks.ts`; mirrored here so
1114 // every hook the shell fires is reachable from a single `HOOKS`
1115 // import. See `docs/examples/os-file-drop.md`.
1116 // ------------------------------------------------------------------
1117 /** Filter — `(files: File[], ctx) => File[]`, before mime/size check. */
1118 FILE_DROP_FILES_DETECTED: "desktop-mode.drop.files-detected",
1119 /** Action — `{ rejections, context }` for files that failed policy. */
1120 FILE_DROP_FILES_REJECTED: "desktop-mode.drop.files-rejected",
1121 /** Filter — `(entry, ctx) => entry`, per-file dialog defaults. */
1122 FILE_DROP_DIALOG_FIELDS: "desktop-mode.drop.dialog-fields",
1123 /** Filter — `(payload, ctx) => payload | null`, last call before POST. */
1124 FILE_DROP_BEFORE_UPLOAD: "desktop-mode.drop.before-upload",
1125 /** Action — `{ file, fields, context, abort }` once XHR is open and about to send. @since 0.31.0 */
1126 FILE_DROP_UPLOAD_STARTED: "desktop-mode.drop.upload-started",
1127 /** Action — `{ file, fields, context, loaded, total, indeterminate }` per progress tick. @since 0.31.0 */
1128 FILE_DROP_UPLOAD_PROGRESS: "desktop-mode.drop.upload-progress",
1129 /** Action — `{ file, result, fields, context }` after successful upload. `file` since 0.31.0. */
1130 FILE_DROP_AFTER_UPLOAD: "desktop-mode.drop.after-upload",
1131 /** Action — `{ file, error, context }` on upload failure. */
1132 FILE_DROP_UPLOAD_FAILED: "desktop-mode.drop.upload-failed"
1133 };
1134 const HOOK_PREFIX = "desktop-mode.activity.";
1135 function hookName(channel) {
1136 return `${HOOK_PREFIX}${String(channel)}`;
1137 }
1138 let subscribeSeq = 0;
1139 const activity = {
1140 publish(channel, payload) {
1141 doAction(hookName(channel), payload);
1142 },
1143 subscribe(channel, cb) {
1144 const ns = `desktop-mode/activity-sub/${++subscribeSeq}`;
1145 const hook = hookName(channel);
1146 addAction(
1147 hook,
1148 ns,
1149 (payload) => cb(payload)
1150 );
1151 let removed = false;
1152 return () => {
1153 if (removed) {
1154 return;
1155 }
1156 removed = true;
1157 removeAction(hook, ns);
1158 };
1159 },
1160 filter(channel, value, ...args) {
1161 return applyFilters(hookName(channel), value, ...args);
1162 }
1163 };
1164 const _parentSubs = /* @__PURE__ */ new Map();
1165 const _nativeSubs = /* @__PURE__ */ new Map();
1166 function bucket(root, windowId, channel, create) {
1167 let perWindow = root.get(windowId);
1168 if (!perWindow) {
1169 if (!create) {
1170 return void 0;
1171 }
1172 perWindow = /* @__PURE__ */ new Map();
1173 root.set(windowId, perWindow);
1174 }
1175 let bucketSet = perWindow.get(channel);
1176 if (!bucketSet) {
1177 if (!create) {
1178 return void 0;
1179 }
1180 bucketSet = /* @__PURE__ */ new Set();
1181 perWindow.set(channel, bucketSet);
1182 }
1183 return bucketSet;
1184 }
1185 function dispatch(root, windowId, channel, payload) {
1186 const meta = { channel, windowId };
1187 const exact = bucket(root, windowId, channel, false);
1188 if (exact) {
1189 for (const cb of Array.from(exact)) {
1190 try {
1191 cb(payload, meta);
1192 } catch (err) {
1193 if (typeof console !== "undefined") {
1194 console.error(
1195 `[desktop-mode] window-channel subscriber for "${channel}" threw:`,
1196 err
1197 );
1198 }
1199 }
1200 }
1201 }
1202 const wildcard = bucket(root, windowId, "*", false);
1203 if (wildcard) {
1204 for (const cb of Array.from(wildcard)) {
1205 try {
1206 cb(payload, meta);
1207 } catch (err) {
1208 if (typeof console !== "undefined") {
1209 console.error(
1210 `[desktop-mode] window-channel wildcard subscriber for "${windowId}" threw:`,
1211 err
1212 );
1213 }
1214 }
1215 }
1216 }
1217 }
1218 function addParentSubscriber(windowId, channel, cb) {
1219 const set = bucket(_parentSubs, windowId, channel, true);
1220 set.add(cb);
1221 let removed = false;
1222 return () => {
1223 if (removed) {
1224 return;
1225 }
1226 removed = true;
1227 set.delete(cb);
1228 };
1229 }
1230 function dispatchFromWindow(windowId, channel, payload) {
1231 dispatch(_parentSubs, windowId, channel, payload);
1232 }
1233 function addNativeSubscriber(windowId, channel, cb) {
1234 const set = bucket(_nativeSubs, windowId, channel, true);
1235 set.add(cb);
1236 let removed = false;
1237 return () => {
1238 if (removed) {
1239 return;
1240 }
1241 removed = true;
1242 set.delete(cb);
1243 };
1244 }
1245 function dispatchToNative(windowId, channel, payload) {
1246 dispatch(_nativeSubs, windowId, channel, payload);
1247 }
1248 const _readyWindows = /* @__PURE__ */ new Set();
1249 const _loadingWindows = /* @__PURE__ */ new Set();
1250 const _pendingSends = /* @__PURE__ */ new Map();
1251 function isWindowContentReady(windowId) {
1252 return _readyWindows.has(windowId);
1253 }
1254 function markWindowContentLoading(windowId) {
1255 if (_loadingWindows.has(windowId)) {
1256 return;
1257 }
1258 _loadingWindows.add(windowId);
1259 doAction(HOOKS.WINDOW_CONTENT_LOADING, { windowId });
1260 if (typeof document !== "undefined") {
1261 document.dispatchEvent(
1262 new CustomEvent("desktop-mode-window-content-loading", {
1263 detail: { windowId }
1264 })
1265 );
1266 }
1267 }
1268 function markWindowContentReady(windowId) {
1269 if (!_readyWindows.has(windowId)) {
1270 _readyWindows.add(windowId);
1271 const queued = _pendingSends.get(windowId);
1272 if (queued) {
1273 _pendingSends.delete(windowId);
1274 for (const m of queued) {
1275 try {
1276 m.flush();
1277 } catch (err) {
1278 if (typeof console !== "undefined") {
1279 console.error(
1280 `[desktop-mode] flushing queued window-send for "${m.channel}" threw:`,
1281 err
1282 );
1283 }
1284 }
1285 }
1286 }
1287 }
1288 if (_loadingWindows.delete(windowId)) {
1289 doAction(HOOKS.WINDOW_CONTENT_LOADED, { windowId });
1290 if (typeof document !== "undefined") {
1291 document.dispatchEvent(
1292 new CustomEvent("desktop-mode-window-content-loaded", {
1293 detail: { windowId }
1294 })
1295 );
1296 }
1297 }
1298 }
1299 function enqueueWindowSend(windowId, channel, payload, flush) {
1300 let q = _pendingSends.get(windowId);
1301 if (!q) {
1302 q = [];
1303 _pendingSends.set(windowId, q);
1304 }
1305 q.push({ channel, payload, flush });
1306 }
1307 function clearWindowChannels(windowId) {
1308 _parentSubs.delete(windowId);
1309 _nativeSubs.delete(windowId);
1310 _readyWindows.delete(windowId);
1311 _loadingWindows.delete(windowId);
1312 _pendingSends.delete(windowId);
1313 }
1314 const _syntheticIframes = /* @__PURE__ */ new Map();
1315 function getSyntheticIframe(windowId) {
1316 return _syntheticIframes.get(windowId) ?? null;
1317 }
1318 const TEXT_DOMAIN = "desktop-mode";
1319 function i18n() {
1320 return window.wp?.i18n;
1321 }
1322 function __(text, domain = TEXT_DOMAIN) {
1323 return i18n()?.__(text, domain) ?? text;
1324 }
1325 function sprintf(format, ...args) {
1326 const impl = i18n()?.sprintf;
1327 if (impl) {
1328 return impl(format, ...args);
1329 }
1330 let i = 0;
1331 return format.replace(/%(?:(\d+)\$)?[sd]/g, (_match, pos) => {
1332 const idx = pos ? Number.parseInt(pos, 10) - 1 : i++;
1333 return String(args[idx] ?? "");
1334 });
1335 }
1336 let _ctxInstance = 0;
1337 function buildNativeRenderContext(windowId) {
1338 const instance = ++_ctxInstance;
1339 const ns = (label) => `desktop-mode/native-render-ctx/${windowId}/${instance}/${label}`;
1340 const controller = new AbortController();
1341 const teardowns = [];
1342 const subscribeWindowed = (hookName2, label, match, invoke) => {
1343 const namespace = ns(label);
1344 addAction(hookName2, namespace, (payload) => {
1345 if (match(payload)) {
1346 invoke(payload);
1347 }
1348 });
1349 const off = () => {
1350 removeAction(hookName2, namespace);
1351 };
1352 teardowns.push(off);
1353 return off;
1354 };
1355 const matchByWindowId = (payload) => !!payload && typeof payload === "object" && payload.windowId === windowId;
1356 const ctx = {
1357 window: {
1358 send(channel, payload) {
1359 if (typeof channel !== "string" || channel === "") {
1360 return;
1361 }
1362 dispatchFromWindow(windowId, channel, payload);
1363 },
1364 on(channel, cb) {
1365 if (typeof channel !== "string" || channel === "" || typeof cb !== "function") {
1366 return () => void 0;
1367 }
1368 return addNativeSubscriber(
1369 windowId,
1370 channel,
1371 cb
1372 );
1373 },
1374 markLoading() {
1375 markWindowContentLoading(windowId);
1376 },
1377 markReady() {
1378 markWindowContentReady(windowId);
1379 }
1380 },
1381 markLoading() {
1382 markWindowContentLoading(windowId);
1383 },
1384 markReady() {
1385 markWindowContentReady(windowId);
1386 },
1387 signal: controller.signal,
1388 onResize(cb) {
1389 if (typeof cb !== "function") {
1390 return () => void 0;
1391 }
1392 return subscribeWindowed(
1393 HOOKS.WINDOW_BODY_RESIZED,
1394 "on-resize",
1395 matchByWindowId,
1396 (payload) => {
1397 const { width, height } = payload;
1398 try {
1399 cb(width, height);
1400 } catch (err) {
1401 doAction(HOOKS.SHELL_ERROR, {
1402 scope: "native-render-ctx/onResize",
1403 id: windowId,
1404 error: err
1405 });
1406 }
1407 }
1408 );
1409 },
1410 onHide(cb) {
1411 if (typeof cb !== "function") {
1412 return () => void 0;
1413 }
1414 return subscribeWindowed(
1415 HOOKS.WINDOW_MINIMIZED,
1416 "on-hide",
1417 matchByWindowId,
1418 () => {
1419 try {
1420 cb();
1421 } catch (err) {
1422 doAction(HOOKS.SHELL_ERROR, {
1423 scope: "native-render-ctx/onHide",
1424 id: windowId,
1425 error: err
1426 });
1427 }
1428 }
1429 );
1430 },
1431 onShow(cb) {
1432 if (typeof cb !== "function") {
1433 return () => void 0;
1434 }
1435 return subscribeWindowed(
1436 HOOKS.WINDOW_RESTORED,
1437 "on-show",
1438 matchByWindowId,
1439 () => {
1440 try {
1441 cb();
1442 } catch (err) {
1443 doAction(HOOKS.SHELL_ERROR, {
1444 scope: "native-render-ctx/onShow",
1445 id: windowId,
1446 error: err
1447 });
1448 }
1449 }
1450 );
1451 }
1452 };
1453 const dispose = () => {
1454 try {
1455 controller.abort();
1456 } catch {
1457 }
1458 while (teardowns.length) {
1459 const off = teardowns.pop();
1460 try {
1461 off?.();
1462 } catch {
1463 }
1464 }
1465 };
1466 return { ctx, dispose };
1467 }
1468 function sanitizeClassName(value) {
1469 return value.replace(/[^a-zA-Z0-9_-]/g, "");
1470 }
1471 function urlMatchKey(url) {
1472 try {
1473 const parsed = new URL(url, window.location.origin);
1474 parsed.searchParams.delete("desktop_mode_chromeless");
1475 parsed.searchParams.delete("desktop_mode_portal");
1476 return parsed.pathname.replace(/\/+$/, "") + "?" + parsed.searchParams.toString();
1477 } catch {
1478 return url;
1479 }
1480 }
1481 const WINDOW_CONFIG_KEY = Symbol.for("desktop-mode/window-config");
1482 function setWindowConfigOnElement(el, config) {
1483 el[WINDOW_CONFIG_KEY] = config;
1484 }
1485 const INITIAL_ORIGIN$2 = window.location.origin;
1486 function withChromelessParam(url) {
1487 const parsed = new URL(url, INITIAL_ORIGIN$2);
1488 if (parsed.origin !== INITIAL_ORIGIN$2) {
1489 return null;
1490 }
1491 parsed.searchParams.set("desktop_mode_chromeless", "1");
1492 return parsed.toString();
1493 }
1494 function updateFullscreenBodyClass() {
1495 const hasFullscreen = document.querySelectorAll(".desktop-mode-window--fullscreen").length > 0;
1496 document.body.classList.toggle("desktop-mode-has-fullscreen-window", hasFullscreen);
1497 }
1498 function buildDefaultLoadingOverlay() {
1499 const overlay = document.createElement("div");
1500 overlay.className = "desktop-mode-window__loading";
1501 overlay.setAttribute("aria-hidden", "true");
1502 const spinner = document.createElement("wpd-spinner");
1503 spinner.setAttribute("preset", "classic");
1504 spinner.setAttribute("size", "clamp(96px, 14vw, 192px)");
1505 spinner.setAttribute("label", __("Loading window content"));
1506 overlay.appendChild(spinner);
1507 return overlay;
1508 }
1509 function createLoadingOverlay(config) {
1510 let overlay = buildDefaultLoadingOverlay();
1511 const ctx = { windowId: config.id, config };
1512 if (typeof config.loading?.render === "function") {
1513 try {
1514 config.loading.render(overlay, ctx);
1515 } catch (err) {
1516 if (typeof console !== "undefined") {
1517 console.error(
1518 `[desktop-mode] loading.render threw for "${config.id}":`,
1519 err
1520 );
1521 }
1522 }
1523 }
1524 try {
1525 const filtered = applyFilters(
1526 HOOKS.WINDOW_LOADING_OVERLAY,
1527 overlay,
1528 ctx
1529 );
1530 if (filtered instanceof HTMLElement) {
1531 overlay = filtered;
1532 }
1533 } catch (err) {
1534 if (typeof console !== "undefined") {
1535 console.error(
1536 `[desktop-mode] WINDOW_LOADING_OVERLAY filter threw for "${config.id}":`,
1537 err
1538 );
1539 }
1540 }
1541 if (overlay && !overlay.classList.contains("desktop-mode-window__loading")) {
1542 overlay.classList.add("desktop-mode-window__loading");
1543 }
1544 return overlay;
1545 }
1546 function createSlotHost(name) {
1547 const host = document.createElement("span");
1548 host.className = `desktop-mode-window__slot desktop-mode-window__slot--${name}`;
1549 host.dataset.slot = name;
1550 return host;
1551 }
1552 function createWindowElement(config) {
1553 const el = document.createElement("div");
1554 el.className = "desktop-mode-window";
1555 if (config.native) {
1556 el.classList.add("desktop-mode-window--native");
1557 }
1558 el.id = `wp-window-${config.id}`;
1559 el.setAttribute("role", "dialog");
1560 el.setAttribute("aria-labelledby", `wp-window-title-${config.id}`);
1561 el.style.left = `${config.x}px`;
1562 el.style.top = `${config.y}px`;
1563 el.style.width = `${config.width}px`;
1564 el.style.height = `${config.height}px`;
1565 const titleBar = document.createElement("div");
1566 titleBar.className = "desktop-mode-window__titlebar";
1567 const menuBtn = document.createElement("wpd-window-button");
1568 menuBtn.setAttribute("icon", "menu");
1569 menuBtn.setAttribute("aria-label", __("Window actions"));
1570 menuBtn.setAttribute("aria-haspopup", "menu");
1571 menuBtn.setAttribute("aria-expanded", "false");
1572 menuBtn.classList.add("desktop-mode-window__btn");
1573 menuBtn.classList.add("desktop-mode-window__menu-btn");
1574 const menuPanel = document.createElement("wpd-menu");
1575 menuPanel.classList.add("desktop-mode-window__menu-panel");
1576 menuPanel.hidden = true;
1577 const startup = document.createElement("wpd-menu-item");
1578 startup.setAttribute("role", "menuitemcheckbox");
1579 startup.setAttribute("value", "startup");
1580 startup.classList.add("desktop-mode-window__menu-item");
1581 startup.classList.add("desktop-mode-window__menu-item--startup");
1582 startup.textContent = __("Open on startup");
1583 menuPanel.appendChild(startup);
1584 if (config.multi) {
1585 const openAnother = document.createElement("wpd-menu-item");
1586 openAnother.setAttribute("role", "menuitem");
1587 openAnother.setAttribute("value", "open-another");
1588 openAnother.setAttribute("icon", "dashicons-plus-alt2");
1589 openAnother.classList.add("desktop-mode-window__menu-item");
1590 openAnother.classList.add(
1591 "desktop-mode-window__menu-item--open-another"
1592 );
1593 openAnother.textContent = sprintf(
1594 // translators: %s is the window's admin-page name (e.g., "Posts")
1595 __("Open another %s"),
1596 config.title
1597 );
1598 menuPanel.appendChild(openAnother);
1599 }
1600 if (!config.native) {
1601 const openInNew = document.createElement("wpd-menu-item");
1602 openInNew.setAttribute("role", "menuitem");
1603 openInNew.setAttribute("value", "open-in-new-window");
1604 openInNew.setAttribute("icon", "dashicons-plus-alt");
1605 openInNew.classList.add("desktop-mode-window__menu-item");
1606 openInNew.classList.add("desktop-mode-window__menu-item--open-in-new-window");
1607 openInNew.textContent = __("Open in new window");
1608 menuPanel.appendChild(openInNew);
1609 }
1610 if (!config.native) {
1611 const reload = document.createElement("wpd-menu-item");
1612 reload.setAttribute("role", "menuitem");
1613 reload.setAttribute("value", "reload");
1614 reload.setAttribute("icon", "dashicons-update");
1615 reload.classList.add("desktop-mode-window__menu-item");
1616 reload.classList.add("desktop-mode-window__menu-item--reload");
1617 reload.textContent = __("Reload");
1618 menuPanel.appendChild(reload);
1619 const openExternal = document.createElement("wpd-menu-item");
1620 openExternal.setAttribute("role", "menuitem");
1621 openExternal.setAttribute("value", "open-external");
1622 openExternal.setAttribute("icon", "dashicons-external");
1623 openExternal.classList.add("desktop-mode-window__menu-item");
1624 openExternal.classList.add("desktop-mode-window__menu-item--open-external");
1625 openExternal.textContent = __("Open in browser tab");
1626 menuPanel.appendChild(openExternal);
1627 }
1628 const slotIcon = createSlotHost("icon");
1629 const iconEl = document.createElement("span");
1630 iconEl.className = `desktop-mode-window__icon dashicons ${sanitizeClassName(config.icon)}`;
1631 iconEl.setAttribute("aria-hidden", "true");
1632 slotIcon.appendChild(iconEl);
1633 const slotTitle = createSlotHost("title");
1634 const titleEl = document.createElement("span");
1635 titleEl.className = "desktop-mode-window__title";
1636 titleEl.id = `wp-window-title-${config.id}`;
1637 titleEl.textContent = config.title;
1638 slotTitle.appendChild(titleEl);
1639 const slotBeforeTitlebar = createSlotHost("before-titlebar");
1640 const slotBeforeIcon = createSlotHost("before-icon");
1641 const slotAfterTitle = createSlotHost("after-title");
1642 const slotBeforeControls = createSlotHost("before-controls");
1643 const slotAfterControls = createSlotHost("after-controls");
1644 const slotAfterTitlebar = createSlotHost("after-titlebar");
1645 const controls = document.createElement("div");
1646 controls.className = "desktop-mode-window__controls";
1647 const screenMeta = document.createElement("div");
1648 screenMeta.className = "desktop-mode-window__screen-meta";
1649 const customLeft = document.createElement("span");
1650 customLeft.className = "desktop-mode-window__custom-buttons desktop-mode-window__custom-buttons--left";
1651 const customRight = document.createElement("span");
1652 customRight.className = "desktop-mode-window__custom-buttons desktop-mode-window__custom-buttons--right";
1653 const activityHost = document.createElement("span");
1654 activityHost.className = "desktop-mode-window__activity";
1655 const activityStatus = document.createElement("wpd-save-status");
1656 activityStatus.setAttribute("mode", "dot");
1657 activityStatus.setAttribute("animation", "modem");
1658 activityStatus.setAttribute("phase", "idle");
1659 activityStatus.setAttribute("data-desktop-mode-activity-indicator", "");
1660 activityHost.appendChild(activityStatus);
1661 titleBar.appendChild(slotBeforeIcon);
1662 titleBar.appendChild(slotIcon);
1663 titleBar.appendChild(activityHost);
1664 titleBar.appendChild(slotTitle);
1665 titleBar.appendChild(slotAfterTitle);
1666 titleBar.appendChild(customLeft);
1667 titleBar.appendChild(screenMeta);
1668 if (menuBtn && menuPanel && menuPanel.children.length > 0) {
1669 titleBar.appendChild(menuBtn);
1670 titleBar.appendChild(menuPanel);
1671 }
1672 titleBar.appendChild(customRight);
1673 titleBar.appendChild(slotBeforeControls);
1674 titleBar.appendChild(controls);
1675 titleBar.appendChild(slotAfterControls);
1676 for (const child of Array.from(titleBar.children)) {
1677 child.setAttribute(
1678 "data-desktop-mode-default-chrome",
1679 ""
1680 );
1681 }
1682 const body = document.createElement("div");
1683 body.className = "desktop-mode-window__body desktop-mode-window__body--loading";
1684 if (!config.native) {
1685 const iframe = document.createElement("iframe");
1686 iframe.className = "desktop-mode-window__iframe";
1687 iframe.setAttribute("name", `desktop-mode-frame-${config.id}`);
1688 const chromelessSrc = config.url ? withChromelessParam(config.url) : null;
1689 iframe.src = chromelessSrc ?? "about:blank";
1690 body.appendChild(iframe);
1691 const onIframeLoad = () => {
1692 markWindowContentReady(config.id);
1693 };
1694 iframe.addEventListener("load", onIframeLoad);
1695 } else {
1696 body.classList.add("desktop-mode-window__body--native");
1697 }
1698 body.appendChild(createLoadingOverlay(config));
1699 markWindowContentLoading(config.id);
1700 const resizeHandles = [];
1701 for (const dir of ["ne", "nw", "se", "sw"]) {
1702 const h = document.createElement("div");
1703 h.className = `desktop-mode-window__resize-handle desktop-mode-window__resize-handle--${dir}`;
1704 h.dataset.dir = dir;
1705 h.setAttribute("aria-hidden", "true");
1706 resizeHandles.push(h);
1707 }
1708 el.appendChild(slotBeforeTitlebar);
1709 el.appendChild(titleBar);
1710 el.appendChild(slotAfterTitlebar);
1711 if (!config.native) {
1712 const tabs = document.createElement("nav");
1713 tabs.className = "desktop-mode-window__tabs";
1714 tabs.setAttribute("role", "tablist");
1715 tabs.setAttribute("aria-label", sprintf(__("%s sub-pages"), config.title));
1716 if (config.submenu && config.submenu.length > 0 && config.url) {
1717 const initialKey = urlMatchKey(config.url);
1718 const synthUrl = config.parentUrl ?? config.url;
1719 const synthKey = urlMatchKey(synthUrl);
1720 const parentAlreadyInSubmenu = config.submenu.some(
1721 (s) => urlMatchKey(s.url) === synthKey
1722 );
1723 const seedSubmenu = parentAlreadyInSubmenu ? [...config.submenu] : [{ title: config.title, url: synthUrl }, ...config.submenu];
1724 for (const sub of seedSubmenu) {
1725 const tab = document.createElement("button");
1726 tab.className = "desktop-mode-window__tab";
1727 tab.dataset.kind = "submenu";
1728 tab.setAttribute("type", "button");
1729 tab.setAttribute("role", "tab");
1730 tab.dataset.url = sub.url;
1731 tab.textContent = sub.title;
1732 if (urlMatchKey(sub.url) === initialKey) {
1733 tab.classList.add("desktop-mode-window__tab--active");
1734 tab.setAttribute("aria-selected", "true");
1735 } else {
1736 tab.setAttribute("aria-selected", "false");
1737 }
1738 tabs.appendChild(tab);
1739 }
1740 }
1741 el.appendChild(tabs);
1742 }
1743 el.appendChild(body);
1744 for (const h of resizeHandles) {
1745 el.appendChild(h);
1746 }
1747 setWindowConfigOnElement(el, config);
1748 return el;
1749 }
1750 const CANARY_TAG = "wpd-confirm-dialog";
1751 let inflight = null;
1752 function isLoaded() {
1753 return typeof window.customElements !== "undefined" && !!window.customElements.get(CANARY_TAG);
1754 }
1755 function injectScript(scriptUrl) {
1756 return new Promise((resolve, reject) => {
1757 const existing = document.querySelector(
1758 'script[data-desktop-mode-shell-overlays="1"]'
1759 );
1760 const finish = () => {
1761 if (isLoaded()) {
1762 resolve();
1763 return;
1764 }
1765 reject(
1766 new Error(
1767 "[desktop-mode] shell-overlays bundle loaded but did not register the overlay components."
1768 )
1769 );
1770 };
1771 if (existing) {
1772 if (isLoaded()) {
1773 finish();
1774 } else {
1775 existing.addEventListener("load", finish);
1776 existing.addEventListener(
1777 "error",
1778 () => reject(new Error("failed to load shell-overlays bundle"))
1779 );
1780 }
1781 return;
1782 }
1783 const s = document.createElement("script");
1784 s.src = scriptUrl;
1785 s.async = true;
1786 s.dataset.desktopModeShellOverlays = "1";
1787 s.addEventListener("load", finish);
1788 s.addEventListener(
1789 "error",
1790 () => reject(new Error("failed to load shell-overlays bundle"))
1791 );
1792 document.head.appendChild(s);
1793 });
1794 }
1795 function ensureShellOverlaysLoaded(scriptUrl) {
1796 if (isLoaded()) {
1797 return Promise.resolve();
1798 }
1799 if (!scriptUrl) {
1800 return Promise.resolve();
1801 }
1802 if (!inflight) {
1803 inflight = injectScript(scriptUrl);
1804 }
1805 return inflight;
1806 }
1807 function shellOverlaysBundleUrl() {
1808 const cfg = window.desktopModeConfig;
1809 return cfg?.shellOverlaysBundleUrl ?? "";
1810 }
1811 function openWithShellOverlays(isStillCurrent, fn) {
1812 const url = shellOverlaysBundleUrl();
1813 if (isLoaded() || !url) {
1814 fn();
1815 return;
1816 }
1817 void ensureShellOverlaysLoaded(url).then(() => {
1818 if (!isStillCurrent()) {
1819 return;
1820 }
1821 fn();
1822 }).catch((err) => {
1823 if (typeof console !== "undefined") {
1824 console.warn(
1825 "[desktop-mode] shell-overlays failed to load; menu/dialog suppressed:",
1826 err
1827 );
1828 }
1829 });
1830 }
1831 const DEFAULT_DURATION_MS = 4e3;
1832 const FADE_OUT_MS = 200;
1833 function showToast(options) {
1834 const intent = activity.filter(
1835 "desktop-mode/toast-requested",
1836 { ...options }
1837 );
1838 if (!intent || intent.cancel === true) {
1839 return () => void 0;
1840 }
1841 let dismissRequested = false;
1842 let realDismiss = null;
1843 openWithShellOverlays(
1844 () => !dismissRequested,
1845 () => {
1846 realDismiss = renderToast(intent);
1847 }
1848 );
1849 return () => {
1850 dismissRequested = true;
1851 if (realDismiss) {
1852 realDismiss();
1853 }
1854 };
1855 }
1856 function renderToast(intent) {
1857 const container = ensureContainer();
1858 const toast = document.createElement("wpd-toast");
1859 toast.textContent = intent.message;
1860 if (intent.action) {
1861 toast.setAttribute("action", intent.action.label);
1862 toast.addEventListener("wpd-toast-action", () => {
1863 intent.action?.onClick();
1864 dismiss();
1865 });
1866 }
1867 if (intent.dismissible) {
1868 toast.setAttribute("dismissible", "");
1869 toast.addEventListener("wpd-toast-dismiss", () => {
1870 intent.onDismiss?.();
1871 dismiss();
1872 });
1873 }
1874 container.appendChild(toast);
1875 let dismissed = false;
1876 let dismissTimer = null;
1877 const dismiss = () => {
1878 if (dismissed) {
1879 return;
1880 }
1881 dismissed = true;
1882 if (dismissTimer !== null) {
1883 window.clearTimeout(dismissTimer);
1884 dismissTimer = null;
1885 }
1886 toast.setAttribute("state", "out");
1887 window.setTimeout(() => {
1888 toast.remove();
1889 }, FADE_OUT_MS);
1890 };
1891 requestAnimationFrame(() => {
1892 toast.setAttribute("state", "in");
1893 });
1894 if (!intent.persistent) {
1895 dismissTimer = window.setTimeout(
1896 dismiss,
1897 intent.duration ?? DEFAULT_DURATION_MS
1898 );
1899 }
1900 activity.publish("desktop-mode/toast-shown", { ...intent });
1901 return dismiss;
1902 }
1903 function ensureContainer() {
1904 const existing = document.querySelector(
1905 "wpd-toast-container"
1906 );
1907 if (existing) {
1908 return existing;
1909 }
1910 const el = document.createElement("wpd-toast-container");
1911 document.body.appendChild(el);
1912 return el;
1913 }
1914 const EDGE_MARGIN = 0;
1915 const DRAG_THRESHOLD_PX = 5;
1916 const DRAG_THRESHOLD_SQUARED = DRAG_THRESHOLD_PX * DRAG_THRESHOLD_PX;
1917 const EXTERNAL_IFRAME_READY_TIMEOUT_MS = 3e3;
1918 function syncActiveTab(win, currentUrl) {
1919 const submenuTabs = win.element.querySelectorAll(
1920 '.desktop-mode-window__tab[data-kind="submenu"]'
1921 );
1922 if (!submenuTabs.length) {
1923 return;
1924 }
1925 if (win._activeTabId !== "primary") {
1926 for (const tab of submenuTabs) {
1927 tab.classList.remove("desktop-mode-window__tab--active");
1928 tab.setAttribute("aria-selected", "false");
1929 }
1930 return;
1931 }
1932 const activeKey = urlMatchKey(currentUrl);
1933 for (const tab of submenuTabs) {
1934 const tabUrl = tab.dataset.url;
1935 const isActive = !!tabUrl && urlMatchKey(tabUrl) === activeKey;
1936 tab.classList.toggle("desktop-mode-window__tab--active", isActive);
1937 tab.setAttribute("aria-selected", isActive ? "true" : "false");
1938 }
1939 }
1940 function addExternalTab(win, url, label) {
1941 if (!win.iframe) {
1942 return;
1943 }
1944 const tabStrip = win.element.querySelector(
1945 ".desktop-mode-window__tabs"
1946 );
1947 const body = win.element.querySelector(
1948 ".desktop-mode-window__body"
1949 );
1950 if (!tabStrip || !body) {
1951 return;
1952 }
1953 ensureMainTab(win, tabStrip);
1954 const tabId = `ext-${++win._externalTabSeq}`;
1955 const tabEl = document.createElement("button");
1956 tabEl.className = "desktop-mode-window__tab desktop-mode-window__tab--external";
1957 tabEl.dataset.kind = "external";
1958 tabEl.dataset.tabId = tabId;
1959 tabEl.setAttribute("type", "button");
1960 tabEl.setAttribute("role", "tab");
1961 tabEl.setAttribute("aria-selected", "false");
1962 tabEl.title = url;
1963 const labelEl = document.createElement("span");
1964 labelEl.className = "desktop-mode-window__tab-label";
1965 labelEl.textContent = label;
1966 tabEl.appendChild(labelEl);
1967 const detachBtn = document.createElement("wpd-tab-chip");
1968 detachBtn.setAttribute("variant", "detach");
1969 detachBtn.dataset.tabAction = "detach";
1970 detachBtn.dataset.tabId = tabId;
1971 detachBtn.setAttribute("aria-label", __("Open in a new browser tab"));
1972 detachBtn.title = __("Open in a new browser tab");
1973 tabEl.appendChild(detachBtn);
1974 const closeBtn = document.createElement("wpd-tab-chip");
1975 closeBtn.setAttribute("variant", "close");
1976 closeBtn.dataset.tabAction = "close";
1977 closeBtn.dataset.tabId = tabId;
1978 closeBtn.setAttribute("aria-label", __("Close tab"));
1979 closeBtn.title = __("Close tab");
1980 tabEl.appendChild(closeBtn);
1981 tabStrip.appendChild(tabEl);
1982 const iframe = document.createElement("iframe");
1983 iframe.className = "desktop-mode-window__iframe desktop-mode-window__iframe--external";
1984 iframe.dataset.tabId = tabId;
1985 iframe.style.display = "none";
1986 iframe.src = url;
1987 body.appendChild(iframe);
1988 let loaded = false;
1989 const onLoad = () => {
1990 loaded = true;
1991 };
1992 iframe.addEventListener("load", onLoad, { once: true });
1993 const probeTimer = window.setTimeout(() => {
1994 if (loaded) {
1995 return;
1996 }
1997 iframe.removeEventListener("load", onLoad);
1998 fallbackToBrowserTab(win, tabId);
1999 }, EXTERNAL_IFRAME_READY_TIMEOUT_MS);
2000 const cancelProbe = () => {
2001 iframe.removeEventListener("load", onLoad);
2002 window.clearTimeout(probeTimer);
2003 };
2004 win._externalTabs.set(tabId, {
2005 tabEl,
2006 iframe,
2007 url,
2008 label,
2009 cancelProbe
2010 });
2011 switchToTab(win, tabId);
2012 tabEl.scrollIntoView({ behavior: "smooth", inline: "end", block: "nearest" });
2013 win._emitChange("state");
2014 }
2015 function ensureMainTab(win, tabStrip) {
2016 if (tabStrip.querySelector('[data-kind="main"]')) {
2017 return;
2018 }
2019 if (tabStrip.querySelector('[data-kind="submenu"]')) {
2020 return;
2021 }
2022 const main = document.createElement("button");
2023 main.className = "desktop-mode-window__tab desktop-mode-window__tab--main desktop-mode-window__tab--active";
2024 main.dataset.kind = "main";
2025 main.setAttribute("type", "button");
2026 main.setAttribute("role", "tab");
2027 main.setAttribute("aria-selected", "true");
2028 main.textContent = win.config.title || "Main";
2029 tabStrip.prepend(main);
2030 }
2031 function switchToTab(win, tabId) {
2032 if (win._activeTabId === tabId) {
2033 return;
2034 }
2035 win._activeTabId = tabId;
2036 if (win.iframe) {
2037 win.iframe.style.display = tabId === "primary" ? "" : "none";
2038 }
2039 for (const [id, entry] of win._externalTabs) {
2040 entry.iframe.style.display = tabId === id ? "" : "none";
2041 }
2042 const tabEls = win.element.querySelectorAll(
2043 ".desktop-mode-window__tab"
2044 );
2045 tabEls.forEach((t) => {
2046 let isActive;
2047 if (t.dataset.kind === "main") {
2048 isActive = tabId === "primary";
2049 } else if (t.dataset.kind === "external") {
2050 isActive = t.dataset.tabId === tabId;
2051 } else {
2052 isActive = tabId === "primary" && t.classList.contains("desktop-mode-window__tab--active");
2053 }
2054 t.classList.toggle("desktop-mode-window__tab--active", isActive);
2055 t.setAttribute("aria-selected", isActive ? "true" : "false");
2056 });
2057 }
2058 function closeExternalTab(win, tabId) {
2059 const entry = win._externalTabs.get(tabId);
2060 if (!entry) {
2061 return;
2062 }
2063 entry.cancelProbe();
2064 entry.tabEl.remove();
2065 entry.iframe.remove();
2066 win._externalTabs.delete(tabId);
2067 if (win._activeTabId === tabId) {
2068 switchToTab(win, "primary");
2069 }
2070 if (win._externalTabs.size === 0) {
2071 const main = win.element.querySelector(
2072 ".desktop-mode-window__tab--main"
2073 );
2074 main?.remove();
2075 }
2076 win._emitChange("state");
2077 }
2078 function detachExternalTab(win, tabId) {
2079 const entry = win._externalTabs.get(tabId);
2080 if (!entry) {
2081 return;
2082 }
2083 let url = entry.url;
2084 try {
2085 const href = entry.iframe.contentWindow?.location.href;
2086 if (href && href !== "about:blank") {
2087 url = href;
2088 }
2089 } catch {
2090 }
2091 window.open(url, "_blank", "noopener");
2092 closeExternalTab(win, tabId);
2093 }
2094 function fallbackToBrowserTab(win, tabId) {
2095 const entry = win._externalTabs.get(tabId);
2096 if (!entry) {
2097 return;
2098 }
2099 const { url, label } = entry;
2100 closeExternalTab(win, tabId);
2101 showToast({
2102 message: sprintf(
2103 // translators: %s is the external site's title or URL.
2104 __(
2105 `Opened "%s" in a new browser tab — this site doesn't allow embedding.`
2106 ),
2107 label
2108 ),
2109 action: {
2110 label: __("Open"),
2111 onClick: () => {
2112 window.open(url, "_blank", "noopener");
2113 }
2114 }
2115 });
2116 window.open(url, "_blank", "noopener");
2117 }
2118 function externalTabCount(win) {
2119 return win._externalTabs.size;
2120 }
2121 function externalTabsSnapshot(win) {
2122 const out = [];
2123 for (const entry of win._externalTabs.values()) {
2124 let url = entry.url;
2125 try {
2126 const href = entry.iframe.contentWindow?.location.href;
2127 if (href && href !== "about:blank") {
2128 url = href;
2129 }
2130 } catch {
2131 }
2132 out.push({ url, label: entry.label });
2133 }
2134 return out;
2135 }
2136 function handleTabStripClick(win, e) {
2137 const target = e.target;
2138 const chip = target.closest("[data-tab-action]");
2139 if (chip) {
2140 e.stopPropagation();
2141 const action = chip.dataset.tabAction;
2142 const tabId2 = chip.dataset.tabId;
2143 if (!tabId2) {
2144 return;
2145 }
2146 if (action === "close") {
2147 closeExternalTab(win, tabId2);
2148 } else if (action === "detach") {
2149 detachExternalTab(win, tabId2);
2150 }
2151 return;
2152 }
2153 const tab = target.closest(".desktop-mode-window__tab");
2154 if (!tab) {
2155 return;
2156 }
2157 e.stopPropagation();
2158 const kind = tab.dataset.kind;
2159 const tabId = tab.dataset.tabId;
2160 if (kind === "external" && tabId) {
2161 switchToTab(win, tabId);
2162 return;
2163 }
2164 if (kind === "main") {
2165 switchToTab(win, "primary");
2166 return;
2167 }
2168 if (tab.dataset.url) {
2169 const next = withChromelessParam(tab.dataset.url);
2170 if (next && win.iframe) {
2171 win.markContentLoading();
2172 win.iframe.src = next;
2173 }
2174 switchToTab(win, "primary");
2175 }
2176 }
2177 const SHARED_STORES_SLOT = "__desktopModeSharedStores";
2178 function resolveSlot() {
2179 const w = window;
2180 let slot = w[SHARED_STORES_SLOT];
2181 if (!slot) {
2182 slot = /* @__PURE__ */ new Map();
2183 w[SHARED_STORES_SLOT] = slot;
2184 }
2185 return slot;
2186 }
2187 function createSharedStore(key, initialState) {
2188 const slot = resolveSlot();
2189 let record = slot.get(key);
2190 if (!record) {
2191 record = {
2192 state: initialState(),
2193 listeners: /* @__PURE__ */ new Set(),
2194 rebuild: initialState
2195 };
2196 slot.set(key, record);
2197 }
2198 const handle = {
2199 // `record.state` is the live reference. The getter on the
2200 // `state` field reads the latest value even if `reset()`
2201 // reassigned it to a fresh object.
2202 get state() {
2203 return record.state;
2204 },
2205 set state(next) {
2206 record.state = next;
2207 },
2208 getState() {
2209 return record.state;
2210 },
2211 notify() {
2212 for (const cb of Array.from(record.listeners)) {
2213 try {
2214 cb(record.state);
2215 } catch (err) {
2216 console.error(
2217 `[desktop-mode/shared-store:${key}] subscriber threw:`,
2218 err
2219 );
2220 }
2221 }
2222 },
2223 subscribe(cb) {
2224 record.listeners.add(cb);
2225 return () => {
2226 record.listeners.delete(cb);
2227 };
2228 },
2229 setState(patch) {
2230 const cur = record.state;
2231 if (typeof cur !== "object" || cur === null) {
2232 console.warn(
2233 `[desktop-mode/shared-store:${key}] setState called on a primitive store; use the state setter instead.`
2234 );
2235 return;
2236 }
2237 Object.assign(cur, patch);
2238 handle.notify();
2239 },
2240 reset() {
2241 const fresh = record.rebuild();
2242 const cur = record.state;
2243 if (typeof cur === "object" && cur !== null && typeof fresh === "object" && fresh !== null) {
2244 const target = cur;
2245 for (const k of Object.keys(target)) {
2246 delete target[k];
2247 }
2248 Object.assign(target, fresh);
2249 } else {
2250 record.state = fresh;
2251 }
2252 record.listeners.clear();
2253 }
2254 };
2255 return handle;
2256 }
2257 const remapStore = createSharedStore(
2258 "desktop-mode/native-url-remap",
2259 () => ({ remaps: [], deps: null })
2260 );
2261 function tryNativeUrlRemap(url) {
2262 const { deps, remaps } = remapStore.state;
2263 if (!deps || !url) {
2264 return false;
2265 }
2266 let parsed;
2267 try {
2268 parsed = new URL(url, deps.adminUrl);
2269 } catch {
2270 return false;
2271 }
2272 const snapshot = deps.getSnapshot();
2273 for (const entry of remaps) {
2274 if (!entry.matches(url, parsed)) {
2275 continue;
2276 }
2277 if (entry.enabled && !entry.enabled(snapshot)) {
2278 continue;
2279 }
2280 if (entry.onMatch) {
2281 try {
2282 entry.onMatch(url, parsed);
2283 } catch (err) {
2284 console.warn(
2285 `[desktop-mode] URL remap onMatch hook threw for "${entry.id}":`,
2286 err
2287 );
2288 }
2289 }
2290 if (deps.openById(entry.nativeWindowId)) {
2291 return true;
2292 }
2293 }
2294 return false;
2295 }
2296 const store$6 = createSharedStore(
2297 "desktop-mode/destructive-admin-actions",
2298 () => ({ entries: [] })
2299 );
2300 function matchDestructiveAdminAction(url, parsed) {
2301 for (const entry of store$6.state.entries) {
2302 try {
2303 if (entry.matches(url, parsed)) {
2304 return entry.id;
2305 }
2306 } catch (err) {
2307 console.warn(
2308 `[desktop-mode] destructive-action predicate threw for "${entry.id}":`,
2309 err
2310 );
2311 }
2312 }
2313 return null;
2314 }
2315 function collectRegistrationErrors(def, checks) {
2316 if (!def || typeof def !== "object") {
2317 return ["def (not an object)"];
2318 }
2319 const d = def;
2320 const errors = [];
2321 for (const check of checks) {
2322 if (!check.valid(d)) {
2323 errors.push(`${check.field} (${check.message})`);
2324 }
2325 }
2326 return errors;
2327 }
2328 class RegistrationError extends Error {
2329 constructor(kind, errors, def) {
2330 super(
2331 `[desktop-mode] ${kind} registration rejected — fields: ` + errors.join(", ") + "."
2332 );
2333 this.name = "RegistrationError";
2334 this.kind = kind;
2335 this.errors = errors;
2336 this.def = def;
2337 }
2338 }
2339 function throwOnRegistrationErrors(kind, errors, def) {
2340 if (errors.length === 0) {
2341 return;
2342 }
2343 throw new RegistrationError(kind, errors, def);
2344 }
2345 function logRegistrationErrors(kind, errors, def) {
2346 if (typeof console === "undefined") {
2347 return;
2348 }
2349 console.warn(
2350 `[desktop-mode] ${kind} registration rejected — fields: ` + errors.join(", ") + ".",
2351 def
2352 );
2353 }
2354 const MAX_LINKS = 32;
2355 const CONTENT_TYPE_ID = /^[a-z0-9_/-]+$/;
2356 const store$5 = createSharedStore(
2357 "desktop-mode/window-links",
2358 () => ({
2359 contentByWindow: /* @__PURE__ */ new Map(),
2360 focusSeq: /* @__PURE__ */ new Map(),
2361 seq: 0,
2362 listeners: /* @__PURE__ */ new Set(),
2363 lastGroupsSignature: "",
2364 manager: null,
2365 started: false
2366 })
2367 );
2368 function keyOf(ref) {
2369 return `${ref.type}:${ref.id}`;
2370 }
2371 function rootKeyOf(ref) {
2372 return ref.root ? keyOf(ref.root) : keyOf(ref);
2373 }
2374 function validateRef(ref) {
2375 const isValidId = (v) => typeof v === "number" && Number.isFinite(v) || typeof v === "string" && v.trim() !== "";
2376 const isValidType = (v) => typeof v === "string" && CONTENT_TYPE_ID.test(v.trim().toLowerCase());
2377 return collectRegistrationErrors(ref, [
2378 {
2379 field: "type",
2380 valid: (r) => isValidType(r.type),
2381 message: "must match /^[a-z0-9_/-]+$/ — lowercase alphanum, hyphens, underscores, slashes for vendor/sub-type"
2382 },
2383 {
2384 field: "id",
2385 valid: (r) => isValidId(r.id),
2386 message: "must be a finite number or non-empty string"
2387 },
2388 {
2389 field: "root",
2390 valid: (r) => r.root === void 0 || !!r.root && typeof r.root === "object" && isValidType(r.root.type) && isValidId(r.root.id),
2391 message: "when present, must be { type, id } with the same shapes as the ref itself"
2392 },
2393 {
2394 field: "links",
2395 valid: (r) => r.links === void 0 || Array.isArray(r.links) && r.links.every(
2396 (l) => !!l && typeof l === "object" && isValidType(l.type) && isValidId(l.id) && (l.rel === void 0 || l.rel === "references" || l.rel === "child")
2397 ),
2398 message: "when present, must be an array of { type, id, rel?: 'references'|'child' } entries"
2399 }
2400 ]);
2401 }
2402 function normalizeRef(ref, source) {
2403 const next = {
2404 type: ref.type.trim().toLowerCase(),
2405 id: ref.id,
2406 source
2407 };
2408 if (ref.root) {
2409 next.root = {
2410 type: ref.root.type.trim().toLowerCase(),
2411 id: ref.root.id
2412 };
2413 }
2414 if (Array.isArray(ref.links) && ref.links.length > 0) {
2415 next.links = ref.links.slice(0, MAX_LINKS).map((l) => {
2416 const entry = {
2417 type: l.type.trim().toLowerCase(),
2418 id: l.id
2419 };
2420 if (l.rel === "child") {
2421 entry.rel = "child";
2422 }
2423 return entry;
2424 });
2425 }
2426 if (typeof ref.label === "string" && ref.label !== "") {
2427 next.label = ref.label;
2428 }
2429 return next;
2430 }
2431 function refSignature(ref) {
2432 if (!ref) {
2433 return "";
2434 }
2435 return [
2436 keyOf(ref),
2437 rootKeyOf(ref),
2438 ...(ref.links ?? []).map(
2439 (l) => keyOf(l) + (l.rel === "child" ? "!child" : "")
2440 )
2441 ].join("|");
2442 }
2443 function setWindowContent(windowId, ref, opts = {}) {
2444 const source = opts.source ?? "api";
2445 if (typeof windowId !== "string" || windowId === "") {
2446 throwOnRegistrationErrors(
2447 "WindowContentRef",
2448 ["windowId (must be a non-empty string)"],
2449 ref
2450 );
2451 return;
2452 }
2453 let next = null;
2454 if (ref !== null && ref !== void 0) {
2455 const errors = validateRef(ref);
2456 if (errors.length > 0) {
2457 if (source === "api") {
2458 throwOnRegistrationErrors("WindowContentRef", errors, ref);
2459 }
2460 logRegistrationErrors("WindowContentRef", errors, ref);
2461 return;
2462 }
2463 next = normalizeRef(ref, source);
2464 }
2465 next = applyFilters(
2466 HOOKS.WINDOW_LINKS_CONTENT,
2467 next,
2468 { windowId, source }
2469 );
2470 if (next !== null && (!next || validateRef(next).length > 0)) {
2471 logRegistrationErrors(
2472 "WindowContentRef",
2473 ["filter (desktop-mode.window-links.content returned an invalid ref)"],
2474 next
2475 );
2476 return;
2477 }
2478 const previous = store$5.state.contentByWindow.get(windowId) ?? null;
2479 if (next === null && previous === null) {
2480 return;
2481 }
2482 if (next !== null && previous !== null && refSignature(next) === refSignature(previous) && next.label === previous.label) {
2483 return;
2484 }
2485 if (next === null) {
2486 store$5.state.contentByWindow.delete(windowId);
2487 } else {
2488 store$5.state.contentByWindow.set(windowId, next);
2489 }
2490 const changedDetail = { windowId, content: next, previous, source };
2491 document.dispatchEvent(
2492 new CustomEvent("desktop-mode-window-content-changed", {
2493 detail: changedDetail
2494 })
2495 );
2496 doAction(HOOKS.WINDOW_CONTENT_CHANGED, changedDetail);
2497 broadcastGroupsIfChanged();
2498 notify();
2499 }
2500 function listWindowLinkGroups() {
2501 const byKey = /* @__PURE__ */ new Map();
2502 for (const [windowId, ref] of store$5.state.contentByWindow) {
2503 const groupKey = rootKeyOf(ref);
2504 let group = byKey.get(groupKey);
2505 if (!group) {
2506 group = {
2507 key: groupKey,
2508 root: ref.root ? { ...ref.root } : { type: ref.type, id: ref.id },
2509 rootWindowIds: [],
2510 children: []
2511 };
2512 byKey.set(groupKey, group);
2513 }
2514 if (ref.root) {
2515 group.children.push({ windowId, content: ref });
2516 } else {
2517 group.rootWindowIds.push(windowId);
2518 }
2519 }
2520 const seq = store$5.state.focusSeq;
2521 for (const group of byKey.values()) {
2522 group.rootWindowIds.sort(
2523 (a, b) => (seq.get(b) ?? 0) - (seq.get(a) ?? 0)
2524 );
2525 }
2526 const copy = Array.from(byKey.values());
2527 const filtered = applyFilters(
2528 HOOKS.WINDOW_LINK_GROUPS,
2529 copy
2530 );
2531 if (!Array.isArray(filtered)) {
2532 if (typeof console !== "undefined") {
2533 console.warn(
2534 "[desktop-mode] `desktop-mode.window-links.groups` filter returned a non-array; falling back to computed groups."
2535 );
2536 }
2537 return copy;
2538 }
2539 return filtered;
2540 }
2541 function notify() {
2542 for (const cb of Array.from(store$5.state.listeners)) {
2543 try {
2544 cb();
2545 } catch (err) {
2546 if (typeof console !== "undefined") {
2547 console.error(
2548 "[desktop-mode] window-links listener threw:",
2549 err
2550 );
2551 }
2552 }
2553 }
2554 }
2555 function relationsSignature() {
2556 return Array.from(store$5.state.contentByWindow).map(([id, ref]) => `${id}=${refSignature(ref)}`).sort().join(";");
2557 }
2558 function broadcastGroupsIfChanged() {
2559 const signature = relationsSignature();
2560 if (signature === store$5.state.lastGroupsSignature) {
2561 return;
2562 }
2563 store$5.state.lastGroupsSignature = signature;
2564 const groups = listWindowLinkGroups();
2565 const detail = { groups };
2566 document.dispatchEvent(
2567 new CustomEvent("desktop-mode-window-link-groups-changed", {
2568 detail
2569 })
2570 );
2571 doAction(HOOKS.WINDOW_LINK_GROUPS_CHANGED, detail);
2572 }
2573 const WINDOW_ID = "desktop-mode-my-wordpress";
2574 const _initial = Object.freeze({
2575 userId: null,
2576 userName: "",
2577 requestedAt: 0
2578 });
2579 function getDesktop() {
2580 return window.wp?.desktop;
2581 }
2582 let _store = null;
2583 function getStore() {
2584 if (_store) {
2585 return _store;
2586 }
2587 const factory2 = getDesktop()?.createSharedStore;
2588 if (typeof factory2 !== "function") {
2589 return null;
2590 }
2591 _store = factory2(
2592 "desktop-mode/my-wordpress/footprint-target",
2593 () => ({ ..._initial })
2594 );
2595 return _store;
2596 }
2597 function setFootprintTarget(userId, userName = "") {
2598 const store2 = getStore();
2599 if (store2) {
2600 store2.state.userId = userId;
2601 store2.state.userName = userName;
2602 store2.state.requestedAt = Date.now();
2603 store2.notify();
2604 return;
2605 }
2606 window._wpdFootprintTarget = {
2607 userId,
2608 userName,
2609 requestedAt: Date.now()
2610 };
2611 }
2612 function openUserFootprintWindow(args) {
2613 const userId = Number(args.userId);
2614 if (!Number.isFinite(userId) || userId <= 0) {
2615 return;
2616 }
2617 setFootprintTarget(userId, args.userName ?? "");
2618 getDesktop()?.openWindow?.(WINDOW_ID, {
2619 source: "my-wordpress/open-user-footprint"
2620 });
2621 }
2622 const INITIAL_ORIGIN$1 = window.location.origin;
2623 const adminLinkDepsStore = createSharedStore(
2624 "desktop-mode/admin-link-deps",
2625 () => ({ deps: null })
2626 );
2627 function handleWindowMessage(win, event) {
2628 if (event.origin !== INITIAL_ORIGIN$1) {
2629 return;
2630 }
2631 if (!win.iframe || event.source !== win.iframe.contentWindow) {
2632 return;
2633 }
2634 const data = event.data;
2635 if (!data || typeof data.type !== "string") {
2636 return;
2637 }
2638 if (data.type === "desktop-mode-title-change" && typeof data.title === "string") {
2639 win.setTitle(data.title);
2640 }
2641 if (data.type === "desktop-mode-content-identity") {
2642 setWindowContent(win.id, data.identity ?? null, {
2643 source: "bridge"
2644 });
2645 }
2646 if (data.type === "desktop-mode-window-publish" && typeof data.channel === "string" && data.channel !== "") {
2647 dispatchFromWindow(win.id, data.channel, data.payload);
2648 }
2649 if (typeof data.type === "string" && data.type.startsWith("desktop-mode-bridge-")) {
2650 const bridge = window.__desktopModeConnectionBridge;
2651 bridge?.routeIncomingFromIframe(data, win.id);
2652 }
2653 if (data.type === "desktop-mode-ready") {
2654 win._iframeBridgeReady = true;
2655 markWindowContentReady(win.id);
2656 doAction(HOOKS.IFRAME_READY, { windowId: win.id });
2657 }
2658 if (data.type === "desktop-mode-bridge-beforeunload-response") {
2659 if (win._isDestroyed) {
2660 return;
2661 }
2662 win._closePending = false;
2663 if (win._iframeCloseTimeout) {
2664 clearTimeout(win._iframeCloseTimeout);
2665 win._iframeCloseTimeout = null;
2666 }
2667 if (data.prevent) {
2668 Promise.resolve().then(() => wpdConfirmDialog).then(
2669 ({ wpdConfirm: wpdConfirm2 }) => wpdConfirm2({
2670 title: typeof data.message === "string" && data.message ? data.message : __("Unsaved changes"),
2671 message: __("You have unsaved changes. Are you sure you want to close this window?"),
2672 confirmLabel: __("Close window"),
2673 danger: true
2674 })
2675 ).then((confirmed) => {
2676 if (confirmed) {
2677 win.destroy();
2678 }
2679 }).catch(() => {
2680 win.destroy();
2681 });
2682 } else {
2683 win.destroy();
2684 }
2685 }
2686 if (data.type === "desktop-mode-navigate" && typeof data.url === "string" && data.url !== "") {
2687 handleDesktopNavigate(
2688 win,
2689 data.url,
2690 data.target === "new" ? "new" : "self"
2691 );
2692 }
2693 if (data.type === "desktop-mode-iframe-admin-link" && typeof data.url === "string" && data.url !== "") {
2694 const deps = adminLinkDepsStore.state.deps;
2695 if (tryNativeUrlRemap(data.url)) {
2696 win.close();
2697 } else if (deps) {
2698 const linkLabel = typeof data.label === "string" ? data.label : "";
2699 handleCrossPageAdminLink(win, data.url, linkLabel, deps);
2700 }
2701 }
2702 if (data.type === "desktop-mode-open-user-footprint" && typeof data.userId === "number" && data.userId > 0) {
2703 openUserFootprintWindow({
2704 userId: data.userId,
2705 userName: typeof data.userName === "string" ? data.userName : ""
2706 });
2707 }
2708 if (data.type === "desktop-mode-notification" && typeof data.title === "string" && data.title !== "") {
2709 handleDesktopNotification(
2710 data.title,
2711 typeof data.body === "string" ? data.body : ""
2712 );
2713 }
2714 if (data.type === "desktop-mode-focus-request") {
2715 if (!win.element.classList.contains("desktop-mode-window--overview")) {
2716 win.onFocusRequest?.(win);
2717 }
2718 }
2719 if (data.type === "desktop-mode-screen-meta" && Array.isArray(data.panels)) {
2720 addScreenMetaButtons(win, data.panels);
2721 }
2722 if (data.type === "desktop-mode-screen-meta-state") {
2723 setActiveScreenMetaPanel(
2724 win,
2725 typeof data.open === "string" ? data.open : null
2726 );
2727 }
2728 if (data.type === "desktop-mode-external-link" && typeof data.url === "string" && data.url !== "") {
2729 const label = typeof data.label === "string" && data.label !== "" ? data.label : data.url;
2730 addExternalTab(win, data.url, label);
2731 }
2732 if (data.type === "desktop-mode-iframe-error") {
2733 doAction(HOOKS.IFRAME_ERROR, {
2734 windowId: win.id,
2735 kind: data.kind === "unhandledrejection" ? "unhandledrejection" : "error",
2736 message: typeof data.message === "string" ? data.message : "",
2737 filename: typeof data.filename === "string" ? data.filename : null,
2738 lineno: typeof data.lineno === "number" ? data.lineno : null,
2739 colno: typeof data.colno === "number" ? data.colno : null,
2740 stack: typeof data.stack === "string" ? data.stack : null
2741 });
2742 }
2743 if (data.type === "desktop-mode-chrome-theme" && data.tokens && typeof data.tokens === "object") {
2744 try {
2745 win.setAppearanceTheme(
2746 data.tokens
2747 );
2748 } catch (err) {
2749 doAction(HOOKS.SHELL_ERROR, {
2750 scope: "window-bridge-chrome-theme",
2751 windowId: win.id,
2752 error: err
2753 });
2754 }
2755 }
2756 if (data.type === "desktop-mode-chrome-controls" && data.config && typeof data.config === "object") {
2757 try {
2758 win.setAppearanceControls(
2759 data.config
2760 );
2761 } catch (err) {
2762 doAction(HOOKS.SHELL_ERROR, {
2763 scope: "window-bridge-chrome-controls",
2764 windowId: win.id,
2765 error: err
2766 });
2767 }
2768 }
2769 if (data.type === "desktop-mode-chrome-slot" && typeof data.slot === "string" && typeof data.html === "string") {
2770 try {
2771 win.setAppearanceSlot(
2772 data.slot,
2773 { html: data.html }
2774 );
2775 } catch (err) {
2776 doAction(HOOKS.SHELL_ERROR, {
2777 scope: "window-bridge-chrome-slot",
2778 windowId: win.id,
2779 error: err
2780 });
2781 }
2782 }
2783 if (data.type === "desktop-mode-iframe-network") {
2784 const networkPayload = {
2785 windowId: win.id,
2786 method: typeof data.method === "string" ? data.method : "GET",
2787 url: typeof data.url === "string" ? data.url : "",
2788 status: typeof data.status === "number" ? data.status : 0,
2789 duration: typeof data.duration === "number" ? data.duration : 0,
2790 failed: !!data.failed
2791 };
2792 if (data.requestHeaders && typeof data.requestHeaders === "object") {
2793 networkPayload.requestHeaders = data.requestHeaders;
2794 }
2795 if (data.responseHeaders && typeof data.responseHeaders === "object") {
2796 networkPayload.responseHeaders = data.responseHeaders;
2797 }
2798 doAction(HOOKS.IFRAME_NETWORK_COMPLETED, networkPayload);
2799 }
2800 }
2801 function handleDesktopNavigate(win, rawUrl, target) {
2802 let url;
2803 try {
2804 url = new URL(rawUrl, INITIAL_ORIGIN$1);
2805 } catch {
2806 return;
2807 }
2808 if (url.origin !== INITIAL_ORIGIN$1) {
2809 return;
2810 }
2811 if (target === "new") {
2812 window.open(url.toString(), "_blank", "noopener,noreferrer");
2813 return;
2814 }
2815 if (win.iframe) {
2816 win.iframe.src = url.toString();
2817 }
2818 }
2819 const DESTRUCTIVE_ADMIN_ACTIONS = /* @__PURE__ */ new Set([
2820 // wp-admin/post.php
2821 "trash",
2822 "untrash",
2823 "delete",
2824 // wp-admin/comment.php
2825 "spam",
2826 "unspam",
2827 "spamcomment",
2828 "unspamcomment",
2829 "trashcomment",
2830 "untrashcomment",
2831 "deletecomment",
2832 "approvecomment",
2833 "unapprovecomment"
2834 ]);
2835 function isDestructiveActionUrl(url) {
2836 const action = url.searchParams.get("action");
2837 if (action && DESTRUCTIVE_ADMIN_ACTIONS.has(action)) {
2838 if (url.searchParams.has("_wpnonce") || url.searchParams.has("_wp_nonce")) {
2839 return true;
2840 }
2841 }
2842 return matchDestructiveAdminAction(url.toString(), url) !== null;
2843 }
2844 function stampSourceReferer(url, win) {
2845 if (url.searchParams.has("_wp_http_referer")) {
2846 return url;
2847 }
2848 let sourceHref = "";
2849 try {
2850 sourceHref = win.iframe?.contentWindow?.location.href ?? "";
2851 } catch {
2852 }
2853 if (!sourceHref) {
2854 sourceHref = win.config.url || "";
2855 }
2856 if (!sourceHref) {
2857 return url;
2858 }
2859 try {
2860 const sourceUrl = new URL(sourceHref, INITIAL_ORIGIN$1);
2861 if (sourceUrl.origin !== INITIAL_ORIGIN$1) {
2862 return url;
2863 }
2864 const out = new URL(url.href);
2865 const cleaned = new URL(sourceUrl.href);
2866 cleaned.searchParams.delete("desktop_mode_chromeless");
2867 out.searchParams.set(
2868 "_wp_http_referer",
2869 cleaned.pathname + (cleaned.search ? cleaned.search : "")
2870 );
2871 return out;
2872 } catch {
2873 return url;
2874 }
2875 }
2876 function handleCrossPageAdminLink(win, rawUrl, linkLabel, deps) {
2877 let url;
2878 try {
2879 url = new URL(rawUrl, deps.adminUrl);
2880 } catch {
2881 return;
2882 }
2883 if (url.origin !== INITIAL_ORIGIN$1) {
2884 return;
2885 }
2886 const absolute = url.toString();
2887 const targetSlug = deps.deriveSlug(absolute);
2888 const sourceSlug = win.config.baseId || win.id;
2889 if (targetSlug !== sourceSlug && isDestructiveActionUrl(url)) {
2890 const trashUrl = stampSourceReferer(url, win);
2891 const inner = win.iframe?.contentWindow;
2892 if (inner) {
2893 try {
2894 inner.location.assign(trashUrl.href);
2895 } catch {
2896 if (win.iframe) {
2897 win.iframe.src = trashUrl.href;
2898 }
2899 }
2900 }
2901 return;
2902 }
2903 if (targetSlug === sourceSlug) {
2904 const inner = win.iframe?.contentWindow;
2905 if (inner) {
2906 try {
2907 inner.location.assign(absolute);
2908 } catch {
2909 if (win.iframe) {
2910 win.iframe.src = absolute;
2911 }
2912 }
2913 }
2914 return;
2915 }
2916 const entry = deps.findDockEntry(absolute);
2917 const trimmedLabel = linkLabel.trim();
2918 const title = entry?.title || (trimmedLabel !== "" ? trimmedLabel : targetSlug);
2919 const urlWithReferer = stampSourceReferer(url, win);
2920 deps.openWindow({
2921 id: targetSlug,
2922 baseId: targetSlug,
2923 url: urlWithReferer.toString(),
2924 parentUrl: entry?.url ?? absolute,
2925 title,
2926 icon: entry?.icon ?? "dashicons-admin-generic",
2927 submenu: entry?.submenu,
2928 multi: entry?.multi
2929 });
2930 }
2931 function handleDesktopNotification(title, body) {
2932 const message = body !== "" ? `${title}${body}` : title;
2933 showToast({ message });
2934 }
2935 function addScreenMetaButtons(win, panels) {
2936 const container = win.element.querySelector(".desktop-mode-window__screen-meta");
2937 if (!container) {
2938 return;
2939 }
2940 container.innerHTML = "";
2941 const panelConfig = {
2942 "screen-options": { icon: "dashicons-admin-generic", label: "Screen Options" },
2943 help: { icon: "dashicons-editor-help", label: "Help" }
2944 };
2945 for (const panel of panels) {
2946 const cfg = panelConfig[panel];
2947 if (!cfg) {
2948 continue;
2949 }
2950 const btn = document.createElement("button");
2951 btn.className = "desktop-mode-window__meta-btn";
2952 btn.setAttribute("type", "button");
2953 btn.setAttribute("aria-label", cfg.label);
2954 btn.setAttribute("aria-pressed", "false");
2955 btn.dataset.panel = panel;
2956 btn.innerHTML = `<span class="dashicons ${cfg.icon}" aria-hidden="true"></span>`;
2957 btn.addEventListener("click", (e) => {
2958 e.stopPropagation();
2959 win.iframe?.contentWindow?.postMessage(
2960 { type: "desktop-mode-toggle-panel", panel },
2961 INITIAL_ORIGIN$1
2962 );
2963 });
2964 container.appendChild(btn);
2965 }
2966 }
2967 function setActiveScreenMetaPanel(win, panel) {
2968 const container = win.element.querySelector(".desktop-mode-window__screen-meta");
2969 if (!container) {
2970 return;
2971 }
2972 container.querySelectorAll(".desktop-mode-window__meta-btn").forEach((btn) => {
2973 const isActive = btn.dataset.panel === panel;
2974 btn.classList.toggle("desktop-mode-window__meta-btn--active", isActive);
2975 btn.setAttribute("aria-pressed", isActive ? "true" : "false");
2976 });
2977 }
2978 const store$4 = createSharedStore(
2979 "desktop-mode/title-bar-buttons-registry",
2980 () => ({ registry: /* @__PURE__ */ new Map(), listeners: /* @__PURE__ */ new Set() })
2981 );
2982 const registry$4 = store$4.state.registry;
2983 const listeners$4 = store$4.state.listeners;
2984 function listTitleBarButtons() {
2985 return Array.from(registry$4.values()).sort(
2986 (a, b) => (a.order ?? 100) - (b.order ?? 100)
2987 );
2988 }
2989 function buttonsForWindow(win) {
2990 const left = [];
2991 const right = [];
2992 for (const def of listTitleBarButtons()) {
2993 try {
2994 if (!def.match(win)) {
2995 continue;
2996 }
2997 } catch {
2998 continue;
2999 }
3000 if (def.placement === "right") {
3001 right.push(def);
3002 } else {
3003 left.push(def);
3004 }
3005 }
3006 return { left, right };
3007 }
3008 function subscribeTitleBarButtons(cb) {
3009 listeners$4.add(cb);
3010 return () => {
3011 listeners$4.delete(cb);
3012 };
3013 }
3014 const DASHICON_PATTERN = /^dashicons-[a-z0-9-]+$/i;
3015 const INLINE_SVG_PATTERN = /^\s*<svg[\s>]/i;
3016 function paintTitleBarButtonIcon(host, icon) {
3017 if (!icon) {
3018 return;
3019 }
3020 if (DASHICON_PATTERN.test(icon)) {
3021 const span = document.createElement("span");
3022 span.className = `dashicons ${icon}`;
3023 span.setAttribute("aria-hidden", "true");
3024 host.appendChild(span);
3025 return;
3026 }
3027 if (INLINE_SVG_PATTERN.test(icon)) {
3028 const wrapper = document.createElement("span");
3029 wrapper.setAttribute("aria-hidden", "true");
3030 wrapper.innerHTML = icon;
3031 host.appendChild(wrapper);
3032 return;
3033 }
3034 host.setAttribute("icon", icon);
3035 }
3036 const store$3 = createSharedStore(
3037 "desktop-mode/window-themes-registry",
3038 () => ({ registry: /* @__PURE__ */ new Map(), listeners: /* @__PURE__ */ new Set() })
3039 );
3040 const registry$3 = store$3.state.registry;
3041 const listeners$3 = store$3.state.listeners;
3042 function listWindowThemes() {
3043 return Array.from(registry$3.values()).sort(
3044 (a, b) => (a.priority ?? 100) - (b.priority ?? 100)
3045 );
3046 }
3047 function resolveWindowTheme(win) {
3048 let winner = null;
3049 for (const def of listWindowThemes()) {
3050 try {
3051 if (!def.match(win)) {
3052 continue;
3053 }
3054 } catch (err) {
3055 if (typeof console !== "undefined") {
3056 console.warn(
3057 `[desktop-mode] window-theme "${def.id}" match() threw — skipping`,
3058 err
3059 );
3060 }
3061 continue;
3062 }
3063 winner = def;
3064 }
3065 return winner;
3066 }
3067 function subscribeWindowThemes(cb) {
3068 listeners$3.add(cb);
3069 return () => {
3070 listeners$3.delete(cb);
3071 };
3072 }
3073 const applied = /* @__PURE__ */ new WeakMap();
3074 function resolveActiveTheme(win, override) {
3075 let themeId = null;
3076 let tokens = {};
3077 if (override && "tokens" in override && override.tokens) {
3078 themeId = null;
3079 tokens = { ...override.tokens };
3080 } else if (override && "themeId" in override && override.themeId) {
3081 const list = resolveByThemeId(override.themeId);
3082 if (list) {
3083 themeId = list.id;
3084 tokens = { ...list.tokens };
3085 }
3086 } else {
3087 const winner = resolveWindowTheme(win);
3088 if (winner) {
3089 themeId = winner.id;
3090 tokens = { ...winner.tokens };
3091 }
3092 }
3093 const filtered = applyFilters(
3094 HOOKS.WINDOW_CHROME_THEME,
3095 tokens,
3096 { windowId: win.id, themeId, config: win.config }
3097 );
3098 return { themeId, tokens: filtered };
3099 }
3100 function applyWindowTheme(win, override) {
3101 const element = win.element;
3102 if (!element) {
3103 return;
3104 }
3105 const previous = applied.get(element);
3106 const { themeId, tokens } = resolveActiveTheme(win, override);
3107 if (previous) {
3108 for (const key of previous.keys) {
3109 if (!(key in tokens)) {
3110 try {
3111 element.style.removeProperty(key);
3112 } catch {
3113 }
3114 }
3115 }
3116 }
3117 const keys = /* @__PURE__ */ new Set();
3118 for (const [key, value] of Object.entries(tokens)) {
3119 try {
3120 element.style.setProperty(key, value);
3121 keys.add(key);
3122 } catch (err) {
3123 doAction(HOOKS.SHELL_ERROR, {
3124 scope: "window-theme-apply",
3125 windowId: win.id,
3126 key,
3127 error: err
3128 });
3129 }
3130 }
3131 applied.set(element, { themeId, keys });
3132 doAction(HOOKS.WINDOW_CHROME_THEME_CHANGED, {
3133 windowId: win.id,
3134 themeId,
3135 tokens
3136 });
3137 }
3138 function clearWindowTheme(win) {
3139 const element = win.element;
3140 if (!element) {
3141 return;
3142 }
3143 const previous = applied.get(element);
3144 if (!previous) {
3145 return;
3146 }
3147 for (const key of previous.keys) {
3148 try {
3149 element.style.removeProperty(key);
3150 } catch {
3151 }
3152 }
3153 applied.delete(element);
3154 }
3155 function resolveByThemeId(id) {
3156 for (const def of listWindowThemes()) {
3157 if (def.id === id) {
3158 return { id: def.id, tokens: def.tokens };
3159 }
3160 }
3161 return null;
3162 }
3163 const store$2 = createSharedStore(
3164 "desktop-mode/window-controls-registry",
3165 () => ({ registry: /* @__PURE__ */ new Map(), listeners: /* @__PURE__ */ new Set() })
3166 );
3167 const registry$2 = store$2.state.registry;
3168 const listeners$2 = store$2.state.listeners;
3169 function listWindowControls() {
3170 return Array.from(registry$2.values()).sort((a, b) => {
3171 const oa = a.order ?? 100;
3172 const ob = b.order ?? 100;
3173 if (oa !== ob) {
3174 return oa - ob;
3175 }
3176 return a.id.localeCompare(b.id);
3177 });
3178 }
3179 function controlsForWindow(win) {
3180 const left = [];
3181 const right = [];
3182 const controls = [];
3183 for (const def of listWindowControls()) {
3184 try {
3185 if (!def.match(win)) {
3186 continue;
3187 }
3188 } catch (err) {
3189 if (typeof console !== "undefined") {
3190 console.warn(
3191 `[desktop-mode] window-control "${def.id}" match() threw — skipping`,
3192 err
3193 );
3194 }
3195 continue;
3196 }
3197 const placement = def.placement ?? "left";
3198 if (placement === "right") {
3199 right.push(def);
3200 } else if (placement === "controls") {
3201 controls.push(def);
3202 } else {
3203 left.push(def);
3204 }
3205 }
3206 return { left, right, controls };
3207 }
3208 function subscribeWindowControls(cb) {
3209 listeners$2.add(cb);
3210 return () => {
3211 listeners$2.delete(cb);
3212 };
3213 }
3214 function resolveWindowControls(win, override) {
3215 const buckets = controlsForWindow(win);
3216 const hide = new Set(override?.hide ?? []);
3217 let left = buckets.left.filter((c) => !hide.has(c.id));
3218 let right = buckets.right.filter((c) => !hide.has(c.id));
3219 let controls = buckets.controls.filter((c) => !hide.has(c.id));
3220 if (override?.custom) {
3221 for (const def of override.custom) {
3222 if (hide.has(def.id)) {
3223 continue;
3224 }
3225 const adapted = {
3226 id: def.id,
3227 label: def.label,
3228 icon: def.icon,
3229 placement: def.placement ?? "controls",
3230 order: def.order ?? 100,
3231 match: () => true,
3232 onClick: def.onClick ? (_, ev) => def.onClick(ev) : void 0,
3233 render: def.render ? (host) => def.render(host) : void 0
3234 };
3235 if (adapted.placement === "left") {
3236 left.push(adapted);
3237 } else if (adapted.placement === "right") {
3238 right.push(adapted);
3239 } else {
3240 controls.push(adapted);
3241 }
3242 }
3243 left = sortByOrder(left);
3244 right = sortByOrder(right);
3245 controls = sortByOrder(controls);
3246 }
3247 if (override?.order && override.order.length > 0) {
3248 controls = applyExplicitOrder(controls, override.order);
3249 }
3250 const placement = override?.placement ?? "right";
3251 const ctx = { windowId: win.id, config: win.config };
3252 left = applyFilters(
3253 HOOKS.WINDOW_CHROME_CONTROLS,
3254 left,
3255 { ...ctx, placement: "left" }
3256 );
3257 right = applyFilters(
3258 HOOKS.WINDOW_CHROME_CONTROLS,
3259 right,
3260 { ...ctx, placement: "right" }
3261 );
3262 controls = applyFilters(
3263 HOOKS.WINDOW_CHROME_CONTROLS,
3264 controls,
3265 { ...ctx, placement: "controls" }
3266 );
3267 return { left, right, controls, placement };
3268 }
3269 function sortByOrder(list) {
3270 return [...list].sort((a, b) => {
3271 const oa = a.order ?? 100;
3272 const ob = b.order ?? 100;
3273 if (oa !== ob) {
3274 return oa - ob;
3275 }
3276 return a.id.localeCompare(b.id);
3277 });
3278 }
3279 function applyExplicitOrder(list, order) {
3280 const byId = /* @__PURE__ */ new Map();
3281 for (const def of list) {
3282 byId.set(def.id, def);
3283 }
3284 const out = [];
3285 const used = /* @__PURE__ */ new Set();
3286 for (const id of order) {
3287 const def = byId.get(id);
3288 if (def && !used.has(id)) {
3289 out.push(def);
3290 used.add(id);
3291 }
3292 }
3293 for (const def of list) {
3294 if (!used.has(def.id)) {
3295 out.push(def);
3296 }
3297 }
3298 return out;
3299 }
3300 function buildControlElement(def, win) {
3301 const host = document.createElement("wpd-window-button");
3302 host.setAttribute("aria-label", def.label);
3303 host.classList.add("desktop-mode-window__btn");
3304 const variant = legacyVariantFor(def.id);
3305 host.classList.add(`desktop-mode-window__btn--${variant}`);
3306 if (def.id === "core/close") {
3307 host.setAttribute("danger", "");
3308 }
3309 if (typeof def.render === "function") {
3310 try {
3311 def.render(host, win);
3312 } catch (err) {
3313 doAction(HOOKS.SHELL_ERROR, {
3314 scope: "window-control-render",
3315 id: def.id,
3316 windowId: win.id,
3317 error: err
3318 });
3319 return { element: host };
3320 }
3321 } else {
3322 paintTitleBarButtonIcon(host, def.icon ?? "");
3323 if (typeof def.onClick === "function") {
3324 const handler = (ev) => {
3325 ev.stopPropagation();
3326 try {
3327 def.onClick(win, ev);
3328 } catch (err) {
3329 doAction(HOOKS.SHELL_ERROR, {
3330 scope: "window-control-onclick",
3331 id: def.id,
3332 windowId: win.id,
3333 error: err
3334 });
3335 }
3336 };
3337 host.addEventListener("wpd-button-activate", handler);
3338 return {
3339 element: host,
3340 teardown: () => {
3341 host.removeEventListener("wpd-button-activate", handler);
3342 }
3343 };
3344 }
3345 }
3346 return { element: host };
3347 }
3348 function legacyVariantFor(id) {
3349 if (id.startsWith("core/")) {
3350 return id.slice("core/".length);
3351 }
3352 return id.replace(/\//g, "-");
3353 }
3354 function paintWindowControls(win, controlsHost) {
3355 const teardowns = [];
3356 while (controlsHost.firstChild) {
3357 controlsHost.removeChild(controlsHost.firstChild);
3358 }
3359 const resolved = resolveWindowControls(
3360 win,
3361 win.config.appearance?.controls
3362 );
3363 controlsHost.classList.toggle(
3364 "desktop-mode-window__controls--left",
3365 resolved.placement === "left"
3366 );
3367 for (const def of resolved.controls) {
3368 const { element, teardown } = buildControlElement(def, win);
3369 controlsHost.appendChild(element);
3370 if (teardown) {
3371 teardowns.push(teardown);
3372 }
3373 }
3374 doAction(HOOKS.WINDOW_CHROME_APPLIED, {
3375 windowId: win.id,
3376 layer: "controls"
3377 });
3378 return () => {
3379 for (const fn of teardowns) {
3380 try {
3381 fn();
3382 } catch {
3383 }
3384 }
3385 };
3386 }
3387 const store$1 = createSharedStore(
3388 "desktop-mode/window-slots-registry",
3389 () => ({ registry: /* @__PURE__ */ new Map(), listeners: /* @__PURE__ */ new Set() })
3390 );
3391 const registry$1 = store$1.state.registry;
3392 const listeners$1 = store$1.state.listeners;
3393 function listWindowSlots() {
3394 return Array.from(registry$1.values()).sort((a, b) => {
3395 const oa = a.order ?? 100;
3396 const ob = b.order ?? 100;
3397 if (oa !== ob) {
3398 return oa - ob;
3399 }
3400 return a.id.localeCompare(b.id);
3401 });
3402 }
3403 function slotsForWindow(win, slot) {
3404 const out = [];
3405 for (const def of listWindowSlots()) {
3406 if (def.slot !== slot) {
3407 continue;
3408 }
3409 try {
3410 if (!def.match(win)) {
3411 continue;
3412 }
3413 } catch (err) {
3414 if (typeof console !== "undefined") {
3415 console.warn(
3416 `[desktop-mode] window-slot "${def.id}" match() threw — skipping`,
3417 err
3418 );
3419 }
3420 continue;
3421 }
3422 out.push(def);
3423 }
3424 return out;
3425 }
3426 function subscribeWindowSlots(cb) {
3427 listeners$1.add(cb);
3428 return () => {
3429 listeners$1.delete(cb);
3430 };
3431 }
3432 const SLOT_NAMES = [
3433 "before-titlebar",
3434 "before-icon",
3435 "icon",
3436 "title",
3437 "after-title",
3438 "before-controls",
3439 "after-controls",
3440 "after-titlebar"
3441 ];
3442 const defaultsCache = /* @__PURE__ */ new WeakMap();
3443 function getSlotHost(root, name) {
3444 return root.querySelector(
3445 `[data-slot="${name}"]`
3446 );
3447 }
3448 function captureDefaults(root) {
3449 const map = /* @__PURE__ */ new Map();
3450 for (const name of SLOT_NAMES) {
3451 const host = getSlotHost(root, name);
3452 if (!host) {
3453 continue;
3454 }
3455 map.set(name, Array.from(host.childNodes).map((n) => n.cloneNode(true)));
3456 }
3457 return map;
3458 }
3459 function clearHost(host) {
3460 while (host.firstChild) {
3461 host.removeChild(host.firstChild);
3462 }
3463 }
3464 function restoreDefault(host, defaults) {
3465 clearHost(host);
3466 for (const node of defaults) {
3467 host.appendChild(node.cloneNode(true));
3468 }
3469 }
3470 function paintWindowSlots(win) {
3471 const teardowns = [];
3472 const root = win.element;
3473 if (!root) {
3474 return () => {
3475 };
3476 }
3477 let defaults = defaultsCache.get(root);
3478 if (!defaults) {
3479 defaults = captureDefaults(root);
3480 defaultsCache.set(root, defaults);
3481 }
3482 const overrides = win.config.appearance?.slots ?? {};
3483 for (const name of SLOT_NAMES) {
3484 const host = getSlotHost(root, name);
3485 if (!host) {
3486 continue;
3487 }
3488 const slotDefaults = defaults.get(name) ?? [];
3489 const override = overrides[name];
3490 const matchingRegistry = slotsForWindow(win, name);
3491 if (override === null) {
3492 clearHost(host);
3493 } else if (override && "html" in override) {
3494 clearHost(host);
3495 host.textContent = override.html;
3496 } else if (override && "render" in override) {
3497 const replace = override.replace !== false;
3498 if (replace) {
3499 clearHost(host);
3500 }
3501 try {
3502 const teardown = override.render(host);
3503 if (typeof teardown === "function") {
3504 teardowns.push(teardown);
3505 }
3506 } catch (err) {
3507 doAction(HOOKS.SHELL_ERROR, {
3508 scope: "window-slot-inline-render",
3509 windowId: win.id,
3510 slot: name,
3511 error: err
3512 });
3513 }
3514 } else {
3515 restoreDefault(host, slotDefaults);
3516 }
3517 if (override !== null) {
3518 let firstReplaceFired = false;
3519 for (const def of matchingRegistry) {
3520 const replace = def.replace !== false;
3521 if (replace && !firstReplaceFired) {
3522 clearHost(host);
3523 firstReplaceFired = true;
3524 }
3525 try {
3526 const teardown = def.render(host, { window: win, slot: name });
3527 if (typeof teardown === "function") {
3528 teardowns.push(teardown);
3529 }
3530 } catch (err) {
3531 doAction(HOOKS.SHELL_ERROR, {
3532 scope: "window-slot-registry-render",
3533 windowId: win.id,
3534 slot: name,
3535 id: def.id,
3536 error: err
3537 });
3538 }
3539 }
3540 }
3541 applyFilters(
3542 HOOKS.WINDOW_CHROME_SLOT,
3543 host,
3544 { windowId: win.id, slot: name, config: win.config }
3545 );
3546 }
3547 doAction(HOOKS.WINDOW_CHROME_APPLIED, {
3548 windowId: win.id,
3549 layer: "slots"
3550 });
3551 return () => {
3552 for (const fn of teardowns) {
3553 try {
3554 fn();
3555 } catch {
3556 }
3557 }
3558 };
3559 }
3560 const store = createSharedStore(
3561 "desktop-mode/window-chrome-registry",
3562 () => ({ registry: /* @__PURE__ */ new Map(), listeners: /* @__PURE__ */ new Set() })
3563 );
3564 const registry = store.state.registry;
3565 const listeners = store.state.listeners;
3566 function getWindowChrome(id) {
3567 return registry.get(id.toLowerCase()) ?? null;
3568 }
3569 function subscribeWindowChromes(cb) {
3570 listeners.add(cb);
3571 return () => {
3572 listeners.delete(cb);
3573 };
3574 }
3575 const STANDARD_CHROME_ID = "core/standard";
3576 const CUSTOM_CHROME_CLASS = "desktop-mode-window--custom-chrome";
3577 function resolveChromeId(win) {
3578 const inline = win.config.appearance?.chrome ?? STANDARD_CHROME_ID;
3579 const id = applyFilters(
3580 HOOKS.WINDOW_CHROME_RENDER,
3581 inline,
3582 { windowId: win.id, config: win.config }
3583 );
3584 return id;
3585 }
3586 function captureChromeState(win) {
3587 return {
3588 title: win.config.title,
3589 icon: win.config.icon,
3590 focused: win.element.classList.contains("desktop-mode-window--focused"),
3591 state: win.state
3592 };
3593 }
3594 function mountWindowChrome(win) {
3595 const id = resolveChromeId(win);
3596 if (id === STANDARD_CHROME_ID) {
3597 return null;
3598 }
3599 const def = getWindowChrome(id);
3600 if (!def) {
3601 return null;
3602 }
3603 try {
3604 if (def.match && !def.match(win)) {
3605 return null;
3606 }
3607 } catch {
3608 return null;
3609 }
3610 win.element.classList.add(CUSTOM_CHROME_CLASS);
3611 let handle;
3612 try {
3613 handle = def.render(win.element, {
3614 window: win,
3615 state: captureChromeState(win)
3616 });
3617 } catch (err) {
3618 win.element.classList.remove(CUSTOM_CHROME_CLASS);
3619 doAction(HOOKS.SHELL_ERROR, {
3620 scope: "window-chrome-render",
3621 windowId: win.id,
3622 chromeId: id,
3623 error: err
3624 });
3625 return null;
3626 }
3627 doAction(HOOKS.WINDOW_CHROME_APPLIED, {
3628 windowId: win.id,
3629 layer: "chrome",
3630 chromeId: id
3631 });
3632 return { id, handle };
3633 }
3634 function toggleActionsMenu(win) {
3635 const panel = win.element.querySelector(
3636 ".desktop-mode-window__menu-panel"
3637 );
3638 if (!panel) {
3639 return;
3640 }
3641 if (panel.hidden) {
3642 openActionsMenu(win);
3643 } else {
3644 closeActionsMenu(win);
3645 }
3646 }
3647 function openActionsMenu(win) {
3648 const panel = win.element.querySelector(
3649 ".desktop-mode-window__menu-panel"
3650 );
3651 const btn = win.element.querySelector(
3652 ".desktop-mode-window__menu-btn"
3653 );
3654 if (!panel || !btn) {
3655 return;
3656 }
3657 panel.hidden = false;
3658 btn.setAttribute("aria-expanded", "true");
3659 const startup = panel.querySelector(
3660 ".desktop-mode-window__menu-item--startup"
3661 );
3662 if (startup) {
3663 refreshStartupCheckState(win, startup);
3664 }
3665 if (!win._boundOnDocumentPointerDown) {
3666 win._boundOnDocumentPointerDown = (e) => {
3667 const target = e.target;
3668 if (!target) {
3669 return;
3670 }
3671 if (panel.contains(target) || btn.contains(target)) {
3672 return;
3673 }
3674 closeActionsMenu(win);
3675 };
3676 }
3677 setTimeout(() => {
3678 if (win._boundOnDocumentPointerDown) {
3679 document.addEventListener(
3680 "pointerdown",
3681 win._boundOnDocumentPointerDown,
3682 true
3683 );
3684 }
3685 }, 0);
3686 const firstItem = panel.querySelector('[role="menuitem"]');
3687 firstItem?.focus();
3688 }
3689 function closeActionsMenu(win) {
3690 const panel = win.element.querySelector(
3691 ".desktop-mode-window__menu-panel"
3692 );
3693 const btn = win.element.querySelector(
3694 ".desktop-mode-window__menu-btn"
3695 );
3696 if (panel) {
3697 panel.hidden = true;
3698 }
3699 if (btn) {
3700 btn.setAttribute("aria-expanded", "false");
3701 }
3702 if (win._boundOnDocumentPointerDown) {
3703 document.removeEventListener(
3704 "pointerdown",
3705 win._boundOnDocumentPointerDown,
3706 true
3707 );
3708 }
3709 }
3710 function flipStartupCheckOptimistically(item) {
3711 const isChecked = item.hasAttribute("checked");
3712 if (isChecked) {
3713 item.removeAttribute("checked");
3714 } else {
3715 item.setAttribute("checked", "");
3716 }
3717 }
3718 function refreshStartupCheckState(win, item) {
3719 const pref = window.wp?.desktop?.config?.defaultWindow;
3720 let isDefault = false;
3721 if (pref && pref.enabled && typeof pref.url === "string") {
3722 if (win.config.native) {
3723 isDefault = pref.url === `native:${win.id}`;
3724 } else {
3725 try {
3726 const currentKey = urlMatchKey(win.getCurrentUrl());
3727 const prefKey = urlMatchKey(pref.url);
3728 isDefault = currentKey === prefKey;
3729 } catch {
3730 isDefault = false;
3731 }
3732 }
3733 }
3734 if (isDefault) {
3735 item.setAttribute("checked", "");
3736 } else {
3737 item.removeAttribute("checked");
3738 }
3739 }
3740 function makeBoundsEmitter(win, phase) {
3741 let pending = false;
3742 return () => {
3743 if (pending) {
3744 return;
3745 }
3746 pending = true;
3747 requestAnimationFrame(() => {
3748 pending = false;
3749 if (phase === "drag" && !win._isDragging) {
3750 return;
3751 }
3752 if (phase === "resize" && !win._isResizing) {
3753 return;
3754 }
3755 if (win._isDestroyed || !win.element.isConnected) {
3756 return;
3757 }
3758 try {
3759 doAction(HOOKS.WINDOW_BOUNDS_CHANGED, {
3760 windowId: win.id,
3761 x: win.element.offsetLeft,
3762 y: win.element.offsetTop,
3763 width: win.element.offsetWidth,
3764 height: win.element.offsetHeight,
3765 state: win.state,
3766 phase
3767 });
3768 } catch {
3769 }
3770 });
3771 };
3772 }
3773 function handleDragStart(win, e) {
3774 const target = e.target;
3775 if (target.closest(".desktop-mode-window__btn") || target.closest(".desktop-mode-window__custom-buttons") || target.closest(".desktop-mode-window__controls") || target.closest(".desktop-mode-window__screen-meta") || target.closest(".desktop-mode-window__menu-btn") || target.closest(".desktop-mode-window__menu-panel")) {
3776 return;
3777 }
3778 const isMaximized = win.state === "maximized";
3779 const isSnapped = win.state === "snapped-left" || win.state === "snapped-right";
3780 const needsUnstate = isMaximized || isSnapped;
3781 const startClientX = e.clientX;
3782 const startClientY = e.clientY;
3783 const pointerId = e.pointerId;
3784 const unstateParams = needsUnstate ? captureUnstateParams(win, e) : null;
3785 win._titleBar.setPointerCapture(pointerId);
3786 const snap = win.snapConfigProvider?.() ?? { enabled: false, cellWidth: 0, cellHeight: 0 };
3787 const emitBoundsChanged = makeBoundsEmitter(win, "drag");
3788 let started = false;
3789 const beginDrag = (cursorX, cursorY) => {
3790 if (started) {
3791 return;
3792 }
3793 started = true;
3794 let newLeft;
3795 let newTop;
3796 if (unstateParams) {
3797 const placed = commitUnstate(win, unstateParams, cursorX, cursorY);
3798 newLeft = placed.left;
3799 newTop = placed.top;
3800 } else {
3801 newLeft = win.element.offsetLeft;
3802 newTop = win.element.offsetTop;
3803 }
3804 win.element.classList.add("desktop-mode-window--dragging");
3805 if (snap.enabled) {
3806 win.element.classList.add("desktop-mode-window--snap-drag");
3807 }
3808 win._isDragging = true;
3809 win._dragOffsetX = cursorX - newLeft;
3810 win._dragOffsetY = cursorY - newTop;
3811 doAction(HOOKS.WINDOW_DRAG_START, { windowId: win.id });
3812 };
3813 if (!needsUnstate) {
3814 beginDrag(startClientX, startClientY);
3815 }
3816 const onDragMove = (ev) => {
3817 if (!started) {
3818 const dx = ev.clientX - startClientX;
3819 const dy = ev.clientY - startClientY;
3820 if (dx * dx + dy * dy < DRAG_THRESHOLD_SQUARED) {
3821 return;
3822 }
3823 beginDrag(ev.clientX, ev.clientY);
3824 }
3825 if (!win._isDragging) {
3826 return;
3827 }
3828 let x = ev.clientX - win._dragOffsetX;
3829 let y = ev.clientY - win._dragOffsetY;
3830 const desktop = win.element.parentElement;
3831 if (desktop) {
3832 x = Math.max(EDGE_MARGIN, Math.min(x, desktop.clientWidth - EDGE_MARGIN));
3833 y = Math.max(EDGE_MARGIN, Math.min(y, desktop.clientHeight - EDGE_MARGIN));
3834 }
3835 if (snap.enabled) {
3836 x = Math.round(x / snap.cellWidth) * snap.cellWidth;
3837 y = Math.round(y / snap.cellHeight) * snap.cellHeight;
3838 }
3839 win.element.style.left = `${x}px`;
3840 win.element.style.top = `${y}px`;
3841 win.onDragMove?.(win, ev.clientX, ev.clientY);
3842 emitBoundsChanged();
3843 };
3844 const releaseCapture = () => {
3845 try {
3846 win._titleBar.releasePointerCapture(pointerId);
3847 } catch {
3848 }
3849 };
3850 const detachListeners = () => {
3851 win._titleBar.removeEventListener("pointermove", onDragMove);
3852 win._titleBar.removeEventListener("pointerup", onDragEnd);
3853 win._titleBar.removeEventListener("pointercancel", onDragEnd);
3854 win._titleBar.removeEventListener("lostpointercapture", onDragEnd);
3855 };
3856 const onDragEnd = () => {
3857 if (!started) {
3858 releaseCapture();
3859 detachListeners();
3860 return;
3861 }
3862 if (!win._isDragging) {
3863 return;
3864 }
3865 win._isDragging = false;
3866 win.element.classList.remove("desktop-mode-window--dragging");
3867 win.element.classList.remove("desktop-mode-window--snap-drag");
3868 releaseCapture();
3869 detachListeners();
3870 const consumed = win.onDragEnd?.(win) ?? false;
3871 if (consumed) {
3872 return;
3873 }
3874 win._emitChange("moved");
3875 const payload = {
3876 windowId: win.id,
3877 x: win.element.offsetLeft,
3878 y: win.element.offsetTop
3879 };
3880 doAction(HOOKS.WINDOW_DRAG_END, payload);
3881 doAction(HOOKS.WINDOW_MOVED, payload);
3882 };
3883 win._titleBar.addEventListener("pointermove", onDragMove);
3884 win._titleBar.addEventListener("pointerup", onDragEnd);
3885 win._titleBar.addEventListener("pointercancel", onDragEnd);
3886 win._titleBar.addEventListener("lostpointercapture", onDragEnd);
3887 }
3888 function captureUnstateParams(win, e) {
3889 const titleRect = win._titleBar.getBoundingClientRect();
3890 const cursorRatioX = titleRect.width > 0 ? (e.clientX - titleRect.left) / titleRect.width : 0.5;
3891 const parent = win.element.parentElement;
3892 const fallbackW = parent ? Math.min(960, Math.round(parent.clientWidth * 0.6)) : 640;
3893 const fallbackH = parent ? Math.min(640, Math.round(parent.clientHeight * 0.7)) : 480;
3894 const w = win._savedGeometry?.width ?? fallbackW;
3895 const h = win._savedGeometry?.height ?? fallbackH;
3896 const parentRect = parent?.getBoundingClientRect();
3897 return {
3898 isMaximized: win.state === "maximized",
3899 cursorRatioX,
3900 titleBarHeight: titleRect.height,
3901 // `clientX` / `clientY` are viewport-relative but
3902 // `style.left` / `.top` resolve against the window's
3903 // offsetParent (the desktop area). Subtract the area's own
3904 // viewport origin so the re-anchor math lands in the right
3905 // space — otherwise an admin bar above + a dock on the left
3906 // would shift the window below + right of the cursor.
3907 areaLeft: parentRect?.left ?? 0,
3908 areaTop: parentRect?.top ?? 0,
3909 targetW: w,
3910 targetH: h
3911 };
3912 }
3913 function commitUnstate(win, params, cursorX, cursorY) {
3914 win.element.classList.remove(
3915 "desktop-mode-window--maximized",
3916 "desktop-mode-window--snapped-left",
3917 "desktop-mode-window--snapped-right"
3918 );
3919 win.element.style.width = `${params.targetW}px`;
3920 win.element.style.height = `${params.targetH}px`;
3921 const left = Math.max(
3922 EDGE_MARGIN,
3923 Math.round(
3924 cursorX - params.areaLeft - params.targetW * params.cursorRatioX
3925 )
3926 );
3927 const top = Math.max(
3928 EDGE_MARGIN,
3929 Math.round(cursorY - params.areaTop - params.titleBarHeight / 2)
3930 );
3931 win.element.style.left = `${left}px`;
3932 win.element.style.top = `${top}px`;
3933 win.state = "normal";
3934 win._emitChange("state");
3935 if (params.isMaximized) {
3936 doAction(HOOKS.WINDOW_UNMAXIMIZED, { windowId: win.id });
3937 }
3938 return { left, top };
3939 }
3940 function handleResizeStart(win, e) {
3941 if (win.state === "maximized" || win.state === "fullscreen") {
3942 return;
3943 }
3944 e.preventDefault();
3945 e.stopPropagation();
3946 const handle = e.target;
3947 const dir = handle.dataset.dir ?? "se";
3948 win._isResizing = true;
3949 win._resizeStartX = e.clientX;
3950 win._resizeStartY = e.clientY;
3951 win._resizeStartW = win.element.offsetWidth;
3952 win._resizeStartH = win.element.offsetHeight;
3953 const startLeft = win.element.offsetLeft;
3954 const startTop = win.element.offsetTop;
3955 handle.setPointerCapture(e.pointerId);
3956 win.element.classList.add("desktop-mode-window--resizing");
3957 doAction(HOOKS.WINDOW_RESIZE_START, { windowId: win.id });
3958 const emitBoundsChanged = makeBoundsEmitter(win, "resize");
3959 const snap = win.snapConfigProvider?.() ?? { enabled: false, cellWidth: 0, cellHeight: 0 };
3960 if (snap.enabled) {
3961 win.element.classList.add("desktop-mode-window--snap-drag");
3962 }
3963 if (win.state === "snapped-left" || win.state === "snapped-right") {
3964 win.element.classList.remove(
3965 "desktop-mode-window--snapped-left",
3966 "desktop-mode-window--snapped-right"
3967 );
3968 win.state = "normal";
3969 }
3970 const onResizeMove = (ev) => {
3971 if (!win._isResizing) {
3972 return;
3973 }
3974 const dx = ev.clientX - win._resizeStartX;
3975 const dy = ev.clientY - win._resizeStartY;
3976 const geom = computeResize(
3977 dir,
3978 dx,
3979 dy,
3980 startLeft,
3981 startTop,
3982 win._resizeStartW,
3983 win._resizeStartH,
3984 win.config.minWidth,
3985 win.config.minHeight,
3986 snap
3987 );
3988 win.element.style.left = `${geom.x}px`;
3989 win.element.style.top = `${geom.y}px`;
3990 win.element.style.width = `${geom.width}px`;
3991 win.element.style.height = `${geom.height}px`;
3992 emitBoundsChanged();
3993 };
3994 const onResizeEnd = () => {
3995 if (!win._isResizing) {
3996 return;
3997 }
3998 win._isResizing = false;
3999 win.element.classList.remove("desktop-mode-window--resizing");
4000 win.element.classList.remove("desktop-mode-window--snap-drag");
4001 handle.removeEventListener("pointermove", onResizeMove);
4002 handle.removeEventListener("pointerup", onResizeEnd);
4003 handle.removeEventListener("pointercancel", onResizeEnd);
4004 handle.removeEventListener("lostpointercapture", onResizeEnd);
4005 win._emitChange("resized");
4006 const payload = {
4007 windowId: win.id,
4008 width: win.element.offsetWidth,
4009 height: win.element.offsetHeight
4010 };
4011 doAction(HOOKS.WINDOW_RESIZE_END, payload);
4012 doAction(HOOKS.WINDOW_RESIZED, payload);
4013 };
4014 handle.addEventListener("pointermove", onResizeMove);
4015 handle.addEventListener("pointerup", onResizeEnd);
4016 handle.addEventListener("pointercancel", onResizeEnd);
4017 handle.addEventListener("lostpointercapture", onResizeEnd);
4018 }
4019 function computeResize(dir, dx, dy, startLeft, startTop, startW, startH, minWidth, minHeight, snap) {
4020 let width = startW;
4021 let height = startH;
4022 let x = startLeft;
4023 let y = startTop;
4024 if (dir === "ne" || dir === "se") {
4025 width = Math.max(minWidth, startW + dx);
4026 }
4027 if (dir === "nw" || dir === "sw") {
4028 const nextWidth = Math.max(minWidth, startW - dx);
4029 x = startLeft + (startW - nextWidth);
4030 width = nextWidth;
4031 }
4032 if (dir === "se" || dir === "sw") {
4033 height = Math.max(minHeight, startH + dy);
4034 }
4035 if (dir === "ne" || dir === "nw") {
4036 const nextHeight = Math.max(minHeight, startH - dy);
4037 y = startTop + (startH - nextHeight);
4038 height = nextHeight;
4039 }
4040 if (snap.enabled) {
4041 const nextWidth = Math.max(
4042 minWidth,
4043 Math.round(width / snap.cellWidth) * snap.cellWidth
4044 );
4045 const nextHeight = Math.max(
4046 minHeight,
4047 Math.round(height / snap.cellHeight) * snap.cellHeight
4048 );
4049 if (dir === "nw" || dir === "sw") {
4050 x = startLeft + (width - nextWidth);
4051 }
4052 if (dir === "nw" || dir === "ne") {
4053 y = startTop + (height - nextHeight);
4054 }
4055 width = nextWidth;
4056 height = nextHeight;
4057 }
4058 return { x, y, width, height };
4059 }
4060 const INITIAL_ORIGIN = window.location.origin;
4061 const _Window = class _Window {
4062 constructor(config) {
4063 this.state = "normal";
4064 this._activityCount = 0;
4065 this._activityPhase = "idle";
4066 this._activityError = null;
4067 this._activityClearTimer = null;
4068 this._activitySavingStartedAt = 0;
4069 this._activitySettleTimer = null;
4070 this._isDragging = false;
4071 this._isResizing = false;
4072 this._isDestroyed = false;
4073 this._dragOffsetX = 0;
4074 this._dragOffsetY = 0;
4075 this._resizeStartX = 0;
4076 this._resizeStartY = 0;
4077 this._resizeStartW = 0;
4078 this._resizeStartH = 0;
4079 this._savedGeometry = null;
4080 this._savedFullscreenState = null;
4081 this._stateBeforeMinimize = null;
4082 this._externalTabs = /* @__PURE__ */ new Map();
4083 this._externalTabSeq = 0;
4084 this._titleBarButtonsUnsubscribe = null;
4085 this._windowThemesUnsubscribe = null;
4086 this._windowControlsUnsubscribe = null;
4087 this._windowControlsTeardown = null;
4088 this._windowSlotsUnsubscribe = null;
4089 this._windowSlotsTeardown = null;
4090 this._chromeHandle = null;
4091 this._chromeId = STANDARD_CHROME_ID;
4092 this._windowChromesUnsubscribe = null;
4093 this._nativeRenderTeardown = null;
4094 this._nativeRenderCtxDispose = null;
4095 this._closeSafetyNetTimer = null;
4096 this._onCloseTransitionEnd = null;
4097 this._isFinalized = false;
4098 this._iframeBridgeReady = false;
4099 this._iframeCloseTimeout = null;
4100 this._closePending = false;
4101 this._activeTabId = "primary";
4102 this.onFocusRequest = null;
4103 this.onClose = null;
4104 this.onMinimize = null;
4105 this.onOpenAnother = null;
4106 this.onOpenInNewWindow = null;
4107 this.onToggleStartup = null;
4108 this.snapConfigProvider = null;
4109 this.onDragMove = null;
4110 this.onDragEnd = null;
4111 this._boundOnDocumentPointerDown = null;
4112 this._bodyResizeObserver = null;
4113 this._suppressCloseFilter = false;
4114 this.id = config.id;
4115 this.config = config;
4116 this.element = createWindowElement(config);
4117 this.iframe = config.native ? null : this.element.querySelector(".desktop-mode-window__iframe");
4118 this._titleBar = this.element.querySelector(".desktop-mode-window__titlebar");
4119 this._titleEl = this.element.querySelector(".desktop-mode-window__title");
4120 this._boundOnMessage = (e) => handleWindowMessage(this, e);
4121 this.bindEvents();
4122 this.renderCustomTitleBarButtons();
4123 this._titleBarButtonsUnsubscribe = subscribeTitleBarButtons(() => {
4124 this.renderCustomTitleBarButtons();
4125 });
4126 applyWindowTheme(this, this.config.appearance?.theme);
4127 this._windowThemesUnsubscribe = subscribeWindowThemes(() => {
4128 if (this._isDestroyed) {
4129 return;
4130 }
4131 applyWindowTheme(this, this.config.appearance?.theme);
4132 });
4133 this.repaintWindowControls();
4134 this._windowControlsUnsubscribe = subscribeWindowControls(() => {
4135 if (this._isDestroyed) {
4136 return;
4137 }
4138 this.repaintWindowControls();
4139 });
4140 this.repaintWindowSlots();
4141 this._windowSlotsUnsubscribe = subscribeWindowSlots(() => {
4142 if (this._isDestroyed) {
4143 return;
4144 }
4145 this.repaintWindowSlots();
4146 });
4147 this.remountWindowChrome();
4148 this._windowChromesUnsubscribe = subscribeWindowChromes(() => {
4149 if (this._isDestroyed) {
4150 return;
4151 }
4152 const next = resolveChromeId(this);
4153 if (next !== this._chromeId) {
4154 this.remountWindowChrome();
4155 }
4156 });
4157 this._bodyResizeObserver = this.installBodyResizeObserver();
4158 if (config.initialState === "minimized") {
4159 this.state = "minimized";
4160 this.element.classList.add("desktop-mode-window--minimized");
4161 if (this.iframe) {
4162 this.iframe.style.visibility = "hidden";
4163 }
4164 return;
4165 }
4166 if (config.initialState === "snapped-left" || config.initialState === "snapped-right") {
4167 this.element.classList.add(
4168 `desktop-mode-window--${config.initialState}`
4169 );
4170 }
4171 this.element.classList.add("desktop-mode-window--opening");
4172 this.element.addEventListener("animationend", () => {
4173 this.element.classList.remove("desktop-mode-window--opening");
4174 }, { once: true });
4175 if (config.initialState && config.initialState !== "normal") {
4176 requestAnimationFrame(() => this.applyInitialState(config.initialState));
4177 }
4178 }
4179 /**
4180 * Run the plugin's render callback for a native window.
4181 *
4182 * Called by the window manager immediately after appending the
4183 * window element to the desktop. At that point the element (and
4184 * everything reachable inside it) is connected to the document,
4185 * so custom elements upgrade synchronously — a prerequisite for
4186 * the declarative component-kit API (`element.items = […]`) to
4187 * reach the class setter instead of creating a shadowing own
4188 * data property on the pre-upgrade instance.
4189 *
4190 * No-op for iframe windows.
4191 *
4192 * Per-event contract preserved from 0.5.0:
4193 * - `NATIVE_WINDOW_BEFORE_RENDER` filter fires, same args.
4194 * - `NATIVE_WINDOW_AFTER_RENDER` action fires, same args.
4195 * - `config.autofocus` is honoured with a `requestAnimationFrame`
4196 * defer so layout side-effects of `render()` settle before
4197 * `.focus()` resolves.
4198 *
4199 * @since 0.5.0
4200 * @internal
4201 */
4202 hydrateNative() {
4203 if (!this.config.native || !this.config.render) {
4204 return;
4205 }
4206 const rawBody = this.element.querySelector(
4207 ".desktop-mode-window__body"
4208 );
4209 if (!rawBody) {
4210 return;
4211 }
4212 const filtered = applyFilters(
4213 HOOKS.NATIVE_WINDOW_BEFORE_RENDER,
4214 rawBody,
4215 { windowId: this.id, config: this.config }
4216 );
4217 const body = filtered instanceof HTMLElement ? filtered : rawBody;
4218 const { ctx, dispose } = buildNativeRenderContext(this.id);
4219 this._nativeRenderCtxDispose = dispose;
4220 const maybeTeardown = this.config.render(body, ctx);
4221 const captureTeardown = (v) => {
4222 if (typeof v === "function") {
4223 this._nativeRenderTeardown = v;
4224 }
4225 };
4226 if (maybeTeardown instanceof Promise) {
4227 maybeTeardown.then(
4228 (resolved) => {
4229 if (this._isDestroyed) {
4230 return;
4231 }
4232 captureTeardown(resolved);
4233 markWindowContentReady(this.id);
4234 },
4235 (err) => {
4236 if (typeof console !== "undefined") {
4237 console.error(
4238 `[desktop-mode] native render rejected for "${this.id}":`,
4239 err
4240 );
4241 }
4242 doAction(HOOKS.SHELL_ERROR, {
4243 scope: "window-open",
4244 id: this.id,
4245 error: err
4246 });
4247 if (this._isDestroyed) {
4248 return;
4249 }
4250 markWindowContentReady(this.id);
4251 }
4252 );
4253 } else {
4254 captureTeardown(maybeTeardown);
4255 requestAnimationFrame(() => {
4256 if (this._isDestroyed) {
4257 return;
4258 }
4259 markWindowContentReady(this.id);
4260 });
4261 }
4262 doAction(HOOKS.NATIVE_WINDOW_AFTER_RENDER, {
4263 windowId: this.id,
4264 body,
4265 config: this.config
4266 });
4267 const autofocus = this.config.autofocus;
4268 if (autofocus) {
4269 requestAnimationFrame(() => {
4270 if (this._isDestroyed) {
4271 return;
4272 }
4273 if (typeof autofocus === "string") {
4274 const target = body.querySelector(
4275 autofocus
4276 );
4277 target?.focus();
4278 return;
4279 }
4280 const hadTabIndex = body.hasAttribute("tabindex");
4281 if (!hadTabIndex) {
4282 body.tabIndex = -1;
4283 }
4284 body.focus();
4285 });
4286 }
4287 }
4288 /**
4289 * Apply a state restored from the session. Called once, after
4290 * construction.
4291 */
4292 applyInitialState(state) {
4293 if (state === "minimized") {
4294 this.minimize();
4295 } else if (state === "maximized") {
4296 this.toggleMaximize();
4297 } else if (state === "fullscreen") {
4298 this.toggleFullscreen();
4299 } else if (state === "snapped-left") {
4300 this.applySnap("left");
4301 } else if (state === "snapped-right") {
4302 this.applySnap("right");
4303 }
4304 }
4305 /**
4306 * Dispatch a `desktop-mode-window-changed` event so the session-save
4307 * path can schedule a debounced write.
4308 *
4309 * Called after any state change that should end up persisted: drag
4310 * end, resize end, minimize, restore, maximize toggle, fullscreen
4311 * toggle. Exposed as `_emitChange` so sibling modules (tabs,
4312 * pointer) can fire the same event.
4313 *
4314 * @internal
4315 */
4316 _emitChange(reason) {
4317 document.dispatchEvent(
4318 new CustomEvent("desktop-mode-window-changed", {
4319 detail: { windowId: this.id, reason, state: this.state }
4320 })
4321 );
4322 }
4323 /**
4324 * Round an `{ x, y, width, height }` rect onto the live snap grid
4325 * when snap-to-grid is enabled, otherwise return it unchanged.
4326 *
4327 * Used by both the un-maximize restore (so geometry saved while
4328 * snap was off doesn't leave the window off-grid when snap is on)
4329 * and any other code path that wants "the current geometry, but
4330 * grid-aligned." Width/height are floored to whole cells to avoid
4331 * crossing the EDGE_MARGIN constraint after rounding up.
4332 */
4333 snapGeometry(g) {
4334 const snap = this.snapConfigProvider?.();
4335 if (!snap || !snap.enabled) {
4336 return g;
4337 }
4338 const width = Math.max(
4339 this.config.minWidth,
4340 Math.round(g.width / snap.cellWidth) * snap.cellWidth
4341 );
4342 const height = Math.max(
4343 this.config.minHeight,
4344 Math.round(g.height / snap.cellHeight) * snap.cellHeight
4345 );
4346 return {
4347 x: Math.round(g.x / snap.cellWidth) * snap.cellWidth,
4348 y: Math.round(g.y / snap.cellHeight) * snap.cellHeight,
4349 width,
4350 height
4351 };
4352 }
4353 /**
4354 * Returns the current resolved URL of the iframe — preferring the
4355 * content window's location (reflects in-window navigation) and
4356 * falling back to the iframe's src attribute for cases where the
4357 * content document isn't yet reachable (cross-origin edge, early
4358 * load).
4359 */
4360 getCurrentUrl() {
4361 if (!this.iframe) {
4362 return this.config.url || `#${this.id}`;
4363 }
4364 try {
4365 const href = this.iframe.contentWindow?.location.href;
4366 if (href && href !== "about:blank") {
4367 return href;
4368 }
4369 } catch {
4370 }
4371 return this.iframe.src;
4372 }
4373 /** Bind all DOM event handlers. */
4374 bindEvents() {
4375 this.element.addEventListener("pointerdown", () => {
4376 if (this.element.classList.contains("desktop-mode-window--overview")) {
4377 return;
4378 }
4379 this.onFocusRequest?.(this);
4380 });
4381 this.element.addEventListener("focusin", () => {
4382 if (this.element.classList.contains("desktop-mode-window--overview")) {
4383 return;
4384 }
4385 this.onFocusRequest?.(this);
4386 });
4387 this._titleBar.addEventListener(
4388 "pointerdown",
4389 (e) => handleDragStart(this, e)
4390 );
4391 const resizeHandles = this.element.querySelectorAll(
4392 ".desktop-mode-window__resize-handle"
4393 );
4394 resizeHandles.forEach((handle) => {
4395 handle.addEventListener(
4396 "pointerdown",
4397 (e) => handleResizeStart(this, e)
4398 );
4399 });
4400 const menuBtn = this.element.querySelector(
4401 ".desktop-mode-window__menu-btn"
4402 );
4403 const menuPanel = this.element.querySelector(
4404 ".desktop-mode-window__menu-panel"
4405 );
4406 if (menuBtn && menuPanel) {
4407 menuBtn.addEventListener("click", (e) => {
4408 e.stopPropagation();
4409 toggleActionsMenu(this);
4410 });
4411 const openAnother = menuPanel.querySelector(
4412 ".desktop-mode-window__menu-item--open-another"
4413 );
4414 if (openAnother) {
4415 openAnother.addEventListener("wpd-menu-item-click", (e) => {
4416 e.stopPropagation();
4417 closeActionsMenu(this);
4418 this.onOpenAnother?.(this);
4419 });
4420 }
4421 const openInNew = menuPanel.querySelector(
4422 ".desktop-mode-window__menu-item--open-in-new-window"
4423 );
4424 if (openInNew) {
4425 openInNew.addEventListener("wpd-menu-item-click", (e) => {
4426 e.stopPropagation();
4427 closeActionsMenu(this);
4428 this.onOpenInNewWindow?.(this);
4429 });
4430 }
4431 const reload = menuPanel.querySelector(
4432 ".desktop-mode-window__menu-item--reload"
4433 );
4434 if (reload) {
4435 reload.addEventListener("wpd-menu-item-click", (e) => {
4436 e.stopPropagation();
4437 closeActionsMenu(this);
4438 this.reload();
4439 });
4440 }
4441 const openExternal = menuPanel.querySelector(
4442 ".desktop-mode-window__menu-item--open-external"
4443 );
4444 if (openExternal) {
4445 openExternal.addEventListener("wpd-menu-item-click", (e) => {
4446 e.stopPropagation();
4447 closeActionsMenu(this);
4448 this.detach();
4449 });
4450 }
4451 const startup = menuPanel.querySelector(
4452 ".desktop-mode-window__menu-item--startup"
4453 );
4454 if (startup) {
4455 refreshStartupCheckState(this, startup);
4456 startup.addEventListener("wpd-menu-item-click", (e) => {
4457 e.stopPropagation();
4458 flipStartupCheckOptimistically(startup);
4459 this.onToggleStartup?.(this);
4460 });
4461 document.addEventListener(
4462 "desktop-mode-default-window-changed",
4463 () => {
4464 refreshStartupCheckState(this, startup);
4465 }
4466 );
4467 }
4468 menuPanel.addEventListener("keydown", (e) => {
4469 const kev = e;
4470 if (kev.key === "Escape") {
4471 e.stopPropagation();
4472 closeActionsMenu(this);
4473 menuBtn.focus();
4474 }
4475 });
4476 }
4477 this._titleBar.addEventListener("dblclick", (e) => {
4478 const target = e.target;
4479 if (target?.closest(
4480 'button, [role="button"], [role="menuitem"], [role="menuitemcheckbox"], wpd-window-button, wpd-menu, wpd-menu-item, .desktop-mode-window__menu-panel, .desktop-mode-window__custom-buttons, input, select, textarea, a'
4481 )) {
4482 return;
4483 }
4484 this.toggleMaximize();
4485 });
4486 if (this.iframe) {
4487 const iframe = this.iframe;
4488 const tabs = this.element.querySelector(".desktop-mode-window__tabs");
4489 if (tabs) {
4490 tabs.addEventListener(
4491 "click",
4492 (e) => handleTabStripClick(this, e)
4493 );
4494 }
4495 iframe.addEventListener("load", () => {
4496 try {
4497 const href = iframe.contentWindow?.location.href;
4498 if (href) {
4499 syncActiveTab(this, href);
4500 }
4501 } catch {
4502 }
4503 });
4504 window.addEventListener("message", this._boundOnMessage);
4505 }
4506 }
4507 /** Add a closeable+detachable sub-tab hosting an external URL. */
4508 addExternalTab(url, label) {
4509 addExternalTab(this, url, label);
4510 }
4511 /** Set the z-index of this window. */
4512 setZIndex(z) {
4513 this.element.style.zIndex = String(z);
4514 }
4515 /** Mark this window as focused or unfocused. */
4516 setFocused(focused) {
4517 this.element.classList.toggle("desktop-mode-window--focused", focused);
4518 this._notifyChromeStateChanged();
4519 }
4520 /** Update the window title. */
4521 setTitle(title) {
4522 this._titleEl.textContent = title;
4523 this.config.title = title;
4524 doAction(HOOKS.WINDOW_TITLE_CHANGED, { windowId: this.id, title });
4525 this._notifyChromeStateChanged();
4526 }
4527 /**
4528 * Re-render the controls cluster from the Layer-2 registry +
4529 * the per-window `appearance.controls` block. Idempotent. The
4530 * old buttons (and any plugin-supplied render() teardowns) are
4531 * cleaned up before the new ones mount.
4532 *
4533 * @internal
4534 * @since 0.6.0
4535 */
4536 repaintWindowControls() {
4537 const controlsHost = this.element.querySelector(
4538 ".desktop-mode-window__controls"
4539 );
4540 if (!controlsHost) {
4541 return;
4542 }
4543 if (this._windowControlsTeardown) {
4544 try {
4545 this._windowControlsTeardown();
4546 } catch {
4547 }
4548 this._windowControlsTeardown = null;
4549 }
4550 this._windowControlsTeardown = paintWindowControls(this, controlsHost);
4551 }
4552 /**
4553 * Apply (or clear) a per-window controls config at runtime.
4554 * Mutates `this.config.appearance.controls` and re-paints. Pass
4555 * `null` or `undefined` to clear the override and fall back to
4556 * the registry-only resolution.
4557 *
4558 * @since 0.6.0
4559 */
4560 setAppearanceControls(override) {
4561 this.config.appearance = {
4562 ...this.config.appearance ?? {},
4563 controls: override ?? void 0
4564 };
4565 this.repaintWindowControls();
4566 }
4567 /**
4568 * Re-render every Layer-3 title-bar slot from the registry +
4569 * the per-window `appearance.slots` block. Idempotent. Plugin-
4570 * supplied teardowns from the previous paint run before the new
4571 * paint.
4572 *
4573 * @internal
4574 * @since 0.6.0
4575 */
4576 repaintWindowSlots() {
4577 if (this._windowSlotsTeardown) {
4578 try {
4579 this._windowSlotsTeardown();
4580 } catch {
4581 }
4582 this._windowSlotsTeardown = null;
4583 }
4584 this._windowSlotsTeardown = paintWindowSlots(this);
4585 }
4586 /**
4587 * Tear down the active custom chrome (if any) and mount the
4588 * resolved one. No-op when both old and new resolve to
4589 * `'core/standard'`. Idempotent.
4590 *
4591 * @internal
4592 * @since 0.6.0
4593 */
4594 remountWindowChrome() {
4595 if (this._chromeHandle) {
4596 try {
4597 this._chromeHandle.destroy();
4598 } catch {
4599 }
4600 this._chromeHandle = null;
4601 }
4602 this.element.classList.remove(CUSTOM_CHROME_CLASS);
4603 const mounted = mountWindowChrome(this);
4604 if (mounted) {
4605 this._chromeHandle = mounted.handle;
4606 this._chromeId = mounted.id;
4607 } else {
4608 this._chromeId = STANDARD_CHROME_ID;
4609 }
4610 }
4611 /**
4612 * Set the chrome id at runtime. Pass `null` / `undefined` to
4613 * fall back to the standard chrome.
4614 *
4615 * **Experimental** since 0.6.0 — the chrome render contract may
4616 * change in future minor versions.
4617 */
4618 setAppearanceChrome(chromeId) {
4619 this.config.appearance = {
4620 ...this.config.appearance ?? {},
4621 chrome: chromeId ?? void 0
4622 };
4623 this.remountWindowChrome();
4624 }
4625 /**
4626 * Push the current window state into the active custom chrome
4627 * (if any). Called from {@link setTitle}, {@link setFocused}, and
4628 * the maximize / minimize / fullscreen transitions so chrome
4629 * implementations don't have to subscribe to lifecycle events to
4630 * keep their visual in sync.
4631 *
4632 * @internal
4633 * @since 0.6.0
4634 */
4635 _notifyChromeStateChanged() {
4636 if (this._isDestroyed) {
4637 return;
4638 }
4639 if (!this._chromeHandle?.update) {
4640 return;
4641 }
4642 try {
4643 this._chromeHandle.update(captureChromeState(this));
4644 } catch {
4645 }
4646 }
4647 /**
4648 * Apply (or clear) per-window slot overrides at runtime.
4649 * `slot === null` removes the named override; `slots === null`
4650 * clears all per-window slot overrides at once.
4651 *
4652 * @since 0.6.0
4653 */
4654 setAppearanceSlot(slot, config) {
4655 const existing = this.config.appearance?.slots ?? {};
4656 const next = { ...existing };
4657 if (config === void 0) {
4658 delete next[slot];
4659 } else {
4660 next[slot] = config;
4661 }
4662 this.config.appearance = {
4663 ...this.config.appearance ?? {},
4664 slots: next
4665 };
4666 this.repaintWindowSlots();
4667 }
4668 /**
4669 * Apply (or clear) a per-window theme override at runtime. Accepts
4670 * three shapes for ergonomics:
4671 *
4672 * - `string` — interpreted as a registered theme id.
4673 * - `Record< string, string >` — interpreted as inline tokens.
4674 * - `WindowThemeRef` — explicit `{ themeId }` or `{ tokens }`.
4675 * - `null` / `undefined` — clear the override; the window falls
4676 * back to whatever the registry's match resolves to.
4677 *
4678 * Calls through to {@link applyWindowTheme}. The override is
4679 * also written to `this.config.appearance.theme` so the next
4680 * registry-driven re-apply preserves the runtime choice.
4681 *
4682 * @since 0.6.0
4683 */
4684 setAppearanceTheme(override) {
4685 let resolved;
4686 if (override === null || override === void 0) {
4687 resolved = void 0;
4688 } else if (typeof override === "string") {
4689 resolved = { themeId: override };
4690 } else if (typeof override === "object" && ("themeId" in override || "tokens" in override)) {
4691 resolved = override;
4692 } else if (typeof override === "object") {
4693 resolved = { tokens: override };
4694 }
4695 this.config.appearance = {
4696 ...this.config.appearance ?? {},
4697 theme: resolved
4698 };
4699 applyWindowTheme(this, resolved);
4700 }
4701 /** Minimize the window. */
4702 /**
4703 * Write the half-screen snap geometry for `zone` and apply the
4704 * corresponding state class. Shared by session-restore (which
4705 * calls it from `applyInitialState`) and the manager's live-snap
4706 * commit path so both enter the "snapped" state via identical
4707 * geometry math — and the ResizeObserver that reflows stateful
4708 * windows on desktop-area size changes.
4709 */
4710 applySnap(zone) {
4711 if (!this._applySnapVisuals(zone)) {
4712 return;
4713 }
4714 this.state = zone === "left" ? "snapped-left" : "snapped-right";
4715 this._emitChange("state");
4716 }
4717 /**
4718 * Apply the snap-zone visuals (state class + inline geometry). Does
4719 * NOT mutate `state`, save geometry, emit a change event, or fire
4720 * any action — callers own all of those side-effects so the same
4721 * helper can power both the public {@link applySnap} (which emits +
4722 * sets state) and the fullscreen-exit-to-snapped path in
4723 * {@link toggleFullscreen} (which emits + fires hooks exactly once
4724 * across the transition).
4725 *
4726 * @return `true` when geometry was applied; `false` when the
4727 * element has no parent and we can't size against it.
4728 * @internal
4729 */
4730 _applySnapVisuals(zone) {
4731 const parent = this.element.parentElement;
4732 if (!parent) {
4733 return false;
4734 }
4735 const halfW = Math.floor(parent.clientWidth / 2);
4736 const height = parent.clientHeight;
4737 this.element.classList.remove(
4738 "desktop-mode-window--maximized",
4739 "desktop-mode-window--fullscreen",
4740 "desktop-mode-window--snapped-left",
4741 "desktop-mode-window--snapped-right"
4742 );
4743 this.element.classList.add(`desktop-mode-window--snapped-${zone}`);
4744 this.element.style.left = zone === "left" ? "0px" : `${halfW}px`;
4745 this.element.style.top = "0px";
4746 this.element.style.width = `${halfW}px`;
4747 this.element.style.height = `${height}px`;
4748 return true;
4749 }
4750 /**
4751 * Predicate: is this window currently minimized?
4752 *
4753 * Equivalent to `state === 'minimized'`, but expressed as a
4754 * method so callers don't have to grep for the canonical
4755 * state-string values. The state machine is:
4756 * `'normal' | 'minimized' | 'maximized' | 'fullscreen' |
4757 * 'snapped-left' | 'snapped-right'`.
4758 *
4759 * @public
4760 * @since 0.5.2
4761 */
4762 isMinimized() {
4763 return this.state === "minimized";
4764 }
4765 /** Predicate: is this window currently maximized? @since 0.5.2 */
4766 isMaximized() {
4767 return this.state === "maximized";
4768 }
4769 /** Predicate: is this window in fullscreen mode? @since 0.5.2 */
4770 isFullscreen() {
4771 return this.state === "fullscreen";
4772 }
4773 /**
4774 * Predicate: is this window currently snapped to a screen edge?
4775 * Returns `true` for both half-screen positions; pass an explicit
4776 * side string if you need to distinguish.
4777 *
4778 * @since 0.5.2
4779 */
4780 isSnapped(side) {
4781 if (side === "left") {
4782 return this.state === "snapped-left";
4783 }
4784 if (side === "right") {
4785 return this.state === "snapped-right";
4786 }
4787 return this.state === "snapped-left" || this.state === "snapped-right";
4788 }
4789 /**
4790 * Predicate: is this window currently the focused (top of stack)
4791 * window? Reads the `desktop-mode-window--focused` class the manager
4792 * toggles in `focus()` so the result matches what's visible.
4793 *
4794 * @since 0.5.2
4795 */
4796 isFocused() {
4797 return this.element.classList.contains("desktop-mode-window--focused");
4798 }
4799 minimize() {
4800 if (this.state === "minimized") {
4801 return;
4802 }
4803 this._stateBeforeMinimize = this.state;
4804 this.state = "minimized";
4805 this.element.classList.add("desktop-mode-window--minimized");
4806 if (this.iframe) {
4807 const iframe = this.iframe;
4808 this.element.addEventListener("transitionend", (e) => {
4809 if (e.propertyName === "opacity" && this.state === "minimized") {
4810 iframe.style.visibility = "hidden";
4811 }
4812 }, { once: true });
4813 }
4814 this.onMinimize?.(this);
4815 this._emitChange("state");
4816 doAction(HOOKS.WINDOW_MINIMIZED, {
4817 windowId: this.id,
4818 element: this.element
4819 });
4820 }
4821 /**
4822 * Restore the window from minimized state. Returns the window to
4823 * whichever underlying state it occupied before {@link minimize}
4824 * so a previously-maximized window comes back maximized rather than
4825 * silently dropping into 'normal' while the `--maximized` class
4826 * (still on the element from before minimize) leaves the visual
4827 * out of sync with `this.state`.
4828 */
4829 restore() {
4830 if (this.iframe) {
4831 this.iframe.style.visibility = "";
4832 }
4833 const wasMinimized = this.state === "minimized";
4834 this.element.classList.remove("desktop-mode-window--minimized");
4835 if (wasMinimized) {
4836 this.state = this._stateBeforeMinimize ?? "normal";
4837 this._stateBeforeMinimize = null;
4838 if (this.state === "fullscreen") {
4839 updateFullscreenBodyClass();
4840 this.updateFocusButtonState();
4841 }
4842 }
4843 this.onFocusRequest?.(this);
4844 this._emitChange("state");
4845 if (wasMinimized) {
4846 doAction(HOOKS.WINDOW_RESTORED, {
4847 windowId: this.id,
4848 element: this.element
4849 });
4850 }
4851 }
4852 /**
4853 * Enter maximized state idempotently.
4854 *
4855 * Different from `toggleMaximize` in that it's a one-way: a caller
4856 * that wants the window maximized can call this without worrying
4857 * about the current state. No-op if already maximized.
4858 *
4859 * Used by the Overview-exit path so clicking a thumbnail can
4860 * animate directly from the grid position to maximized in one
4861 * co-animation, rather than the two chained animations a
4862 * `toggleMaximize` call would produce (first back-to-normal, then
4863 * normal-to-maximized).
4864 */
4865 maximize() {
4866 if (this.state === "maximized") {
4867 return;
4868 }
4869 if (this.state === "normal") {
4870 this._savedGeometry = {
4871 x: this.element.offsetLeft,
4872 y: this.element.offsetTop,
4873 width: this.element.offsetWidth,
4874 height: this.element.offsetHeight
4875 };
4876 }
4877 if (!this._applyMaximizeVisuals()) {
4878 return;
4879 }
4880 this.state = "maximized";
4881 this._emitChange("state");
4882 doAction(HOOKS.WINDOW_MAXIMIZED, {
4883 windowId: this.id,
4884 element: this.element
4885 });
4886 }
4887 /**
4888 * Apply the maximize visuals (state class + inline geometry against
4889 * the live parent bounds). Mirror of {@link _applySnapVisuals}
4890 * does NOT mutate `state`, save geometry, emit a change event, or
4891 * fire any action. Callers control all of that so the same helper
4892 * powers {@link maximize}, {@link toggleMaximize}'s fullscreen
4893 * branch, and {@link toggleFullscreen}'s exit-to-maximized branch
4894 * without duplicating the class+geometry math AND without the
4895 * idempotency-guard / save-geometry interlock that bit the
4896 * exit-to-maximized path before this refactor.
4897 *
4898 * @return `true` when geometry was applied; `false` when the
4899 * element has no parent and we can't size against it.
4900 * @internal
4901 */
4902 _applyMaximizeVisuals() {
4903 const parent = this.element.parentElement;
4904 if (!parent) {
4905 return false;
4906 }
4907 this.element.classList.remove(
4908 "desktop-mode-window--fullscreen",
4909 "desktop-mode-window--snapped-left",
4910 "desktop-mode-window--snapped-right"
4911 );
4912 this.element.classList.add("desktop-mode-window--maximized");
4913 this.element.style.left = "0px";
4914 this.element.style.top = "0px";
4915 this.element.style.width = `${parent.clientWidth}px`;
4916 this.element.style.height = `${parent.clientHeight}px`;
4917 return true;
4918 }
4919 /** Toggle between maximized and normal states. */
4920 toggleMaximize() {
4921 const parent = this.element.parentElement;
4922 if (!parent) {
4923 return;
4924 }
4925 if (this.state === "maximized") {
4926 this.element.classList.remove("desktop-mode-window--maximized");
4927 if (this._savedGeometry) {
4928 const restored = this.snapGeometry(this._savedGeometry);
4929 this.element.style.left = `${restored.x}px`;
4930 this.element.style.top = `${restored.y}px`;
4931 this.element.style.width = `${restored.width}px`;
4932 this.element.style.height = `${restored.height}px`;
4933 this._savedGeometry = restored;
4934 }
4935 this.state = "normal";
4936 this._emitChange("state");
4937 doAction(HOOKS.WINDOW_UNMAXIMIZED, {
4938 windowId: this.id,
4939 element: this.element
4940 });
4941 return;
4942 }
4943 if (this.state === "fullscreen") {
4944 this._savedFullscreenState = null;
4945 this._applyMaximizeVisuals();
4946 this.state = "maximized";
4947 updateFullscreenBodyClass();
4948 this.updateFocusButtonState();
4949 this._emitChange("state");
4950 doAction(HOOKS.WINDOW_FULLSCREEN_EXITED, {
4951 windowId: this.id,
4952 element: this.element
4953 });
4954 doAction(HOOKS.WINDOW_MAXIMIZED, {
4955 windowId: this.id,
4956 element: this.element
4957 });
4958 return;
4959 }
4960 this.maximize();
4961 }
4962 /**
4963 * Toggle fullscreen ("focus") mode — the window covers the entire
4964 * viewport, hiding the admin bar and dock behind it.
4965 *
4966 * This is the equivalent of macOS's green zoom-to-fullscreen: an
4967 * immersive mode distinct from maximize (which only fills the
4968 * desktop area, respecting the dock inset).
4969 */
4970 toggleFullscreen() {
4971 if (this.state === "fullscreen") {
4972 this.element.classList.remove("desktop-mode-window--fullscreen");
4973 const s = this._savedFullscreenState;
4974 this._savedFullscreenState = null;
4975 let landedOnMaximize = false;
4976 if (s && s.state === "maximized") {
4977 this._applyMaximizeVisuals();
4978 this.state = "maximized";
4979 landedOnMaximize = true;
4980 } else if (s && (s.state === "snapped-left" || s.state === "snapped-right")) {
4981 const zone = s.state === "snapped-left" ? "left" : "right";
4982 this._applySnapVisuals(zone);
4983 this.state = s.state;
4984 } else if (s) {
4985 this.element.style.left = `${s.x}px`;
4986 this.element.style.top = `${s.y}px`;
4987 this.element.style.width = `${s.width}px`;
4988 this.element.style.height = `${s.height}px`;
4989 this.state = "normal";
4990 } else {
4991 this.state = "normal";
4992 }
4993 updateFullscreenBodyClass();
4994 this.updateFocusButtonState();
4995 this._emitChange("state");
4996 doAction(HOOKS.WINDOW_FULLSCREEN_EXITED, {
4997 windowId: this.id,
4998 element: this.element
4999 });
5000 if (landedOnMaximize) {
5001 doAction(HOOKS.WINDOW_MAXIMIZED, {
5002 windowId: this.id,
5003 element: this.element
5004 });
5005 }
5006 return;
5007 }
5008 if (this.state === "normal") {
5009 this._savedGeometry = {
5010 x: this.element.offsetLeft,
5011 y: this.element.offsetTop,
5012 width: this.element.offsetWidth,
5013 height: this.element.offsetHeight
5014 };
5015 }
5016 this._savedFullscreenState = {
5017 state: this.state,
5018 x: this.element.offsetLeft,
5019 y: this.element.offsetTop,
5020 width: this.element.offsetWidth,
5021 height: this.element.offsetHeight
5022 };
5023 this.element.classList.remove(
5024 "desktop-mode-window--maximized",
5025 "desktop-mode-window--snapped-left",
5026 "desktop-mode-window--snapped-right"
5027 );
5028 this.element.classList.add("desktop-mode-window--fullscreen");
5029 this.state = "fullscreen";
5030 updateFullscreenBodyClass();
5031 this.updateFocusButtonState();
5032 this._emitChange("state");
5033 doAction(HOOKS.WINDOW_FULLSCREEN_ENTERED, {
5034 windowId: this.id,
5035 element: this.element
5036 });
5037 }
5038 /**
5039 * Reflect fullscreen state on the focus-mode button (active class,
5040 * aria-pressed, and label).
5041 */
5042 updateFocusButtonState() {
5043 const btn = this.element.querySelector(
5044 ".desktop-mode-window__btn--focus"
5045 );
5046 if (!btn) {
5047 return;
5048 }
5049 const isFullscreen = this.state === "fullscreen";
5050 btn.classList.toggle("desktop-mode-window__btn--active", isFullscreen);
5051 btn.setAttribute("aria-pressed", isFullscreen ? "true" : "false");
5052 btn.setAttribute(
5053 "aria-label",
5054 isFullscreen ? __("Exit fullscreen") : __("Enter fullscreen")
5055 );
5056 }
5057 /**
5058 * Open the window's current URL in a new browser tab as classic
5059 * wp-admin.
5060 *
5061 * Strips the chromeless `desktop_mode_chromeless` flag and the transient
5062 * `desktop_mode_portal` flag, and tags the URL with
5063 * `desktop_mode_classic=1` so the server-side admin_init redirect
5064 * (which otherwise forwards plain admin URLs to `/desktop-mode/`)
5065 * lets the request through. The tag only has to survive the first
5066 * request; once the browser renders the page, the user's in-tab
5067 * navigation returns to normal admin flow.
5068 *
5069 * The desktop window itself stays open — detach is a branch, not
5070 * a move. If the user wants to close it afterwards, they can.
5071 */
5072 detach() {
5073 const current = this.getCurrentUrl();
5074 let url;
5075 try {
5076 url = new URL(current, INITIAL_ORIGIN);
5077 } catch {
5078 return;
5079 }
5080 if (url.origin !== INITIAL_ORIGIN) {
5081 return;
5082 }
5083 url.searchParams.delete("desktop_mode_chromeless");
5084 url.searchParams.delete("desktop_mode_portal");
5085 url.searchParams.set("desktop_mode_classic", "1");
5086 window.open(url.toString(), "_blank", "noopener");
5087 doAction(HOOKS.WINDOW_DETACHED, { windowId: this.id, url: url.toString() });
5088 }
5089 /**
5090 * Reload the active iframe of this window. If an external sub-tab
5091 * is foregrounded, that iframe is reloaded instead of the primary
5092 * one. Same-origin iframes use `location.reload()` for a clean
5093 * reload that preserves scroll position semantics; cross-origin
5094 * external tabs fall back to re-assigning `iframe.src`.
5095 *
5096 * No-op for native windows — they own their DOM directly and the
5097 * title-bar menu's Reload item is only built for iframe windows
5098 * (`dom.ts` skips it when `config.native`), but this guard keeps
5099 * the contract honest if the method is called by other code paths
5100 * in the future.
5101 */
5102 reload() {
5103 if (this.config.native) {
5104 return;
5105 }
5106 const body = this.element.querySelector(".desktop-mode-window__body");
5107 if (body?.classList.contains("desktop-mode-window__body--loading")) {
5108 return;
5109 }
5110 let reloadedUrl;
5111 let triggerReload;
5112 if (this._activeTabId === "primary") {
5113 if (!this.iframe) {
5114 return;
5115 }
5116 const iframe = this.iframe;
5117 reloadedUrl = this.getCurrentUrl();
5118 triggerReload = () => {
5119 try {
5120 iframe.contentWindow?.location.reload();
5121 } catch {
5122 iframe.src = iframe.src;
5123 }
5124 };
5125 } else {
5126 const entry = this._externalTabs.get(this._activeTabId);
5127 if (!entry) {
5128 return;
5129 }
5130 reloadedUrl = entry.url;
5131 triggerReload = () => {
5132 try {
5133 entry.iframe.contentWindow?.location.reload();
5134 } catch {
5135 entry.iframe.src = entry.url;
5136 }
5137 };
5138 }
5139 this._spinReloadButton();
5140 this.markContentLoading();
5141 triggerReload();
5142 doAction(HOOKS.WINDOW_RELOADED, {
5143 windowId: this.id,
5144 url: reloadedUrl
5145 });
5146 }
5147 /**
5148 * Navigate the window's primary iframe to a new admin URL.
5149 *
5150 * Used by `WindowManager.open()` when a caller re-opens an
5151 * existing window with a URL it isn't already showing — e.g. the
5152 * post-install "Activate" link
5153 * (`plugins.php?action=activate&plugin=…&_wpnonce=…`) clicked
5154 * while a Plugins window is already open. The URL gets the
5155 * chromeless flag appended via `withChromelessParam()`, which
5156 * doubles as the same-origin gate. Navigation prefers
5157 * `location.assign()` so the iframe keeps a real session-history
5158 * entry (Back still works), falling back to `iframe.src` when the
5159 * content window is torn down or inaccessible.
5160 *
5161 * Returns `true` when a navigation was started, `false` for
5162 * native windows, missing iframes, or cross-origin URLs.
5163 *
5164 * @since 0.9.4
5165 */
5166 navigateTo(url) {
5167 if (this.config.native || !this.iframe) {
5168 return false;
5169 }
5170 const target = withChromelessParam(url);
5171 if (!target) {
5172 return false;
5173 }
5174 this.markContentLoading();
5175 const inner = this.iframe.contentWindow;
5176 if (inner) {
5177 try {
5178 inner.location.assign(target);
5179 return true;
5180 } catch {
5181 }
5182 }
5183 this.iframe.src = target;
5184 return true;
5185 }
5186 /**
5187 * Trigger the one-shot 360° rotation on the title-bar reload
5188 * button. Force-restart the animation by removing the class,
5189 * flushing a reflow, then re-adding it; otherwise a click during
5190 * an in-flight animation would be a no-op (CSS ignores re-applying
5191 * the same animation to an unchanged class). Pattern mirrors
5192 * {@link shake} for the same restart-on-repeat reason.
5193 *
5194 * Silent no-op when the title bar has been replaced by a custom
5195 * chrome layer that doesn't render the standard reload button.
5196 *
5197 * @internal
5198 */
5199 _spinReloadButton() {
5200 const btn = this.element.querySelector(
5201 ".desktop-mode-window__btn--reload"
5202 );
5203 if (!(btn instanceof HTMLElement)) {
5204 return;
5205 }
5206 btn.classList.remove("desktop-mode-window__btn--spinning");
5207 void btn.offsetWidth;
5208 btn.classList.add("desktop-mode-window__btn--spinning");
5209 btn.addEventListener(
5210 "animationend",
5211 () => {
5212 btn.classList.remove("desktop-mode-window__btn--spinning");
5213 },
5214 { once: true }
5215 );
5216 }
5217 /**
5218 * (Re)render plugin-registered title-bar buttons that match this
5219 * window. Called once from the constructor and again whenever
5220 * the registry changes. Cheap — clears each slot then walks the
5221 * filtered list; matching N predicates against this single
5222 * window is O(N).
5223 *
5224 * @internal
5225 */
5226 renderCustomTitleBarButtons() {
5227 const leftSlot = this.element.querySelector(
5228 ".desktop-mode-window__custom-buttons--left"
5229 );
5230 const rightSlot = this.element.querySelector(
5231 ".desktop-mode-window__custom-buttons--right"
5232 );
5233 if (!leftSlot || !rightSlot) {
5234 return;
5235 }
5236 leftSlot.innerHTML = "";
5237 rightSlot.innerHTML = "";
5238 const { left, right } = buttonsForWindow(this);
5239 const fill = (slot, defs) => {
5240 for (const def of defs) {
5241 const host = document.createElement("wpd-window-button");
5242 paintTitleBarButtonIcon(host, def.icon);
5243 host.setAttribute("aria-label", def.label);
5244 host.setAttribute("title", def.label);
5245 host.classList.add("desktop-mode-window__btn");
5246 host.classList.add("desktop-mode-window__btn--custom");
5247 host.dataset.buttonId = def.id;
5248 slot.appendChild(host);
5249 if (typeof def.render === "function") {
5250 try {
5251 def.render(host, this);
5252 } catch (err) {
5253 if (typeof console !== "undefined") {
5254 console.error(
5255 "[desktop-mode] title-bar-button render threw:",
5256 def.id,
5257 err
5258 );
5259 }
5260 }
5261 } else if (typeof def.onClick === "function") {
5262 host.addEventListener("wpd-button-activate", (ev) => {
5263 try {
5264 def.onClick(this, ev);
5265 } catch (err) {
5266 if (typeof console !== "undefined") {
5267 console.error(
5268 "[desktop-mode] title-bar-button onClick threw:",
5269 def.id,
5270 err
5271 );
5272 }
5273 }
5274 });
5275 }
5276 }
5277 };
5278 fill(leftSlot, left);
5279 fill(rightSlot, right);
5280 }
5281 /**
5282 * Publish a payload on a named channel into this window's
5283 * content. The unified abstraction over iframe `postMessage` and
5284 * native render-callback dispatch — plugin authors write the
5285 * same call regardless of how the window is rendered.
5286 *
5287 * **Iframe windows** (real iframes OR `iframeContent` natives):
5288 * the payload is delivered as `desktop-mode-window-send` via
5289 * `postMessage` and surfaces inside the iframe via
5290 * `wp.desktop.on( channel, cb )` (the iframe-bridge installs
5291 * the API on `wp.desktop`). Calls made before the iframe has
5292 * announced itself ready are queued in FIFO order and flushed
5293 * once the bridge connects — `Window.send` is safe the moment
5294 * the window object exists.
5295 *
5296 * **Pure native windows**: the payload is delivered in-process
5297 * to subscribers the render callback registered through its
5298 * `windowApi.on( channel, cb )` (the second argument the render
5299 * receives). Always considered ready — no async boundary.
5300 *
5301 * Plugin authors never branch on window type — same call, same
5302 * channel, same payload.
5303 *
5304 * @since 0.5.5
5305 *
5306 * @param channel Slash- or dot-separated identifier (e.g.
5307 * `'reload'`, `'editor/insert-block'`).
5308 * @param payload Anything `postMessage` can serialise.
5309 */
5310 send(channel, payload) {
5311 if (typeof channel !== "string" || channel === "") {
5312 return;
5313 }
5314 const target = this.iframe ?? getSyntheticIframe(this.id);
5315 if (!target) {
5316 dispatchToNative(this.id, channel, payload);
5317 return;
5318 }
5319 const sendNow = () => {
5320 try {
5321 target.contentWindow?.postMessage(
5322 {
5323 type: "desktop-mode-window-send",
5324 channel,
5325 payload
5326 },
5327 INITIAL_ORIGIN
5328 );
5329 } catch (err) {
5330 if (typeof console !== "undefined") {
5331 console.error(
5332 "[desktop-mode] Window.send: postMessage failed",
5333 err
5334 );
5335 }
5336 }
5337 };
5338 if (isWindowContentReady(this.id)) {
5339 sendNow();
5340 return;
5341 }
5342 enqueueWindowSend(this.id, channel, payload, sendNow);
5343 }
5344 /**
5345 * Subscribe to a named channel published BY this window's
5346 * content. Mirror of {@link send} for the inbound direction.
5347 *
5348 * Iframe content publishes via `wp.desktop.send( channel,
5349 * payload )` (installed by the iframe bridge); native render
5350 * code publishes via `windowApi.send( channel, payload )`. Both
5351 * land here.
5352 *
5353 * Use the literal `'*'` to wildcard-subscribe to every channel
5354 * this window publishes.
5355 *
5356 * @since 0.5.5
5357 *
5358 * @return Unsubscribe handle. Idempotent.
5359 */
5360 on(channel, cb) {
5361 if (typeof channel !== "string" || channel === "" || typeof cb !== "function") {
5362 return () => void 0;
5363 }
5364 return addParentSubscriber(
5365 this.id,
5366 channel,
5367 cb
5368 );
5369 }
5370 /**
5371 * Re-show the loading-spinner overlay over this window's body
5372 * and fade the content out. Mirror of {@link markContentLoaded}
5373 * for the entry edge — plugins call this before kicking off an
5374 * async refetch so the user sees the same affordance they saw
5375 * at first paint, and call `markContentLoaded()` once the work
5376 * resolves.
5377 *
5378 * The shell:
5379 * - Adds the `desktop-mode-window__body--loading` modifier to
5380 * the body (CSS fades the content out, fades the overlay
5381 * in).
5382 * - Re-attaches the overlay element if it was already torn
5383 * down by a prior `markContentLoaded` call.
5384 * - Fires the {@link HOOKS.WINDOW_CONTENT_LOADING} action +
5385 * dispatches `desktop-mode-window-content-loading` on
5386 * `document` (idempotent — no re-fire when already
5387 * loading).
5388 *
5389 * Idempotent. Cheap to call repeatedly.
5390 *
5391 * @since 0.6.0
5392 */
5393 markContentLoading() {
5394 markWindowContentLoading(this.id);
5395 }
5396 /**
5397 * Tell the shell this window's body content is ready — fades
5398 * the spinner overlay out, fades the content in, removes the
5399 * overlay element after the transition lands.
5400 *
5401 * Iframe windows mark themselves ready automatically on the
5402 * `desktop-mode-ready` postMessage from the chromeless bridge.
5403 * Native windows mark themselves ready automatically after
5404 * their `render( body )` callback (or its returned `Promise`)
5405 * resolves. Plugins only call this directly when:
5406 *
5407 * - They're doing event-listener-based async loading the
5408 * framework can't observe.
5409 * - They re-armed loading via {@link markContentLoading}
5410 * and need to clear it again.
5411 *
5412 * Idempotent. Fires the {@link HOOKS.WINDOW_CONTENT_LOADED}
5413 * action only on a loading → ready transition.
5414 *
5415 * @since 0.6.0
5416 */
5417 markContentLoaded() {
5418 markWindowContentReady(this.id);
5419 }
5420 /**
5421 * Resolve when this window's content is ready to receive sends.
5422 * Returns a Promise that resolves immediately for windows that
5423 * are already ready, and otherwise waits for the next
5424 * {@link HOOKS.WINDOW_CONTENT_LOADED} matching this window's id.
5425 *
5426 * Backstop for the iframe bridge handshake race: plugin authors
5427 * coordinating with an `iframeContent: { bridge: true }` native
5428 * window can `await win.whenContentReady()` before issuing the
5429 * first send/connect, instead of wiring iframe.load themselves
5430 * or hoping that {@link HOOKS.IFRAME_READY} has fired by their
5431 * boot.
5432 *
5433 * Resolves regardless of whether the content path was an iframe
5434 * `load`, the chromeless `desktop-mode-ready` postMessage, or a
5435 * native render's synchronous `markContentLoaded()` — all three
5436 * end up calling {@link markWindowContentReady}.
5437 *
5438 * @public
5439 * @since 0.6.0
5440 */
5441 whenContentReady() {
5442 if (isWindowContentReady(this.id)) {
5443 return Promise.resolve();
5444 }
5445 return new Promise((resolve) => {
5446 const expectedId = this.id;
5447 const onLoaded = (e) => {
5448 const detail = e.detail;
5449 if (!detail || detail.windowId !== expectedId) {
5450 return;
5451 }
5452 document.removeEventListener(
5453 "desktop-mode-window-content-loaded",
5454 onLoaded
5455 );
5456 resolve();
5457 };
5458 document.addEventListener(
5459 "desktop-mode-window-content-loaded",
5460 onLoaded
5461 );
5462 });
5463 }
5464 /**
5465 * Set the activity indicator's phase explicitly. Most callers
5466 * should prefer {@link trackActivity} (or `wp.desktop.fetch()`
5467 * which calls it internally) — this is the escape hatch for code
5468 * paths that aren't a single Promise (event-listener-driven
5469 * loaders, Heartbeat polls, manual save buttons that want to
5470 * pulse "Saved" without a wrapped fetch).
5471 *
5472 * Phases:
5473 *
5474 * - `idle` — clear. Indicator fades out.
5475 * - `pending` / `saving` — modem-blink while a request is in flight.
5476 * - `saved` — green flash; auto-clears to `idle` after ~2.2s.
5477 * - `failed` — red dot with `opts.error` as tooltip text;
5478 * auto-clears after ~6s.
5479 *
5480 * Idempotent: setting the same phase twice is a no-op except for
5481 * resetting the auto-clear timer.
5482 *
5483 * @since 0.8.0
5484 */
5485 markActivity(phase, opts = {}) {
5486 this._activityPhase = phase;
5487 this._activityError = opts.error ?? null;
5488 this._paintActivityIndicator();
5489 }
5490 /**
5491 * Track a Promise's lifecycle on this window's activity indicator.
5492 * The dot pulses while the Promise is in flight; on resolve it
5493 * settles to `saved` (green flash); on reject it shows `failed`
5494 * (red, error message tooltip). Returns the Promise unchanged so
5495 * callers can chain.
5496 *
5497 * Multiple concurrent calls are reference-counted: the dot stays
5498 * lit until the LAST tracked Promise settles. The terminal phase
5499 * (`saved` vs `failed`) reflects the LAST settled outcome, so a
5500 * burst of 5 successful fetches followed by 1 error reads
5501 * "failed", which is the right signal — surface the bad news.
5502 *
5503 * Use `wp.desktop.fetch()` for HTTP requests; reach for this
5504 * directly when you have a Promise from a different source
5505 * (postMessage handshake, IndexedDB transaction, …).
5506 *
5507 * @since 0.8.0
5508 */
5509 trackActivity(promise) {
5510 this._markActivityStart();
5511 return promise.then(
5512 (value) => {
5513 this._markActivitySettled(true);
5514 return value;
5515 },
5516 (err) => {
5517 const message = err instanceof Error ? err.message : String(err);
5518 this._markActivitySettled(false, message);
5519 throw err;
5520 }
5521 );
5522 }
5523 /**
5524 * Increment the in-flight counter and paint.
5525 *
5526 * @internal
5527 */
5528 _markActivityStart() {
5529 this._activityCount++;
5530 if (this._activitySettleTimer !== null) {
5531 window.clearTimeout(this._activitySettleTimer);
5532 this._activitySettleTimer = null;
5533 }
5534 if (this._activityCount === 1) {
5535 this._activityPhase = "saving";
5536 this._activityError = null;
5537 this._activitySavingStartedAt = Date.now();
5538 this._paintActivityIndicator();
5539 }
5540 }
5541 /**
5542 * Decrement the in-flight counter and, when it hits zero,
5543 * transition to `saved` or `failed`. Schedules an auto-clear
5544 * back to `idle`.
5545 *
5546 * Honours `MIN_SAVING_DISPLAY_MS` — when a fetch settles before
5547 * the minimum has elapsed, the transition is deferred so the
5548 * modem-blink animation has time to register visually. Concurrent
5549 * activity that re-starts during the deferral cancels it.
5550 *
5551 * @internal
5552 */
5553 _markActivitySettled(ok, error) {
5554 if (this._activityCount > 0) {
5555 this._activityCount--;
5556 }
5557 if (this._activityCount > 0) {
5558 if (!ok && error) {
5559 this._activityError = error;
5560 }
5561 return;
5562 }
5563 const elapsed = Date.now() - this._activitySavingStartedAt;
5564 const remaining = _Window.MIN_SAVING_DISPLAY_MS - elapsed;
5565 if (remaining > 0) {
5566 if (this._activitySettleTimer !== null) {
5567 window.clearTimeout(this._activitySettleTimer);
5568 }
5569 this._activitySettleTimer = window.setTimeout(() => {
5570 this._activitySettleTimer = null;
5571 this._finalizeActivitySettle(ok, error);
5572 }, remaining);
5573 return;
5574 }
5575 this._finalizeActivitySettle(ok, error);
5576 }
5577 /**
5578 * Apply the terminal `saved` / `failed` phase and schedule the
5579 * fade back to `idle`. Split out of `_markActivitySettled` so
5580 * the deferred-settle path and the immediate path share one
5581 * implementation.
5582 *
5583 * @internal
5584 */
5585 _finalizeActivitySettle(ok, error) {
5586 this._activityPhase = ok && !this._activityError ? "saved" : "failed";
5587 if (!ok && error) {
5588 this._activityError = error;
5589 }
5590 this._paintActivityIndicator();
5591 if (this._activityClearTimer !== null) {
5592 window.clearTimeout(this._activityClearTimer);
5593 this._activityClearTimer = null;
5594 }
5595 if (this._activityPhase === "saved") {
5596 this._activityClearTimer = window.setTimeout(() => {
5597 this._activityClearTimer = null;
5598 this._activityPhase = "idle";
5599 this._activityError = null;
5600 this._paintActivityIndicator();
5601 }, 2200);
5602 }
5603 }
5604 /**
5605 * Push the current activity state onto the title-bar dot.
5606 *
5607 * @internal
5608 */
5609 _paintActivityIndicator() {
5610 if (this._isDestroyed) {
5611 return;
5612 }
5613 const indicator = this._titleBar.querySelector(
5614 "[data-desktop-mode-activity-indicator]"
5615 );
5616 if (!indicator) {
5617 return;
5618 }
5619 indicator.setAttribute("phase", this._activityPhase);
5620 if (this._activityError) {
5621 indicator.setAttribute("error", this._activityError);
5622 } else {
5623 indicator.removeAttribute("error");
5624 }
5625 }
5626 /**
5627 * Request a visual "attention" signal on this window's tile in
5628 * the dock or taskbar — pulse, shake, or bounce. Used by plugins
5629 * that need to grab the user's eye when the window is closed or
5630 * unfocused (incoming chat message, long task finished, etc.).
5631 *
5632 * Resolution order:
5633 * 1. If a tile exists for this window's id on either rail
5634 * (`wp.desktop.dock` or `wp.desktop.taskbar`), call
5635 * `Dock.setAttention( id, mode, opts )`.
5636 * 2. Otherwise (e.g. `placement: 'none'`) fall back to
5637 * `setHighlight('persistent')` on the window itself, auto-
5638 * cleared after `opts.durationMs`. No-op if the window has
5639 * no rendered chrome.
5640 *
5641 * The mode + opts pass through the `desktop-mode.window.attention`
5642 * filter first so plugins (or a Do-Not-Disturb preference) can
5643 * mute (`return null`) or modify the request.
5644 *
5645 * Animations are gated on `prefers-reduced-motion`; reduced-motion
5646 * users see a static accent ring for the same duration so the
5647 * affordance still works.
5648 *
5649 * @since 0.6.0
5650 */
5651 requestAttention(mode, opts = {}) {
5652 const intent = activity.filter(
5653 "desktop-mode/window-attention-requested",
5654 {
5655 windowId: this.id,
5656 mode,
5657 durationMs: opts.durationMs,
5658 intensity: opts.intensity
5659 },
5660 opts
5661 );
5662 if (!intent || intent.cancel === true) {
5663 return;
5664 }
5665 const intentMode = intent.mode ?? mode;
5666 const intentOpts = {
5667 ...opts,
5668 durationMs: typeof intent.durationMs === "number" ? intent.durationMs : opts.durationMs,
5669 intensity: typeof intent.intensity === "string" ? intent.intensity : opts.intensity
5670 };
5671 const filtered = applyFilters(
5672 "desktop-mode.window.attention",
5673 intentMode,
5674 { windowId: this.id, opts: intentOpts }
5675 );
5676 const wp = window.wp;
5677 const dockApi = wp?.desktop?.dock;
5678 const taskbarApi = wp?.desktop?.taskbar;
5679 let routed = false;
5680 if (typeof dockApi?.setAttention === "function") {
5681 dockApi.setAttention(this.id, filtered, intentOpts);
5682 routed = true;
5683 }
5684 if (typeof taskbarApi?.setAttention === "function") {
5685 taskbarApi.setAttention(this.id, filtered, intentOpts);
5686 routed = true;
5687 }
5688 if (!routed && filtered !== null) {
5689 this.setHighlight("persistent");
5690 const duration = intentOpts.durationMs ?? 4e3;
5691 if (duration > 0) {
5692 window.setTimeout(() => {
5693 this.setHighlight(null);
5694 }, duration);
5695 }
5696 } else if (!routed && filtered === null) {
5697 this.setHighlight(null);
5698 }
5699 }
5700 /**
5701 * Briefly jiggle the window element horizontally — the classic
5702 * MSN-Messenger nudge affordance. Plugins can request "look at
5703 * me" attention on their own window programmatically (e.g. a
5704 * chat plugin on inbound nudge, a CI plugin on a broken build).
5705 *
5706 * Composes with the inline `left`/`top` the window manager
5707 * writes (the shake is a CSS `transform`, not a position
5708 * change). Auto-clears the class on `animationend`. If a second
5709 * shake is requested while one is mid-flight, the class is
5710 * removed and re-added so the animation restarts.
5711 *
5712 * Reduced-motion fallback: a static accent ring for the same
5713 * duration. Authors who want a different visual can listen on
5714 * the JS filter `desktop-mode.window.shake` and return falsy to mute.
5715 *
5716 * @since 0.6.0
5717 */
5718 shake() {
5719 const filtered = applyFilters(
5720 "desktop-mode.window.shake",
5721 true,
5722 { windowId: this.id }
5723 );
5724 if (filtered === false) {
5725 return;
5726 }
5727 const el = this.element;
5728 el.classList.remove("desktop-mode-window--shaking");
5729 void el.offsetWidth;
5730 el.classList.add("desktop-mode-window--shaking");
5731 const onEnd = () => {
5732 el.classList.remove("desktop-mode-window--shaking");
5733 el.removeEventListener("animationend", onEnd);
5734 };
5735 el.addEventListener("animationend", onEnd);
5736 }
5737 /**
5738 * Toggle a visual highlight on the window. Used by plugins that
5739 * need to point at a window from outside it — e.g. a "connect to"
5740 * dropdown that highlights candidate windows on hover.
5741 *
5742 * - `'preview'` — temporary ring; caller is expected to
5743 * clear on `mouseleave`. Multiple plugins
5744 * can hover-preview without stomping each
5745 * other (last write wins).
5746 * - `'persistent'` — sticky ring; caller is responsible for
5747 * clearing it.
5748 * - `null` / unset — clear all highlight state.
5749 *
5750 * Override the colour per-call via `opts.color`, or globally
5751 * via the `--wp-window-highlight-color` custom property.
5752 *
5753 * @since 0.5.2
5754 */
5755 setHighlight(mode, opts) {
5756 const el = this.element;
5757 if (!el) {
5758 return;
5759 }
5760 el.classList.remove(
5761 "wp-window--highlight-preview",
5762 "wp-window--highlight-persistent"
5763 );
5764 if (mode === "preview") {
5765 el.classList.add("wp-window--highlight-preview");
5766 } else if (mode === "persistent") {
5767 el.classList.add("wp-window--highlight-persistent");
5768 }
5769 if (opts?.color) {
5770 el.style.setProperty("--wp-window-highlight-color", opts.color);
5771 } else if (mode === null) {
5772 el.style.removeProperty("--wp-window-highlight-color");
5773 }
5774 doAction(HOOKS.WINDOW_HIGHLIGHT_CHANGED, {
5775 windowId: this.id,
5776 mode,
5777 color: opts?.color
5778 });
5779 }
5780 /**
5781 * Close and destroy the window.
5782 *
5783 * Plays a subtle closing animation before removing the element.
5784 */
5785 close() {
5786 if (this._isDestroyed) {
5787 return;
5788 }
5789 if (this.config.native && !this._suppressCloseFilter) {
5790 const proceed = applyFilters(
5791 HOOKS.NATIVE_WINDOW_BEFORE_CLOSE,
5792 true,
5793 { windowId: this.id, config: this.config }
5794 );
5795 if (proceed === false) {
5796 return;
5797 }
5798 } else if (!this.config.native && !this._suppressCloseFilter && this._iframeBridgeReady && this.iframe) {
5799 if (this._closePending) {
5800 return;
5801 }
5802 this._closePending = true;
5803 try {
5804 this.iframe.contentWindow?.postMessage(
5805 { type: "desktop-mode-bridge-beforeunload-query" },
5806 location.origin
5807 );
5808 this._iframeCloseTimeout = setTimeout(() => {
5809 if (this._isDestroyed) {
5810 return;
5811 }
5812 this._suppressCloseFilter = true;
5813 this._closePending = false;
5814 this.close();
5815 }, 500);
5816 return;
5817 } catch {
5818 this._closePending = false;
5819 }
5820 }
5821 this._isDestroyed = true;
5822 if (this._activityClearTimer !== null) {
5823 window.clearTimeout(this._activityClearTimer);
5824 this._activityClearTimer = null;
5825 }
5826 if (this._activitySettleTimer !== null) {
5827 window.clearTimeout(this._activitySettleTimer);
5828 this._activitySettleTimer = null;
5829 }
5830 if (this._titleBarButtonsUnsubscribe) {
5831 this._titleBarButtonsUnsubscribe();
5832 this._titleBarButtonsUnsubscribe = null;
5833 }
5834 if (this._windowThemesUnsubscribe) {
5835 this._windowThemesUnsubscribe();
5836 this._windowThemesUnsubscribe = null;
5837 }
5838 if (this._windowControlsUnsubscribe) {
5839 this._windowControlsUnsubscribe();
5840 this._windowControlsUnsubscribe = null;
5841 }
5842 if (this._windowSlotsUnsubscribe) {
5843 this._windowSlotsUnsubscribe();
5844 this._windowSlotsUnsubscribe = null;
5845 }
5846 if (this._windowChromesUnsubscribe) {
5847 this._windowChromesUnsubscribe();
5848 this._windowChromesUnsubscribe = null;
5849 }
5850 if (this._nativeRenderCtxDispose) {
5851 try {
5852 this._nativeRenderCtxDispose();
5853 } catch (err) {
5854 doAction(HOOKS.SHELL_ERROR, {
5855 scope: "native-window-ctx-dispose",
5856 id: this.id,
5857 error: err
5858 });
5859 }
5860 this._nativeRenderCtxDispose = null;
5861 }
5862 this._bodyResizeObserver?.disconnect();
5863 this._bodyResizeObserver = null;
5864 clearWindowChannels(this.id);
5865 try {
5866 this.config.onClose?.();
5867 } catch (err) {
5868 doAction(HOOKS.SHELL_ERROR, {
5869 scope: "native-window-close",
5870 id: this.id,
5871 error: err
5872 });
5873 }
5874 this.onClose?.(this);
5875 this.element.classList.add("desktop-mode-window--closing");
5876 this._onCloseTransitionEnd = (e) => {
5877 if (e.propertyName === "opacity") {
5878 this._finalizeClose();
5879 }
5880 };
5881 this.element.addEventListener("transitionend", this._onCloseTransitionEnd);
5882 this._closeSafetyNetTimer = setTimeout(() => this._finalizeClose(), 300);
5883 }
5884 /**
5885 * Synchronously tear down a window with no animation. Use in:
5886 *
5887 * - Test `afterEach` hooks where the suite needs deterministic
5888 * cleanup before the environment unwinds.
5889 * - Plugin deactivation flows where the tile is going away
5890 * immediately and a fade-out would feel wrong.
5891 * - Forced shutdowns that must bypass the
5892 * `NATIVE_WINDOW_BEFORE_CLOSE` veto filter (e.g. the user
5893 * closed a parent that owns this window).
5894 *
5895 * Idempotent: a second `destroy()` call is a no-op once the
5896 * window has finalised. If `close()` had already started the
5897 * animation, `destroy()` cancels the pending timer and runs
5898 * finalise immediately.
5899 *
5900 * @public
5901 * @since 0.8.2
5902 */
5903 destroy() {
5904 if (this._isFinalized) {
5905 return;
5906 }
5907 if (!this._isDestroyed) {
5908 this._suppressCloseFilter = true;
5909 try {
5910 this.close();
5911 } finally {
5912 this._suppressCloseFilter = false;
5913 }
5914 }
5915 this._finalizeClose();
5916 }
5917 /**
5918 * Run the post-animation teardown — the work that used to live
5919 * in `close()`'s inner `onDone` closure. Idempotent via
5920 * `_isFinalized`. Cancels the safety-net timer + the
5921 * `transitionend` listener it might have been racing.
5922 *
5923 * @internal
5924 * @since 0.8.2
5925 */
5926 _finalizeClose() {
5927 if (this._isFinalized) {
5928 return;
5929 }
5930 this._isFinalized = true;
5931 if (this._closeSafetyNetTimer !== null) {
5932 clearTimeout(this._closeSafetyNetTimer);
5933 this._closeSafetyNetTimer = null;
5934 }
5935 if (this._onCloseTransitionEnd) {
5936 this.element.removeEventListener(
5937 "transitionend",
5938 this._onCloseTransitionEnd
5939 );
5940 this._onCloseTransitionEnd = null;
5941 }
5942 if (this._windowControlsTeardown) {
5943 try {
5944 this._windowControlsTeardown();
5945 } catch {
5946 }
5947 this._windowControlsTeardown = null;
5948 }
5949 if (this._windowSlotsTeardown) {
5950 try {
5951 this._windowSlotsTeardown();
5952 } catch {
5953 }
5954 this._windowSlotsTeardown = null;
5955 }
5956 if (this._chromeHandle) {
5957 try {
5958 this._chromeHandle.destroy();
5959 } catch {
5960 }
5961 this._chromeHandle = null;
5962 }
5963 clearWindowTheme(this);
5964 if (this._nativeRenderTeardown) {
5965 try {
5966 this._nativeRenderTeardown();
5967 } catch (err) {
5968 doAction(HOOKS.SHELL_ERROR, {
5969 scope: "native-window-teardown",
5970 id: this.id,
5971 error: err
5972 });
5973 }
5974 this._nativeRenderTeardown = null;
5975 }
5976 window.removeEventListener("message", this._boundOnMessage);
5977 if (this._boundOnDocumentPointerDown) {
5978 document.removeEventListener(
5979 "pointerdown",
5980 this._boundOnDocumentPointerDown,
5981 true
5982 );
5983 }
5984 this.element.remove();
5985 updateFullscreenBodyClass();
5986 }
5987 /**
5988 * Wire up a ResizeObserver on the body element. Fires the
5989 * inline `config.onResize` callback AND the
5990 * `WINDOW_BODY_RESIZED` hook on every size change. Returns the
5991 * observer so `close()` can disconnect it; returns null when
5992 * the body element is missing or the environment has no
5993 * ResizeObserver (jsdom without a shim, older browsers).
5994 */
5995 installBodyResizeObserver() {
5996 const body = this.element.querySelector(
5997 ".desktop-mode-window__body"
5998 );
5999 if (!body) {
6000 return null;
6001 }
6002 if (typeof ResizeObserver === "undefined") {
6003 return null;
6004 }
6005 const observer = new ResizeObserver((entries) => {
6006 const entry = entries[0];
6007 if (!entry) {
6008 return;
6009 }
6010 const cr = entry.contentRect;
6011 const width = Math.round(cr.width);
6012 const height = Math.round(cr.height);
6013 try {
6014 this.config.onResize?.(width, height);
6015 } catch (err) {
6016 doAction(HOOKS.SHELL_ERROR, {
6017 scope: "native-window-resize",
6018 id: this.id,
6019 error: err
6020 });
6021 }
6022 doAction(HOOKS.WINDOW_BODY_RESIZED, {
6023 windowId: this.id,
6024 width,
6025 height
6026 });
6027 });
6028 observer.observe(body);
6029 return observer;
6030 }
6031 /** Get a snapshot of the window state for persistence. */
6032 getSnapshot() {
6033 const isHidden = this.element.offsetParent === null;
6034 if (isHidden) {
6035 const parse = (raw) => {
6036 const n = parseFloat(raw);
6037 return Number.isFinite(n) ? Math.round(n) : 0;
6038 };
6039 return {
6040 id: this.id,
6041 x: parse(this.element.style.left),
6042 y: parse(this.element.style.top),
6043 width: parse(this.element.style.width),
6044 height: parse(this.element.style.height),
6045 state: this.state
6046 };
6047 }
6048 return {
6049 id: this.id,
6050 x: this.element.offsetLeft,
6051 y: this.element.offsetTop,
6052 width: this.element.offsetWidth,
6053 height: this.element.offsetHeight,
6054 state: this.state
6055 };
6056 }
6057 /** Number of external sub-tabs currently open on this window. */
6058 getExternalTabCount() {
6059 return externalTabCount(this);
6060 }
6061 /** Serializable snapshot of this window's external sub-tabs. */
6062 getExternalTabsSnapshot() {
6063 return externalTabsSnapshot(this);
6064 }
6065 /**
6066 * Toggle the actions menu from an external caller (e.g., keyboard
6067 * shortcut). Kept here so the panel-focus + outside-click wiring
6068 * lives in a single place.
6069 */
6070 toggleActionsMenu() {
6071 toggleActionsMenu(this);
6072 }
6073 /** Close the actions menu from an external caller. */
6074 closeActionsMenu() {
6075 closeActionsMenu(this);
6076 }
6077 /** Open the actions menu from an external caller. */
6078 openActionsMenu() {
6079 openActionsMenu(this);
6080 }
6081 };
6082 _Window.MIN_SAVING_DISPLAY_MS = 1200;
6083 let Window = _Window;
6084 function html(strings, ...values) {
6085 return { __wpdHtml: true, strings, values };
6086 }
6087 function isTemplateResult(v) {
6088 return !!v && v.__wpdHtml === true;
6089 }
6090 const MARKER_PREFIX = "$$wpd$$";
6091 const MARKER_RE = /\$\$wpd\$\$(\d+)\$\$/g;
6092 function joinWithMarkers(strings) {
6093 let out = strings[0];
6094 for (let i = 1; i < strings.length; i++) {
6095 out += `${MARKER_PREFIX}${i - 1}$$` + strings[i];
6096 }
6097 return out;
6098 }
6099 const compiledCache = /* @__PURE__ */ new WeakMap();
6100 function compile(strings) {
6101 const cached = compiledCache.get(strings);
6102 if (cached) {
6103 return cached;
6104 }
6105 const template = document.createElement("template");
6106 template.innerHTML = joinWithMarkers(strings);
6107 const recipes = [];
6108 const walk = (node, path) => {
6109 if (node.nodeType === Node.ELEMENT_NODE) {
6110 const el = node;
6111 for (const attr of Array.from(el.attributes)) {
6112 const rawName = attr.name;
6113 const rawValue = attr.value;
6114 const prefix = rawName[0];
6115 if (MARKER_RE.test(rawValue)) {
6116 MARKER_RE.lastIndex = 0;
6117 if (prefix === "@") {
6118 const match = MARKER_RE.exec(rawValue);
6119 MARKER_RE.lastIndex = 0;
6120 recipes.push({
6121 path,
6122 kind: "event",
6123 name: rawName.slice(1),
6124 valueIndex: match ? Number(match[1]) : 0
6125 });
6126 el.removeAttribute(rawName);
6127 } else if (prefix === ".") {
6128 const match = MARKER_RE.exec(rawValue);
6129 MARKER_RE.lastIndex = 0;
6130 recipes.push({
6131 path,
6132 kind: "prop",
6133 name: rawName.slice(1),
6134 valueIndex: match ? Number(match[1]) : 0
6135 });
6136 el.removeAttribute(rawName);
6137 } else if (prefix === "?") {
6138 const match = MARKER_RE.exec(rawValue);
6139 MARKER_RE.lastIndex = 0;
6140 recipes.push({
6141 path,
6142 kind: "bool",
6143 name: rawName.slice(1),
6144 valueIndex: match ? Number(match[1]) : 0
6145 });
6146 el.removeAttribute(rawName);
6147 } else {
6148 const fragments = [];
6149 const indices = [];
6150 let lastEnd = 0;
6151 let m;
6152 MARKER_RE.lastIndex = 0;
6153 while ((m = MARKER_RE.exec(rawValue)) !== null) {
6154 fragments.push(rawValue.slice(lastEnd, m.index));
6155 indices.push(Number(m[1]));
6156 lastEnd = m.index + m[0].length;
6157 }
6158 fragments.push(rawValue.slice(lastEnd));
6159 recipes.push({
6160 path,
6161 kind: "attr",
6162 name: rawName,
6163 template: fragments,
6164 valueIndices: indices
6165 });
6166 el.setAttribute(rawName, "");
6167 }
6168 }
6169 }
6170 }
6171 const children = Array.from(node.childNodes);
6172 let shift = 0;
6173 for (let i = 0; i < children.length; i++) {
6174 const child = children[i];
6175 const liveIndex = i + shift;
6176 if (child.nodeType === Node.TEXT_NODE) {
6177 const text = child.textContent || "";
6178 if (!MARKER_RE.test(text)) {
6179 MARKER_RE.lastIndex = 0;
6180 continue;
6181 }
6182 MARKER_RE.lastIndex = 0;
6183 const parent = child.parentNode;
6184 let lastEnd = 0;
6185 let m;
6186 const newNodes = [];
6187 const newRecipes = [];
6188 MARKER_RE.lastIndex = 0;
6189 while ((m = MARKER_RE.exec(text)) !== null) {
6190 if (m.index > lastEnd) {
6191 newNodes.push(document.createTextNode(text.slice(lastEnd, m.index)));
6192 }
6193 const placeholder = document.createTextNode("");
6194 newNodes.push(placeholder);
6195 newRecipes.push({
6196 path: [...path, liveIndex + newNodes.length - 1],
6197 kind: "node",
6198 valueIndex: Number(m[1])
6199 });
6200 lastEnd = m.index + m[0].length;
6201 }
6202 if (lastEnd < text.length) {
6203 newNodes.push(document.createTextNode(text.slice(lastEnd)));
6204 }
6205 for (const nn of newNodes) {
6206 parent.insertBefore(nn, child);
6207 }
6208 parent.removeChild(child);
6209 shift += newNodes.length - 1;
6210 recipes.push(...newRecipes);
6211 } else {
6212 walk(child, [...path, liveIndex]);
6213 }
6214 }
6215 };
6216 walk(template.content, []);
6217 const buildParts = (fragment) => {
6218 const out = [];
6219 for (const r of recipes) {
6220 let node = fragment;
6221 for (const idx of r.path) {
6222 node = node.childNodes[idx];
6223 }
6224 if (r.kind === "node") {
6225 out.push({
6226 kind: "node",
6227 valueIndex: r.valueIndex,
6228 child: {
6229 anchor: node,
6230 state: null
6231 }
6232 });
6233 } else if (r.kind === "attr") {
6234 out.push({
6235 kind: "attr",
6236 element: node,
6237 name: r.name,
6238 template: r.template,
6239 valueIndices: r.valueIndices
6240 });
6241 } else if (r.kind === "event") {
6242 out.push({
6243 kind: "event",
6244 valueIndex: r.valueIndex,
6245 element: node,
6246 name: r.name
6247 });
6248 } else if (r.kind === "prop") {
6249 out.push({
6250 kind: "prop",
6251 valueIndex: r.valueIndex,
6252 element: node,
6253 name: r.name
6254 });
6255 } else if (r.kind === "bool") {
6256 out.push({
6257 kind: "bool",
6258 valueIndex: r.valueIndex,
6259 element: node,
6260 name: r.name
6261 });
6262 }
6263 }
6264 return out;
6265 };
6266 const entry = { template, buildParts };
6267 compiledCache.set(strings, entry);
6268 return entry;
6269 }
6270 const mountState = /* @__PURE__ */ new WeakMap();
6271 function render(result, container) {
6272 const existing = mountState.get(container);
6273 if (existing && existing.strings === result.strings) {
6274 applyValues(existing.parts, result.values);
6275 return;
6276 }
6277 const compiled = compile(result.strings);
6278 const fragment = compiled.template.content.cloneNode(true);
6279 const parts = compiled.buildParts(fragment);
6280 while (container.firstChild) {
6281 container.removeChild(container.firstChild);
6282 }
6283 container.appendChild(fragment);
6284 applyValues(parts, result.values);
6285 mountState.set(container, { strings: result.strings, parts });
6286 }
6287 function applyValues(parts, values) {
6288 for (const part of parts) {
6289 if (part.kind === "node") {
6290 updateChildPart(part.child, values[part.valueIndex]);
6291 } else if (part.kind === "attr") {
6292 let composed = part.template[0];
6293 for (let i = 0; i < part.valueIndices.length; i++) {
6294 composed += formatText(values[part.valueIndices[i]]);
6295 composed += part.template[i + 1];
6296 }
6297 if (composed !== part.last) {
6298 part.last = composed;
6299 if (composed === "") {
6300 part.element.removeAttribute(part.name);
6301 } else {
6302 part.element.setAttribute(part.name, composed);
6303 }
6304 }
6305 } else if (part.kind === "event") {
6306 const next = values[part.valueIndex];
6307 if (next !== part.current) {
6308 if (part.current) {
6309 part.element.removeEventListener(part.name, part.current);
6310 }
6311 if (next) {
6312 part.element.addEventListener(part.name, next);
6313 }
6314 part.current = next;
6315 }
6316 } else if (part.kind === "prop") {
6317 const next = values[part.valueIndex];
6318 if (next !== part.last) {
6319 part.last = next;
6320 part.element[part.name] = next;
6321 }
6322 } else if (part.kind === "bool") {
6323 const next = !!values[part.valueIndex];
6324 if (next !== part.last) {
6325 part.last = next;
6326 if (next) {
6327 part.element.setAttribute(part.name, "");
6328 } else {
6329 part.element.removeAttribute(part.name);
6330 }
6331 }
6332 }
6333 }
6334 }
6335 function updateChildPart(child, value) {
6336 if (value === null || value === void 0 || value === false) {
6337 if (child.state) {
6338 disposeChildState(child.state);
6339 child.state = null;
6340 }
6341 return;
6342 }
6343 if (Array.isArray(value)) {
6344 updateArrayChild(child, value);
6345 return;
6346 }
6347 if (isTemplateResult(value)) {
6348 updateTemplateChild(child, value);
6349 return;
6350 }
6351 if (value instanceof Node) {
6352 updateNodeChild(child, value);
6353 return;
6354 }
6355 updateTextChild(child, formatText(value));
6356 }
6357 function updateNodeChild(child, node) {
6358 const old = child.state;
6359 if (old?.shape === "node" && old.node === node) {
6360 return;
6361 }
6362 if (old) {
6363 disposeChildState(old);
6364 }
6365 insertBeforeAnchor(child, [node]);
6366 child.state = { shape: "node", node };
6367 }
6368 function updateTextChild(child, text) {
6369 const old = child.state;
6370 if (old?.shape === "text") {
6371 if (old.text !== text) {
6372 old.node.textContent = text;
6373 old.text = text;
6374 }
6375 return;
6376 }
6377 if (old) {
6378 disposeChildState(old);
6379 }
6380 const node = document.createTextNode(text);
6381 insertBeforeAnchor(child, [node]);
6382 child.state = { shape: "text", node, text };
6383 }
6384 function updateTemplateChild(child, result) {
6385 const old = child.state;
6386 if (old?.shape === "template" && old.strings === result.strings) {
6387 applyValues(old.parts, result.values);
6388 return;
6389 }
6390 if (old) {
6391 disposeChildState(old);
6392 }
6393 const compiled = compile(result.strings);
6394 const fragment = compiled.template.content.cloneNode(true);
6395 const parts = compiled.buildParts(fragment);
6396 const topNodes = Array.from(fragment.childNodes);
6397 insertBeforeAnchor(child, [fragment]);
6398 applyValues(parts, result.values);
6399 child.state = {
6400 shape: "template",
6401 strings: result.strings,
6402 parts,
6403 nodes: topNodes
6404 };
6405 }
6406 function updateArrayChild(child, arr) {
6407 const old = child.state;
6408 if (old?.shape === "array" && old.entries.length === arr.length) {
6409 for (let i = 0; i < arr.length; i++) {
6410 updateChildPart(old.entries[i], arr[i]);
6411 }
6412 return;
6413 }
6414 if (old) {
6415 disposeChildState(old);
6416 }
6417 const entries = [];
6418 for (const v of arr) {
6419 const entryAnchor = document.createTextNode("");
6420 insertBeforeAnchor(child, [entryAnchor]);
6421 const entry = { anchor: entryAnchor, state: null };
6422 updateChildPart(entry, v);
6423 entries.push(entry);
6424 }
6425 child.state = { shape: "array", entries };
6426 }
6427 function insertBeforeAnchor(child, nodes) {
6428 const parent = child.anchor.parentNode;
6429 if (!parent) {
6430 return;
6431 }
6432 for (const node of nodes) {
6433 parent.insertBefore(node, child.anchor);
6434 }
6435 }
6436 function disposeChildState(state) {
6437 if (state.shape === "text") {
6438 state.node.remove();
6439 return;
6440 }
6441 if (state.shape === "template") {
6442 for (const node of state.nodes) {
6443 if (node.parentNode) {
6444 node.parentNode.removeChild(node);
6445 }
6446 }
6447 return;
6448 }
6449 if (state.shape === "node") {
6450 if (state.node.parentNode) {
6451 state.node.parentNode.removeChild(state.node);
6452 }
6453 return;
6454 }
6455 for (const entry of state.entries) {
6456 if (entry.state) {
6457 disposeChildState(entry.state);
6458 }
6459 entry.anchor.remove();
6460 }
6461 }
6462 function formatText(v) {
6463 if (v === null || v === void 0 || v === false) {
6464 return "";
6465 }
6466 return String(v);
6467 }
6468 const _Component = class _Component extends HTMLElement {
6469 constructor() {
6470 super();
6471 this._renderScheduled = false;
6472 this._propValues = {};
6473 const ctor = this.constructor;
6474 if (ctor.shadow) {
6475 this.attachShadow({ mode: "open" });
6476 this._renderRoot = this.shadowRoot;
6477 } else {
6478 this._renderRoot = this;
6479 }
6480 this._installPropAccessors();
6481 }
6482 static get observedAttributes() {
6483 return this.props.map(kebab);
6484 }
6485 connectedCallback() {
6486 this._adoptStyles();
6487 this.requestUpdate();
6488 }
6489 attributeChangedCallback(name, oldValue, newValue) {
6490 if (oldValue === newValue) {
6491 return;
6492 }
6493 const prop = camel(name);
6494 this._propValues[prop] = newValue;
6495 this.requestUpdate();
6496 }
6497 /**
6498 * Declarative class-name setter. Assign an array (or a
6499 * space-separated string) and the host's `class` attribute is
6500 * rewritten to match. Intended for programmatic styling — when
6501 * a plugin has enqueued its own stylesheet and wants to apply
6502 * one of those classes to a shell component:
6503 *
6504 * ```js
6505 * element.classNames = [ 'my-plugin-brand', 'is-active' ];
6506 * // → <wpd-select class="my-plugin-brand is-active">
6507 * ```
6508 *
6509 * The plain HTML `class="…"` attribute works just the same and
6510 * is always preferred when writing markup by hand — this setter
6511 * exists for the JS-API case where the caller has an array of
6512 * conditional classes in hand.
6513 *
6514 * Getter returns the current `classList` as a plain array for
6515 * symmetric read/write.
6516 *
6517 * @since 0.5.0
6518 */
6519 get classNames() {
6520 return Array.from(this.classList);
6521 }
6522 set classNames(next) {
6523 if (next === null || next === void 0) {
6524 this.removeAttribute("class");
6525 return;
6526 }
6527 const list = Array.isArray(next) ? next : String(next).split(/\s+/);
6528 const cleaned = list.map((s) => String(s).trim()).filter((s) => s !== "");
6529 this.className = cleaned.join(" ");
6530 }
6531 /**
6532 * Request a re-render explicitly. Components rarely need this —
6533 * declare state via props + attribute observers and the render
6534 * loop picks up changes automatically.
6535 */
6536 requestUpdate() {
6537 this._scheduleRender();
6538 }
6539 /**
6540 * Dispatch a `CustomEvent` with a `detail`. Bubbles + composed
6541 * by default (matches typical WC UX — events cross shadow
6542 * boundaries, parents can listen without knowing about internal
6543 * structure).
6544 */
6545 emit(name, detail) {
6546 return this.dispatchEvent(
6547 new CustomEvent(name, {
6548 detail,
6549 bubbles: true,
6550 composed: true
6551 })
6552 );
6553 }
6554 // ------------------------------------------------------------------
6555 // Internals
6556 // ------------------------------------------------------------------
6557 /**
6558 * Wire every `static props` entry to a matched property getter +
6559 * setter on the element. Setting the property reflects into the
6560 * attribute (so downstream observers + CSS selectors see it);
6561 * reading the property falls back to the attribute.
6562 */
6563 _installPropAccessors() {
6564 const ctor = this.constructor;
6565 for (const prop of ctor.props) {
6566 if (Object.getOwnPropertyDescriptor(this, prop)) {
6567 continue;
6568 }
6569 const attr = kebab(prop);
6570 Object.defineProperty(this, prop, {
6571 get: () => {
6572 if (prop in this._propValues) {
6573 return this._propValues[prop];
6574 }
6575 return this.getAttribute(attr);
6576 },
6577 set: (value) => {
6578 let str;
6579 if (value === null || value === void 0 || value === false) {
6580 str = null;
6581 } else if (value === true) {
6582 str = "";
6583 } else {
6584 str = String(value);
6585 }
6586 this._propValues[prop] = str;
6587 if (str === null) {
6588 this.removeAttribute(attr);
6589 } else {
6590 this.setAttribute(attr, str);
6591 }
6592 this.requestUpdate();
6593 },
6594 enumerable: true,
6595 configurable: true
6596 });
6597 }
6598 }
6599 /**
6600 * Schedule a render on the next microtask. Multiple property
6601 * assignments in the same tick collapse into a single render.
6602 */
6603 _scheduleRender() {
6604 if (this._renderScheduled || !this.isConnected) {
6605 return;
6606 }
6607 this._renderScheduled = true;
6608 queueMicrotask(() => {
6609 this._renderScheduled = false;
6610 if (!this.isConnected) {
6611 return;
6612 }
6613 render(this.render(), this._renderRoot);
6614 });
6615 }
6616 /**
6617 * Mount adoptable stylesheets onto the shadow root (via
6618 * `adoptedStyleSheets`) or the light DOM (via one `<style>`
6619 * tag per def). No-op if `static styles` is empty.
6620 */
6621 _adoptStyles() {
6622 const ctor = this.constructor;
6623 if (ctor.styles.length === 0) {
6624 return;
6625 }
6626 if (ctor.shadow && this.shadowRoot) {
6627 const sheets = ctor.styles.map((s) => s.sheet).filter((s) => s !== null);
6628 this.shadowRoot.adoptedStyleSheets = sheets;
6629 if (sheets.length !== ctor.styles.length) {
6630 for (const s of ctor.styles) {
6631 if (!s.sheet) {
6632 const tag = document.createElement("style");
6633 tag.textContent = s.cssText;
6634 this.shadowRoot.appendChild(tag);
6635 }
6636 }
6637 }
6638 } else {
6639 this._adoptLightStyles(ctor);
6640 }
6641 }
6642 _adoptLightStyles(ctor) {
6643 if (_Component._lightStylesAdopted.has(ctor)) {
6644 return;
6645 }
6646 _Component._lightStylesAdopted.add(ctor);
6647 for (const s of ctor.styles) {
6648 const tag = document.createElement("style");
6649 tag.dataset.wpdUi = this.tagName.toLowerCase();
6650 tag.textContent = s.cssText;
6651 document.head.appendChild(tag);
6652 }
6653 }
6654 };
6655 _Component.props = [];
6656 _Component.styles = [];
6657 _Component.shadow = true;
6658 _Component._lightStylesAdopted = /* @__PURE__ */ new WeakSet();
6659 let Component = _Component;
6660 function defineComponent(tag, ctor) {
6661 if (customElements.get(tag)) {
6662 return;
6663 }
6664 customElements.define(tag, ctor);
6665 }
6666 function kebab(s) {
6667 return s.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase());
6668 }
6669 function camel(s) {
6670 return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
6671 }
6672 const SUPPORTS_CONSTRUCTABLE_SHEETS = (() => {
6673 try {
6674 const s = new CSSStyleSheet();
6675 return typeof s.replaceSync === "function";
6676 } catch {
6677 return false;
6678 }
6679 })();
6680 function css(strings, ...values) {
6681 let text = strings[0];
6682 for (let i = 1; i < strings.length; i++) {
6683 const v = values[i - 1];
6684 if (typeof v === "string" || typeof v === "number") {
6685 text += String(v);
6686 } else if (v && v.__wpdCss) {
6687 text += v.cssText;
6688 } else {
6689 throw new TypeError(
6690 "[wpd-ui] css`` interpolations must be strings, numbers, or other css`` results. Got: " + typeof v
6691 );
6692 }
6693 text += strings[i];
6694 }
6695 if (SUPPORTS_CONSTRUCTABLE_SHEETS) {
6696 const sheet = new CSSStyleSheet();
6697 sheet.replaceSync(text);
6698 return { __wpdCss: true, sheet, cssText: text };
6699 }
6700 return { __wpdCss: true, sheet: null, cssText: text };
6701 }
6702 const styles$3 = css`:host{display:inline-flex}button{display:flex;align-items:center;justify-content:center;width:30px;height:30px;padding:0;border:none;border-radius:5px;background:transparent;color:var( --wpd-btn-color,currentColor );cursor:pointer;transition:background-color 0.15s ease,color 0.15s ease}button:hover{color:var( --wpd-btn-color-hover,currentColor );background:var( --wpd-btn-bg-hover,rgba( 0,0,0,0.06 ) )}button:focus-visible{color:var( --wpd-btn-color-hover,currentColor );background:var( --wpd-btn-bg-hover,rgba( 0,0,0,0.06 ) );outline:2px solid var( --wpd-btn-outline,currentColor );outline-offset:1px}:host( [ active ] ) button{color:var( --wpd-btn-color-hover,currentColor );background:var( --wpd-btn-bg-active,rgba( 0,0,0,0.08 ) )}:host( [ danger ] ) button:hover{color:#fff;background:var( --wpd-btn-danger-hover,#d63638 )}svg{display:block;pointer-events:none;flex-shrink:0}svg:empty{display:none}::slotted( span ){line-height:1}::slotted( svg ){display:block}`;
6703 const ICONS$1 = {
6704 minimize: '<path d="M3 6h6" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"/>',
6705 maximize: '<rect x="3" y="3" width="6" height="6" rx="1" stroke="currentColor" stroke-width="1.25" fill="none"/>',
6706 fullscreen: '<path d="M4.5 2H2v2.5M10 4.5V2H7.5M4.5 10H2V7.5M10 7.5V10H7.5" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
6707 "fullscreen-exit": '<path d="M2 4.5H4.5V2M7.5 2V4.5H10M2 7.5H4.5V10M7.5 10V7.5H10" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
6708 detach: '<path d="M5 2H2.5v7.5H10V7M6.5 2H10v3.5M10 2L5.5 6.5" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
6709 reload: (
6710 // Filled icon scaled from a 512×512 source into the 12×12 viewBox
6711 // shared with the other title-bar glyphs. The wrapping `<g>` does
6712 // the math; the inner path is dropped in unmodified so its
6713 // authoring tool can be re-edited and copy-pasted again.
6714 // `scale(0.021)` ≈ 90% of full fit, with `translate(0.6)` to
6715 // keep the result centered inside the 12×12 viewBox so the
6716 // glyph reads slightly smaller than min/max/close — closer to
6717 // the visual weight of the other title-bar buttons.
6718 '<g transform="translate(0.6 0.6) scale(0.021)" fill="currentColor"><path d="m504.554 233.704-76.447 91.467c-6.329 7.572-15.417 11.479-24.571 11.479a31.872 31.872 0 0 1-20.504-7.447l-91.467-76.447c-13.561-11.334-15.366-31.515-4.032-45.075s31.515-15.366 45.075-4.032l37.506 31.347c-10.274-74.891-74.668-132.774-152.337-132.774C132.984 102.223 64 171.207 64 256s68.984 153.777 153.777 153.777c17.673 0 32 14.327 32 32s-14.327 32-32 32c-58.17 0-112.859-22.653-153.991-63.785C22.653 368.859 0 314.17 0 256s22.653-112.859 63.786-153.992c41.132-41.132 95.821-63.785 153.991-63.785s112.859 22.653 153.992 63.785c32.517 32.516 53.471 73.508 60.829 117.991l22.849-27.339c11.334-13.56 31.515-15.364 45.075-4.032 13.56 11.335 15.365 31.516 4.032 45.076z"/></g>'
6719 ),
6720 close: '<path d="M3.25 3.25l5.5 5.5M3.25 8.75l5.5-5.5" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"/>',
6721 menu: '<circle cx="3" cy="6" r="1.2" fill="currentColor"/><circle cx="6" cy="6" r="1.2" fill="currentColor"/><circle cx="9" cy="6" r="1.2" fill="currentColor"/>'
6722 };
6723 const _WpdWindowButton = class _WpdWindowButton extends Component {
6724 constructor() {
6725 super(...arguments);
6726 this._activateWired = false;
6727 }
6728 render() {
6729 const iconKey = this.icon || "";
6730 const svgInner = ICONS$1[iconKey] || "";
6731 return html`
6732 <button type="button">
6733 <svg
6734 width="14"
6735 height="14"
6736 viewBox="0 0 12 12"
6737 aria-hidden="true"
6738 focusable="false"
6739 ></svg>
6740 <slot></slot>
6741 </button>
6742 <span data-svg-buffer style="display:none">${svgInner}</span>
6743 `;
6744 }
6745 /**
6746 * After each render, copy the raw SVG markup into the actual
6747 * `<svg>` element. The templater only writes text into slots,
6748 * so we stash the intended markup in a hidden buffer and
6749 * `innerHTML = ` the svg once here — a one-shot post-render
6750 * hook that keeps the declarative template honest.
6751 *
6752 * Also wires up the `wpd-button-activate` CustomEvent that
6753 * fires exactly once per gesture — the canonical contract
6754 * for plugin-registered title-bar buttons. Plugin authors who
6755 * use `addEventListener( 'click', cb )` directly still get
6756 * what they expect (the title bar's drag-handler now excludes
6757 * chrome buttons by class so static clicks land normally),
6758 * but `wpd-button-activate` is the documented surface that
6759 * documents the once-per-gesture contract explicitly. See
6760 * the class-level docblock for rationale.
6761 */
6762 connectedCallback() {
6763 super.connectedCallback();
6764 queueMicrotask(() => this._paintSvg());
6765 queueMicrotask(() => this._wireActivateEvent());
6766 }
6767 attributeChangedCallback(name, oldValue, newValue) {
6768 super.attributeChangedCallback(name, oldValue, newValue);
6769 queueMicrotask(() => this._paintSvg());
6770 }
6771 _paintSvg() {
6772 const root = this.shadowRoot;
6773 if (!root) {
6774 return;
6775 }
6776 const svg = root.querySelector("svg");
6777 const buffer = root.querySelector("[data-svg-buffer]");
6778 if (svg && buffer) {
6779 const markup = buffer.textContent || "";
6780 if (svg.innerHTML !== markup) {
6781 svg.innerHTML = markup;
6782 }
6783 }
6784 }
6785 _wireActivateEvent() {
6786 if (this._activateWired) {
6787 return;
6788 }
6789 const root = this.shadowRoot;
6790 if (!root) {
6791 return;
6792 }
6793 const button = root.querySelector("button");
6794 if (!button) {
6795 return;
6796 }
6797 this._activateWired = true;
6798 button.addEventListener("click", () => {
6799 this.dispatchEvent(
6800 new CustomEvent("wpd-button-activate", {
6801 bubbles: true,
6802 composed: true,
6803 cancelable: true
6804 })
6805 );
6806 });
6807 }
6808 };
6809 _WpdWindowButton.props = ["icon", "active", "danger"];
6810 _WpdWindowButton.styles = [styles$3];
6811 _WpdWindowButton.help = {
6812 title: "Window button",
6813 summary: "Chrome button used in native-window title bars. Built-in icons cover the standard controls (minimize, maximize, fullscreen, detach, close, menu). Focused/unfocused coloring is driven by --wpd-btn-* CSS custom properties the window shell owns.",
6814 status: "stable",
6815 since: "0.9.0",
6816 props: [
6817 {
6818 name: "icon",
6819 type: "'minimize' | 'maximize' | 'fullscreen' | 'fullscreen-exit' | 'detach' | 'reload' | 'close' | 'menu'",
6820 description: "Which built-in inline SVG to paint. Omit to supply your own via the slot."
6821 },
6822 {
6823 name: "active",
6824 type: "boolean attribute",
6825 description: "Applies the pressed-down look (used e.g. while a menu it triggers is open)."
6826 },
6827 {
6828 name: "danger",
6829 type: "boolean attribute",
6830 description: "Swaps the hover wash to red — used by the close button."
6831 }
6832 ],
6833 slots: [
6834 { name: "(default)", description: "Optional custom icon markup (inline SVG) when `icon` is omitted." }
6835 ],
6836 cssProps: [
6837 { name: "--wpd-btn-color", description: "Resting foreground." },
6838 { name: "--wpd-btn-color-hover", description: "Hover foreground." },
6839 { name: "--wpd-btn-bg-hover", description: "Hover background wash." },
6840 { name: "--wpd-btn-bg-active", description: "Pressed background." },
6841 { name: "--wpd-btn-danger-hover", description: "Hover background for danger variant." },
6842 { name: "--wpd-btn-outline", description: "Focus outline colour." }
6843 ],
6844 example: html`
6845 <wpd-cluster gap="2">
6846 <wpd-window-button icon="minimize"></wpd-window-button>
6847 <wpd-window-button icon="maximize"></wpd-window-button>
6848 <wpd-window-button icon="menu"></wpd-window-button>
6849 <wpd-window-button icon="close" danger></wpd-window-button>
6850 </wpd-cluster>
6851 `
6852 };
6853 let WpdWindowButton = _WpdWindowButton;
6854 defineComponent("wpd-window-button", WpdWindowButton);
6855 const styles$2 = css`:host{display:inline-flex;align-items:center;gap:6px;font-size:var( --wpd-save-status-font-size,11px );line-height:1;color:var( --wpd-save-status-fg,currentColor );vertical-align:middle;min-width:0;opacity:1;pointer-events:auto}.wpd-save-status__indicator{display:inline-flex;align-items:center;justify-content:center;width:12px;height:12px;border-radius:50%;flex-shrink:0;box-sizing:border-box;background:var( --wpd-save-status-bg,transparent );border:2px solid var( --wpd-save-status-idle-color,color-mix( in srgb,var( --wp-admin-theme-color,#2271b1 ) 55%,transparent ) );color:var( --wp-admin-theme-color,#2271b1 );transition:background-color 0.2s ease,border-color 0.2s ease,box-shadow 0.2s ease}:host( [ phase='pending' ] ) .wpd-save-status__indicator,:host( [ phase='saving' ] ) .wpd-save-status__indicator{background:var( --wpd-save-status-bg,var( --wp-admin-theme-color,#2271b1 ) );border-color:transparent;color:var( --wp-admin-theme-color,#2271b1 );animation:wpd-save-status-pulse 1.2s ease-in-out infinite}:host( [ animation='modem' ][ phase='pending' ] ) .wpd-save-status__indicator,:host( [ animation='modem' ][ phase='saving' ] ) .wpd-save-status__indicator{background:var( --wpd-save-status-bg,var( --wp-admin-theme-color,#2271b1 ) );border-color:transparent;color:var( --wp-admin-theme-color,#2271b1 );animation:wpd-save-status-modem-stutter 1.8s ease-in-out infinite,wpd-save-status-modem-glow 2.4s ease-in-out infinite}@keyframes wpd-save-status-modem-stutter{0%,4%{opacity:1}5%,30%{opacity:0.22}31%,36%{opacity:1}37%,39%{opacity:0.22}40%,44%{opacity:1}45%,67%{opacity:0.22}68%,76%{opacity:1}77%,100%{opacity:0.22}}@keyframes wpd-save-status-modem-glow{0%,12%{box-shadow:0 0 0 0 transparent}13%,22%{box-shadow:0 0 4px 0 currentColor}23%,50%{box-shadow:0 0 0 0 transparent}51%,58%{box-shadow:0 0 4px 0 currentColor}59%,84%{box-shadow:0 0 0 0 transparent}85%,94%{box-shadow:0 0 5px 0 currentColor}95%,100%{box-shadow:0 0 0 0 transparent}}@media ( prefers-reduced-motion:reduce ){:host( [ phase='pending' ] ) .wpd-save-status__indicator,:host( [ phase='saving' ] ) .wpd-save-status__indicator,:host( [ animation='modem' ][ phase='pending' ] ) .wpd-save-status__indicator,:host( [ animation='modem' ][ phase='saving' ] ) .wpd-save-status__indicator{animation:none;opacity:0.85}}:host( [ phase='saved' ] ) .wpd-save-status__indicator{background:var( --wpd-save-status-saved-bg,#1d6f42 );border-color:transparent;color:var( --wpd-save-status-saved-bg,#1d6f42 )}:host( [ phase='failed' ] ) .wpd-save-status__indicator{background:var( --wpd-save-status-failed-bg,#d63638 );border-color:transparent;color:var( --wpd-save-status-failed-bg,#d63638 );animation:wpd-save-status-pulse 0.8s ease-in-out 2}@keyframes wpd-save-status-pulse{0%,100%{opacity:0.55;transform:scale( 0.9 )}50%{opacity:1;transform:scale( 1 )}}:host( [ mode='pill' ] ) .wpd-save-status{display:inline-flex;align-items:center;gap:6px;padding:2px 10px;border-radius:999px;background:var( --wpd-save-status-pill-bg,transparent );font-weight:500;white-space:nowrap}:host( [ mode='pill' ][ phase='saving' ] ) .wpd-save-status,:host( [ mode='pill' ][ phase='pending' ] ) .wpd-save-status{background:var( --wpd-save-status-pill-bg,rgba( 0,0,0,0.04 ) );color:var( --wpd-save-status-pill-fg,#50575e )}:host( [ mode='pill' ][ phase='saved' ] ) .wpd-save-status{background:var( --wpd-save-status-pill-bg,rgba( 30,132,73,0.12 ) );color:var( --wpd-save-status-pill-fg,#1d6f42 )}:host( [ mode='pill' ][ phase='failed' ] ) .wpd-save-status{background:var( --wpd-save-status-pill-bg,rgba( 214,54,56,0.12 ) );color:var( --wpd-save-status-pill-fg,#a02622 )}.wpd-save-status__label{min-width:0;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}:host( [ phase='saved' ] ) .wpd-save-status__glyph,:host( [ phase='failed' ] ) .wpd-save-status__glyph{display:inline-block;color:#fff;width:8px;height:8px}.wpd-save-status__glyph{display:none}.wpd-save-status__glyph svg{display:block;width:100%;height:100%}`;
6856 const DEFAULT_EVENT = "desktop-mode-os-settings-save-lifecycle";
6857 const DEFAULT_AUTO_CLEAR_SAVED_MS = 2200;
6858 const DEFAULT_AUTO_CLEAR_FAILED_MS = 6e3;
6859 const _WpdSaveStatus = class _WpdSaveStatus extends Component {
6860 constructor() {
6861 super(...arguments);
6862 this._autoTimer = null;
6863 this._docListener = null;
6864 }
6865 connectedCallback() {
6866 super.connectedCallback();
6867 if (this.auto !== null) {
6868 this._installAutoListener();
6869 }
6870 }
6871 disconnectedCallback() {
6872 this._removeAutoListener();
6873 if (this._autoTimer !== null) {
6874 window.clearTimeout(this._autoTimer);
6875 this._autoTimer = null;
6876 }
6877 }
6878 attributeChangedCallback(name, oldValue, newValue) {
6879 super.attributeChangedCallback(name, oldValue, newValue);
6880 if (name === "auto" || name === "event") {
6881 this._removeAutoListener();
6882 if (this.auto !== null) {
6883 this._installAutoListener();
6884 }
6885 }
6886 if (name === "phase") {
6887 this._scheduleAutoClear();
6888 const detail = {
6889 phase: this.phase ?? "idle",
6890 error: this.error ?? void 0
6891 };
6892 this.emit("wpd-save-status-change", detail);
6893 }
6894 }
6895 render() {
6896 const phase = this.phase ?? "idle";
6897 const mode = this.mode ?? "dot";
6898 const error = this.error ?? "";
6899 const title = error || this._labelForPhase(phase);
6900 if (title) {
6901 this.setAttribute("title", title);
6902 } else {
6903 this.removeAttribute("title");
6904 }
6905 this.setAttribute("aria-live", phase === "failed" ? "assertive" : "polite");
6906 this.setAttribute("role", phase === "failed" ? "alert" : "status");
6907 return html`
6908 <span class="wpd-save-status">
6909 <span class="wpd-save-status__indicator" aria-hidden="true">
6910 <span class="wpd-save-status__glyph">${this._renderGlyph(phase)}</span>
6911 </span>
6912 ${mode === "pill" ? html`<span class="wpd-save-status__label"
6913 >${this._labelForPhase(phase)}</span
6914 >` : html``}
6915 </span>
6916 `;
6917 }
6918 _renderGlyph(phase) {
6919 if (phase === "saved") {
6920 return _iconCheck();
6921 }
6922 if (phase === "failed") {
6923 return _iconBang();
6924 }
6925 return "";
6926 }
6927 _labelForPhase(phase) {
6928 switch (phase) {
6929 case "pending":
6930 case "saving":
6931 return this["saving-label"] ?? "Saving…";
6932 case "saved":
6933 return this["saved-label"] ?? "Saved";
6934 case "failed": {
6935 const err = this.error ?? "";
6936 return err || "Couldn’t save";
6937 }
6938 default:
6939 return this["idle-label"] ?? "";
6940 }
6941 }
6942 _installAutoListener() {
6943 const eventName = this.event || DEFAULT_EVENT;
6944 this._docListener = (e) => {
6945 const detail = e.detail;
6946 if (!detail || typeof detail.phase !== "string") {
6947 return;
6948 }
6949 this.phase = detail.phase;
6950 if (detail.error) {
6951 this.error = detail.error;
6952 } else if (detail.phase !== "failed" && this.error) {
6953 this.removeAttribute("error");
6954 }
6955 };
6956 document.addEventListener(eventName, this._docListener);
6957 }
6958 _removeAutoListener() {
6959 if (!this._docListener) {
6960 return;
6961 }
6962 const eventName = this.event || DEFAULT_EVENT;
6963 document.removeEventListener(eventName, this._docListener);
6964 this._docListener = null;
6965 }
6966 _scheduleAutoClear() {
6967 if (this._autoTimer !== null) {
6968 window.clearTimeout(this._autoTimer);
6969 this._autoTimer = null;
6970 }
6971 const phase = this.phase ?? "idle";
6972 const ms = this._autoClearMsFor(phase);
6973 if (ms <= 0) {
6974 return;
6975 }
6976 this._autoTimer = window.setTimeout(() => {
6977 this._autoTimer = null;
6978 this.phase = "idle";
6979 }, ms);
6980 }
6981 _autoClearMsFor(phase) {
6982 if (phase === "saved") {
6983 const raw = this["auto-clear-saved-ms"];
6984 return parseInt(raw || "", 10) || DEFAULT_AUTO_CLEAR_SAVED_MS;
6985 }
6986 if (phase === "failed") {
6987 const raw = this["auto-clear-failed-ms"];
6988 return parseInt(raw || "", 10) || DEFAULT_AUTO_CLEAR_FAILED_MS;
6989 }
6990 return 0;
6991 }
6992 };
6993 _WpdSaveStatus.props = [
6994 "phase",
6995 "mode",
6996 "animation",
6997 "auto",
6998 "event",
6999 "error",
7000 "saving-label",
7001 "saved-label",
7002 "idle-label",
7003 "auto-clear-saved-ms",
7004 "auto-clear-failed-ms"
7005 ];
7006 _WpdSaveStatus.styles = [styles$2];
7007 _WpdSaveStatus.help = {
7008 title: "Save status",
7009 summary: 'Tiny status indicator for "is this change saved yet?" affordances. Three layouts (dot / icon / pill), five phases, optional auto-listen to a save-lifecycle CustomEvent so every input in the panel inherits feedback for free.',
7010 status: "experimental",
7011 since: "0.8.0",
7012 props: [
7013 {
7014 name: "phase",
7015 type: "'idle' | 'pending' | 'saving' | 'saved' | 'failed'",
7016 default: "idle",
7017 description: "Current lifecycle phase. Set manually for one-off integrations, or rely on `auto` to populate it from a CustomEvent."
7018 },
7019 {
7020 name: "mode",
7021 type: "'dot' | 'icon' | 'pill'",
7022 default: "dot",
7023 description: "Layout. `dot` is the smallest (10×10 colored dot); `icon` adds a glyph inside on saved/failed; `pill` adds an inline label."
7024 },
7025 {
7026 name: "animation",
7027 type: "'pulse' | 'modem'",
7028 default: "pulse",
7029 description: "Animation cadence during the saving phase. `pulse` (default) is a smooth ease-in-out; `modem` is an irregular activity-LED blink with a soft glow — suits a 'data-flowing' affordance in window title bars."
7030 },
7031 {
7032 name: "auto",
7033 type: "boolean attribute",
7034 description: 'Subscribe to a CustomEvent on `document` and populate phase + error from its detail. Default event name is `desktop-mode-os-settings-save-lifecycle`; override with `event="…"`.'
7035 },
7036 {
7037 name: "event",
7038 type: "string",
7039 default: "desktop-mode-os-settings-save-lifecycle",
7040 description: "CustomEvent name to listen on when `auto` is set."
7041 },
7042 {
7043 name: "error",
7044 type: "string",
7045 description: "Error message shown in `pill` mode and exposed as the host title attribute (so dot/icon modes still surface the message via tooltip)."
7046 },
7047 {
7048 name: "saving-label",
7049 type: "string",
7050 default: "Saving…",
7051 description: "Pill-mode label shown during `pending` / `saving`."
7052 },
7053 {
7054 name: "saved-label",
7055 type: "string",
7056 default: "Saved",
7057 description: "Pill-mode label shown during `saved`."
7058 },
7059 {
7060 name: "idle-label",
7061 type: "string",
7062 description: 'Optional pill-mode label shown during `idle` (e.g. "All changes saved"). When unset, the pill collapses to invisible while idle.'
7063 },
7064 {
7065 name: "auto-clear-saved-ms",
7066 type: "integer",
7067 default: "2200",
7068 description: "How long the `saved` phase stays visible before auto-fading back to `idle`."
7069 },
7070 {
7071 name: "auto-clear-failed-ms",
7072 type: "integer",
7073 default: "6000",
7074 description: "How long the `failed` phase stays visible before auto-fading back to `idle`."
7075 }
7076 ],
7077 events: [
7078 {
7079 name: "wpd-save-status-change",
7080 description: "Fires when the phase changes (manually or via auto-listen).",
7081 detail: "{ phase, error }"
7082 }
7083 ],
7084 cssProps: [
7085 {
7086 name: "--wpd-save-status-bg",
7087 description: "Indicator background color (saving/pending phase)."
7088 },
7089 {
7090 name: "--wpd-save-status-saved-bg",
7091 description: "Indicator background on saved."
7092 },
7093 {
7094 name: "--wpd-save-status-failed-bg",
7095 description: "Indicator background on failed."
7096 },
7097 {
7098 name: "--wpd-save-status-pill-bg",
7099 description: "Pill background (mode=pill)."
7100 },
7101 {
7102 name: "--wpd-save-status-pill-fg",
7103 description: "Pill foreground (mode=pill)."
7104 }
7105 ],
7106 example: html`
7107 <wpd-cluster gap="12">
7108 <wpd-save-status phase="pending"></wpd-save-status>
7109 <wpd-save-status phase="saving"></wpd-save-status>
7110 <wpd-save-status phase="saved"></wpd-save-status>
7111 <wpd-save-status phase="failed"></wpd-save-status>
7112 <wpd-save-status mode="pill" phase="saving"></wpd-save-status>
7113 <wpd-save-status mode="pill" phase="saved"></wpd-save-status>
7114 <wpd-save-status mode="pill" phase="failed" error="Network error."></wpd-save-status>
7115 </wpd-cluster>
7116 `
7117 };
7118 let WpdSaveStatus = _WpdSaveStatus;
7119 defineComponent("wpd-save-status", WpdSaveStatus);
7120 function _iconCheck() {
7121 return html`
7122 <svg
7123 viewBox="0 0 12 12"
7124 aria-hidden="true"
7125 focusable="false"
7126 fill="none"
7127 stroke="currentColor"
7128 stroke-width="2"
7129 stroke-linecap="round"
7130 stroke-linejoin="round"
7131 >
7132 <path d="M2.5 6 L5 8.5 L9.5 4" />
7133 </svg>
7134 `;
7135 }
7136 function _iconBang() {
7137 return html`
7138 <svg
7139 viewBox="0 0 12 12"
7140 aria-hidden="true"
7141 focusable="false"
7142 fill="currentColor"
7143 >
7144 <path
7145 d="M5 2 H7 V7 H5 z M5 8.5 H7 V10.5 H5 z"
7146 />
7147 </svg>
7148 `;
7149 }
7150 const styles$1 = css`:host{display:inline-block;--wpd-spinner-color:var( --wp-admin-theme-color,#21759b );--wpd-spinner-accent:#fff;--wpd-spinner-size:48px;width:var( --wpd-spinner-size );height:var( --wpd-spinner-size );color:var( --wpd-spinner-color );vertical-align:middle;line-height:0}:host( [ hidden ] ){display:none}.root,.root svg{display:block;width:100%;height:100%}.root svg .mark{fill:var( --wpd-spinner-accent,#fff )}@keyframes wpd-spinner-spin{to{transform:rotate( 360deg )}}@keyframes wpd-spinner-scale{0%,100%{transform:scale( 1 )}50%{transform:scale( 1.045 )}}@keyframes wpd-spinner-opacity{0%,100%{opacity:1}50%{opacity:0.7}}@media ( prefers-reduced-motion:reduce ){.root svg [ style*='animation' ]{animation:none !important}}`;
7151 const WPD_SPINNER_PRESETS = Object.freeze({
7152 classic: {
7153 sp1: 12,
7154 sp2: 24,
7155 sp3: 40,
7156 a1: 28,
7157 a2: 15,
7158 a3: 8,
7159 gap: 4,
7160 dir2: 1,
7161 dir3: -1,
7162 pulse: "none",
7163 dots: 0
7164 },
7165 comet: {
7166 sp1: 8,
7167 sp2: 14,
7168 sp3: 26,
7169 a1: 50,
7170 a2: 28,
7171 a3: 12,
7172 gap: 3,
7173 dir2: 1,
7174 dir3: 1,
7175 pulse: "none",
7176 dots: 5
7177 },
7178 orbit: {
7179 sp1: 10,
7180 sp2: 10,
7181 sp3: 32,
7182 a1: 50,
7183 a2: 50,
7184 a3: 8,
7185 gap: 5,
7186 dir2: -1,
7187 dir3: -1,
7188 pulse: "opacity",
7189 dots: 3
7190 },
7191 pulse: {
7192 sp1: 6,
7193 sp2: 18,
7194 sp3: 30,
7195 a1: 20,
7196 a2: 12,
7197 a3: 6,
7198 gap: 4,
7199 dir2: 1,
7200 dir3: -1,
7201 pulse: "both",
7202 dots: 8
7203 }
7204 });
7205 const CX = 61.26;
7206 const CY = 61.26;
7207 const DISC_R = 58.453;
7208 const W_PATHS = '<path d="m8.708 61.26c0 20.802 12.089 38.779 29.619 47.298l-25.069-68.686c-2.916 6.536-4.55 13.769-4.55 21.388z"/><path d="m96.74 58.608c0-6.495-2.333-10.993-4.334-14.494-2.664-4.329-5.161-7.995-5.161-12.324 0-4.831 3.664-9.328 8.825-9.328.233 0 .454.029.681.042-9.35-8.566-21.807-13.796-35.489-13.796-18.36 0-34.513 9.42-43.91 23.688 1.233.037 2.395.063 3.382.063 5.497 0 14.006-.667 14.006-.667 2.833-.167 3.167 3.994.337 4.329 0 0-2.847.335-6.015.501l19.138 56.925 11.501-34.493-8.188-22.434c-2.83-.166-5.511-.501-5.511-.501-2.832-.166-2.5-4.496.332-4.329 0 0 8.679.667 13.843.667 5.496 0 14.006-.667 14.006-.667 2.835-.167 3.168 3.994.337 4.329 0 0-2.853.335-6.015.501l18.992 56.494 5.242-17.517c2.272-7.269 4.001-12.49 4.001-16.989z"/><path d="m62.184 65.857-15.768 45.819c4.708 1.384 9.687 2.141 14.846 2.141 6.12 0 11.989-1.058 17.452-2.979-.141-.225-.269-.464-.374-.724z"/><path d="m107.376 36.046c.226 1.674.354 3.471.354 5.404 0 5.333-.996 11.328-3.996 18.824l-16.053 46.413c15.624-9.111 26.133-26.038 26.133-45.426.001-9.137-2.333-17.729-6.438-25.215z"/>';
7209 const _WpdSpinner = class _WpdSpinner extends Component {
7210 constructor() {
7211 super(...arguments);
7212 this._paintScheduled = false;
7213 }
7214 connectedCallback() {
7215 super.connectedCallback();
7216 this._schedulePaint();
7217 }
7218 render() {
7219 return html`<div class="root" part="root"></div>`;
7220 }
7221 requestUpdate() {
7222 super.requestUpdate();
7223 this._schedulePaint();
7224 }
7225 _schedulePaint() {
7226 if (this._paintScheduled || !this.isConnected) {
7227 return;
7228 }
7229 this._paintScheduled = true;
7230 queueMicrotask(() => {
7231 this._paintScheduled = false;
7232 if (!this.isConnected) {
7233 return;
7234 }
7235 this._paint();
7236 });
7237 }
7238 _paint() {
7239 this._syncCssVars();
7240 const root = this.shadowRoot?.querySelector(
7241 ".root"
7242 );
7243 if (!root) {
7244 return;
7245 }
7246 root.innerHTML = this._buildSvg();
7247 }
7248 /**
7249 * Reflect the color / accent / size attributes onto CSS custom
7250 * properties on the host. Removing the attribute clears the var
7251 * so the default cascades back in.
7252 */
7253 _syncCssVars() {
7254 const sync = (attr, varName, transform) => {
7255 const v = this.getAttribute(attr);
7256 if (v === null) {
7257 this.style.removeProperty(varName);
7258 } else {
7259 this.style.setProperty(
7260 varName,
7261 transform ? transform(v) : v
7262 );
7263 }
7264 };
7265 sync("color", "--wpd-spinner-color");
7266 sync("accent", "--wpd-spinner-accent");
7267 sync(
7268 "size",
7269 "--wpd-spinner-size",
7270 (v) => /^-?\d+(\.\d+)?$/.test(v.trim()) ? `${v}px` : v
7271 );
7272 }
7273 _effectiveConfig() {
7274 const presetName = this.getAttribute("preset") ?? "classic";
7275 const preset = WPD_SPINNER_PRESETS[presetName] ?? WPD_SPINNER_PRESETS.classic;
7276 const num = (attr, fallback) => {
7277 const v = this.getAttribute(attr);
7278 if (v === null) {
7279 return fallback;
7280 }
7281 const n = parseFloat(v);
7282 return Number.isFinite(n) ? n : fallback;
7283 };
7284 const dir = (attr, fallback) => {
7285 const v = this.getAttribute(attr);
7286 if (v === null) {
7287 return fallback;
7288 }
7289 const lc = v.toLowerCase();
7290 if (lc === "-1" || lc === "ccw" || lc === "reverse") {
7291 return -1;
7292 }
7293 return 1;
7294 };
7295 const pulse = () => {
7296 const v = this.getAttribute("pulse");
7297 if (v === "scale" || v === "opacity" || v === "both" || v === "none") {
7298 return v;
7299 }
7300 return preset.pulse;
7301 };
7302 return {
7303 sp1: num("sp1", preset.sp1),
7304 sp2: num("sp2", preset.sp2),
7305 sp3: num("sp3", preset.sp3),
7306 a1: num("a1", preset.a1),
7307 a2: num("a2", preset.a2),
7308 a3: num("a3", preset.a3),
7309 gap: num("gap", preset.gap),
7310 dir2: dir("dir2", preset.dir2),
7311 dir3: dir("dir3", preset.dir3),
7312 pulse: pulse(),
7313 dots: Math.max(0, Math.floor(num("dots", preset.dots)))
7314 };
7315 }
7316 _buildSvg() {
7317 const cfg = this._effectiveConfig();
7318 const label = escAttr(this.getAttribute("label") ?? "Loading");
7319 const pad = cfg.gap * 3 + 14;
7320 const vbMin = -pad;
7321 const vbSize = 122.52 + pad * 2;
7322 const r1 = DISC_R + cfg.gap + 2;
7323 const r2 = r1 + cfg.gap + 2;
7324 const r3 = r2 + cfg.gap + 1.5;
7325 const ring1Anim = `animation: wpd-spinner-spin ${(cfg.sp1 / 10).toFixed(2)}s linear infinite`;
7326 const ring2Anim = `animation: wpd-spinner-spin ${(cfg.sp2 / 10).toFixed(2)}s linear infinite${cfg.dir2 < 0 ? " reverse" : ""}`;
7327 const ring3Anim = `animation: wpd-spinner-spin ${(cfg.sp3 / 10).toFixed(2)}s linear infinite${cfg.dir3 < 0 ? " reverse" : ""}`;
7328 const pspd = (cfg.sp1 * 1.8 / 10).toFixed(1);
7329 const ospd = (cfg.sp1 * 2.3 / 10).toFixed(1);
7330 let pulseStyle = "";
7331 if (cfg.pulse === "scale") {
7332 pulseStyle = `animation: wpd-spinner-scale ${pspd}s ease-in-out infinite`;
7333 } else if (cfg.pulse === "opacity") {
7334 pulseStyle = `animation: wpd-spinner-opacity ${ospd}s ease-in-out infinite`;
7335 } else if (cfg.pulse === "both") {
7336 pulseStyle = `animation: wpd-spinner-scale ${pspd}s ease-in-out infinite, wpd-spinner-opacity ${ospd}s ease-in-out infinite`;
7337 }
7338 let dotEls = "";
7339 if (cfg.dots > 0) {
7340 const dr = r3 + cfg.gap + 1;
7341 const dc2 = 2 * Math.PI * dr;
7342 const dsz = 1.6;
7343 const dotDur = (cfg.sp1 * 0.65 / 10).toFixed(2);
7344 for (let i = 0; i < cfg.dots; i++) {
7345 const offset = -(i / cfg.dots) * dc2;
7346 dotEls += `<circle cx="${CX}" cy="${CY}" r="${dr.toFixed(2)}" fill="none" stroke="currentColor" stroke-width="${dsz}" stroke-dasharray="${dsz.toFixed(2)} ${(dc2 - dsz).toFixed(2)}" stroke-dashoffset="${offset.toFixed(2)}" stroke-linecap="round" stroke-opacity="0.65" style="transform-origin:${CX}px ${CY}px;animation: wpd-spinner-spin ${dotDur}s linear infinite"/>`;
7347 }
7348 }
7349 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${vbMin} ${vbMin} ${vbSize} ${vbSize}" role="img" aria-label="${label}"><g style="transform-origin:${CX}px ${CY}px${pulseStyle ? ";" + pulseStyle : ""}"><circle cx="${CX}" cy="${CY}" r="${DISC_R}" fill="currentColor"/><g class="mark">${W_PATHS}</g></g><circle cx="${CX}" cy="${CY}" r="${r1.toFixed(2)}" fill="none" stroke="currentColor" stroke-width="0.6" stroke-opacity="0.2"/><circle cx="${CX}" cy="${CY}" r="${r1.toFixed(2)}" fill="none" stroke="currentColor" stroke-width="2.2" stroke-dasharray="${dasharray(r1, cfg.a1)}" stroke-linecap="round" style="transform-origin:${CX}px ${CY}px;${ring1Anim}"/><circle cx="${CX}" cy="${CY}" r="${r2.toFixed(2)}" fill="none" stroke="currentColor" stroke-width="0.5" stroke-opacity="0.15"/><circle cx="${CX}" cy="${CY}" r="${r2.toFixed(2)}" fill="none" stroke="currentColor" stroke-width="1.6" stroke-opacity="0.8" stroke-dasharray="${dasharray(r2, cfg.a2)}" stroke-linecap="round" style="transform-origin:${CX}px ${CY}px;${ring2Anim}"/><circle cx="${CX}" cy="${CY}" r="${r3.toFixed(2)}" fill="none" stroke="currentColor" stroke-width="0.4" stroke-opacity="0.12"/><circle cx="${CX}" cy="${CY}" r="${r3.toFixed(2)}" fill="none" stroke="currentColor" stroke-width="1.0" stroke-opacity="0.6" stroke-dasharray="${dasharray(r3, cfg.a3)}" stroke-linecap="round" style="transform-origin:${CX}px ${CY}px;${ring3Anim}"/>` + dotEls + `</svg>`;
7350 }
7351 };
7352 _WpdSpinner.props = [
7353 "preset",
7354 "size",
7355 "color",
7356 "accent",
7357 "sp1",
7358 "sp2",
7359 "sp3",
7360 "a1",
7361 "a2",
7362 "a3",
7363 "gap",
7364 "dir2",
7365 "dir3",
7366 "pulse",
7367 "dots",
7368 "label"
7369 ];
7370 _WpdSpinner.styles = [styles$1];
7371 _WpdSpinner.help = {
7372 title: "Spinner",
7373 summary: "Animated WordPress-mark loading indicator with four curated presets and full per-attribute overrides. CSS variables drive disc + accent colors and size; reduced-motion preferences are respected.",
7374 status: "experimental",
7375 since: "0.6.0",
7376 props: [
7377 {
7378 name: "preset",
7379 type: '"classic" | "comet" | "orbit" | "pulse"',
7380 default: "classic",
7381 description: "Visual personality. Every other attribute defaults to the preset's value and can be overridden individually."
7382 },
7383 {
7384 name: "size",
7385 type: "integer (px) or CSS length",
7386 default: "48",
7387 description: "Sets `--wpd-spinner-size`. Bare numbers are treated as px; pass a CSS length (e.g. `2em`) to opt into ems / rems."
7388 },
7389 {
7390 name: "color",
7391 type: "CSS color",
7392 description: "Disc + ring + dot color. Sets `--wpd-spinner-color`. Default inherits the WP admin theme color."
7393 },
7394 {
7395 name: "accent",
7396 type: "CSS color",
7397 default: "#fff",
7398 description: "Color of the W mark inside the disc. Sets `--wpd-spinner-accent`. Default white — change for dark-on-light or themed marks."
7399 },
7400 {
7401 name: "sp1, sp2, sp3",
7402 type: "integer (deciseconds)",
7403 description: "Per-ring rotation duration in tenths-of-a-second (12 → 1.2s). Higher = slower."
7404 },
7405 {
7406 name: "a1, a2, a3",
7407 type: "integer (0-100)",
7408 description: "Per-ring arc length as a percentage of the ring circumference."
7409 },
7410 {
7411 name: "gap",
7412 type: "integer",
7413 description: "Gap between concentric rings (units approximate to px at 120-viewport)."
7414 },
7415 {
7416 name: "dir2, dir3",
7417 type: '"1" | "-1" | "cw" | "ccw"',
7418 description: "Per-ring direction; ring 1 is always clockwise."
7419 },
7420 {
7421 name: "pulse",
7422 type: '"none" | "scale" | "opacity" | "both"',
7423 description: "Pulse animation applied to the disc + W mark."
7424 },
7425 {
7426 name: "dots",
7427 type: "integer",
7428 description: "Outer trailing dot count. Sensible values: 0, 3, 5, 8."
7429 },
7430 {
7431 name: "label",
7432 type: "string",
7433 default: "Loading",
7434 description: 'Accessible name for the SVG (`role="img"` + `aria-label`).'
7435 }
7436 ],
7437 cssProps: [
7438 { name: "--wpd-spinner-color", default: "var(--wp-admin-theme-color, #21759b)" },
7439 { name: "--wpd-spinner-accent", default: "#fff" },
7440 { name: "--wpd-spinner-size", default: "48px" }
7441 ],
7442 example: html`<wpd-spinner preset="comet" size="80"></wpd-spinner>`
7443 };
7444 let WpdSpinner = _WpdSpinner;
7445 function dasharray(r, pct) {
7446 const c = 2 * Math.PI * r;
7447 const visible = pct / 100 * c;
7448 const gap = c - visible;
7449 return `${visible.toFixed(2)} ${gap.toFixed(2)}`;
7450 }
7451 function escAttr(s) {
7452 return String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
7453 }
7454 defineComponent("wpd-spinner", WpdSpinner);
7455 const menuStyles = css`:host{display:block;min-width:220px;padding:4px;background:var( --desktop-mode-window-bg,#fff );color:var( --desktop-mode-text,#1d2327 );border:1px solid var( --desktop-mode-window-border,#c3c4c7 );border-radius:8px;box-shadow:0 8px 24px rgba( 0,0,0,0.18 ),0 2px 6px rgba( 0,0,0,0.08 )}:host( [ hidden ] ){display:none}`;
7456 const menuItemStyles = css`:host{display:block}button{display:flex;align-items:center;gap:10px;width:100%;min-height:32px;padding:6px 10px;border:none;border-radius:6px;background:transparent;color:inherit;font:inherit;font-size:13px;line-height:1.3;text-align:start;cursor:pointer;transition:background-color 0.12s ease,color 0.12s ease}button:hover,button:focus-visible{background:rgba( 0,0,0,0.06 );color:#000;outline:none}button:focus-visible{outline:2px solid var( --wp-admin-theme-color,#2271b1 );outline-offset:-2px}.wpd-menu-item__icon{flex-shrink:0;width:18px;height:18px;font-size:18px;line-height:1;color:var( --wp-admin-theme-color,#2271b1 )}.wpd-menu-item__icon[ hidden ]{display:none}.wpd-menu-item__label{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.wpd-menu-item__check{flex-shrink:0;width:16px;height:16px;border-radius:3px;border:1.5px solid rgba( 0,0,0,0.25 );position:relative;background:transparent;transition:background-color 0.12s ease,border-color 0.12s ease}.wpd-menu-item__check[ hidden ]{display:none}:host( [ checked ] ) .wpd-menu-item__check{background:var( --wp-admin-theme-color,#2271b1 );border-color:var( --wp-admin-theme-color,#2271b1 )}:host( [ checked ] ) .wpd-menu-item__check::after{content:'';position:absolute;top:1px;left:4px;width:4px;height:8px;border:solid #fff;border-width:0 2px 2px 0;transform:rotate( 45deg )}`;
7457 const _WpdMenu = class _WpdMenu extends Component {
7458 connectedCallback() {
7459 super.connectedCallback();
7460 this.setAttribute("role", "menu");
7461 }
7462 render() {
7463 return html`<slot></slot>`;
7464 }
7465 };
7466 _WpdMenu.styles = [menuStyles];
7467 _WpdMenu.help = {
7468 title: "Menu",
7469 summary: "Popover menu used in window title bars and other overflow triggers. Presentation-only: the consumer owns open/close state via the `hidden` attribute and any outside-click dismissal.",
7470 status: "stable",
7471 since: "0.9.0",
7472 slots: [
7473 { name: "(default)", description: "<wpd-menu-item> children." }
7474 ],
7475 cssProps: [
7476 { name: "--desktop-mode-window-bg", description: "Menu background." },
7477 { name: "--desktop-mode-window-border", description: "Menu border." },
7478 { name: "--desktop-mode-text", description: "Item text colour." }
7479 ],
7480 example: html`
7481 <wpd-menu>
7482 <wpd-menu-item value="new" icon="dashicons-plus">Open another window</wpd-menu-item>
7483 <wpd-menu-item value="startup" role="menuitemcheckbox" checked>Open on startup</wpd-menu-item>
7484 <wpd-menu-item value="close">Close window</wpd-menu-item>
7485 </wpd-menu>
7486 `
7487 };
7488 let WpdMenu = _WpdMenu;
7489 defineComponent("wpd-menu", WpdMenu);
7490 const _WpdMenuItem = class _WpdMenuItem extends Component {
7491 connectedCallback() {
7492 super.connectedCallback();
7493 if (!this.hasAttribute("role")) {
7494 this.setAttribute("role", "menuitem");
7495 }
7496 }
7497 render() {
7498 const icon = this.icon || "";
7499 const isCheckbox = this.getAttribute("role") === "menuitemcheckbox";
7500 const checked = this.checked !== null;
7501 if (isCheckbox) {
7502 this.setAttribute("aria-checked", checked ? "true" : "false");
7503 }
7504 return html`
7505 <button type="button" @click=${(e) => this._onPick(e)}>
7506 <span
7507 class="wpd-menu-item__check"
7508 ?hidden=${!isCheckbox}
7509 ></span>
7510 <span
7511 class="wpd-menu-item__icon dashicons ${icon}"
7512 aria-hidden="true"
7513 ?hidden=${isCheckbox || !icon}
7514 ></span>
7515 <span class="wpd-menu-item__label">
7516 <slot></slot>
7517 </span>
7518 </button>
7519 `;
7520 }
7521 _onPick(e) {
7522 e.preventDefault();
7523 this.emit("wpd-menu-item-click", {
7524 value: this.value
7525 });
7526 }
7527 };
7528 _WpdMenuItem.props = ["icon", "value", "checked"];
7529 _WpdMenuItem.styles = [menuItemStyles];
7530 _WpdMenuItem.help = {
7531 title: "Menu item",
7532 summary: 'Single row inside a <wpd-menu>. Supports three looks: plain label, left-aligned dashicon (icon="dashicons-…"), or a checkbox indicator (role="menuitemcheckbox" + checked).',
7533 status: "stable",
7534 since: "0.9.0",
7535 props: [
7536 {
7537 name: "icon",
7538 type: "string (dashicons class)",
7539 description: 'Dashicons class rendered on the left. Ignored when role="menuitemcheckbox".'
7540 },
7541 {
7542 name: "value",
7543 type: "string",
7544 description: "Identifier emitted in wpd-menu-item-click.detail.value."
7545 },
7546 {
7547 name: "checked",
7548 type: "boolean attribute",
7549 description: 'Visible check indicator. Only honoured when role="menuitemcheckbox".'
7550 }
7551 ],
7552 slots: [
7553 { name: "(default)", description: "Menu item label." }
7554 ],
7555 events: [
7556 {
7557 name: "wpd-menu-item-click",
7558 description: "Fires when the item is clicked; bubbles so the <wpd-menu> parent can delegate.",
7559 detail: "{ value: string | null }"
7560 }
7561 ]
7562 };
7563 let WpdMenuItem = _WpdMenuItem;
7564 defineComponent("wpd-menu-item", WpdMenuItem);
7565 const styles = css`:host{display:inline-flex}button{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:none;border-radius:4px;background:transparent;color:rgba( 0,0,0,0.45 );cursor:pointer;transition:background-color 0.15s ease,color 0.15s ease,transform 0.12s ease}:host( [ variant='detach' ] ) button:hover{color:var( --wp-admin-theme-color,#2271b1 );background:rgba( 34,113,177,0.12 );transform:translateY( -1px )}:host( [ variant='detach' ] ) button:focus-visible{outline:2px solid var( --wp-admin-theme-color,#2271b1 );outline-offset:1px}:host( [ variant='close' ] ) button:hover{color:#fff;background:#d63638}:host( [ variant='close' ] ) button:focus-visible{color:#fff;background:#d63638;outline:2px solid rgba( 214,54,56,0.6 );outline-offset:1px}svg{display:block;pointer-events:none;width:12px;height:12px}@media ( prefers-reduced-motion:reduce ){button{transition-duration:0.01ms}:host( [ variant='detach' ] ) button:hover{transform:none}}`;
7566 const ICONS = {
7567 detach: '<path d="M5 2H2.5v7.5H10V7M6.5 2H10v3.5M10 2L5.5 6.5" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
7568 close: '<path d="M2.5 2.5l7 7M9.5 2.5l-7 7" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>'
7569 };
7570 const _WpdTabChip = class _WpdTabChip extends Component {
7571 render() {
7572 const variant = this.variant || "";
7573 const svgInner = ICONS[variant] || "";
7574 return html`
7575 <button type="button">
7576 <svg
7577 viewBox="0 0 12 12"
7578 aria-hidden="true"
7579 focusable="false"
7580 ></svg>
7581 <slot></slot>
7582 </button>
7583 <span data-svg-buffer style="display:none">${svgInner}</span>
7584 `;
7585 }
7586 connectedCallback() {
7587 super.connectedCallback();
7588 queueMicrotask(() => this._paintSvg());
7589 }
7590 attributeChangedCallback(name, oldValue, newValue) {
7591 super.attributeChangedCallback(name, oldValue, newValue);
7592 queueMicrotask(() => this._paintSvg());
7593 }
7594 _paintSvg() {
7595 const root = this.shadowRoot;
7596 if (!root) {
7597 return;
7598 }
7599 const svg = root.querySelector("svg");
7600 const buffer = root.querySelector("[data-svg-buffer]");
7601 if (svg && buffer) {
7602 const markup = buffer.textContent || "";
7603 if (svg.innerHTML !== markup) {
7604 svg.innerHTML = markup;
7605 }
7606 }
7607 }
7608 };
7609 _WpdTabChip.props = ["variant"];
7610 _WpdTabChip.styles = [styles];
7611 _WpdTabChip.help = {
7612 title: "Tab chip",
7613 summary: "Small action button dropped inside an external sub-tab. `detach` lifts with an accent wash on hover; `close` uses a red destructive wash. Click bubbles as a native click — consumers read `variant` if they need to distinguish.",
7614 status: "stable",
7615 since: "0.9.0",
7616 props: [
7617 {
7618 name: "variant",
7619 type: "'detach' | 'close'",
7620 description: "Selects the built-in SVG icon and the hover wash colour."
7621 }
7622 ],
7623 slots: [
7624 { name: "(default)", description: "Optional custom icon markup when `variant` is omitted." }
7625 ],
7626 example: html`
7627 <wpd-cluster gap="4">
7628 <wpd-tab-chip variant="detach"></wpd-tab-chip>
7629 <wpd-tab-chip variant="close"></wpd-tab-chip>
7630 </wpd-cluster>
7631 `
7632 };
7633 let WpdTabChip = _WpdTabChip;
7634 defineComponent("wpd-tab-chip", WpdTabChip);
7635 const factory = {
7636 createWindow(cfg) {
7637 return new Window(cfg);
7638 }
7639 };
7640 window.desktopModeWindowSystem = factory;
7641 const dialogStyles = css`:host{display:none;position:fixed;inset:0;align-items:center;justify-content:center;background:rgba( 0,0,0,0.45 );backdrop-filter:blur( 2px );z-index:10000}:host( [ open ] ){display:flex}.dialog{width:min( 420px,92vw );background:var( --wpd-confirm-dialog-bg,var( --desktop-mode-bg,#1d2327 ) );color:var( --wpd-confirm-dialog-fg,var( --desktop-mode-fg,#fff ) );border:1px solid rgba( 255,255,255,0.08 );border-radius:10px;box-shadow:0 20px 50px rgba( 0,0,0,0.6 );padding:20px 22px 18px;display:flex;flex-direction:column;gap:10px;position:relative}.close{position:absolute;top:8px;right:10px;width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;background:transparent;border:0;border-radius:6px;color:var( --wpd-confirm-dialog-fg-muted,rgba( 255,255,255,0.7 ) );cursor:pointer;font-size:22px;line-height:1;padding:0}.close:hover{background:rgba( 255,255,255,0.08 );color:inherit}.title{margin:0 0 4px;font-size:16px;font-weight:600}.message{margin:0;color:var( --wpd-confirm-dialog-fg-muted,rgba( 255,255,255,0.7 ) );line-height:1.45;white-space:pre-line}.actions{display:flex;justify-content:flex-end;gap:8px;margin-top:6px}.btn{border:0;border-radius:6px;padding:8px 14px;font-size:13px;cursor:pointer;font-weight:500}.btn--secondary{background:rgba( 255,255,255,0.08 );color:inherit}.btn--secondary:hover{background:rgba( 255,255,255,0.14 )}.btn--primary{background:var( --wp-admin-theme-color,#2271b1 );color:#fff}.btn--primary:hover{filter:brightness( 1.08 )}.btn--danger{background:#d63638;color:#fff}.btn--danger:hover{filter:brightness( 1.08 )}`;
7642 const _WpdConfirmDialog = class _WpdConfirmDialog extends Component {
7643 constructor() {
7644 super(...arguments);
7645 this._onKey = (e) => {
7646 if (e.key === "Escape") {
7647 e.preventDefault();
7648 this._cancel();
7649 }
7650 if (e.key === "Enter" && !e.isComposing) {
7651 e.preventDefault();
7652 this._confirm();
7653 }
7654 };
7655 this._onBackdrop = (e) => {
7656 const path = e.composedPath();
7657 const original = path.length > 0 ? path[0] : e.target;
7658 if (original === this) {
7659 this._cancel();
7660 }
7661 };
7662 this._confirm = () => {
7663 this.emit("wpd-confirm", { confirmed: true });
7664 this.removeAttribute("open");
7665 };
7666 this._cancel = () => {
7667 this.emit("wpd-cancel", { confirmed: false });
7668 this.removeAttribute("open");
7669 };
7670 }
7671 connectedCallback() {
7672 super.connectedCallback();
7673 this.setAttribute("role", "dialog");
7674 this.setAttribute("aria-modal", "true");
7675 this.addEventListener("keydown", this._onKey);
7676 this.addEventListener("click", this._onBackdrop);
7677 }
7678 disconnectedCallback() {
7679 this.removeEventListener("keydown", this._onKey);
7680 this.removeEventListener("click", this._onBackdrop);
7681 }
7682 render() {
7683 const title = this.title ?? "";
7684 const message = this.message ?? "";
7685 const confirmLabel = this["confirm-label"] || "Confirm";
7686 const cancelLabel = this["cancel-label"] || "Cancel";
7687 const isDanger = this.hasAttribute("danger");
7688 const hideCancel = this.hasAttribute("hide-cancel");
7689 const isDismissable = this.hasAttribute("dismissable");
7690 return html`
7691 <div class="dialog" tabindex="-1">
7692 ${isDismissable ? html`<button
7693 type="button"
7694 class="close"
7695 aria-label="Close"
7696 @click=${() => this._cancel()}
7697 >&times;</button>` : html``}
7698 ${title ? html`<h2 class="title">${title}</h2>` : html``}
7699 ${message ? html`<p class="message">${message}</p>` : html``}
7700 <div class="actions">
7701 ${hideCancel ? html`` : html`<button
7702 type="button"
7703 class="btn btn--secondary"
7704 @click=${() => this._cancel()}
7705 >
7706 ${cancelLabel}
7707 </button>`}
7708 <button
7709 type="button"
7710 class="btn ${isDanger ? "btn--danger" : "btn--primary"}"
7711 @click=${() => this._confirm()}
7712 >
7713 ${confirmLabel}
7714 </button>
7715 </div>
7716 </div>
7717 `;
7718 }
7719 };
7720 _WpdConfirmDialog.props = [
7721 "open",
7722 "title",
7723 "message",
7724 "confirm-label",
7725 "cancel-label",
7726 "danger",
7727 "hide-cancel",
7728 "dismissable"
7729 ];
7730 _WpdConfirmDialog.styles = [dialogStyles];
7731 _WpdConfirmDialog.help = {
7732 title: "Confirm dialog",
7733 summary: "Modal Yes/No replacement for window.confirm(). Two consumption paths: declarative element with `open` + `wpd-confirm` event, or the imperative Promise-returning `wpdConfirm()` helper.",
7734 status: "experimental",
7735 since: "0.9.0",
7736 props: [
7737 { name: "open", type: "boolean attribute", description: "Mounts the dialog visible." },
7738 { name: "title", type: "string", description: "Heading shown at the top." },
7739 { name: "message", type: "string", description: "Body copy. Newlines preserved." },
7740 { name: "confirm-label", type: "string", default: "Confirm", description: "Confirm-button label." },
7741 { name: "cancel-label", type: "string", default: "Cancel", description: "Cancel-button label." },
7742 { name: "danger", type: "boolean attribute", description: "Renders the confirm button red." },
7743 { name: "hide-cancel", type: "boolean attribute", description: "Hides the cancel button entirely. Useful when there is no alternative action — pair with `dismissable` so the user still has an explicit way to close." },
7744 { name: "dismissable", type: "boolean attribute", description: "Renders an X close button in the top-right corner. Click emits `wpd-cancel`." }
7745 ],
7746 events: [
7747 {
7748 name: "wpd-confirm",
7749 description: "Fires on confirm. Detail: `{ confirmed: true }`."
7750 },
7751 {
7752 name: "wpd-cancel",
7753 description: "Fires on cancel (Cancel button, Escape, backdrop click). Detail: `{ confirmed: false }`."
7754 }
7755 ]
7756 };
7757 let WpdConfirmDialog = _WpdConfirmDialog;
7758 defineComponent("wpd-confirm-dialog", WpdConfirmDialog);
7759 function wpdConfirm(options) {
7760 return new Promise((resolve) => {
7761 const dialog = document.createElement("wpd-confirm-dialog");
7762 dialog.setAttribute("open", "");
7763 if (options.title) {
7764 dialog.setAttribute("title", options.title);
7765 }
7766 dialog.setAttribute("message", options.message);
7767 if (options.confirmLabel) {
7768 dialog.setAttribute("confirm-label", options.confirmLabel);
7769 }
7770 if (options.cancelLabel) {
7771 dialog.setAttribute("cancel-label", options.cancelLabel);
7772 }
7773 if (options.danger) {
7774 dialog.setAttribute("danger", "");
7775 }
7776 if (options.hideCancel) {
7777 dialog.setAttribute("hide-cancel", "");
7778 }
7779 if (options.dismissable) {
7780 dialog.setAttribute("dismissable", "");
7781 }
7782 const cleanup = (ok) => {
7783 dialog.remove();
7784 resolve(ok);
7785 };
7786 dialog.addEventListener("wpd-confirm", () => cleanup(true));
7787 dialog.addEventListener("wpd-cancel", () => cleanup(false));
7788 document.body.appendChild(dialog);
7789 const inner = dialog.shadowRoot?.querySelector(".dialog");
7790 (inner ?? dialog).focus?.();
7791 });
7792 }
7793 const wpdConfirmDialog = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
7794 __proto__: null,
7795 WpdConfirmDialog,
7796 wpdConfirm
7797 }, Symbol.toStringTag, { value: "Module" }));
7798 })();
7799