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

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