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

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