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

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