PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.0
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.0
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.0, at assets/js/window-system.js

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