PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.0.0
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.0.0
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 / css / window-chrome.css

window-chrome.css in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.0.0, at assets/css/window-chrome.css

1,340 lines 47.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * OpenStation — Window chrome.
3 *
4 * Everything that renders the base window frame: title bar, icon +
5 * title text, control buttons, focused / unfocused colour variants,
6 * the ⋯ actions menu panel, screen-meta buttons, the tab strip (for
7 * submenu + external tabs), the window body, the primary iframe, and
8 * the native-body variant. State-driven rules (dragging / resizing /
9 * maximized / fullscreen / overview / minimized / closing) live in
10 * sibling files loaded via `windows.css`.
11 *
12 * @since 6.9.0
13 */
14
15 /* Base window. */
16 .os-window {
17 /*
18 * border-box sizing is essential for pixel-perfect maximize.
19 * `Window.maximize()` sets inline `width = parent.clientWidth`,
20 * which is the area's inner width. Under content-box sizing, the
21 * window's 1px border would add 2 extra pixels to the rendered
22 * element, pushing its right + bottom borders past the
23 * overflow-hidden boundary of `.os-area` and making them
24 * look clipped/offscreen. With border-box, width/height INCLUDE
25 * the border, so `width = clientWidth` fits exactly.
26 *
27 * WP admin CSS does set `*, *::before, *::after { box-sizing:
28 * border-box }` globally, but setting it explicitly here
29 * insulates us from stylesheets or embeds that might reset the
30 * universal rule — the correctness of maximize geometry is too
31 * important to depend on ambient CSS.
32 */
33 box-sizing: border-box;
34 position: absolute;
35 display: flex;
36 flex-direction: column;
37 background: var(--os-window-bg);
38 border: 1px solid var(--os-window-border);
39 border-radius: var(--os-window-radius);
40 box-shadow: var(--os-window-shadow);
41 /*
42 * Desktop-theme window frame (WINDOW_FRAME texture slot). All
43 * four properties default to their CSS initial value via the
44 * `var( …, fallback )` second argument, so with no theme active
45 * this costs nothing and the 1px border above stays in charge.
46 * A theme that sets `--os-window-border-image-source`
47 * takes the frame over entirely — border-image paints on top of
48 * (and visually replaces) the border. See docs/desktop-themes.md.
49 */
50 border-image-source: var(--os-window-border-image-source, none);
51 border-image-slice: var(--os-window-border-image-slice, 100%);
52 border-image-width: var(--os-window-border-image-width, 1);
53 border-image-repeat: var(--os-window-border-image-repeat, stretch);
54 overflow: hidden;
55 min-width: 320px;
56 min-height: 200px;
57 transform-origin: center center;
58 transition:
59 left 0.25s cubic-bezier(0.2, 0, 0.2, 1),
60 top 0.25s cubic-bezier(0.2, 0, 0.2, 1),
61 width 0.25s cubic-bezier(0.2, 0, 0.2, 1),
62 height 0.25s cubic-bezier(0.2, 0, 0.2, 1),
63 border-radius 0.25s ease,
64 transform 0.2s ease,
65 opacity 0.2s ease,
66 box-shadow 0.2s ease,
67 /* Unfocus effects (e.g. `--fx-darken` / `--fx-frost`) toggle
68 * `filter`; keep it in the shared transition list so the
69 * treatment fades both ways as focus moves between windows.
70 * Duration is a custom property (default in `effects.css`) so
71 * the blur/darken ramp can be tuned without editing this list. */
72 filter var( --os-fx-transition-duration, 0.5s ) ease-in-out;
73 }
74
75 /*
76 * Suppress the left/top/width/height transition during drag or resize —
77 * otherwise every pointer move lerps and the window lags the cursor.
78 *
79 * Also during a viewport reflow (`--reflowing`) — when the browser
80 * window is being dragged smaller, the ResizeObserver fires many
81 * times per second and each style write would restart the 250 ms
82 * transition, leaving maximized / snapped windows ~250 ms behind the
83 * viewport the whole time. The class is added by
84 * `reflowStatefulWindows` and cleared ~140 ms after the last tick.
85 */
86 .os-window--dragging,
87 .os-window--resizing,
88 .os-window--reflowing {
89 transition: none;
90 }
91
92 /*
93 * Snap-to-grid drag/resize: re-enable a SHORT transition so each
94 * cell-to-cell jump animates smoothly instead of teleporting. The
95 * `--snap-drag` class is added by the window class only when snap
96 * is on at drag/resize start; it composes with `--dragging` /
97 * `--resizing` and the source-order ensures it wins. Kept short
98 * (90 ms) so the window still feels glued to the cursor — anything
99 * longer reads as lag.
100 */
101 .os-window--snap-drag {
102 transition:
103 left 0.09s ease-out,
104 top 0.09s ease-out,
105 width 0.09s ease-out,
106 height 0.09s ease-out;
107 }
108
109 /* Focused window gets elevated shadow and distinct title bar. */
110 .os-window--focused {
111 box-shadow: var(--os-window-shadow-focused);
112 /*
113 * WINDOW_FRAME_FOCUSED texture slot. Each property falls through
114 * the unfocused WINDOW_FRAME value before reaching its initial, so
115 * a theme shipping one frame gets it on both states and a theme
116 * shipping two gets a frame that lights up on focus — the same
117 * fallback chain TITLEBAR_FOCUSED uses.
118 */
119 border-image-source: var(
120 --os-window-border-image-focused-source,
121 var(--os-window-border-image-source, none)
122 );
123 border-image-slice: var(
124 --os-window-border-image-focused-slice,
125 var(--os-window-border-image-slice, 100%)
126 );
127 border-image-width: var(
128 --os-window-border-image-focused-width,
129 var(--os-window-border-image-width, 1)
130 );
131 border-image-repeat: var(
132 --os-window-border-image-focused-repeat,
133 var(--os-window-border-image-repeat, stretch)
134 );
135 }
136
137 /* Title bar. */
138 .os-window__titlebar {
139 position: relative;
140 /* Promote the titlebar into its own stacking context above the
141 * body. Without this, the body (which comes later in source order
142 * and inherits z-auto) wins document-order overlap battles —
143 * notably any sticky-positioned content inside the body (e.g. the
144 * native Posts table's sticky header at z-index 20–40) would
145 * paint over the titlebar's ⋯ menu popover. */
146 z-index: 21;
147 display: flex;
148 align-items: center;
149 height: var(--os-titlebar-height);
150 padding: 0 8px;
151 background-color: var(--os-titlebar-bg);
152 /*
153 * Desktop-theme title-bar texture (TITLEBAR slot). `none` when
154 * unset, so the background-color above is the whole story for
155 * an unthemed shell. The `-repeat` / `-size` companions are
156 * declared unconditionally with their CSS initial values as
157 * fallbacks — cheaper than a second selector and it keeps the
158 * shorthand from resetting them.
159 */
160 background-image: var(--os-titlebar-image, none);
161 background-repeat: var(--os-titlebar-image-repeat, repeat);
162 background-size: var(--os-titlebar-image-size, auto);
163 background-position: var(--os-titlebar-image-position, center);
164 color: var(--os-titlebar-color);
165 /*
166 * Title bars can carry their own face — a display typeface here
167 * with a text face in the body is the classic desktop split. Two
168 * levels of fallback: the title-bar token, then the shell-wide
169 * one, then `inherit`, which is what an undeclared font-family
170 * would have computed to anyway. Unthemed shells are unchanged.
171 */
172 font-family: var(
173 --os-titlebar-font,
174 var(--os-font, inherit)
175 );
176 cursor: default;
177 user-select: none;
178 flex-shrink: 0;
179 gap: 8px;
180 }
181
182 .os-window--focused .os-window__titlebar {
183 background-color: var(--os-titlebar-bg-focused);
184 /*
185 * TITLEBAR_FOCUSED slot. Falls back through the unfocused
186 * TITLEBAR image before reaching `none`, so a theme that ships
187 * only one title-bar texture gets it on both states for free.
188 */
189 background-image: var(
190 --os-titlebar-image-focused,
191 var(--os-titlebar-image, none)
192 );
193 color: var(--os-titlebar-color-focused);
194 }
195
196 /* Window icon in title bar. */
197 .os-window__icon {
198 font-size: 18px;
199 width: 18px;
200 height: 18px;
201 flex-shrink: 0;
202 }
203
204 /* Letter-badge fallback (unrecognized icon values) — shrink the
205 * one-/two-letter monogram so it fits the 18px icon box. */
206 .os-window__icon.os-icon-letter {
207 font-size: 9px;
208 }
209
210 /* Activity indicator slot — sits between the icon and the title.
211 * Reserves a fixed width so the indicator's blink animation can't
212 * shift the title text horizontally. The inner `<os-save-status>`
213 * paints a 12px modem-style dot (always visible, accent-colored).
214 *
215 * `--wp-admin-theme-color` is forwarded as `color` on the host so
216 * the inner shadow-DOM stylesheet's `currentColor` references
217 * (used in the box-shadow glow) resolve to the live accent. */
218 .os-window__activity {
219 display: inline-flex;
220 align-items: center;
221 justify-content: center;
222 width: 14px;
223 height: 14px;
224 flex-shrink: 0;
225 margin-inline-start: 6px;
226 margin-inline-end: 4px;
227 color: var(--wp-admin-theme-color, #2271b1);
228 }
229
230 /* Window title text. */
231 .os-window__title {
232 flex: 1;
233 overflow: hidden;
234 text-overflow: ellipsis;
235 white-space: nowrap;
236 font-size: 14px;
237 font-weight: 500;
238 line-height: var(--os-titlebar-height);
239 }
240
241 /*
242 * Window control buttons container.
243 *
244 * The cluster is TRANSPARENT by default, which is the whole point: a
245 * themed title-bar texture runs edge to edge underneath it, and the
246 * buttons float on the artwork rather than sitting on a plate. Every
247 * property below resolves to exactly that when unset.
248 *
249 * A theme that wants a plate instead sets `--os-titlebar-
250 * controls-bg` (and optionally the TITLEBAR_CONTROLS texture slot,
251 * a radius, and some inline padding) — the classic "the controls live
252 * in their own well" look, without the framework picking it for
253 * everyone.
254 *
255 * `padding-block` stays 0 so the cluster never changes the title
256 * bar's height; only the inline padding is themable.
257 *
258 * @since 0.9.8
259 */
260 .os-window__controls {
261 display: flex;
262 gap: var( --os-titlebar-controls-gap, 4px );
263 align-items: center;
264 flex-shrink: 0;
265 padding-inline: var( --os-titlebar-controls-padding, 0 );
266 border-radius: var( --os-titlebar-controls-radius, 0 );
267 background-color: var( --os-titlebar-controls-bg, transparent );
268 background-image: var( --os-titlebar-controls-image, none );
269 background-repeat: var( --os-titlebar-controls-image-repeat, repeat );
270 background-size: var( --os-titlebar-controls-image-size, auto );
271 background-position: var( --os-titlebar-controls-image-position, center );
272 }
273
274 /*
275 * Same treatment for the Screen Options / Help cluster, so a theme
276 * that plates one can plate the other and keep them consistent.
277 * Falls through to the controls tokens, so setting the pair above is
278 * enough for both.
279 */
280 .os-window__screen-meta {
281 border-radius: var(
282 --os-titlebar-meta-radius,
283 var( --os-titlebar-controls-radius, 0 )
284 );
285 background-color: var( --os-titlebar-meta-bg, transparent );
286 background-image: var( --os-titlebar-meta-image, none );
287 background-repeat: var( --os-titlebar-meta-image-repeat, repeat );
288 background-size: var( --os-titlebar-meta-image-size, auto );
289 background-position: var( --os-titlebar-meta-image-position, center );
290 }
291
292 /*
293 * Layer-3 slot hosts.
294 *
295 * Most in-titlebar slots wrap canonical chrome elements without
296 * contributing to the flex layout themselves — `display: contents`
297 * removes the wrapper's box so the inner element (icon span with
298 * `flex-shrink: 0`, before/after spacers, etc.) keeps its
299 * original layout role.
300 *
301 * The `title` slot is the exception: it's the flex-grow region of
302 * the title bar (`flex: 1`). When a plugin replaces the default
303 * title with custom HTML or a render callback, we want the new
304 * content to inherit the same "fill the remaining horizontal
305 * space" behaviour — so the slot itself owns `flex: 1` and the
306 * default title element's own `flex: 1` cooperates inside the
307 * nested flex.
308 *
309 * before/after-titlebar slots are flex-column children of
310 * `.os-window` (siblings of the title bar). They're real
311 * boxes — plugins can paint backgrounds, padding, borders. Empty
312 * hosts collapse via `:empty` so they take no space until populated.
313 *
314 * @since 0.6.0
315 */
316 .os-window__slot--before-icon,
317 .os-window__slot--icon,
318 .os-window__slot--after-title,
319 .os-window__slot--before-controls,
320 .os-window__slot--after-controls {
321 display: contents;
322 }
323
324 .os-window__slot--title {
325 flex: 1;
326 min-width: 0;
327 display: flex;
328 align-items: center;
329 overflow: hidden;
330 }
331
332 .os-window__slot--before-titlebar,
333 .os-window__slot--after-titlebar {
334 display: block;
335 flex-shrink: 0;
336 }
337
338 .os-window__slot--before-titlebar:empty,
339 .os-window__slot--after-titlebar:empty {
340 display: none;
341 }
342
343 /* ---------------------------------------------------------------
344 * Window control buttons.
345 *
346 * Flat, icon-only buttons styled to feel at home in the WordPress
347 * admin: transparent by default, subtle tinted hover, focus ring in
348 * the active admin theme color, inline SVG icons inheriting
349 * currentColor so they adapt to focused / unfocused title bars.
350 *
351 * The close button gets a destructive red wash on hover only —
352 * never as a default state — so it reads as safe until interacted
353 * with, matching the WordPress admin's pattern of reserving color
354 * for semantic signal.
355 * --------------------------------------------------------------- */
356 /*
357 * Title-bar chrome buttons render as `<os-window-button>`.
358 * Shadow DOM can't reach across the window's focus class, so we
359 * drive the coloring via custom properties that inherit through
360 * the boundary. The component reads `--os-ui-btn-color`,
361 * `--os-ui-btn-bg-hover`, etc. and paints accordingly.
362 *
363 * Focused control glyphs are white at 70% — correct against the
364 * default focused title bar, which is the admin theme colour and so
365 * always a mid-to-dark fill, and invisible against a pale one. A
366 * theme could not reach them: these declarations land on the WINDOW
367 * element, so a `--os-ui-btn-color` set at the shell root loses to
368 * them, and the same names also drive buttons outside the title bar
369 * (sticky notes, the desk chrome) that a theme usually does not want
370 * to move in the same stroke.
371 *
372 * `--os-titlebar-btn-focused-*` mirrors the unfocused set
373 * below, name for name, so the two halves of the title bar are
374 * addressed the same way. Every one is UNDECLARED and falls back to
375 * the literal it replaced, so an unthemed shell paints exactly what
376 * it painted before.
377 *
378 * There is no `-danger-hover` twin: destructive red is semantic, not
379 * chrome, and both halves already resolve it through `--os-ui-danger`.
380 */
381 .os-window--focused {
382 --os-ui-btn-color: var( --os-titlebar-btn-focused-color, rgba( 255, 255, 255, 0.7 ) );
383 --os-ui-btn-color-hover: var( --os-titlebar-btn-focused-color-hover, #fff );
384 --os-ui-btn-bg-hover: var( --os-titlebar-btn-focused-bg-hover, rgba( 255, 255, 255, 0.18 ) );
385 --os-ui-btn-bg-active: var( --os-titlebar-btn-focused-bg-active, rgba( 255, 255, 255, 0.25 ) );
386 --os-ui-btn-outline: var( --os-titlebar-btn-focused-outline, rgba( 255, 255, 255, 0.65 ) );
387 --os-ui-btn-danger-hover: var( --os-ui-danger, #d63638 );
388 }
389
390 .os-window:not( .os-window--focused ) {
391 --os-ui-btn-color: var( --os-titlebar-btn-color, rgba( 0, 0, 0, 0.45 ) );
392 --os-ui-btn-color-hover: var( --os-titlebar-btn-color-hover, rgba( 0, 0, 0, 0.85 ) );
393 --os-ui-btn-bg-hover: var( --os-titlebar-btn-bg-hover, rgba( 0, 0, 0, 0.08 ) );
394 --os-ui-btn-bg-active: var( --os-titlebar-btn-bg-active, rgba( 0, 0, 0, 0.12 ) );
395 --os-ui-btn-outline: var( --wp-admin-theme-color, #2271b1 );
396 --os-ui-btn-danger-hover: var( --os-ui-danger, #d63638 );
397 }
398
399 /*
400 * Unfocused control glyphs under an active desktop theme.
401 *
402 * The rule above paints them BLACK at 45% — correct against the
403 * default light unfocused title bar (`#f0f0f1`), invisible against a
404 * dark themed one. They can't simply follow the palette either:
405 * these are title-bar chrome, so the colour they owe allegiance to is
406 * the title bar's own text colour, not the window body's.
407 *
408 * Deriving it with `color-mix` means a theme gets legible unfocused
409 * controls for free from the `--os-titlebar-color` it was
410 * already setting — no extra tokens to discover. The four
411 * `--os-titlebar-btn-*` overrides above still win when an
412 * author wants exact control.
413 *
414 * Scoped to `[data-os-desktop-theme]` on purpose: the
415 * default title-bar colour is `#50575e`, so applying this
416 * unconditionally would lighten the unfocused glyphs on every
417 * unthemed shell. No theme, no change.
418 */
419 .os-shell[ data-os-desktop-theme ]
420 .os-window:not( .os-window--focused ) {
421 --os-ui-btn-color: var(
422 --os-titlebar-btn-color,
423 color-mix( in srgb, var( --os-titlebar-color, #50575e ) 72%, transparent )
424 );
425 --os-ui-btn-color-hover: var(
426 --os-titlebar-btn-color-hover,
427 var( --os-titlebar-color, #50575e )
428 );
429 --os-ui-btn-bg-hover: var(
430 --os-titlebar-btn-bg-hover,
431 color-mix( in srgb, var( --os-titlebar-color, #50575e ) 18%, transparent )
432 );
433 --os-ui-btn-bg-active: var(
434 --os-titlebar-btn-bg-active,
435 color-mix( in srgb, var( --os-titlebar-color, #50575e ) 26%, transparent )
436 );
437 }
438
439 /* ---------------------------------------------------------------
440 * Title-bar actions menu (leading edge, before icon + title).
441 *
442 * Trigger is a standard `.os-window__btn` — inherits focused /
443 * unfocused coloring from the existing control-button rules. The only
444 * bespoke rule is margin: it sits at the leading edge so a small trailing
445 * gap separates it from the icon without affecting overall title-bar gap.
446 *
447 * Panel is an absolute dropdown anchored to the title bar (which is now
448 * position: relative). Visibility is driven by the `hidden` attribute on
449 * the element itself — no separate `--open` class to keep in sync with
450 * aria-expanded.
451 * --------------------------------------------------------------- */
452 /*
453 * ⋯ button sits between the screen-meta cluster and the window
454 * controls, as the "last page-level chrome" item. When present it
455 * takes over the divider duty: screen-meta drops its own trailing
456 * divider, and the controls container gains a leading one — so a
457 * single clean line always sits between page chrome and window
458 * chrome regardless of which combination is visible.
459 */
460 .os-window__titlebar:has(.os-window__menu-btn) .os-window__screen-meta {
461 margin-inline-end: 0;
462 padding-inline-end: 0;
463 border-inline-end: none;
464 }
465
466 .os-window__titlebar:has(.os-window__menu-btn) .os-window__controls {
467 margin-inline-start: 8px;
468 padding-inline-start: 8px;
469 /* Tokenized so a theme can retint it — or set `transparent` and
470 * let its own title-bar artwork carry the separation. */
471 border-inline-start: 1px solid
472 var( --os-titlebar-divider, rgba(255, 255, 255, 0.15) );
473 }
474
475 .os-window:not(.os-window--focused) .os-window__titlebar:has(.os-window__menu-btn) .os-window__controls {
476 border-inline-start-color: var(
477 --os-titlebar-divider-unfocused,
478 rgba(0, 0, 0, 0.1)
479 );
480 }
481
482 /*
483 * Menu popover lives inside the title bar's absolute-positioned
484 * coordinate system — positioning is caller-specific and stays
485 * outer. Background, border, items, checkbox styling all moved
486 * into `<os-menu>` / `<os-menu-item>`.
487 */
488 .os-window__menu-panel {
489 position: absolute;
490 top: calc( var( --os-titlebar-height ) + 2px );
491 inset-inline-end: 60px;
492 z-index: 2;
493 }
494
495 /*
496 * "Related" dropdown — opened by the related-entities title-bar
497 * button (`src/related-entities/`). The panel ALSO carries
498 * `.os-window__menu-panel` (positioning + the drag-tracker
499 * exclusion come from there); this class only layers the related-menu
500 * extras: an internal scroll cap for media-heavy posts and a sane
501 * minimum width.
502 */
503 .os-window__related-panel {
504 max-height: min( 60vh, 420px );
505 overflow-y: auto;
506 min-width: 180px;
507 }
508
509 /* Section header inside the Related dropdown ("Categories", "Media"). */
510 .os-window__related-group {
511 padding: 6px 12px 2px;
512 font-size: 11px;
513 font-weight: 600;
514 text-transform: uppercase;
515 letter-spacing: 0.04em;
516 opacity: 0.6;
517 color: var( --os-ui-fg );
518 }
519
520 .os-window__related-group:not(:first-child) {
521 margin-block-start: 4px;
522 border-block-start: 1px solid rgba( 128, 128, 128, 0.25 );
523 padding-block-start: 8px;
524 }
525
526 /* ---------------------------------------------------------------
527 * Screen Meta buttons (Screen Options / Help) in the title bar.
528 * Positioned right after the title text, visually separated from
529 * the close / maximize / minimize cluster by a subtle divider.
530 * Sized to meet WCAG 2.2 target size (24x24 minimum).
531 * --------------------------------------------------------------- */
532 .os-window__screen-meta {
533 display: flex;
534 gap: 4px;
535 align-items: center;
536 flex-shrink: 0;
537 margin-inline-end: 8px;
538 padding-inline-end: 8px;
539 border-inline-end: 1px solid
540 var( --os-titlebar-divider, rgba(255, 255, 255, 0.15) );
541 }
542
543 /* Hide the divider when there are no screen-meta buttons. */
544 .os-window__screen-meta:empty {
545 display: none;
546 }
547
548 .os-window__meta-btn {
549 display: flex;
550 align-items: center;
551 justify-content: center;
552 width: 28px;
553 height: 28px;
554 border: none;
555 border-radius: 6px;
556 cursor: pointer;
557 padding: 0;
558 background: transparent;
559 transition: background-color 0.15s ease, color 0.15s ease;
560 }
561
562 .os-window__meta-btn .dashicons {
563 font-size: 18px;
564 width: 18px;
565 height: 18px;
566 }
567 /* The flex-centring override for the dashicon glyph currently lives
568 * in `windows.css`. That placement was a cache workaround from when
569 * this file was an `@import` sub-sheet with no `?ver=` of its own;
570 * it is now a separately registered, filemtime-stamped handle, so
571 * the workaround no longer buys anything. Left where it is for now —
572 * relocating it is behaviour-neutral churn. */
573
574 /* Focused window: meta buttons visible.
575 *
576 * Screen-meta buttons are not `<os-window-button>` — they are plain
577 * buttons in the light DOM, so they paint themselves instead of
578 * reading the `--os-ui-btn-*` bridge. They sit in the same title bar
579 * against the same fill, so they take the same
580 * `--os-titlebar-btn-focused-*` tokens, each keeping its own
581 * literal as the fallback: unthemed they stay a touch dimmer at rest
582 * than the window controls (0.65 vs 0.7), themed they move together
583 * rather than one cluster going legible and the other staying white. */
584 .os-window--focused .os-window__meta-btn {
585 color: var( --os-titlebar-btn-focused-color, rgba(255, 255, 255, 0.65) );
586 }
587 .os-window--focused .os-window__meta-btn:hover {
588 color: var( --os-titlebar-btn-focused-color-hover, var( --os-ui-fg-on-accent, #fff ) );
589 background: var( --os-titlebar-btn-focused-bg-hover, rgba(255, 255, 255, 0.18) );
590 }
591 .os-window--focused .os-window__meta-btn:focus-visible {
592 color: var( --os-titlebar-btn-focused-color-hover, var( --os-ui-fg-on-accent, #fff ) );
593 background: var( --os-titlebar-btn-focused-bg-hover, rgba(255, 255, 255, 0.18) );
594 outline: 2px solid var( --os-titlebar-btn-focused-outline, rgba(255, 255, 255, 0.6) );
595 outline-offset: 1px;
596 }
597 .os-window--focused .os-window__meta-btn--active {
598 color: var( --os-titlebar-btn-focused-color-hover, var( --os-ui-fg-on-accent, #fff ) );
599 background: var( --os-titlebar-btn-focused-bg-active, rgba(255, 255, 255, 0.25) );
600 }
601
602 /* Unfocused window: divider and buttons adapt to light title bar. */
603 .os-window:not(.os-window--focused) .os-window__screen-meta {
604 border-inline-end-color: var(
605 --os-titlebar-divider-unfocused,
606 rgba(0, 0, 0, 0.1)
607 );
608 }
609 /*
610 * Screen Options / Help on an UNFOCUSED title bar. These read the same
611 * two tokens as the window controls beside them rather than their own
612 * literals — a black glyph is invisible on a dark title bar, and these
613 * buttons sit in the same strip as controls that were already themable.
614 * The literals are unchanged, so an unthemed bar looks as it always did.
615 */
616 .os-window:not(.os-window--focused) .os-window__meta-btn {
617 color: var( --os-titlebar-btn-color, rgba(0, 0, 0, 0.3) );
618 }
619 .os-window:not(.os-window--focused) .os-window__meta-btn:hover {
620 color: var( --os-titlebar-btn-color-hover, rgba(0, 0, 0, 0.6) );
621 background: var( --os-ui-hover, rgba(0, 0, 0, 0.08) );
622 }
623 .os-window:not(.os-window--focused) .os-window__meta-btn--active {
624 color: var( --os-titlebar-btn-color-hover, rgba(0, 0, 0, 0.6) );
625 background: rgba(0, 0, 0, 0.12);
626 }
627
628 /* ---------------------------------------------------------------
629 * Tab strip — submenu navigation rendered in the parent shell, just
630 * below the title bar. Each tab swaps the iframe URL in place; no
631 * new window opens. Horizontally scrollable on narrow windows so the
632 * same component works on tablet and mobile shells unchanged.
633 * --------------------------------------------------------------- */
634 .os-window__tabs {
635 display: flex;
636 flex-shrink: 0;
637 gap: 2px;
638 padding: 0 8px;
639 background-color: var( --os-tabs-bg, var( --os-ui-surface-elevated, #f6f7f7 ) );
640 /* TABBAR texture slot — layered over the strip's own colour. */
641 background-image: var( --os-tabs-image, none );
642 background-repeat: var( --os-tabs-image-repeat, repeat );
643 background-size: var( --os-tabs-image-size, auto );
644 background-position: var( --os-tabs-image-position, center );
645 border-bottom: 1px solid var(--os-window-border);
646 overflow-x: auto;
647 overflow-y: hidden;
648 scrollbar-width: thin;
649 /*
650 * `overscroll-behavior-x: contain` keeps horizontal trackpad
651 * swipes inside the strip instead of triggering browser back.
652 */
653 overscroll-behavior-x: contain;
654 }
655
656 /*
657 * Soft edge fades so overflowing tabs tell the user "scroll for more."
658 *
659 * Each fade is painted ONLY on an edge that is actually hiding a tab.
660 * `observeTabOverflow()` in `src/window/tabs.ts` stamps `data-overflow`
661 * with the physical edges currently covered — `left`, `right`, `both`,
662 * or the attribute dropped when the strip fits — and re-measures on
663 * scroll, on resize, and when tabs are added or removed.
664 *
665 * This used to be one unconditional mask on both ends. The reasoning
666 * was that on a strip that fits, the faded ends land past the last tab
667 * and so fade nothing. That held on the pre-brand light strip, where
668 * the tab bar and the title bar above it were near enough the same
669 * near-white that a mask over empty area was invisible. It stopped
670 * holding on the station: the strip is Obsidian over a darker window
671 * edge, so masking its ends to transparent punches two grey smudges
672 * into every window's submenu, whether or not there is anything to
673 * scroll to. A permanent affordance for a state that is usually false
674 * is not an affordance — it is decoration that lies.
675 */
676 .os-window__tabs[ data-overflow='left' ] {
677 mask-image: linear-gradient( to right, transparent 0, #000 16px );
678 -webkit-mask-image: linear-gradient( to right, transparent 0, #000 16px );
679 }
680
681 .os-window__tabs[ data-overflow='right' ] {
682 mask-image: linear-gradient(
683 to right,
684 #000 calc(100% - 16px),
685 transparent 100%
686 );
687 -webkit-mask-image: linear-gradient(
688 to right,
689 #000 calc(100% - 16px),
690 transparent 100%
691 );
692 }
693
694 .os-window__tabs[ data-overflow='both' ] {
695 mask-image: linear-gradient(
696 to right,
697 transparent 0,
698 #000 16px,
699 #000 calc(100% - 16px),
700 transparent 100%
701 );
702 -webkit-mask-image: linear-gradient(
703 to right,
704 transparent 0,
705 #000 16px,
706 #000 calc(100% - 16px),
707 transparent 100%
708 );
709 }
710
711 .os-window__tab {
712 flex-shrink: 0;
713 display: inline-flex;
714 align-items: center;
715 height: 32px;
716 padding: 0 12px;
717 border: none;
718 background: transparent;
719 color: var( --os-tabs-color, var( --os-ui-fg-muted, #50575e ) );
720 font: inherit;
721 font-size: 12px;
722 line-height: 1;
723 cursor: pointer;
724 border-bottom: 2px solid transparent;
725 margin-bottom: -1px;
726 white-space: nowrap;
727 transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
728 }
729
730 .os-window__tab:hover {
731 color: var(--wp-admin-theme-color, #2271b1);
732 background: var( --os-ui-hover, rgba(0, 0, 0, 0.03) );
733 }
734
735 .os-window__tab:focus-visible {
736 outline: 2px solid var(--wp-admin-theme-color, #2271b1);
737 outline-offset: -2px;
738 }
739
740 .os-window__tab--active {
741 color: var(--wp-admin-theme-color, #2271b1);
742 border-bottom-color: var(--wp-admin-theme-color, #2271b1);
743 font-weight: 600;
744 }
745
746 /*
747 * External sub-tab. Same shape as a submenu tab, plus two inline
748 * chips: detach (↗) and close (×). Chips are painted as spans
749 * (rather than nested buttons — nested interactive elements are a
750 * pain for a11y). The tab's click handler routes chip clicks via
751 * the `data-tab-action` dataset attribute.
752 */
753 .os-window__tab--external {
754 /* Slightly wider gap between the label and the chip cluster, and
755 * more breathing room at the trailing edge so the chips aren't
756 * flush against the tab boundary. */
757 gap: 10px;
758 padding-inline-end: 4px;
759 }
760
761 .os-window__tab-label {
762 max-width: 180px;
763 overflow: hidden;
764 text-overflow: ellipsis;
765 white-space: nowrap;
766 }
767
768 /* External-tab action chips moved to `<os-tab-chip>`. Spacing
769 * between chips handled by a single host-level rule since the
770 * component doesn't know about its siblings. */
771 os-tab-chip + os-tab-chip {
772 margin-inline-start: 4px;
773 }
774
775 /* Window body: contains the iframe. */
776 .os-window__body {
777 flex: 1;
778 position: relative;
779 overflow: hidden;
780 /*
781 * WINDOW_BODY texture slot. Visible behind native window content
782 * and behind any iframe whose page has a transparent background;
783 * an opaque wp-admin page inside an iframe paints over it, which
784 * is why a theme wanting texture everywhere reaches for DESKTOP
785 * and the chrome slots instead.
786 */
787 background-image: var( --os-window-body-image, none );
788 background-repeat: var( --os-window-body-image-repeat, repeat );
789 background-size: var( --os-window-body-image-size, auto );
790 background-position: var( --os-window-body-image-position, center );
791 /*
792 * Body typeface. `--os-ui-font` is the component kit's own token, so
793 * one declaration here reaches every `<os-*>` element inside the
794 * window: shadow DOM inherits `font-family` through the boundary.
795 */
796 font-family: var( --os-ui-font, inherit );
797 }
798
799 /* Iframe fills the window body. */
800 .os-window__iframe {
801 width: 100%;
802 height: 100%;
803 border: none;
804 display: block;
805 background: var(--os-window-bg);
806 opacity: 1;
807 transition: opacity 0.25s ease;
808 }
809
810 /*
811 * Headings inside native window bodies.
812 *
813 * WordPress core's `wp-admin/css/common.css` styles headings with
814 * BARE ELEMENT selectors — `h1 { color: #1d2327 }` and
815 * `h2, h3 { color: #1d2327 }`. Native windows render in the parent
816 * shell rather than in an iframe, so those rules reach straight into
817 * our light-DOM window content: an `<h3>` in a `<os-card>` came out
818 * near-black on a dark theme while every sibling paragraph correctly
819 * followed the palette.
820 *
821 * The specificity here is doing real work. It has to sit BETWEEN two
822 * other rules:
823 *
824 * core `h3` (0,0,1) must lose
825 * THIS rule (0,0,1) wins on source order
826 * `.os-my-wordpress__user-section h3` (0,1,1) must still win
827 *
828 * `:where()` contributes zero specificity, so the selector below
829 * weighs the same as core's bare `h3` and beats it only because our
830 * stylesheet prints later — while any of our own class-scoped heading
831 * rules continue to override it. Raising this to a plain
832 * `.os-window__body h3` would tie with those and break them
833 * depending on file order.
834 *
835 * The `#1d2327` fallback is core's own value, so with no theme active
836 * the computed colour is unchanged.
837 */
838 :where( .os-window__body ) :is( h1, h2, h3, h4, h5, h6 ) {
839 color: var( --os-ui-fg, #1d2327 );
840 }
841
842 /*
843 * Same story, same fix, for the other bare-element rules core ships:
844 *
845 * a { color: #2271b1 }
846 * code { background: #f0f0f1 }
847 *
848 * `code` is the worse of the two — a light chip background with
849 * palette-coloured text on it inverts to unreadable the moment a
850 * theme goes dark. Both fallbacks are core's own values, so the
851 * default is unchanged.
852 *
853 * Form controls (`input`, `textarea`, `select`) are deliberately NOT
854 * included: core styles those through attribute selectors that carry
855 * real specificity, native windows overwhelmingly use the `<os-*>`
856 * components instead, and a half-applied override there would look
857 * worse than leaving them alone.
858 */
859 :where( .os-window__body ) a {
860 color: var( --os-ui-accent, #2271b1 );
861 }
862
863 :where( .os-window__body ) code {
864 background: var( --os-ui-surface-sunken, #f0f0f1 );
865 color: var( --os-ui-fg, inherit );
866 }
867
868 /*
869 * Raw form controls in native window bodies.
870 *
871 * Core's `forms.css` styles these through ATTRIBUTE selectors —
872 * `input[type="search"], select, textarea { background-color: #fff;
873 * color: #1e1e1e; border: 1px solid #949494 }` — which weighs
874 * (0,1,1). That beats a plain class selector, so a control our own
875 * CSS had already tokenized (My WordPress's search box reads
876 * `background: var( --os-ui-surface, #fff )` and has done all along)
877 * still came out white: core simply outranked it.
878 *
879 * Hence `.os-window__body` + `:is( input[type], … )`, which
880 * weighs (0,2,1) and wins. `<os-*>` form components are untouched —
881 * they live in shadow DOM, where none of this reaches.
882 *
883 * Fallbacks are core's own values, so an unthemed shell is unchanged.
884 */
885 .os-window__body
886 :is(
887 input[ type="text" ],
888 input[ type="search" ],
889 input[ type="email" ],
890 input[ type="url" ],
891 input[ type="tel" ],
892 input[ type="number" ],
893 input[ type="password" ],
894 input[ type="date" ],
895 input[ type="datetime-local" ],
896 input[ type="month" ],
897 input[ type="time" ],
898 input[ type="week" ],
899 select,
900 textarea
901 ) {
902 background-color: var( --os-ui-surface, #fff );
903 color: var( --os-ui-fg, #1e1e1e );
904 border-color: var( --os-ui-border-strong, #949494 );
905 }
906
907 .os-window__body :is( input, textarea )::placeholder {
908 color: var( --os-ui-fg-muted, #646970 );
909 }
910
911 /* Cross-window drag drop-overlay rules currently live in windows.css.
912 * That was originally a cache workaround — this file was an `@import`
913 * sub-sheet with no `?ver=` of its own, so edits here could be served
914 * stale indefinitely. It is now a separately registered handle with
915 * its own filemtime stamp, so the workaround is obsolete and those
916 * rules could move back here. Left in place for now because moving
917 * them is a behaviour-neutral churn better done on its own. */
918
919 /*
920 * Loading-state overlay — painted by `src/window/dom.ts` into
921 * every window's body and removed once the body's content reports
922 * ready. The `<os-spinner>` inside is sized responsively against
923 * the window's width via `clamp(96px, 14vw, 192px)` so a tiny
924 * popover gets a small spinner and a maximized window gets a big
925 * one. Placed above the body content (iframe / native render
926 * output) and kept aria-hidden so the spinner's own SR-label is
927 * the only loading announcement.
928 *
929 * The `--visible` modifier turns the overlay on. JS adds it ~120ms
930 * into the load (`LOADING_OVERLAY_SHOW_DELAY_MS`), so a fast render
931 * never paints a spinner. The delay cannot be a `transition-delay`
932 * here: the overlay is appended into a body that already carries
933 * `--loading`, so its first computed style is the visible one and the
934 * transition never runs.
935 */
936 .os-window__loading {
937 position: absolute;
938 inset: 0;
939 display: flex;
940 align-items: center;
941 justify-content: center;
942 pointer-events: none;
943 background: var(--os-window-bg);
944 opacity: 0;
945 /* Default (loaded → loading-removed) — fade out immediately. */
946 transition: opacity 0.25s ease;
947 /*
948 * Above both reveal layers (z-index 2 and 3), so the spinner stays
949 * readable for the whole load and the surface it is sitting on only
950 * becomes visible once the spinner has faded out.
951 */
952 z-index: 4;
953 }
954
955 /*
956 * Content hidden while loading. The reveal surface is excluded
957 * alongside the spinner overlay: it is chrome, not content, and fading
958 * it to transparent would show the very content it exists to cover.
959 */
960 .os-window__body--loading > .os-window__iframe,
961 .os-window__body--loading
962 > :not(.os-window__loading):not(.os-window__reveal) {
963 opacity: 0;
964 transition: opacity 0.25s ease;
965 }
966
967 .os-window__body--loading > .os-window__loading--visible {
968 opacity: 1;
969 }
970
971 /*
972 * Content hand-off. `--loading` drops as soon as the content is ready,
973 * but the overlay above it still needs 250ms to fade out. Without this
974 * both layers are on screen at once, which reads as a flash.
975 *
976 * This modifier holds the content transparent for the length of that
977 * fade, then fades it in. The delay in the shorthand does the holding.
978 *
979 * Only added when the spinner actually painted. A load that finishes
980 * inside the show delay never reached `--visible`, so the shell drops
981 * the overlay in the same tick instead.
982 *
983 * `--revealing` declares the same selector at the same specificity and
984 * comes later in the file, so a window playing a reveal keeps its
985 * content opaque under the reveal surface.
986 */
987 .os-window__body--loading-out > .os-window__iframe,
988 .os-window__body--loading-out
989 > :not(.os-window__loading):not(.os-window__reveal) {
990 opacity: 1;
991 transition: opacity 0.25s ease 0.25s;
992 }
993
994 @media ( prefers-reduced-motion: reduce ) {
995 .os-window__iframe,
996 .os-window__loading,
997 .os-window__body--loading > .os-window__iframe,
998 .os-window__body--loading
999 > :not(.os-window__loading):not(.os-window__reveal),
1000 .os-window__body--loading-out > .os-window__iframe,
1001 .os-window__body--loading-out
1002 > :not(.os-window__loading):not(.os-window__reveal) {
1003 transition: none;
1004 }
1005 }
1006
1007 /*
1008 * Window reveal — the opaque layers a window's content is uncovered
1009 * from once it reports ready. Painted for every window (of either
1010 * kind) from construction, animated away by `src/reveals/surface.ts`,
1011 * then removed.
1012 *
1013 * They are SIBLINGS of the iframe, never wrappers. `clip-path` is
1014 * animated on these elements alone, so the content below keeps its own
1015 * compositing layer, its hit-testing, and its stacking context — a
1016 * reveal cannot swallow a click or re-rasterize the page it is
1017 * uncovering. Native window content gets the identical treatment for
1018 * the same reason.
1019 *
1020 * `--os-window-reveal-surface` is a theme token, white by
1021 * default — the surface has to be opaque or there is nothing to reveal
1022 * from. The EDGE token is `transparent` by default instead, since an
1023 * edge is an accent rather than the effect itself. Either layer that
1024 * computes to no paint is skipped rather than animated. A def may
1025 * override the surface for itself with an inline `background` (see
1026 * `WindowRevealDef.surfaceColor`), which is how `obturator` gets its
1027 * near-black shutter blades regardless of the token.
1028 *
1029 * No `transition` here on purpose — the shape is driven entirely by
1030 * the Web Animations API, which needs a matched `from` / `to` pair a
1031 * CSS transition cannot express. Reduced motion is handled JS-side by
1032 * removing the layers instead of animating them.
1033 */
1034 .os-window__reveal {
1035 position: absolute;
1036 inset: 0;
1037 pointer-events: none;
1038 background: var( --os-window-reveal-surface, #fff );
1039 z-index: 3;
1040 }
1041
1042 /*
1043 * The reveal's leading edge. Same element shape, same keyframes, run
1044 * over a slightly longer duration so it trails the surface — the sliver
1045 * of it that is not yet covered by the surface IS the edge band. That
1046 * is why this needs no shape of its own, and why every reveal (built-in
1047 * or third-party) gets an edge that follows its geometry exactly.
1048 *
1049 * Behind the surface, still above the content.
1050 *
1051 * `--os-window-reveal-edge` is `transparent` by default, and
1052 * the shell drops the layer entirely while it computes that way — so
1053 * the default costs no element and no animation. Give the token a
1054 * colour (or a gradient) to turn the edge on across every reveal at
1055 * once. `--os-window-reveal-edge-thickness` tunes how wide
1056 * the band is; `edgeLag: 0` on a def opts a single reveal out.
1057 */
1058 .os-window__reveal--edge {
1059 background: var( --os-window-reveal-edge, transparent );
1060 z-index: 2;
1061 }
1062
1063 /*
1064 * A reveal that renders its own DOM (`WindowRevealDef.render`) paints
1065 * itself — an `<svg>`, a canvas, whatever it built. The surface token
1066 * must NOT apply underneath it: an opaque rectangle behind the
1067 * renderer's output is what the effect would end up uncovering, so the
1068 * animation would play against a flat colour and the real content
1069 * would only appear when the layer is finally removed.
1070 */
1071 .os-window__reveal--custom {
1072 background: none;
1073 }
1074
1075 /*
1076 * While a reveal plays, pin the content to full opacity with no
1077 * transition. Dropping `--loading` normally starts a 250 ms content
1078 * fade-in; a reveal running over that fade would show a
1079 * half-transparent strip along its leading edge. Beats the base
1080 * `.os-window__iframe` rule on specificity, so no
1081 * `!important` is needed.
1082 */
1083 .os-window__body--revealing
1084 > :not(.os-window__loading):not(.os-window__reveal) {
1085 opacity: 1;
1086 transition: none;
1087 }
1088
1089 /*
1090 * External sub-tab iframes stack in the same body as the primary
1091 * iframe. Only one is visible at a time (managed by `switchToTab`
1092 * via `style.display`). Absolute positioning so they don't push the
1093 * primary iframe around when added to the DOM; `display: none` on
1094 * inactive iframes is set inline by the JS.
1095 */
1096 .os-window__iframe--external {
1097 position: absolute;
1098 inset: 0;
1099 }
1100
1101 /*
1102 * Double-buffer twin for `Window.swapReload()` — the silent refresh
1103 * the editor-preview companion uses. The twin loads UNDERNEATH the
1104 * visible frame at full opacity: a normal, fully-rasterized paint
1105 * target, completely covered by the (opaque) old frame while it
1106 * loads. Deliberately not `opacity: 0`-on-top or
1107 * `visibility: hidden` — browsers defer rasterizing invisible
1108 * iframes, and revealing such a frame paints its blank background
1109 * before its raster lands (a white blink).
1110 *
1111 * Stacking: positioned elements paint above static ones regardless
1112 * of DOM order, so the buffer (absolute) would land on top of the
1113 * static primary frame — `--swap-front` elevates the OLD frame for
1114 * the duration of the swap instead. The swap itself is instant and
1115 * animation-free: removing the old frame exposes the ready-painted
1116 * twin in the same compositor frame. At no point is unpainted
1117 * content the only thing on screen.
1118 */
1119 .os-window__iframe--buffer {
1120 position: absolute;
1121 inset: 0;
1122 pointer-events: none;
1123 }
1124
1125 .os-window__iframe--swap-front {
1126 position: relative;
1127 z-index: 1;
1128 }
1129
1130 /*
1131 * Native window body — fills the window but lets its contents scroll
1132 * independently. Iframe bodies use `overflow: hidden` so the iframe
1133 * controls its own scroll; native bodies render real document content
1134 * that needs the parent to manage overflow.
1135 */
1136 .os-window__body--native {
1137 overflow: auto;
1138 color: var( --os-ui-fg, #1d2327 );
1139 /* background-COLOR, not the shorthand. This is a modifier on the
1140 * SAME element as `.os-window__body`, at the same
1141 * specificity and later in source order — the shorthand would
1142 * reset the WINDOW_BODY texture declared there, which is exactly
1143 * the surface native windows exist to show. */
1144 background-color: var(--os-window-bg);
1145 }
1146
1147 /*
1148 * Highlight rings. Toggled from JS by `Window.setHighlight()` —
1149 * used by plugins that need to point at a window from outside it
1150 * (e.g. a "connect to" dropdown that previews candidate windows on
1151 * hover). `--wp-window-highlight-color` is overridable per-instance
1152 * via `setHighlight( mode, { color } )` or globally via the
1153 * variable.
1154 */
1155 .wp-window {
1156 --wp-window-highlight-color: var(
1157 --wp-admin-theme-color, #2271b1
1158 );
1159 }
1160 .wp-window--highlight-preview {
1161 box-shadow: 0 0 0 3px var( --wp-window-highlight-color ),
1162 0 0 24px 4px var( --wp-window-highlight-color );
1163 transition: box-shadow 0.12s ease;
1164 z-index: 9999;
1165 }
1166 .wp-window--highlight-persistent {
1167 box-shadow: 0 0 0 2px var( --wp-window-highlight-color );
1168 transition: box-shadow 0.12s ease;
1169 }
1170
1171 /*
1172 * Slots for plugin-registered title-bar buttons. Empty by default
1173 * — `display: contents` hides them entirely from layout when no
1174 * plugin has registered a button for the window. When buttons ARE
1175 * present, they sit inline next to the title (left slot) or just
1176 * before the window controls (right slot).
1177 */
1178 .os-window__custom-buttons {
1179 display: contents;
1180 }
1181 .os-window__btn--custom {
1182 margin: 0 2px;
1183 }
1184 /*
1185 * Plugin-supplied icons render in the host's light DOM so the
1186 * global Dashicons stylesheet reaches them (shadow DOM doesn't
1187 * inherit page CSS).
1188 *
1189 * Dashicons render at their native 20×20 — that's the size the
1190 * font is hinted for and the only one where glyph metrics put
1191 * the visual centre on the geometric centre. Earlier we tried
1192 * down-scaling them to 14×14 to match the built-in chrome icons,
1193 * but font-size: 14 on a Dashicon shifts the glyph 1–2 pixels
1194 * off centre because the font's ascent/descent ratio doesn't
1195 * scale linearly. The 30×30 button has 5px of padding around a
1196 * 20×20 glyph — comfortable, and visually consistent with the
1197 * 14×14 chrome icons because both are flex-centred in the same
1198 * box.
1199 *
1200 * For inline-SVG icons that plugin authors ship without explicit
1201 * width/height, we DO clamp to 18×18 — those are the cases where
1202 * a bare `<svg>` would otherwise size to its viewBox or default
1203 * to 300×150 (the spec default) and overflow the button.
1204 */
1205 .os-window__btn--custom svg:not( [ width ] ):not( [ height ] ) {
1206 width: 18px;
1207 height: 18px;
1208 display: block;
1209 }
1210
1211 /*
1212 * Busy state for custom title-bar buttons — a gentle opacity pulse
1213 * while a round-trip is in flight (e.g. the editor-preview eye
1214 * waiting on the editor's autosave before opening the preview).
1215 * Driven by `aria-busy="true"` + this class, both set by the button's
1216 * render callback.
1217 */
1218 .os-window__btn--busy {
1219 animation: os-btn-busy-pulse 1s ease-in-out infinite;
1220 pointer-events: none;
1221 }
1222
1223 @keyframes os-btn-busy-pulse {
1224 0%,
1225 100% {
1226 opacity: 1;
1227 }
1228
1229 50% {
1230 opacity: 0.4;
1231 }
1232 }
1233
1234 @media (prefers-reduced-motion: reduce) {
1235 .os-window__btn--busy {
1236 animation: none;
1237 opacity: 0.6;
1238 }
1239 }
1240
1241 /*
1242 * Disabled state for custom title-bar buttons — visible but inert
1243 * (e.g. the editor-preview eye on an unsaved "Add New" screen, where
1244 * there is nothing to preview until the first save). The button keeps
1245 * pointer events so its explanatory tooltip/toast still works;
1246 * `aria-disabled` carries the semantics.
1247 */
1248 .os-window__btn--disabled {
1249 opacity: 0.4;
1250 cursor: default;
1251 }
1252
1253 /* ----------------------------------------------------------------
1254 * Custom-chrome marker — hides every framework-shipped titlebar
1255 * child while a plugin's chrome is mounted.
1256 *
1257 * The window gets the `os-window--custom-chrome` class the
1258 * moment `mountWindowChrome` succeeds (BEFORE the plugin's
1259 * `render()` runs). Default children carry the
1260 * `data-os-default-chrome` attribute stamped at element-
1261 * creation time. The combination is a load-bearing guarantee:
1262 * even if the plugin's render() doesn't clear `titlebar.innerHTML`,
1263 * even if a plugin's destroy() leaks the standard chrome back into
1264 * place, even mid-fade during a window close — the default chrome
1265 * NEVER becomes visible while the marker class is active.
1266 *
1267 * The class is removed only when the chrome is swapped to standard
1268 * (in `Window.remountWindowChrome`); during a window close it
1269 * persists until `element.remove()` runs in `onDone()`, which is
1270 * after the fade has reached opacity 0 — so the user never sees
1271 * the swap.
1272 *
1273 * @since 0.18.0
1274 * ---------------------------------------------------------------- */
1275
1276 .os-window--custom-chrome
1277 > .os-window__titlebar
1278 > [ data-os-default-chrome ] {
1279 display: none !important;
1280 }
1281
1282 /* ---------------------------------------------------------------
1283 * Reload button feedback.
1284 *
1285 * Click feedback is decoupled from the loading state:
1286 *
1287 * 1. On click, the icon performs a single 360° rotation with a
1288 * fast-start / slow-end ease curve — confirms the user's
1289 * gesture independently of how long the page takes to load.
1290 * The `--spinning` class is added by the click handler and
1291 * removed on `animationend` so a subsequent click restarts
1292 * the animation cleanly.
1293 *
1294 * 2. While the window body is in the `--loading` state (set by
1295 * `markContentLoading()` and cleared on the iframe's
1296 * `os-ready` postMessage), the button is dimmed and
1297 * non-interactive so a second click can't desync the
1298 * chromeless bridge's loading handshake.
1299 *
1300 * `:has()` is used instead of mirroring the `--loading` modifier
1301 * onto the window root because upstream's loading API only
1302 * decorates the body element. Browsers without :has() (very old)
1303 * fall back to a static dimmed button — feature, not bug.
1304 * --------------------------------------------------------------- */
1305
1306 .os-window:has( .os-window__body--loading )
1307 .os-window__btn--reload {
1308 pointer-events: none;
1309 opacity: 0.55;
1310 }
1311
1312 /*
1313 * Click feedback animation. The custom easing — `cubic-bezier( 0.05,
1314 * 0.7, 0.1, 1 )` — is a steeper-than-default ease-out: ~70% of the
1315 * rotation lands in the first 30% of the duration, then it decelerates.
1316 * Reads as "snap, then settle" rather than the linear glide of a
1317 * loading-spinner, reinforcing that this is a one-shot acknowledgement
1318 * of the gesture rather than progress through a long task. The `0.6s`
1319 * duration is short enough to never block the user but long enough
1320 * that the deceleration is legible.
1321 */
1322 .os-window__btn--reload.os-window__btn--spinning {
1323 animation: os-reload-spin-once 0.6s cubic-bezier( 0.05, 0.7, 0.1, 1 );
1324 }
1325
1326 @keyframes os-reload-spin-once {
1327 from {
1328 transform: rotate( 0deg );
1329 }
1330 to {
1331 transform: rotate( 360deg );
1332 }
1333 }
1334
1335 @media ( prefers-reduced-motion: reduce ) {
1336 .os-window__btn--reload.os-window__btn--spinning {
1337 animation: none;
1338 }
1339 }
1340