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

6,992 lines 250.0 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 * Set the activity indicator's phase explicitly. Most callers
4838 * should prefer {@link trackActivity} (or `wp.desktop.fetch()`
4839 * which calls it internally) — this is the escape hatch for code
4840 * paths that aren't a single Promise (event-listener-driven
4841 * loaders, Heartbeat polls, manual save buttons that want to
4842 * pulse "Saved" without a wrapped fetch).
4843 *
4844 * Phases:
4845 *
4846 * - `idle` — clear. Indicator fades out.
4847 * - `pending` / `saving` — modem-blink while a request is in flight.
4848 * - `saved` — green flash; auto-clears to `idle` after ~2.2s.
4849 * - `failed` — red dot with `opts.error` as tooltip text;
4850 * auto-clears after ~6s.
4851 *
4852 * Idempotent: setting the same phase twice is a no-op except for
4853 * resetting the auto-clear timer.
4854 *
4855 * @since 0.8.0
4856 */
4857 markActivity(phase, opts = {}) {
4858 this._activityPhase = phase;
4859 this._activityError = opts.error ?? null;
4860 this._paintActivityIndicator();
4861 }
4862 /**
4863 * Track a Promise's lifecycle on this window's activity indicator.
4864 * The dot pulses while the Promise is in flight; on resolve it
4865 * settles to `saved` (green flash); on reject it shows `failed`
4866 * (red, error message tooltip). Returns the Promise unchanged so
4867 * callers can chain.
4868 *
4869 * Multiple concurrent calls are reference-counted: the dot stays
4870 * lit until the LAST tracked Promise settles. The terminal phase
4871 * (`saved` vs `failed`) reflects the LAST settled outcome, so a
4872 * burst of 5 successful fetches followed by 1 error reads
4873 * "failed", which is the right signal — surface the bad news.
4874 *
4875 * Use `wp.desktop.fetch()` for HTTP requests; reach for this
4876 * directly when you have a Promise from a different source
4877 * (postMessage handshake, IndexedDB transaction, …).
4878 *
4879 * @since 0.8.0
4880 */
4881 trackActivity(promise) {
4882 this._markActivityStart();
4883 return promise.then(
4884 (value) => {
4885 this._markActivitySettled(true);
4886 return value;
4887 },
4888 (err) => {
4889 const message = err instanceof Error ? err.message : String(err);
4890 this._markActivitySettled(false, message);
4891 throw err;
4892 }
4893 );
4894 }
4895 /**
4896 * Increment the in-flight counter and paint.
4897 *
4898 * @internal
4899 */
4900 _markActivityStart() {
4901 this._activityCount++;
4902 if (this._activitySettleTimer !== null) {
4903 window.clearTimeout(this._activitySettleTimer);
4904 this._activitySettleTimer = null;
4905 }
4906 if (this._activityCount === 1) {
4907 this._activityPhase = "saving";
4908 this._activityError = null;
4909 this._activitySavingStartedAt = Date.now();
4910 this._paintActivityIndicator();
4911 }
4912 }
4913 /**
4914 * Decrement the in-flight counter and, when it hits zero,
4915 * transition to `saved` or `failed`. Schedules an auto-clear
4916 * back to `idle`.
4917 *
4918 * Honours `MIN_SAVING_DISPLAY_MS` — when a fetch settles before
4919 * the minimum has elapsed, the transition is deferred so the
4920 * modem-blink animation has time to register visually. Concurrent
4921 * activity that re-starts during the deferral cancels it.
4922 *
4923 * @internal
4924 */
4925 _markActivitySettled(ok, error) {
4926 if (this._activityCount > 0) {
4927 this._activityCount--;
4928 }
4929 if (this._activityCount > 0) {
4930 if (!ok && error) {
4931 this._activityError = error;
4932 }
4933 return;
4934 }
4935 const elapsed = Date.now() - this._activitySavingStartedAt;
4936 const remaining = _Window.MIN_SAVING_DISPLAY_MS - elapsed;
4937 if (remaining > 0) {
4938 if (this._activitySettleTimer !== null) {
4939 window.clearTimeout(this._activitySettleTimer);
4940 }
4941 this._activitySettleTimer = window.setTimeout(() => {
4942 this._activitySettleTimer = null;
4943 this._finalizeActivitySettle(ok, error);
4944 }, remaining);
4945 return;
4946 }
4947 this._finalizeActivitySettle(ok, error);
4948 }
4949 /**
4950 * Apply the terminal `saved` / `failed` phase and schedule the
4951 * fade back to `idle`. Split out of `_markActivitySettled` so
4952 * the deferred-settle path and the immediate path share one
4953 * implementation.
4954 *
4955 * @internal
4956 */
4957 _finalizeActivitySettle(ok, error) {
4958 this._activityPhase = ok && !this._activityError ? "saved" : "failed";
4959 if (!ok && error) {
4960 this._activityError = error;
4961 }
4962 this._paintActivityIndicator();
4963 if (this._activityClearTimer !== null) {
4964 window.clearTimeout(this._activityClearTimer);
4965 this._activityClearTimer = null;
4966 }
4967 if (this._activityPhase === "saved") {
4968 this._activityClearTimer = window.setTimeout(() => {
4969 this._activityClearTimer = null;
4970 this._activityPhase = "idle";
4971 this._activityError = null;
4972 this._paintActivityIndicator();
4973 }, 2200);
4974 }
4975 }
4976 /**
4977 * Push the current activity state onto the title-bar dot.
4978 *
4979 * @internal
4980 */
4981 _paintActivityIndicator() {
4982 if (this._isDestroyed) {
4983 return;
4984 }
4985 const indicator = this._titleBar.querySelector(
4986 "[data-desktop-mode-activity-indicator]"
4987 );
4988 if (!indicator) {
4989 return;
4990 }
4991 indicator.setAttribute("phase", this._activityPhase);
4992 if (this._activityError) {
4993 indicator.setAttribute("error", this._activityError);
4994 } else {
4995 indicator.removeAttribute("error");
4996 }
4997 }
4998 /**
4999 * Toggle a visual highlight on the window. Used by plugins that
5000 * need to point at a window from outside it — e.g. a "connect to"
5001 * dropdown that highlights candidate windows on hover.
5002 *
5003 * - `'preview'` — temporary ring; caller is expected to
5004 * clear on `mouseleave`. Multiple plugins
5005 * can hover-preview without stomping each
5006 * other (last write wins).
5007 * - `'persistent'` — sticky ring; caller is responsible for
5008 * clearing it.
5009 * - `null` / unset — clear all highlight state.
5010 *
5011 * Override the colour per-call via `opts.color`, or globally
5012 * via the `--wp-window-highlight-color` custom property.
5013 *
5014 * @since 0.17.0
5015 */
5016 /**
5017 * Request a visual "attention" signal on this window's tile in
5018 * the dock or taskbar — pulse, shake, or bounce. Used by plugins
5019 * that need to grab the user's eye when the window is closed or
5020 * unfocused (incoming chat message, long task finished, etc.).
5021 *
5022 * Resolution order:
5023 * 1. If a tile exists for this window's id on either rail
5024 * (`wp.desktop.dock` or `wp.desktop.taskbar`), call
5025 * `Dock.setAttention( id, mode, opts )`.
5026 * 2. Otherwise (e.g. `placement: 'none'`) fall back to
5027 * `setHighlight('persistent')` on the window itself, auto-
5028 * cleared after `opts.durationMs`. No-op if the window has
5029 * no rendered chrome.
5030 *
5031 * The mode + opts pass through the `desktop-mode.window.attention`
5032 * filter first so plugins (or a Do-Not-Disturb preference) can
5033 * mute (`return null`) or modify the request.
5034 *
5035 * Animations are gated on `prefers-reduced-motion`; reduced-motion
5036 * users see a static accent ring for the same duration so the
5037 * affordance still works.
5038 *
5039 * @since 0.22.0
5040 */
5041 requestAttention(mode, opts = {}) {
5042 const intent = activity.filter(
5043 "desktop-mode/window-attention-requested",
5044 {
5045 windowId: this.id,
5046 mode,
5047 durationMs: opts.durationMs,
5048 intensity: opts.intensity
5049 },
5050 opts
5051 );
5052 if (!intent || intent.cancel === true) {
5053 return;
5054 }
5055 const intentMode = intent.mode ?? mode;
5056 const intentOpts = {
5057 ...opts,
5058 durationMs: typeof intent.durationMs === "number" ? intent.durationMs : opts.durationMs,
5059 intensity: typeof intent.intensity === "string" ? intent.intensity : opts.intensity
5060 };
5061 const filtered = applyFilters(
5062 "desktop-mode.window.attention",
5063 intentMode,
5064 { windowId: this.id, opts: intentOpts }
5065 );
5066 const wp = window.wp;
5067 const dockApi = wp?.desktop?.dock;
5068 const taskbarApi = wp?.desktop?.taskbar;
5069 let routed = false;
5070 if (typeof dockApi?.setAttention === "function") {
5071 dockApi.setAttention(this.id, filtered, intentOpts);
5072 routed = true;
5073 }
5074 if (typeof taskbarApi?.setAttention === "function") {
5075 taskbarApi.setAttention(this.id, filtered, intentOpts);
5076 routed = true;
5077 }
5078 if (!routed && filtered !== null) {
5079 this.setHighlight("persistent");
5080 const duration = intentOpts.durationMs ?? 4e3;
5081 if (duration > 0) {
5082 window.setTimeout(() => {
5083 this.setHighlight(null);
5084 }, duration);
5085 }
5086 } else if (!routed && filtered === null) {
5087 this.setHighlight(null);
5088 }
5089 }
5090 /**
5091 * Briefly jiggle the window element horizontally — the classic
5092 * MSN-Messenger nudge affordance. Plugins can request "look at
5093 * me" attention on their own window programmatically (e.g. a
5094 * chat plugin on inbound nudge, a CI plugin on a broken build).
5095 *
5096 * Composes with the inline `left`/`top` the window manager
5097 * writes (the shake is a CSS `transform`, not a position
5098 * change). Auto-clears the class on `animationend`. If a second
5099 * shake is requested while one is mid-flight, the class is
5100 * removed and re-added so the animation restarts.
5101 *
5102 * Reduced-motion fallback: a static accent ring for the same
5103 * duration. Authors who want a different visual can listen on
5104 * the JS filter `desktop-mode.window.shake` and return falsy to mute.
5105 *
5106 * @since 0.22.11
5107 */
5108 shake() {
5109 const filtered = applyFilters(
5110 "desktop-mode.window.shake",
5111 true,
5112 { windowId: this.id }
5113 );
5114 if (filtered === false) {
5115 return;
5116 }
5117 const el = this.element;
5118 el.classList.remove("desktop-mode-window--shaking");
5119 void el.offsetWidth;
5120 el.classList.add("desktop-mode-window--shaking");
5121 const onEnd = () => {
5122 el.classList.remove("desktop-mode-window--shaking");
5123 el.removeEventListener("animationend", onEnd);
5124 };
5125 el.addEventListener("animationend", onEnd);
5126 }
5127 setHighlight(mode, opts) {
5128 const el = this.element;
5129 if (!el) {
5130 return;
5131 }
5132 el.classList.remove(
5133 "wp-window--highlight-preview",
5134 "wp-window--highlight-persistent"
5135 );
5136 if (mode === "preview") {
5137 el.classList.add("wp-window--highlight-preview");
5138 } else if (mode === "persistent") {
5139 el.classList.add("wp-window--highlight-persistent");
5140 }
5141 if (opts?.color) {
5142 el.style.setProperty("--wp-window-highlight-color", opts.color);
5143 } else if (mode === null) {
5144 el.style.removeProperty("--wp-window-highlight-color");
5145 }
5146 doAction(HOOKS.WINDOW_HIGHLIGHT_CHANGED, {
5147 windowId: this.id,
5148 mode,
5149 color: opts?.color
5150 });
5151 }
5152 /**
5153 * Close and destroy the window.
5154 *
5155 * Plays a subtle closing animation before removing the element.
5156 */
5157 close() {
5158 if (this._isDestroyed) {
5159 return;
5160 }
5161 if (this.config.native && !this._suppressCloseFilter) {
5162 const proceed = applyFilters(
5163 HOOKS.NATIVE_WINDOW_BEFORE_CLOSE,
5164 true,
5165 { windowId: this.id, config: this.config }
5166 );
5167 if (proceed === false) {
5168 return;
5169 }
5170 }
5171 this._isDestroyed = true;
5172 if (this._activityClearTimer !== null) {
5173 window.clearTimeout(this._activityClearTimer);
5174 this._activityClearTimer = null;
5175 }
5176 if (this._activitySettleTimer !== null) {
5177 window.clearTimeout(this._activitySettleTimer);
5178 this._activitySettleTimer = null;
5179 }
5180 if (this._titleBarButtonsUnsubscribe) {
5181 this._titleBarButtonsUnsubscribe();
5182 this._titleBarButtonsUnsubscribe = null;
5183 }
5184 if (this._windowThemesUnsubscribe) {
5185 this._windowThemesUnsubscribe();
5186 this._windowThemesUnsubscribe = null;
5187 }
5188 if (this._windowControlsUnsubscribe) {
5189 this._windowControlsUnsubscribe();
5190 this._windowControlsUnsubscribe = null;
5191 }
5192 if (this._windowSlotsUnsubscribe) {
5193 this._windowSlotsUnsubscribe();
5194 this._windowSlotsUnsubscribe = null;
5195 }
5196 if (this._windowChromesUnsubscribe) {
5197 this._windowChromesUnsubscribe();
5198 this._windowChromesUnsubscribe = null;
5199 }
5200 if (this._nativeRenderCtxDispose) {
5201 try {
5202 this._nativeRenderCtxDispose();
5203 } catch (err) {
5204 doAction(HOOKS.SHELL_ERROR, {
5205 scope: "native-window-ctx-dispose",
5206 id: this.id,
5207 error: err
5208 });
5209 }
5210 this._nativeRenderCtxDispose = null;
5211 }
5212 this._bodyResizeObserver?.disconnect();
5213 this._bodyResizeObserver = null;
5214 clearWindowChannels(this.id);
5215 try {
5216 this.config.onClose?.();
5217 } catch (err) {
5218 doAction(HOOKS.SHELL_ERROR, {
5219 scope: "native-window-close",
5220 id: this.id,
5221 error: err
5222 });
5223 }
5224 this.onClose?.(this);
5225 this.element.classList.add("desktop-mode-window--closing");
5226 this._onCloseTransitionEnd = (e) => {
5227 if (e.propertyName === "opacity") {
5228 this._finalizeClose();
5229 }
5230 };
5231 this.element.addEventListener("transitionend", this._onCloseTransitionEnd);
5232 this._closeSafetyNetTimer = setTimeout(() => this._finalizeClose(), 300);
5233 }
5234 /**
5235 * Synchronously tear down a window with no animation. Use in:
5236 *
5237 * - Test `afterEach` hooks where the suite needs deterministic
5238 * cleanup before the environment unwinds.
5239 * - Plugin deactivation flows where the tile is going away
5240 * immediately and a fade-out would feel wrong.
5241 * - Forced shutdowns that must bypass the
5242 * `NATIVE_WINDOW_BEFORE_CLOSE` veto filter (e.g. the user
5243 * closed a parent that owns this window).
5244 *
5245 * Idempotent: a second `destroy()` call is a no-op once the
5246 * window has finalised. If `close()` had already started the
5247 * animation, `destroy()` cancels the pending timer and runs
5248 * finalise immediately.
5249 *
5250 * @public
5251 * @since 0.8.2
5252 */
5253 destroy() {
5254 if (this._isFinalized) {
5255 return;
5256 }
5257 if (!this._isDestroyed) {
5258 this._suppressCloseFilter = true;
5259 try {
5260 this.close();
5261 } finally {
5262 this._suppressCloseFilter = false;
5263 }
5264 }
5265 this._finalizeClose();
5266 }
5267 /**
5268 * Run the post-animation teardown — the work that used to live
5269 * in `close()`'s inner `onDone` closure. Idempotent via
5270 * `_isFinalized`. Cancels the safety-net timer + the
5271 * `transitionend` listener it might have been racing.
5272 *
5273 * @internal
5274 * @since 0.8.2
5275 */
5276 _finalizeClose() {
5277 if (this._isFinalized) {
5278 return;
5279 }
5280 this._isFinalized = true;
5281 if (this._closeSafetyNetTimer !== null) {
5282 clearTimeout(this._closeSafetyNetTimer);
5283 this._closeSafetyNetTimer = null;
5284 }
5285 if (this._onCloseTransitionEnd) {
5286 this.element.removeEventListener(
5287 "transitionend",
5288 this._onCloseTransitionEnd
5289 );
5290 this._onCloseTransitionEnd = null;
5291 }
5292 if (this._windowControlsTeardown) {
5293 try {
5294 this._windowControlsTeardown();
5295 } catch {
5296 }
5297 this._windowControlsTeardown = null;
5298 }
5299 if (this._windowSlotsTeardown) {
5300 try {
5301 this._windowSlotsTeardown();
5302 } catch {
5303 }
5304 this._windowSlotsTeardown = null;
5305 }
5306 if (this._chromeHandle) {
5307 try {
5308 this._chromeHandle.destroy();
5309 } catch {
5310 }
5311 this._chromeHandle = null;
5312 }
5313 clearWindowTheme(this);
5314 if (this._nativeRenderTeardown) {
5315 try {
5316 this._nativeRenderTeardown();
5317 } catch (err) {
5318 doAction(HOOKS.SHELL_ERROR, {
5319 scope: "native-window-teardown",
5320 id: this.id,
5321 error: err
5322 });
5323 }
5324 this._nativeRenderTeardown = null;
5325 }
5326 window.removeEventListener("message", this._boundOnMessage);
5327 if (this._boundOnDocumentPointerDown) {
5328 document.removeEventListener(
5329 "pointerdown",
5330 this._boundOnDocumentPointerDown,
5331 true
5332 );
5333 }
5334 this.element.remove();
5335 updateFullscreenBodyClass();
5336 }
5337 /**
5338 * Wire up a ResizeObserver on the body element. Fires the
5339 * inline `config.onResize` callback AND the
5340 * `WINDOW_BODY_RESIZED` hook on every size change. Returns the
5341 * observer so `close()` can disconnect it; returns null when
5342 * the body element is missing or the environment has no
5343 * ResizeObserver (jsdom without a shim, older browsers).
5344 */
5345 installBodyResizeObserver() {
5346 const body = this.element.querySelector(
5347 ".desktop-mode-window__body"
5348 );
5349 if (!body) {
5350 return null;
5351 }
5352 if (typeof ResizeObserver === "undefined") {
5353 return null;
5354 }
5355 const observer = new ResizeObserver((entries) => {
5356 const entry = entries[0];
5357 if (!entry) {
5358 return;
5359 }
5360 const cr = entry.contentRect;
5361 const width = Math.round(cr.width);
5362 const height = Math.round(cr.height);
5363 try {
5364 this.config.onResize?.(width, height);
5365 } catch (err) {
5366 doAction(HOOKS.SHELL_ERROR, {
5367 scope: "native-window-resize",
5368 id: this.id,
5369 error: err
5370 });
5371 }
5372 doAction(HOOKS.WINDOW_BODY_RESIZED, {
5373 windowId: this.id,
5374 width,
5375 height
5376 });
5377 });
5378 observer.observe(body);
5379 return observer;
5380 }
5381 /** Get a snapshot of the window state for persistence. */
5382 getSnapshot() {
5383 const isHidden = this.element.offsetParent === null;
5384 if (isHidden) {
5385 const parse = (raw) => {
5386 const n = parseFloat(raw);
5387 return Number.isFinite(n) ? Math.round(n) : 0;
5388 };
5389 return {
5390 id: this.id,
5391 x: parse(this.element.style.left),
5392 y: parse(this.element.style.top),
5393 width: parse(this.element.style.width),
5394 height: parse(this.element.style.height),
5395 state: this.state
5396 };
5397 }
5398 return {
5399 id: this.id,
5400 x: this.element.offsetLeft,
5401 y: this.element.offsetTop,
5402 width: this.element.offsetWidth,
5403 height: this.element.offsetHeight,
5404 state: this.state
5405 };
5406 }
5407 /** Number of external sub-tabs currently open on this window. */
5408 getExternalTabCount() {
5409 return externalTabCount(this);
5410 }
5411 /** Serializable snapshot of this window's external sub-tabs. */
5412 getExternalTabsSnapshot() {
5413 return externalTabsSnapshot(this);
5414 }
5415 /**
5416 * Toggle the actions menu from an external caller (e.g., keyboard
5417 * shortcut). Kept here so the panel-focus + outside-click wiring
5418 * lives in a single place.
5419 */
5420 toggleActionsMenu() {
5421 toggleActionsMenu(this);
5422 }
5423 /** Close the actions menu from an external caller. */
5424 closeActionsMenu() {
5425 closeActionsMenu(this);
5426 }
5427 /** Open the actions menu from an external caller. */
5428 openActionsMenu() {
5429 openActionsMenu(this);
5430 }
5431 };
5432 _Window.MIN_SAVING_DISPLAY_MS = 1200;
5433 let Window = _Window;
5434 function html(strings, ...values) {
5435 return { __wpdHtml: true, strings, values };
5436 }
5437 function isTemplateResult(v) {
5438 return !!v && v.__wpdHtml === true;
5439 }
5440 const MARKER_PREFIX = "$$wpd$$";
5441 const MARKER_RE = /\$\$wpd\$\$(\d+)\$\$/g;
5442 function joinWithMarkers(strings) {
5443 let out = strings[0];
5444 for (let i = 1; i < strings.length; i++) {
5445 out += `${MARKER_PREFIX}${i - 1}$$` + strings[i];
5446 }
5447 return out;
5448 }
5449 const compiledCache = /* @__PURE__ */ new WeakMap();
5450 function compile(strings) {
5451 const cached = compiledCache.get(strings);
5452 if (cached) {
5453 return cached;
5454 }
5455 const template = document.createElement("template");
5456 template.innerHTML = joinWithMarkers(strings);
5457 const recipes = [];
5458 const walk = (node, path) => {
5459 if (node.nodeType === Node.ELEMENT_NODE) {
5460 const el = node;
5461 for (const attr of Array.from(el.attributes)) {
5462 const rawName = attr.name;
5463 const rawValue = attr.value;
5464 const prefix = rawName[0];
5465 if (MARKER_RE.test(rawValue)) {
5466 MARKER_RE.lastIndex = 0;
5467 if (prefix === "@") {
5468 const match = MARKER_RE.exec(rawValue);
5469 MARKER_RE.lastIndex = 0;
5470 recipes.push({
5471 path,
5472 kind: "event",
5473 name: rawName.slice(1),
5474 valueIndex: match ? Number(match[1]) : 0
5475 });
5476 el.removeAttribute(rawName);
5477 } else if (prefix === ".") {
5478 const match = MARKER_RE.exec(rawValue);
5479 MARKER_RE.lastIndex = 0;
5480 recipes.push({
5481 path,
5482 kind: "prop",
5483 name: rawName.slice(1),
5484 valueIndex: match ? Number(match[1]) : 0
5485 });
5486 el.removeAttribute(rawName);
5487 } else if (prefix === "?") {
5488 const match = MARKER_RE.exec(rawValue);
5489 MARKER_RE.lastIndex = 0;
5490 recipes.push({
5491 path,
5492 kind: "bool",
5493 name: rawName.slice(1),
5494 valueIndex: match ? Number(match[1]) : 0
5495 });
5496 el.removeAttribute(rawName);
5497 } else {
5498 const fragments = [];
5499 const indices = [];
5500 let lastEnd = 0;
5501 let m;
5502 MARKER_RE.lastIndex = 0;
5503 while ((m = MARKER_RE.exec(rawValue)) !== null) {
5504 fragments.push(rawValue.slice(lastEnd, m.index));
5505 indices.push(Number(m[1]));
5506 lastEnd = m.index + m[0].length;
5507 }
5508 fragments.push(rawValue.slice(lastEnd));
5509 recipes.push({
5510 path,
5511 kind: "attr",
5512 name: rawName,
5513 template: fragments,
5514 valueIndices: indices
5515 });
5516 el.setAttribute(rawName, "");
5517 }
5518 }
5519 }
5520 }
5521 const children = Array.from(node.childNodes);
5522 let shift = 0;
5523 for (let i = 0; i < children.length; i++) {
5524 const child = children[i];
5525 const liveIndex = i + shift;
5526 if (child.nodeType === Node.TEXT_NODE) {
5527 const text = child.textContent || "";
5528 if (!MARKER_RE.test(text)) {
5529 MARKER_RE.lastIndex = 0;
5530 continue;
5531 }
5532 MARKER_RE.lastIndex = 0;
5533 const parent = child.parentNode;
5534 let lastEnd = 0;
5535 let m;
5536 const newNodes = [];
5537 const newRecipes = [];
5538 MARKER_RE.lastIndex = 0;
5539 while ((m = MARKER_RE.exec(text)) !== null) {
5540 if (m.index > lastEnd) {
5541 newNodes.push(document.createTextNode(text.slice(lastEnd, m.index)));
5542 }
5543 const placeholder = document.createTextNode("");
5544 newNodes.push(placeholder);
5545 newRecipes.push({
5546 path: [...path, liveIndex + newNodes.length - 1],
5547 kind: "node",
5548 valueIndex: Number(m[1])
5549 });
5550 lastEnd = m.index + m[0].length;
5551 }
5552 if (lastEnd < text.length) {
5553 newNodes.push(document.createTextNode(text.slice(lastEnd)));
5554 }
5555 for (const nn of newNodes) {
5556 parent.insertBefore(nn, child);
5557 }
5558 parent.removeChild(child);
5559 shift += newNodes.length - 1;
5560 recipes.push(...newRecipes);
5561 } else {
5562 walk(child, [...path, liveIndex]);
5563 }
5564 }
5565 };
5566 walk(template.content, []);
5567 const buildParts = (fragment) => {
5568 const out = [];
5569 for (const r of recipes) {
5570 let node = fragment;
5571 for (const idx of r.path) {
5572 node = node.childNodes[idx];
5573 }
5574 if (r.kind === "node") {
5575 out.push({
5576 kind: "node",
5577 valueIndex: r.valueIndex,
5578 child: {
5579 anchor: node,
5580 state: null
5581 }
5582 });
5583 } else if (r.kind === "attr") {
5584 out.push({
5585 kind: "attr",
5586 element: node,
5587 name: r.name,
5588 template: r.template,
5589 valueIndices: r.valueIndices
5590 });
5591 } else if (r.kind === "event") {
5592 out.push({
5593 kind: "event",
5594 valueIndex: r.valueIndex,
5595 element: node,
5596 name: r.name
5597 });
5598 } else if (r.kind === "prop") {
5599 out.push({
5600 kind: "prop",
5601 valueIndex: r.valueIndex,
5602 element: node,
5603 name: r.name
5604 });
5605 } else if (r.kind === "bool") {
5606 out.push({
5607 kind: "bool",
5608 valueIndex: r.valueIndex,
5609 element: node,
5610 name: r.name
5611 });
5612 }
5613 }
5614 return out;
5615 };
5616 const entry = { template, buildParts };
5617 compiledCache.set(strings, entry);
5618 return entry;
5619 }
5620 const mountState = /* @__PURE__ */ new WeakMap();
5621 function render(result, container) {
5622 const existing = mountState.get(container);
5623 if (existing && existing.strings === result.strings) {
5624 applyValues(existing.parts, result.values);
5625 return;
5626 }
5627 const compiled = compile(result.strings);
5628 const fragment = compiled.template.content.cloneNode(true);
5629 const parts = compiled.buildParts(fragment);
5630 while (container.firstChild) {
5631 container.removeChild(container.firstChild);
5632 }
5633 container.appendChild(fragment);
5634 applyValues(parts, result.values);
5635 mountState.set(container, { strings: result.strings, parts });
5636 }
5637 function applyValues(parts, values) {
5638 for (const part of parts) {
5639 if (part.kind === "node") {
5640 updateChildPart(part.child, values[part.valueIndex]);
5641 } else if (part.kind === "attr") {
5642 let composed = part.template[0];
5643 for (let i = 0; i < part.valueIndices.length; i++) {
5644 composed += formatText(values[part.valueIndices[i]]);
5645 composed += part.template[i + 1];
5646 }
5647 if (composed !== part.last) {
5648 part.last = composed;
5649 if (composed === "") {
5650 part.element.removeAttribute(part.name);
5651 } else {
5652 part.element.setAttribute(part.name, composed);
5653 }
5654 }
5655 } else if (part.kind === "event") {
5656 const next = values[part.valueIndex];
5657 if (next !== part.current) {
5658 if (part.current) {
5659 part.element.removeEventListener(part.name, part.current);
5660 }
5661 if (next) {
5662 part.element.addEventListener(part.name, next);
5663 }
5664 part.current = next;
5665 }
5666 } else if (part.kind === "prop") {
5667 const next = values[part.valueIndex];
5668 if (next !== part.last) {
5669 part.last = next;
5670 part.element[part.name] = next;
5671 }
5672 } else if (part.kind === "bool") {
5673 const next = !!values[part.valueIndex];
5674 if (next !== part.last) {
5675 part.last = next;
5676 if (next) {
5677 part.element.setAttribute(part.name, "");
5678 } else {
5679 part.element.removeAttribute(part.name);
5680 }
5681 }
5682 }
5683 }
5684 }
5685 function updateChildPart(child, value) {
5686 if (value === null || value === void 0 || value === false) {
5687 if (child.state) {
5688 disposeChildState(child.state);
5689 child.state = null;
5690 }
5691 return;
5692 }
5693 if (Array.isArray(value)) {
5694 updateArrayChild(child, value);
5695 return;
5696 }
5697 if (isTemplateResult(value)) {
5698 updateTemplateChild(child, value);
5699 return;
5700 }
5701 if (value instanceof Node) {
5702 updateNodeChild(child, value);
5703 return;
5704 }
5705 updateTextChild(child, formatText(value));
5706 }
5707 function updateNodeChild(child, node) {
5708 const old = child.state;
5709 if (old?.shape === "node" && old.node === node) {
5710 return;
5711 }
5712 if (old) {
5713 disposeChildState(old);
5714 }
5715 insertBeforeAnchor(child, [node]);
5716 child.state = { shape: "node", node };
5717 }
5718 function updateTextChild(child, text) {
5719 const old = child.state;
5720 if (old?.shape === "text") {
5721 if (old.text !== text) {
5722 old.node.textContent = text;
5723 old.text = text;
5724 }
5725 return;
5726 }
5727 if (old) {
5728 disposeChildState(old);
5729 }
5730 const node = document.createTextNode(text);
5731 insertBeforeAnchor(child, [node]);
5732 child.state = { shape: "text", node, text };
5733 }
5734 function updateTemplateChild(child, result) {
5735 const old = child.state;
5736 if (old?.shape === "template" && old.strings === result.strings) {
5737 applyValues(old.parts, result.values);
5738 return;
5739 }
5740 if (old) {
5741 disposeChildState(old);
5742 }
5743 const compiled = compile(result.strings);
5744 const fragment = compiled.template.content.cloneNode(true);
5745 const parts = compiled.buildParts(fragment);
5746 const topNodes = Array.from(fragment.childNodes);
5747 insertBeforeAnchor(child, [fragment]);
5748 applyValues(parts, result.values);
5749 child.state = {
5750 shape: "template",
5751 strings: result.strings,
5752 parts,
5753 nodes: topNodes
5754 };
5755 }
5756 function updateArrayChild(child, arr) {
5757 const old = child.state;
5758 if (old?.shape === "array" && old.entries.length === arr.length) {
5759 for (let i = 0; i < arr.length; i++) {
5760 updateChildPart(old.entries[i], arr[i]);
5761 }
5762 return;
5763 }
5764 if (old) {
5765 disposeChildState(old);
5766 }
5767 const entries = [];
5768 for (const v of arr) {
5769 const entryAnchor = document.createTextNode("");
5770 insertBeforeAnchor(child, [entryAnchor]);
5771 const entry = { anchor: entryAnchor, state: null };
5772 updateChildPart(entry, v);
5773 entries.push(entry);
5774 }
5775 child.state = { shape: "array", entries };
5776 }
5777 function insertBeforeAnchor(child, nodes) {
5778 const parent = child.anchor.parentNode;
5779 if (!parent) {
5780 return;
5781 }
5782 for (const node of nodes) {
5783 parent.insertBefore(node, child.anchor);
5784 }
5785 }
5786 function disposeChildState(state) {
5787 if (state.shape === "text") {
5788 state.node.remove();
5789 return;
5790 }
5791 if (state.shape === "template") {
5792 for (const node of state.nodes) {
5793 if (node.parentNode) {
5794 node.parentNode.removeChild(node);
5795 }
5796 }
5797 return;
5798 }
5799 if (state.shape === "node") {
5800 if (state.node.parentNode) {
5801 state.node.parentNode.removeChild(state.node);
5802 }
5803 return;
5804 }
5805 for (const entry of state.entries) {
5806 if (entry.state) {
5807 disposeChildState(entry.state);
5808 }
5809 entry.anchor.remove();
5810 }
5811 }
5812 function formatText(v) {
5813 if (v === null || v === void 0 || v === false) {
5814 return "";
5815 }
5816 return String(v);
5817 }
5818 const _Component = class _Component extends HTMLElement {
5819 constructor() {
5820 super();
5821 this._renderScheduled = false;
5822 this._propValues = {};
5823 const ctor = this.constructor;
5824 if (ctor.shadow) {
5825 this.attachShadow({ mode: "open" });
5826 this._renderRoot = this.shadowRoot;
5827 } else {
5828 this._renderRoot = this;
5829 }
5830 this._installPropAccessors();
5831 }
5832 static get observedAttributes() {
5833 return this.props.map(kebab);
5834 }
5835 connectedCallback() {
5836 this._adoptStyles();
5837 this.requestUpdate();
5838 }
5839 attributeChangedCallback(name, oldValue, newValue) {
5840 if (oldValue === newValue) {
5841 return;
5842 }
5843 const prop = camel(name);
5844 this._propValues[prop] = newValue;
5845 this.requestUpdate();
5846 }
5847 /**
5848 * Declarative class-name setter. Assign an array (or a
5849 * space-separated string) and the host's `class` attribute is
5850 * rewritten to match. Intended for programmatic styling — when
5851 * a plugin has enqueued its own stylesheet and wants to apply
5852 * one of those classes to a shell component:
5853 *
5854 * ```js
5855 * element.classNames = [ 'my-plugin-brand', 'is-active' ];
5856 * // → <wpd-select class="my-plugin-brand is-active">
5857 * ```
5858 *
5859 * The plain HTML `class="…"` attribute works just the same and
5860 * is always preferred when writing markup by hand — this setter
5861 * exists for the JS-API case where the caller has an array of
5862 * conditional classes in hand.
5863 *
5864 * Getter returns the current `classList` as a plain array for
5865 * symmetric read/write.
5866 *
5867 * @since 0.13.0
5868 */
5869 get classNames() {
5870 return Array.from(this.classList);
5871 }
5872 set classNames(next) {
5873 if (next === null || next === void 0) {
5874 this.removeAttribute("class");
5875 return;
5876 }
5877 const list = Array.isArray(next) ? next : String(next).split(/\s+/);
5878 const cleaned = list.map((s) => String(s).trim()).filter((s) => s !== "");
5879 this.className = cleaned.join(" ");
5880 }
5881 /**
5882 * Request a re-render explicitly. Components rarely need this —
5883 * declare state via props + attribute observers and the render
5884 * loop picks up changes automatically.
5885 */
5886 requestUpdate() {
5887 this._scheduleRender();
5888 }
5889 /**
5890 * Dispatch a `CustomEvent` with a `detail`. Bubbles + composed
5891 * by default (matches typical WC UX — events cross shadow
5892 * boundaries, parents can listen without knowing about internal
5893 * structure).
5894 */
5895 emit(name, detail) {
5896 return this.dispatchEvent(
5897 new CustomEvent(name, {
5898 detail,
5899 bubbles: true,
5900 composed: true
5901 })
5902 );
5903 }
5904 // ------------------------------------------------------------------
5905 // Internals
5906 // ------------------------------------------------------------------
5907 /**
5908 * Wire every `static props` entry to a matched property getter +
5909 * setter on the element. Setting the property reflects into the
5910 * attribute (so downstream observers + CSS selectors see it);
5911 * reading the property falls back to the attribute.
5912 */
5913 _installPropAccessors() {
5914 const ctor = this.constructor;
5915 for (const prop of ctor.props) {
5916 if (Object.getOwnPropertyDescriptor(this, prop)) {
5917 continue;
5918 }
5919 const attr = kebab(prop);
5920 Object.defineProperty(this, prop, {
5921 get: () => {
5922 if (prop in this._propValues) {
5923 return this._propValues[prop];
5924 }
5925 return this.getAttribute(attr);
5926 },
5927 set: (value) => {
5928 let str;
5929 if (value === null || value === void 0 || value === false) {
5930 str = null;
5931 } else if (value === true) {
5932 str = "";
5933 } else {
5934 str = String(value);
5935 }
5936 this._propValues[prop] = str;
5937 if (str === null) {
5938 this.removeAttribute(attr);
5939 } else {
5940 this.setAttribute(attr, str);
5941 }
5942 this.requestUpdate();
5943 },
5944 enumerable: true,
5945 configurable: true
5946 });
5947 }
5948 }
5949 /**
5950 * Schedule a render on the next microtask. Multiple property
5951 * assignments in the same tick collapse into a single render.
5952 */
5953 _scheduleRender() {
5954 if (this._renderScheduled || !this.isConnected) {
5955 return;
5956 }
5957 this._renderScheduled = true;
5958 queueMicrotask(() => {
5959 this._renderScheduled = false;
5960 if (!this.isConnected) {
5961 return;
5962 }
5963 render(this.render(), this._renderRoot);
5964 });
5965 }
5966 /**
5967 * Mount adoptable stylesheets onto the shadow root (via
5968 * `adoptedStyleSheets`) or the light DOM (via one `<style>`
5969 * tag per def). No-op if `static styles` is empty.
5970 */
5971 _adoptStyles() {
5972 const ctor = this.constructor;
5973 if (ctor.styles.length === 0) {
5974 return;
5975 }
5976 if (ctor.shadow && this.shadowRoot) {
5977 const sheets = ctor.styles.map((s) => s.sheet).filter((s) => s !== null);
5978 this.shadowRoot.adoptedStyleSheets = sheets;
5979 if (sheets.length !== ctor.styles.length) {
5980 for (const s of ctor.styles) {
5981 if (!s.sheet) {
5982 const tag = document.createElement("style");
5983 tag.textContent = s.cssText;
5984 this.shadowRoot.appendChild(tag);
5985 }
5986 }
5987 }
5988 } else {
5989 this._adoptLightStyles(ctor);
5990 }
5991 }
5992 _adoptLightStyles(ctor) {
5993 if (_Component._lightStylesAdopted.has(ctor)) {
5994 return;
5995 }
5996 _Component._lightStylesAdopted.add(ctor);
5997 for (const s of ctor.styles) {
5998 const tag = document.createElement("style");
5999 tag.dataset.wpdUi = this.tagName.toLowerCase();
6000 tag.textContent = s.cssText;
6001 document.head.appendChild(tag);
6002 }
6003 }
6004 };
6005 _Component.props = [];
6006 _Component.styles = [];
6007 _Component.shadow = true;
6008 _Component._lightStylesAdopted = /* @__PURE__ */ new WeakSet();
6009 let Component = _Component;
6010 function defineComponent(tag, ctor) {
6011 if (customElements.get(tag)) {
6012 return;
6013 }
6014 customElements.define(tag, ctor);
6015 }
6016 function kebab(s) {
6017 return s.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase());
6018 }
6019 function camel(s) {
6020 return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
6021 }
6022 const SUPPORTS_CONSTRUCTABLE_SHEETS = (() => {
6023 try {
6024 const s = new CSSStyleSheet();
6025 return typeof s.replaceSync === "function";
6026 } catch {
6027 return false;
6028 }
6029 })();
6030 function css(strings, ...values) {
6031 let text = strings[0];
6032 for (let i = 1; i < strings.length; i++) {
6033 const v = values[i - 1];
6034 if (typeof v === "string" || typeof v === "number") {
6035 text += String(v);
6036 } else if (v && v.__wpdCss) {
6037 text += v.cssText;
6038 } else {
6039 throw new TypeError(
6040 "[wpd-ui] css`` interpolations must be strings, numbers, or other css`` results. Got: " + typeof v
6041 );
6042 }
6043 text += strings[i];
6044 }
6045 if (SUPPORTS_CONSTRUCTABLE_SHEETS) {
6046 const sheet = new CSSStyleSheet();
6047 sheet.replaceSync(text);
6048 return { __wpdCss: true, sheet, cssText: text };
6049 }
6050 return { __wpdCss: true, sheet: null, cssText: text };
6051 }
6052 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}`;
6053 const ICONS$1 = {
6054 minimize: '<path d="M3 6h6" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"/>',
6055 maximize: '<rect x="3" y="3" width="6" height="6" rx="1" stroke="currentColor" stroke-width="1.25" fill="none"/>',
6056 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"/>',
6057 "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"/>',
6058 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"/>',
6059 reload: (
6060 // Filled icon scaled from a 512×512 source into the 12×12 viewBox
6061 // shared with the other title-bar glyphs. The wrapping `<g>` does
6062 // the math; the inner path is dropped in unmodified so its
6063 // authoring tool can be re-edited and copy-pasted again.
6064 // `scale(0.021)` ≈ 90% of full fit, with `translate(0.6)` to
6065 // keep the result centered inside the 12×12 viewBox so the
6066 // glyph reads slightly smaller than min/max/close — closer to
6067 // the visual weight of the other title-bar buttons.
6068 '<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>'
6069 ),
6070 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"/>',
6071 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"/>'
6072 };
6073 const _WpdWindowButton = class _WpdWindowButton extends Component {
6074 constructor() {
6075 super(...arguments);
6076 this._activateWired = false;
6077 }
6078 render() {
6079 const iconKey = this.icon || "";
6080 const svgInner = ICONS$1[iconKey] || "";
6081 return html`
6082 <button type="button">
6083 <svg
6084 width="14"
6085 height="14"
6086 viewBox="0 0 12 12"
6087 aria-hidden="true"
6088 focusable="false"
6089 ></svg>
6090 <slot></slot>
6091 </button>
6092 <span data-svg-buffer style="display:none">${svgInner}</span>
6093 `;
6094 }
6095 /**
6096 * After each render, copy the raw SVG markup into the actual
6097 * `<svg>` element. The templater only writes text into slots,
6098 * so we stash the intended markup in a hidden buffer and
6099 * `innerHTML = ` the svg once here — a one-shot post-render
6100 * hook that keeps the declarative template honest.
6101 *
6102 * Also wires up the `wpd-button-activate` CustomEvent that
6103 * fires exactly once per gesture — the canonical contract
6104 * for plugin-registered title-bar buttons. Plugin authors who
6105 * use `addEventListener( 'click', cb )` directly still get
6106 * what they expect (the title bar's drag-handler now excludes
6107 * chrome buttons by class so static clicks land normally),
6108 * but `wpd-button-activate` is the documented surface that
6109 * documents the once-per-gesture contract explicitly. See
6110 * the class-level docblock for rationale.
6111 */
6112 connectedCallback() {
6113 super.connectedCallback();
6114 queueMicrotask(() => this._paintSvg());
6115 queueMicrotask(() => this._wireActivateEvent());
6116 }
6117 attributeChangedCallback(name, oldValue, newValue) {
6118 super.attributeChangedCallback(name, oldValue, newValue);
6119 queueMicrotask(() => this._paintSvg());
6120 }
6121 _paintSvg() {
6122 const root = this.shadowRoot;
6123 if (!root) {
6124 return;
6125 }
6126 const svg = root.querySelector("svg");
6127 const buffer = root.querySelector("[data-svg-buffer]");
6128 if (svg && buffer) {
6129 const markup = buffer.textContent || "";
6130 if (svg.innerHTML !== markup) {
6131 svg.innerHTML = markup;
6132 }
6133 }
6134 }
6135 _wireActivateEvent() {
6136 if (this._activateWired) {
6137 return;
6138 }
6139 const root = this.shadowRoot;
6140 if (!root) {
6141 return;
6142 }
6143 const button = root.querySelector("button");
6144 if (!button) {
6145 return;
6146 }
6147 this._activateWired = true;
6148 button.addEventListener("click", () => {
6149 this.dispatchEvent(
6150 new CustomEvent("wpd-button-activate", {
6151 bubbles: true,
6152 composed: true,
6153 cancelable: true
6154 })
6155 );
6156 });
6157 }
6158 };
6159 _WpdWindowButton.props = ["icon", "active", "danger"];
6160 _WpdWindowButton.styles = [styles$3];
6161 _WpdWindowButton.help = {
6162 title: "Window button",
6163 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.",
6164 status: "stable",
6165 since: "0.9.0",
6166 props: [
6167 {
6168 name: "icon",
6169 type: "'minimize' | 'maximize' | 'fullscreen' | 'fullscreen-exit' | 'detach' | 'reload' | 'close' | 'menu'",
6170 description: "Which built-in inline SVG to paint. Omit to supply your own via the slot."
6171 },
6172 {
6173 name: "active",
6174 type: "boolean attribute",
6175 description: "Applies the pressed-down look (used e.g. while a menu it triggers is open)."
6176 },
6177 {
6178 name: "danger",
6179 type: "boolean attribute",
6180 description: "Swaps the hover wash to red — used by the close button."
6181 }
6182 ],
6183 slots: [
6184 { name: "(default)", description: "Optional custom icon markup (inline SVG) when `icon` is omitted." }
6185 ],
6186 cssProps: [
6187 { name: "--wpd-btn-color", description: "Resting foreground." },
6188 { name: "--wpd-btn-color-hover", description: "Hover foreground." },
6189 { name: "--wpd-btn-bg-hover", description: "Hover background wash." },
6190 { name: "--wpd-btn-bg-active", description: "Pressed background." },
6191 { name: "--wpd-btn-danger-hover", description: "Hover background for danger variant." },
6192 { name: "--wpd-btn-outline", description: "Focus outline colour." }
6193 ],
6194 example: html`
6195 <wpd-cluster gap="2">
6196 <wpd-window-button icon="minimize"></wpd-window-button>
6197 <wpd-window-button icon="maximize"></wpd-window-button>
6198 <wpd-window-button icon="menu"></wpd-window-button>
6199 <wpd-window-button icon="close" danger></wpd-window-button>
6200 </wpd-cluster>
6201 `
6202 };
6203 let WpdWindowButton = _WpdWindowButton;
6204 defineComponent("wpd-window-button", WpdWindowButton);
6205 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%}`;
6206 const DEFAULT_EVENT = "desktop-mode-os-settings-save-lifecycle";
6207 const DEFAULT_AUTO_CLEAR_SAVED_MS = 2200;
6208 const DEFAULT_AUTO_CLEAR_FAILED_MS = 6e3;
6209 const _WpdSaveStatus = class _WpdSaveStatus extends Component {
6210 constructor() {
6211 super(...arguments);
6212 this._autoTimer = null;
6213 this._docListener = null;
6214 }
6215 connectedCallback() {
6216 super.connectedCallback();
6217 if (this.auto !== null) {
6218 this._installAutoListener();
6219 }
6220 }
6221 disconnectedCallback() {
6222 this._removeAutoListener();
6223 if (this._autoTimer !== null) {
6224 window.clearTimeout(this._autoTimer);
6225 this._autoTimer = null;
6226 }
6227 }
6228 attributeChangedCallback(name, oldValue, newValue) {
6229 super.attributeChangedCallback(name, oldValue, newValue);
6230 if (name === "auto" || name === "event") {
6231 this._removeAutoListener();
6232 if (this.auto !== null) {
6233 this._installAutoListener();
6234 }
6235 }
6236 if (name === "phase") {
6237 this._scheduleAutoClear();
6238 const detail = {
6239 phase: this.phase ?? "idle",
6240 error: this.error ?? void 0
6241 };
6242 this.emit("wpd-save-status-change", detail);
6243 }
6244 }
6245 render() {
6246 const phase = this.phase ?? "idle";
6247 const mode = this.mode ?? "dot";
6248 const error = this.error ?? "";
6249 const title = error || this._labelForPhase(phase);
6250 if (title) {
6251 this.setAttribute("title", title);
6252 } else {
6253 this.removeAttribute("title");
6254 }
6255 this.setAttribute("aria-live", phase === "failed" ? "assertive" : "polite");
6256 this.setAttribute("role", phase === "failed" ? "alert" : "status");
6257 return html`
6258 <span class="wpd-save-status">
6259 <span class="wpd-save-status__indicator" aria-hidden="true">
6260 <span class="wpd-save-status__glyph">${this._renderGlyph(phase)}</span>
6261 </span>
6262 ${mode === "pill" ? html`<span class="wpd-save-status__label"
6263 >${this._labelForPhase(phase)}</span
6264 >` : html``}
6265 </span>
6266 `;
6267 }
6268 _renderGlyph(phase) {
6269 if (phase === "saved") {
6270 return _iconCheck();
6271 }
6272 if (phase === "failed") {
6273 return _iconBang();
6274 }
6275 return "";
6276 }
6277 _labelForPhase(phase) {
6278 switch (phase) {
6279 case "pending":
6280 case "saving":
6281 return this["saving-label"] ?? "Saving…";
6282 case "saved":
6283 return this["saved-label"] ?? "Saved";
6284 case "failed": {
6285 const err = this.error ?? "";
6286 return err || "Couldn’t save";
6287 }
6288 default:
6289 return this["idle-label"] ?? "";
6290 }
6291 }
6292 _installAutoListener() {
6293 const eventName = this.event || DEFAULT_EVENT;
6294 this._docListener = (e) => {
6295 const detail = e.detail;
6296 if (!detail || typeof detail.phase !== "string") {
6297 return;
6298 }
6299 this.phase = detail.phase;
6300 if (detail.error) {
6301 this.error = detail.error;
6302 } else if (detail.phase !== "failed" && this.error) {
6303 this.removeAttribute("error");
6304 }
6305 };
6306 document.addEventListener(eventName, this._docListener);
6307 }
6308 _removeAutoListener() {
6309 if (!this._docListener) {
6310 return;
6311 }
6312 const eventName = this.event || DEFAULT_EVENT;
6313 document.removeEventListener(eventName, this._docListener);
6314 this._docListener = null;
6315 }
6316 _scheduleAutoClear() {
6317 if (this._autoTimer !== null) {
6318 window.clearTimeout(this._autoTimer);
6319 this._autoTimer = null;
6320 }
6321 const phase = this.phase ?? "idle";
6322 const ms = this._autoClearMsFor(phase);
6323 if (ms <= 0) {
6324 return;
6325 }
6326 this._autoTimer = window.setTimeout(() => {
6327 this._autoTimer = null;
6328 this.phase = "idle";
6329 }, ms);
6330 }
6331 _autoClearMsFor(phase) {
6332 if (phase === "saved") {
6333 const raw = this["auto-clear-saved-ms"];
6334 return parseInt(raw || "", 10) || DEFAULT_AUTO_CLEAR_SAVED_MS;
6335 }
6336 if (phase === "failed") {
6337 const raw = this["auto-clear-failed-ms"];
6338 return parseInt(raw || "", 10) || DEFAULT_AUTO_CLEAR_FAILED_MS;
6339 }
6340 return 0;
6341 }
6342 };
6343 _WpdSaveStatus.props = [
6344 "phase",
6345 "mode",
6346 "animation",
6347 "auto",
6348 "event",
6349 "error",
6350 "saving-label",
6351 "saved-label",
6352 "idle-label",
6353 "auto-clear-saved-ms",
6354 "auto-clear-failed-ms"
6355 ];
6356 _WpdSaveStatus.styles = [styles$2];
6357 _WpdSaveStatus.help = {
6358 title: "Save status",
6359 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.',
6360 status: "experimental",
6361 since: "0.8.0",
6362 props: [
6363 {
6364 name: "phase",
6365 type: "'idle' | 'pending' | 'saving' | 'saved' | 'failed'",
6366 default: "idle",
6367 description: "Current lifecycle phase. Set manually for one-off integrations, or rely on `auto` to populate it from a CustomEvent."
6368 },
6369 {
6370 name: "mode",
6371 type: "'dot' | 'icon' | 'pill'",
6372 default: "dot",
6373 description: "Layout. `dot` is the smallest (10×10 colored dot); `icon` adds a glyph inside on saved/failed; `pill` adds an inline label."
6374 },
6375 {
6376 name: "animation",
6377 type: "'pulse' | 'modem'",
6378 default: "pulse",
6379 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."
6380 },
6381 {
6382 name: "auto",
6383 type: "boolean attribute",
6384 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="…"`.'
6385 },
6386 {
6387 name: "event",
6388 type: "string",
6389 default: "desktop-mode-os-settings-save-lifecycle",
6390 description: "CustomEvent name to listen on when `auto` is set."
6391 },
6392 {
6393 name: "error",
6394 type: "string",
6395 description: "Error message shown in `pill` mode and exposed as the host title attribute (so dot/icon modes still surface the message via tooltip)."
6396 },
6397 {
6398 name: "saving-label",
6399 type: "string",
6400 default: "Saving…",
6401 description: "Pill-mode label shown during `pending` / `saving`."
6402 },
6403 {
6404 name: "saved-label",
6405 type: "string",
6406 default: "Saved",
6407 description: "Pill-mode label shown during `saved`."
6408 },
6409 {
6410 name: "idle-label",
6411 type: "string",
6412 description: 'Optional pill-mode label shown during `idle` (e.g. "All changes saved"). When unset, the pill collapses to invisible while idle.'
6413 },
6414 {
6415 name: "auto-clear-saved-ms",
6416 type: "integer",
6417 default: "2200",
6418 description: "How long the `saved` phase stays visible before auto-fading back to `idle`."
6419 },
6420 {
6421 name: "auto-clear-failed-ms",
6422 type: "integer",
6423 default: "6000",
6424 description: "How long the `failed` phase stays visible before auto-fading back to `idle`."
6425 }
6426 ],
6427 events: [
6428 {
6429 name: "wpd-save-status-change",
6430 description: "Fires when the phase changes (manually or via auto-listen).",
6431 detail: "{ phase, error }"
6432 }
6433 ],
6434 cssProps: [
6435 {
6436 name: "--wpd-save-status-bg",
6437 description: "Indicator background color (saving/pending phase)."
6438 },
6439 {
6440 name: "--wpd-save-status-saved-bg",
6441 description: "Indicator background on saved."
6442 },
6443 {
6444 name: "--wpd-save-status-failed-bg",
6445 description: "Indicator background on failed."
6446 },
6447 {
6448 name: "--wpd-save-status-pill-bg",
6449 description: "Pill background (mode=pill)."
6450 },
6451 {
6452 name: "--wpd-save-status-pill-fg",
6453 description: "Pill foreground (mode=pill)."
6454 }
6455 ],
6456 example: html`
6457 <wpd-cluster gap="12">
6458 <wpd-save-status phase="pending"></wpd-save-status>
6459 <wpd-save-status phase="saving"></wpd-save-status>
6460 <wpd-save-status phase="saved"></wpd-save-status>
6461 <wpd-save-status phase="failed"></wpd-save-status>
6462 <wpd-save-status mode="pill" phase="saving"></wpd-save-status>
6463 <wpd-save-status mode="pill" phase="saved"></wpd-save-status>
6464 <wpd-save-status mode="pill" phase="failed" error="Network error."></wpd-save-status>
6465 </wpd-cluster>
6466 `
6467 };
6468 let WpdSaveStatus = _WpdSaveStatus;
6469 defineComponent("wpd-save-status", WpdSaveStatus);
6470 function _iconCheck() {
6471 return html`
6472 <svg
6473 viewBox="0 0 12 12"
6474 aria-hidden="true"
6475 focusable="false"
6476 fill="none"
6477 stroke="currentColor"
6478 stroke-width="2"
6479 stroke-linecap="round"
6480 stroke-linejoin="round"
6481 >
6482 <path d="M2.5 6 L5 8.5 L9.5 4" />
6483 </svg>
6484 `;
6485 }
6486 function _iconBang() {
6487 return html`
6488 <svg
6489 viewBox="0 0 12 12"
6490 aria-hidden="true"
6491 focusable="false"
6492 fill="currentColor"
6493 >
6494 <path
6495 d="M5 2 H7 V7 H5 z M5 8.5 H7 V10.5 H5 z"
6496 />
6497 </svg>
6498 `;
6499 }
6500 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}}`;
6501 const WPD_SPINNER_PRESETS = Object.freeze({
6502 classic: {
6503 sp1: 12,
6504 sp2: 24,
6505 sp3: 40,
6506 a1: 28,
6507 a2: 15,
6508 a3: 8,
6509 gap: 4,
6510 dir2: 1,
6511 dir3: -1,
6512 pulse: "none",
6513 dots: 0
6514 },
6515 comet: {
6516 sp1: 8,
6517 sp2: 14,
6518 sp3: 26,
6519 a1: 50,
6520 a2: 28,
6521 a3: 12,
6522 gap: 3,
6523 dir2: 1,
6524 dir3: 1,
6525 pulse: "none",
6526 dots: 5
6527 },
6528 orbit: {
6529 sp1: 10,
6530 sp2: 10,
6531 sp3: 32,
6532 a1: 50,
6533 a2: 50,
6534 a3: 8,
6535 gap: 5,
6536 dir2: -1,
6537 dir3: -1,
6538 pulse: "opacity",
6539 dots: 3
6540 },
6541 pulse: {
6542 sp1: 6,
6543 sp2: 18,
6544 sp3: 30,
6545 a1: 20,
6546 a2: 12,
6547 a3: 6,
6548 gap: 4,
6549 dir2: 1,
6550 dir3: -1,
6551 pulse: "both",
6552 dots: 8
6553 }
6554 });
6555 const CX = 61.26;
6556 const CY = 61.26;
6557 const DISC_R = 58.453;
6558 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"/>';
6559 const _WpdSpinner = class _WpdSpinner extends Component {
6560 constructor() {
6561 super(...arguments);
6562 this._paintScheduled = false;
6563 }
6564 connectedCallback() {
6565 super.connectedCallback();
6566 this._schedulePaint();
6567 }
6568 render() {
6569 return html`<div class="root" part="root"></div>`;
6570 }
6571 requestUpdate() {
6572 super.requestUpdate();
6573 this._schedulePaint();
6574 }
6575 _schedulePaint() {
6576 if (this._paintScheduled || !this.isConnected) {
6577 return;
6578 }
6579 this._paintScheduled = true;
6580 queueMicrotask(() => {
6581 this._paintScheduled = false;
6582 if (!this.isConnected) {
6583 return;
6584 }
6585 this._paint();
6586 });
6587 }
6588 _paint() {
6589 this._syncCssVars();
6590 const root = this.shadowRoot?.querySelector(
6591 ".root"
6592 );
6593 if (!root) {
6594 return;
6595 }
6596 root.innerHTML = this._buildSvg();
6597 }
6598 /**
6599 * Reflect the color / accent / size attributes onto CSS custom
6600 * properties on the host. Removing the attribute clears the var
6601 * so the default cascades back in.
6602 */
6603 _syncCssVars() {
6604 const sync = (attr, varName, transform) => {
6605 const v = this.getAttribute(attr);
6606 if (v === null) {
6607 this.style.removeProperty(varName);
6608 } else {
6609 this.style.setProperty(
6610 varName,
6611 transform ? transform(v) : v
6612 );
6613 }
6614 };
6615 sync("color", "--wpd-spinner-color");
6616 sync("accent", "--wpd-spinner-accent");
6617 sync(
6618 "size",
6619 "--wpd-spinner-size",
6620 (v) => /^-?\d+(\.\d+)?$/.test(v.trim()) ? `${v}px` : v
6621 );
6622 }
6623 _effectiveConfig() {
6624 const presetName = this.getAttribute("preset") ?? "classic";
6625 const preset = WPD_SPINNER_PRESETS[presetName] ?? WPD_SPINNER_PRESETS.classic;
6626 const num = (attr, fallback) => {
6627 const v = this.getAttribute(attr);
6628 if (v === null) {
6629 return fallback;
6630 }
6631 const n = parseFloat(v);
6632 return Number.isFinite(n) ? n : fallback;
6633 };
6634 const dir = (attr, fallback) => {
6635 const v = this.getAttribute(attr);
6636 if (v === null) {
6637 return fallback;
6638 }
6639 const lc = v.toLowerCase();
6640 if (lc === "-1" || lc === "ccw" || lc === "reverse") {
6641 return -1;
6642 }
6643 return 1;
6644 };
6645 const pulse = () => {
6646 const v = this.getAttribute("pulse");
6647 if (v === "scale" || v === "opacity" || v === "both" || v === "none") {
6648 return v;
6649 }
6650 return preset.pulse;
6651 };
6652 return {
6653 sp1: num("sp1", preset.sp1),
6654 sp2: num("sp2", preset.sp2),
6655 sp3: num("sp3", preset.sp3),
6656 a1: num("a1", preset.a1),
6657 a2: num("a2", preset.a2),
6658 a3: num("a3", preset.a3),
6659 gap: num("gap", preset.gap),
6660 dir2: dir("dir2", preset.dir2),
6661 dir3: dir("dir3", preset.dir3),
6662 pulse: pulse(),
6663 dots: Math.max(0, Math.floor(num("dots", preset.dots)))
6664 };
6665 }
6666 _buildSvg() {
6667 const cfg = this._effectiveConfig();
6668 const label = escAttr(this.getAttribute("label") ?? "Loading");
6669 const pad = cfg.gap * 3 + 14;
6670 const vbMin = -pad;
6671 const vbSize = 122.52 + pad * 2;
6672 const r1 = DISC_R + cfg.gap + 2;
6673 const r2 = r1 + cfg.gap + 2;
6674 const r3 = r2 + cfg.gap + 1.5;
6675 const ring1Anim = `animation: wpd-spinner-spin ${(cfg.sp1 / 10).toFixed(2)}s linear infinite`;
6676 const ring2Anim = `animation: wpd-spinner-spin ${(cfg.sp2 / 10).toFixed(2)}s linear infinite${cfg.dir2 < 0 ? " reverse" : ""}`;
6677 const ring3Anim = `animation: wpd-spinner-spin ${(cfg.sp3 / 10).toFixed(2)}s linear infinite${cfg.dir3 < 0 ? " reverse" : ""}`;
6678 const pspd = (cfg.sp1 * 1.8 / 10).toFixed(1);
6679 const ospd = (cfg.sp1 * 2.3 / 10).toFixed(1);
6680 let pulseStyle = "";
6681 if (cfg.pulse === "scale") {
6682 pulseStyle = `animation: wpd-spinner-scale ${pspd}s ease-in-out infinite`;
6683 } else if (cfg.pulse === "opacity") {
6684 pulseStyle = `animation: wpd-spinner-opacity ${ospd}s ease-in-out infinite`;
6685 } else if (cfg.pulse === "both") {
6686 pulseStyle = `animation: wpd-spinner-scale ${pspd}s ease-in-out infinite, wpd-spinner-opacity ${ospd}s ease-in-out infinite`;
6687 }
6688 let dotEls = "";
6689 if (cfg.dots > 0) {
6690 const dr = r3 + cfg.gap + 1;
6691 const dc2 = 2 * Math.PI * dr;
6692 const dsz = 1.6;
6693 const dotDur = (cfg.sp1 * 0.65 / 10).toFixed(2);
6694 for (let i = 0; i < cfg.dots; i++) {
6695 const offset = -(i / cfg.dots) * dc2;
6696 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"/>`;
6697 }
6698 }
6699 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>`;
6700 }
6701 };
6702 _WpdSpinner.props = [
6703 "preset",
6704 "size",
6705 "color",
6706 "accent",
6707 "sp1",
6708 "sp2",
6709 "sp3",
6710 "a1",
6711 "a2",
6712 "a3",
6713 "gap",
6714 "dir2",
6715 "dir3",
6716 "pulse",
6717 "dots",
6718 "label"
6719 ];
6720 _WpdSpinner.styles = [styles$1];
6721 _WpdSpinner.help = {
6722 title: "Spinner",
6723 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.",
6724 status: "experimental",
6725 since: "0.18.0",
6726 props: [
6727 {
6728 name: "preset",
6729 type: '"classic" | "comet" | "orbit" | "pulse"',
6730 default: "classic",
6731 description: "Visual personality. Every other attribute defaults to the preset's value and can be overridden individually."
6732 },
6733 {
6734 name: "size",
6735 type: "integer (px) or CSS length",
6736 default: "48",
6737 description: "Sets `--wpd-spinner-size`. Bare numbers are treated as px; pass a CSS length (e.g. `2em`) to opt into ems / rems."
6738 },
6739 {
6740 name: "color",
6741 type: "CSS color",
6742 description: "Disc + ring + dot color. Sets `--wpd-spinner-color`. Default inherits the WP admin theme color."
6743 },
6744 {
6745 name: "accent",
6746 type: "CSS color",
6747 default: "#fff",
6748 description: "Color of the W mark inside the disc. Sets `--wpd-spinner-accent`. Default white — change for dark-on-light or themed marks."
6749 },
6750 {
6751 name: "sp1, sp2, sp3",
6752 type: "integer (deciseconds)",
6753 description: "Per-ring rotation duration in tenths-of-a-second (12 → 1.2s). Higher = slower."
6754 },
6755 {
6756 name: "a1, a2, a3",
6757 type: "integer (0-100)",
6758 description: "Per-ring arc length as a percentage of the ring circumference."
6759 },
6760 {
6761 name: "gap",
6762 type: "integer",
6763 description: "Gap between concentric rings (units approximate to px at 120-viewport)."
6764 },
6765 {
6766 name: "dir2, dir3",
6767 type: '"1" | "-1" | "cw" | "ccw"',
6768 description: "Per-ring direction; ring 1 is always clockwise."
6769 },
6770 {
6771 name: "pulse",
6772 type: '"none" | "scale" | "opacity" | "both"',
6773 description: "Pulse animation applied to the disc + W mark."
6774 },
6775 {
6776 name: "dots",
6777 type: "integer",
6778 description: "Outer trailing dot count. Sensible values: 0, 3, 5, 8."
6779 },
6780 {
6781 name: "label",
6782 type: "string",
6783 default: "Loading",
6784 description: 'Accessible name for the SVG (`role="img"` + `aria-label`).'
6785 }
6786 ],
6787 cssProps: [
6788 { name: "--wpd-spinner-color", default: "var(--wp-admin-theme-color, #21759b)" },
6789 { name: "--wpd-spinner-accent", default: "#fff" },
6790 { name: "--wpd-spinner-size", default: "48px" }
6791 ],
6792 example: html`<wpd-spinner preset="comet" size="80"></wpd-spinner>`
6793 };
6794 let WpdSpinner = _WpdSpinner;
6795 function dasharray(r, pct) {
6796 const c = 2 * Math.PI * r;
6797 const visible = pct / 100 * c;
6798 const gap = c - visible;
6799 return `${visible.toFixed(2)} ${gap.toFixed(2)}`;
6800 }
6801 function escAttr(s) {
6802 return String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
6803 }
6804 defineComponent("wpd-spinner", WpdSpinner);
6805 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}`;
6806 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 )}`;
6807 const _WpdMenu = class _WpdMenu extends Component {
6808 connectedCallback() {
6809 super.connectedCallback();
6810 this.setAttribute("role", "menu");
6811 }
6812 render() {
6813 return html`<slot></slot>`;
6814 }
6815 };
6816 _WpdMenu.styles = [menuStyles];
6817 _WpdMenu.help = {
6818 title: "Menu",
6819 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.",
6820 status: "stable",
6821 since: "0.9.0",
6822 slots: [
6823 { name: "(default)", description: "<wpd-menu-item> children." }
6824 ],
6825 cssProps: [
6826 { name: "--desktop-mode-window-bg", description: "Menu background." },
6827 { name: "--desktop-mode-window-border", description: "Menu border." },
6828 { name: "--desktop-mode-text", description: "Item text colour." }
6829 ],
6830 example: html`
6831 <wpd-menu>
6832 <wpd-menu-item value="new" icon="dashicons-plus">Open another window</wpd-menu-item>
6833 <wpd-menu-item value="startup" role="menuitemcheckbox" checked>Open on startup</wpd-menu-item>
6834 <wpd-menu-item value="close">Close window</wpd-menu-item>
6835 </wpd-menu>
6836 `
6837 };
6838 let WpdMenu = _WpdMenu;
6839 defineComponent("wpd-menu", WpdMenu);
6840 const _WpdMenuItem = class _WpdMenuItem extends Component {
6841 connectedCallback() {
6842 super.connectedCallback();
6843 if (!this.hasAttribute("role")) {
6844 this.setAttribute("role", "menuitem");
6845 }
6846 }
6847 render() {
6848 const icon = this.icon || "";
6849 const isCheckbox = this.getAttribute("role") === "menuitemcheckbox";
6850 const checked = this.checked !== null;
6851 if (isCheckbox) {
6852 this.setAttribute("aria-checked", checked ? "true" : "false");
6853 }
6854 return html`
6855 <button type="button" @click=${(e) => this._onPick(e)}>
6856 <span
6857 class="wpd-menu-item__check"
6858 ?hidden=${!isCheckbox}
6859 ></span>
6860 <span
6861 class="wpd-menu-item__icon dashicons ${icon}"
6862 aria-hidden="true"
6863 ?hidden=${isCheckbox || !icon}
6864 ></span>
6865 <span class="wpd-menu-item__label">
6866 <slot></slot>
6867 </span>
6868 </button>
6869 `;
6870 }
6871 _onPick(e) {
6872 e.preventDefault();
6873 this.emit("wpd-menu-item-click", {
6874 value: this.value
6875 });
6876 }
6877 };
6878 _WpdMenuItem.props = ["icon", "value", "checked"];
6879 _WpdMenuItem.styles = [menuItemStyles];
6880 _WpdMenuItem.help = {
6881 title: "Menu item",
6882 summary: 'Single row inside a <wpd-menu>. Supports three looks: plain label, left-aligned dashicon (icon="dashicons-…"), or a checkbox indicator (role="menuitemcheckbox" + checked).',
6883 status: "stable",
6884 since: "0.9.0",
6885 props: [
6886 {
6887 name: "icon",
6888 type: "string (dashicons class)",
6889 description: 'Dashicons class rendered on the left. Ignored when role="menuitemcheckbox".'
6890 },
6891 {
6892 name: "value",
6893 type: "string",
6894 description: "Identifier emitted in wpd-menu-item-click.detail.value."
6895 },
6896 {
6897 name: "checked",
6898 type: "boolean attribute",
6899 description: 'Visible check indicator. Only honoured when role="menuitemcheckbox".'
6900 }
6901 ],
6902 slots: [
6903 { name: "(default)", description: "Menu item label." }
6904 ],
6905 events: [
6906 {
6907 name: "wpd-menu-item-click",
6908 description: "Fires when the item is clicked; bubbles so the <wpd-menu> parent can delegate.",
6909 detail: "{ value: string | null }"
6910 }
6911 ]
6912 };
6913 let WpdMenuItem = _WpdMenuItem;
6914 defineComponent("wpd-menu-item", WpdMenuItem);
6915 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}}`;
6916 const ICONS = {
6917 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"/>',
6918 close: '<path d="M2.5 2.5l7 7M9.5 2.5l-7 7" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>'
6919 };
6920 const _WpdTabChip = class _WpdTabChip extends Component {
6921 render() {
6922 const variant = this.variant || "";
6923 const svgInner = ICONS[variant] || "";
6924 return html`
6925 <button type="button">
6926 <svg
6927 viewBox="0 0 12 12"
6928 aria-hidden="true"
6929 focusable="false"
6930 ></svg>
6931 <slot></slot>
6932 </button>
6933 <span data-svg-buffer style="display:none">${svgInner}</span>
6934 `;
6935 }
6936 connectedCallback() {
6937 super.connectedCallback();
6938 queueMicrotask(() => this._paintSvg());
6939 }
6940 attributeChangedCallback(name, oldValue, newValue) {
6941 super.attributeChangedCallback(name, oldValue, newValue);
6942 queueMicrotask(() => this._paintSvg());
6943 }
6944 _paintSvg() {
6945 const root = this.shadowRoot;
6946 if (!root) {
6947 return;
6948 }
6949 const svg = root.querySelector("svg");
6950 const buffer = root.querySelector("[data-svg-buffer]");
6951 if (svg && buffer) {
6952 const markup = buffer.textContent || "";
6953 if (svg.innerHTML !== markup) {
6954 svg.innerHTML = markup;
6955 }
6956 }
6957 }
6958 };
6959 _WpdTabChip.props = ["variant"];
6960 _WpdTabChip.styles = [styles];
6961 _WpdTabChip.help = {
6962 title: "Tab chip",
6963 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.",
6964 status: "stable",
6965 since: "0.9.0",
6966 props: [
6967 {
6968 name: "variant",
6969 type: "'detach' | 'close'",
6970 description: "Selects the built-in SVG icon and the hover wash colour."
6971 }
6972 ],
6973 slots: [
6974 { name: "(default)", description: "Optional custom icon markup when `variant` is omitted." }
6975 ],
6976 example: html`
6977 <wpd-cluster gap="4">
6978 <wpd-tab-chip variant="detach"></wpd-tab-chip>
6979 <wpd-tab-chip variant="close"></wpd-tab-chip>
6980 </wpd-cluster>
6981 `
6982 };
6983 let WpdTabChip = _WpdTabChip;
6984 defineComponent("wpd-tab-chip", WpdTabChip);
6985 const factory = {
6986 createWindow(cfg) {
6987 return new Window(cfg);
6988 }
6989 };
6990 window.desktopModeWindowSystem = factory;
6991 })();
6992