/** * Desktop Mode — Window chrome. * * Everything that renders the base window frame: title bar, icon + * title text, control buttons, focused / unfocused colour variants, * the ⋯ actions menu panel, screen-meta buttons, the tab strip (for * submenu + external tabs), the window body, the primary iframe, and * the native-body variant. State-driven rules (dragging / resizing / * maximized / fullscreen / overview / minimized / closing) live in * sibling files loaded via `windows.css`. * * @since 6.9.0 */ /* Base window. */ .desktop-mode-window { /* * border-box sizing is essential for pixel-perfect maximize. * `Window.maximize()` sets inline `width = parent.clientWidth`, * which is the area's inner width. Under content-box sizing, the * window's 1px border would add 2 extra pixels to the rendered * element, pushing its right + bottom borders past the * overflow-hidden boundary of `.desktop-mode-area` and making them * look clipped/offscreen. With border-box, width/height INCLUDE * the border, so `width = clientWidth` fits exactly. * * WP admin CSS does set `*, *::before, *::after { box-sizing: * border-box }` globally, but setting it explicitly here * insulates us from stylesheets or embeds that might reset the * universal rule — the correctness of maximize geometry is too * important to depend on ambient CSS. */ box-sizing: border-box; position: absolute; display: flex; flex-direction: column; background: var(--desktop-mode-window-bg); border: 1px solid var(--desktop-mode-window-border); border-radius: var(--desktop-mode-window-radius); box-shadow: var(--desktop-mode-window-shadow); overflow: hidden; min-width: 320px; min-height: 200px; transform-origin: center center; transition: left 0.25s cubic-bezier(0.2, 0, 0.2, 1), top 0.25s cubic-bezier(0.2, 0, 0.2, 1), width 0.25s cubic-bezier(0.2, 0, 0.2, 1), height 0.25s cubic-bezier(0.2, 0, 0.2, 1), border-radius 0.25s ease, transform 0.2s ease, opacity 0.2s ease, box-shadow 0.2s ease, /* Unfocus effects (e.g. `--fx-darken` / `--fx-frost`) toggle * `filter`; keep it in the shared transition list so the * treatment fades both ways as focus moves between windows. * Duration is a custom property (default in `effects.css`) so * the blur/darken ramp can be tuned without editing this list. */ filter var( --desktop-mode-fx-transition-duration, 0.5s ) ease-in-out; } /* * Suppress the left/top/width/height transition during drag or resize — * otherwise every pointer move lerps and the window lags the cursor. * * Also during a viewport reflow (`--reflowing`) — when the browser * window is being dragged smaller, the ResizeObserver fires many * times per second and each style write would restart the 250 ms * transition, leaving maximized / snapped windows ~250 ms behind the * viewport the whole time. The class is added by * `reflowStatefulWindows` and cleared ~140 ms after the last tick. */ .desktop-mode-window--dragging, .desktop-mode-window--resizing, .desktop-mode-window--reflowing { transition: none; } /* * Snap-to-grid drag/resize: re-enable a SHORT transition so each * cell-to-cell jump animates smoothly instead of teleporting. The * `--snap-drag` class is added by the window class only when snap * is on at drag/resize start; it composes with `--dragging` / * `--resizing` and the source-order ensures it wins. Kept short * (90 ms) so the window still feels glued to the cursor — anything * longer reads as lag. */ .desktop-mode-window--snap-drag { transition: left 0.09s ease-out, top 0.09s ease-out, width 0.09s ease-out, height 0.09s ease-out; } /* Focused window gets elevated shadow and distinct title bar. */ .desktop-mode-window--focused { box-shadow: var(--desktop-mode-window-shadow-focused); } /* Title bar. */ .desktop-mode-window__titlebar { position: relative; /* Promote the titlebar into its own stacking context above the * body. Without this, the body (which comes later in source order * and inherits z-auto) wins document-order overlap battles — * notably any sticky-positioned content inside the body (e.g. the * native Posts table's sticky header at z-index 20–40) would * paint over the titlebar's ⋯ menu popover. */ z-index: 21; display: flex; align-items: center; height: var(--desktop-mode-titlebar-height); padding: 0 8px; background: var(--desktop-mode-titlebar-bg); color: var(--desktop-mode-titlebar-color); cursor: default; user-select: none; flex-shrink: 0; gap: 8px; } .desktop-mode-window--focused .desktop-mode-window__titlebar { background: var(--desktop-mode-titlebar-bg-focused); color: var(--desktop-mode-titlebar-color-focused); } /* Window icon in title bar. */ .desktop-mode-window__icon { font-size: 18px; width: 18px; height: 18px; flex-shrink: 0; } /* Activity indicator slot — sits between the icon and the title. * Reserves a fixed width so the indicator's blink animation can't * shift the title text horizontally. The inner `` * paints a 12px modem-style dot (always visible, accent-colored). * * `--wp-admin-theme-color` is forwarded as `color` on the host so * the inner shadow-DOM stylesheet's `currentColor` references * (used in the box-shadow glow) resolve to the live accent. */ .desktop-mode-window__activity { display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px; flex-shrink: 0; margin-inline-start: 6px; margin-inline-end: 4px; color: var(--wp-admin-theme-color, #2271b1); } /* Window title text. */ .desktop-mode-window__title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 500; line-height: var(--desktop-mode-titlebar-height); } /* Window control buttons container. */ .desktop-mode-window__controls { display: flex; gap: 4px; align-items: center; flex-shrink: 0; } /* * Layer-3 slot hosts. * * Most in-titlebar slots wrap canonical chrome elements without * contributing to the flex layout themselves — `display: contents` * removes the wrapper's box so the inner element (icon span with * `flex-shrink: 0`, before/after spacers, etc.) keeps its * original layout role. * * The `title` slot is the exception: it's the flex-grow region of * the title bar (`flex: 1`). When a plugin replaces the default * title with custom HTML or a render callback, we want the new * content to inherit the same "fill the remaining horizontal * space" behaviour — so the slot itself owns `flex: 1` and the * default title element's own `flex: 1` cooperates inside the * nested flex. * * before/after-titlebar slots are flex-column children of * `.desktop-mode-window` (siblings of the title bar). They're real * boxes — plugins can paint backgrounds, padding, borders. Empty * hosts collapse via `:empty` so they take no space until populated. * * @since 0.6.0 */ .desktop-mode-window__slot--before-icon, .desktop-mode-window__slot--icon, .desktop-mode-window__slot--after-title, .desktop-mode-window__slot--before-controls, .desktop-mode-window__slot--after-controls { display: contents; } .desktop-mode-window__slot--title { flex: 1; min-width: 0; display: flex; align-items: center; overflow: hidden; } .desktop-mode-window__slot--before-titlebar, .desktop-mode-window__slot--after-titlebar { display: block; flex-shrink: 0; } .desktop-mode-window__slot--before-titlebar:empty, .desktop-mode-window__slot--after-titlebar:empty { display: none; } /* --------------------------------------------------------------- * Window control buttons. * * Flat, icon-only buttons styled to feel at home in the WordPress * admin: transparent by default, subtle tinted hover, focus ring in * the active admin theme color, inline SVG icons inheriting * currentColor so they adapt to focused / unfocused title bars. * * The close button gets a destructive red wash on hover only — * never as a default state — so it reads as safe until interacted * with, matching the WordPress admin's pattern of reserving color * for semantic signal. * --------------------------------------------------------------- */ /* * Title-bar chrome buttons render as ``. * Shadow DOM can't reach across the window's focus class, so we * drive the coloring via custom properties that inherit through * the boundary. The component reads `--wpd-btn-color`, * `--wpd-btn-bg-hover`, etc. and paints accordingly. */ .desktop-mode-window--focused { --wpd-btn-color: rgba( 255, 255, 255, 0.7 ); --wpd-btn-color-hover: #fff; --wpd-btn-bg-hover: rgba( 255, 255, 255, 0.18 ); --wpd-btn-bg-active: rgba( 255, 255, 255, 0.25 ); --wpd-btn-outline: rgba( 255, 255, 255, 0.65 ); --wpd-btn-danger-hover: #d63638; } .desktop-mode-window:not( .desktop-mode-window--focused ) { --wpd-btn-color: rgba( 0, 0, 0, 0.45 ); --wpd-btn-color-hover: rgba( 0, 0, 0, 0.85 ); --wpd-btn-bg-hover: rgba( 0, 0, 0, 0.08 ); --wpd-btn-bg-active: rgba( 0, 0, 0, 0.12 ); --wpd-btn-outline: var( --wp-admin-theme-color, #2271b1 ); --wpd-btn-danger-hover: #d63638; } /* --------------------------------------------------------------- * Title-bar actions menu (leading edge, before icon + title). * * Trigger is a standard `.desktop-mode-window__btn` — inherits focused / * unfocused coloring from the existing control-button rules. The only * bespoke rule is margin: it sits at the leading edge so a small trailing * gap separates it from the icon without affecting overall title-bar gap. * * Panel is an absolute dropdown anchored to the title bar (which is now * position: relative). Visibility is driven by the `hidden` attribute on * the element itself — no separate `--open` class to keep in sync with * aria-expanded. * --------------------------------------------------------------- */ /* * ⋯ button sits between the screen-meta cluster and the window * controls, as the "last page-level chrome" item. When present it * takes over the divider duty: screen-meta drops its own trailing * divider, and the controls container gains a leading one — so a * single clean line always sits between page chrome and window * chrome regardless of which combination is visible. */ .desktop-mode-window__titlebar:has(.desktop-mode-window__menu-btn) .desktop-mode-window__screen-meta { margin-inline-end: 0; padding-inline-end: 0; border-inline-end: none; } .desktop-mode-window__titlebar:has(.desktop-mode-window__menu-btn) .desktop-mode-window__controls { margin-inline-start: 8px; padding-inline-start: 8px; border-inline-start: 1px solid rgba(255, 255, 255, 0.15); } .desktop-mode-window:not(.desktop-mode-window--focused) .desktop-mode-window__titlebar:has(.desktop-mode-window__menu-btn) .desktop-mode-window__controls { border-inline-start-color: rgba(0, 0, 0, 0.1); } /* * Menu popover lives inside the title bar's absolute-positioned * coordinate system — positioning is caller-specific and stays * outer. Background, border, items, checkbox styling all moved * into `` / ``. */ .desktop-mode-window__menu-panel { position: absolute; top: calc( var( --desktop-mode-titlebar-height ) + 2px ); inset-inline-end: 60px; z-index: 2; } /* --------------------------------------------------------------- * Screen Meta buttons (Screen Options / Help) in the title bar. * Positioned right after the title text, visually separated from * the close / maximize / minimize cluster by a subtle divider. * Sized to meet WCAG 2.2 target size (24x24 minimum). * --------------------------------------------------------------- */ .desktop-mode-window__screen-meta { display: flex; gap: 4px; align-items: center; flex-shrink: 0; margin-inline-end: 8px; padding-inline-end: 8px; border-inline-end: 1px solid rgba(255, 255, 255, 0.15); } /* Hide the divider when there are no screen-meta buttons. */ .desktop-mode-window__screen-meta:empty { display: none; } .desktop-mode-window__meta-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: none; border-radius: 6px; cursor: pointer; padding: 0; background: transparent; transition: background-color 0.15s ease, color 0.15s ease; } .desktop-mode-window__meta-btn .dashicons { font-size: 18px; width: 18px; height: 18px; } /* The flex-centring override for the dashicon glyph lives in * `windows.css` rather than here. `windows.css` is mtime-stamped * (per `desktop_mode_register_assets` in includes/assets.php) so * its URL changes whenever it does, but `@import`-ed sub-sheets * like this one are fetched without a version query and the * browser will serve a stale cached copy across edits to this * file. Co-locating cache-sensitive overrides with the parent * stylesheet is the rule of the road here. */ /* Focused window: meta buttons visible. */ .desktop-mode-window--focused .desktop-mode-window__meta-btn { color: rgba(255, 255, 255, 0.65); } .desktop-mode-window--focused .desktop-mode-window__meta-btn:hover { color: #fff; background: rgba(255, 255, 255, 0.18); } .desktop-mode-window--focused .desktop-mode-window__meta-btn:focus-visible { color: #fff; background: rgba(255, 255, 255, 0.18); outline: 2px solid rgba(255, 255, 255, 0.6); outline-offset: 1px; } .desktop-mode-window--focused .desktop-mode-window__meta-btn--active { color: #fff; background: rgba(255, 255, 255, 0.25); } /* Unfocused window: divider and buttons adapt to light title bar. */ .desktop-mode-window:not(.desktop-mode-window--focused) .desktop-mode-window__screen-meta { border-inline-end-color: rgba(0, 0, 0, 0.1); } .desktop-mode-window:not(.desktop-mode-window--focused) .desktop-mode-window__meta-btn { color: rgba(0, 0, 0, 0.3); } .desktop-mode-window:not(.desktop-mode-window--focused) .desktop-mode-window__meta-btn:hover { color: rgba(0, 0, 0, 0.6); background: rgba(0, 0, 0, 0.08); } .desktop-mode-window:not(.desktop-mode-window--focused) .desktop-mode-window__meta-btn--active { color: rgba(0, 0, 0, 0.6); background: rgba(0, 0, 0, 0.12); } /* --------------------------------------------------------------- * Tab strip — submenu navigation rendered in the parent shell, just * below the title bar. Each tab swaps the iframe URL in place; no * new window opens. Horizontally scrollable on narrow windows so the * same component works on tablet and mobile shells unchanged. * --------------------------------------------------------------- */ .desktop-mode-window__tabs { display: flex; flex-shrink: 0; gap: 2px; padding: 0 8px; background: var(--desktop-mode-tabs-bg, #f6f7f7); border-bottom: 1px solid var(--desktop-mode-window-border); overflow-x: auto; overflow-y: hidden; scrollbar-width: thin; /* * Soft edge fades so overflowing tabs tell the user "scroll for * more." The mask is always applied — when the strip doesn't * overflow, the faded ends are empty area beyond the last tab so * the cosmetic is invisible. When it DOES overflow, the last- * visible tab fades under the mask, creating the affordance. * `overscroll-behavior-x: contain` keeps horizontal trackpad * swipes inside the strip instead of triggering browser back. */ mask-image: linear-gradient( to right, transparent 0, #000 16px, #000 calc(100% - 16px), transparent 100% ); -webkit-mask-image: linear-gradient( to right, transparent 0, #000 16px, #000 calc(100% - 16px), transparent 100% ); overscroll-behavior-x: contain; } /* * When JS assigns the --has-overflow marker (future enhancement), the * mask is stronger. For now the CSS-only mask is subtle enough to be * invisible on non-overflowing strips — mask cut-offs land outside the * flex content. */ .desktop-mode-window__tab { flex-shrink: 0; display: inline-flex; align-items: center; height: 32px; padding: 0 12px; border: none; background: transparent; color: var(--desktop-mode-tabs-color, #50575e); font: inherit; font-size: 12px; line-height: 1; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap; transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease; } .desktop-mode-window__tab:hover { color: var(--wp-admin-theme-color, #2271b1); background: rgba(0, 0, 0, 0.03); } .desktop-mode-window__tab:focus-visible { outline: 2px solid var(--wp-admin-theme-color, #2271b1); outline-offset: -2px; } .desktop-mode-window__tab--active { color: var(--wp-admin-theme-color, #2271b1); border-bottom-color: var(--wp-admin-theme-color, #2271b1); font-weight: 600; } /* * External sub-tab. Same shape as a submenu tab, plus two inline * chips: detach (↗) and close (×). Chips are painted as spans * (rather than nested buttons — nested interactive elements are a * pain for a11y). The tab's click handler routes chip clicks via * the `data-tab-action` dataset attribute. */ .desktop-mode-window__tab--external { /* Slightly wider gap between the label and the chip cluster, and * more breathing room at the trailing edge so the chips aren't * flush against the tab boundary. */ gap: 10px; padding-inline-end: 4px; } .desktop-mode-window__tab-label { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* External-tab action chips moved to ``. Spacing * between chips handled by a single host-level rule since the * component doesn't know about its siblings. */ wpd-tab-chip + wpd-tab-chip { margin-inline-start: 4px; } /* Window body: contains the iframe. */ .desktop-mode-window__body { flex: 1; position: relative; overflow: hidden; } /* Iframe fills the window body. */ .desktop-mode-window__iframe { width: 100%; height: 100%; border: none; display: block; background: var(--desktop-mode-window-bg); opacity: 1; transition: opacity 0.25s ease; } /* Cross-window drag drop-overlay rules live in windows.css, NOT here. * Putting them in this @import'd sub-sheet means browsers can serve a * stale cached copy even when the parent windows.css has been cache- * busted by mtime, leaving the drag stuck at the iframe boundary * until the user manually clears their stylesheet cache. Keep them * co-located with the @import declarations themselves. */ /* * Loading-state overlay — painted by `src/window/dom.ts` into * every window's body and removed once the body's content reports * ready. The `` inside is sized responsively against * the window's width via `clamp(96px, 14vw, 192px)` so a tiny * popover gets a small spinner and a maximized window gets a big * one. Placed above the body content (iframe / native render * output) and kept aria-hidden so the spinner's own SR-label is * the only loading announcement. * * `transition-delay` on the overlay's fade-in means a render * that lands within ~120ms never paints the spinner — the * overlay only becomes visible when there's actually a wait * worth surfacing. This keeps the affordance from flashing on * fast local-dev loads while still covering the slow production * iframe boots that motivated it. */ .desktop-mode-window__loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; pointer-events: none; background: var(--desktop-mode-window-bg); opacity: 0; /* Default (loaded → loading-removed) — fade out immediately. */ transition: opacity 0.25s ease; z-index: 1; } .desktop-mode-window__body--loading > .desktop-mode-window__iframe, .desktop-mode-window__body--loading > :not(.desktop-mode-window__loading) { opacity: 0; transition: opacity 0.25s ease; } .desktop-mode-window__body--loading > .desktop-mode-window__loading { opacity: 1; /* Entry transition with a small delay — loads that finish in * under ~120ms never paint the spinner, so fast local-dev / * hot-cache fires don't flash. Slow production iframe boots * still see the spinner. */ transition-delay: 0.12s; } @media ( prefers-reduced-motion: reduce ) { .desktop-mode-window__iframe, .desktop-mode-window__loading, .desktop-mode-window__body--loading > .desktop-mode-window__iframe, .desktop-mode-window__body--loading > :not(.desktop-mode-window__loading) { transition: none; } } /* * External sub-tab iframes stack in the same body as the primary * iframe. Only one is visible at a time (managed by `switchToTab` * via `style.display`). Absolute positioning so they don't push the * primary iframe around when added to the DOM; `display: none` on * inactive iframes is set inline by the JS. */ .desktop-mode-window__iframe--external { position: absolute; inset: 0; } /* * Native window body — fills the window but lets its contents scroll * independently. Iframe bodies use `overflow: hidden` so the iframe * controls its own scroll; native bodies render real document content * that needs the parent to manage overflow. */ .desktop-mode-window__body--native { overflow: auto; color: #1d2327; background: var(--desktop-mode-window-bg); } /* * Highlight rings. Toggled from JS by `Window.setHighlight()` — * used by plugins that need to point at a window from outside it * (e.g. a "connect to" dropdown that previews candidate windows on * hover). `--wp-window-highlight-color` is overridable per-instance * via `setHighlight( mode, { color } )` or globally via the * variable. */ .wp-window { --wp-window-highlight-color: var( --wp-admin-theme-color, #2271b1 ); } .wp-window--highlight-preview { box-shadow: 0 0 0 3px var( --wp-window-highlight-color ), 0 0 24px 4px var( --wp-window-highlight-color ); transition: box-shadow 0.12s ease; z-index: 9999; } .wp-window--highlight-persistent { box-shadow: 0 0 0 2px var( --wp-window-highlight-color ); transition: box-shadow 0.12s ease; } /* * Slots for plugin-registered title-bar buttons. Empty by default * — `display: contents` hides them entirely from layout when no * plugin has registered a button for the window. When buttons ARE * present, they sit inline next to the title (left slot) or just * before the window controls (right slot). */ .desktop-mode-window__custom-buttons { display: contents; } .desktop-mode-window__btn--custom { margin: 0 2px; } /* * Plugin-supplied icons render in the host's light DOM so the * global Dashicons stylesheet reaches them (shadow DOM doesn't * inherit page CSS). * * Dashicons render at their native 20×20 — that's the size the * font is hinted for and the only one where glyph metrics put * the visual centre on the geometric centre. Earlier we tried * down-scaling them to 14×14 to match the built-in chrome icons, * but font-size: 14 on a Dashicon shifts the glyph 1–2 pixels * off centre because the font's ascent/descent ratio doesn't * scale linearly. The 30×30 button has 5px of padding around a * 20×20 glyph — comfortable, and visually consistent with the * 14×14 chrome icons because both are flex-centred in the same * box. * * For inline-SVG icons that plugin authors ship without explicit * width/height, we DO clamp to 18×18 — those are the cases where * a bare `` would otherwise size to its viewBox or default * to 300×150 (the spec default) and overflow the button. */ .desktop-mode-window__btn--custom svg:not( [ width ] ):not( [ height ] ) { width: 18px; height: 18px; display: block; } /* ---------------------------------------------------------------- * Custom-chrome marker — hides every framework-shipped titlebar * child while a plugin's chrome is mounted. * * The window gets the `desktop-mode-window--custom-chrome` class the * moment `mountWindowChrome` succeeds (BEFORE the plugin's * `render()` runs). Default children carry the * `data-desktop-mode-default-chrome` attribute stamped at element- * creation time. The combination is a load-bearing guarantee: * even if the plugin's render() doesn't clear `titlebar.innerHTML`, * even if a plugin's destroy() leaks the standard chrome back into * place, even mid-fade during a window close — the default chrome * NEVER becomes visible while the marker class is active. * * The class is removed only when the chrome is swapped to standard * (in `Window.remountWindowChrome`); during a window close it * persists until `element.remove()` runs in `onDone()`, which is * after the fade has reached opacity 0 — so the user never sees * the swap. * * @since 0.18.0 * ---------------------------------------------------------------- */ .desktop-mode-window--custom-chrome > .desktop-mode-window__titlebar > [ data-desktop-mode-default-chrome ] { display: none !important; } /* --------------------------------------------------------------- * Reload button feedback. * * Click feedback is decoupled from the loading state: * * 1. On click, the icon performs a single 360° rotation with a * fast-start / slow-end ease curve — confirms the user's * gesture independently of how long the page takes to load. * The `--spinning` class is added by the click handler and * removed on `animationend` so a subsequent click restarts * the animation cleanly. * * 2. While the window body is in the `--loading` state (set by * `markContentLoading()` and cleared on the iframe's * `desktop-mode-ready` postMessage), the button is dimmed and * non-interactive so a second click can't desync the * chromeless bridge's loading handshake. * * `:has()` is used instead of mirroring the `--loading` modifier * onto the window root because upstream's loading API only * decorates the body element. Browsers without :has() (very old) * fall back to a static dimmed button — feature, not bug. * --------------------------------------------------------------- */ .desktop-mode-window:has( .desktop-mode-window__body--loading ) .desktop-mode-window__btn--reload { pointer-events: none; opacity: 0.55; } /* * Click feedback animation. The custom easing — `cubic-bezier( 0.05, * 0.7, 0.1, 1 )` — is a steeper-than-default ease-out: ~70% of the * rotation lands in the first 30% of the duration, then it decelerates. * Reads as "snap, then settle" rather than the linear glide of a * loading-spinner, reinforcing that this is a one-shot acknowledgement * of the gesture rather than progress through a long task. The `0.6s` * duration is short enough to never block the user but long enough * that the deceleration is legible. */ .desktop-mode-window__btn--reload.desktop-mode-window__btn--spinning { animation: wpd-reload-spin-once 0.6s cubic-bezier( 0.05, 0.7, 0.1, 1 ); } @keyframes wpd-reload-spin-once { from { transform: rotate( 0deg ); } to { transform: rotate( 360deg ); } } @media ( prefers-reduced-motion: reduce ) { .desktop-mode-window__btn--reload.desktop-mode-window__btn--spinning { animation: none; } }