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

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