PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / trunk
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin vtrunk
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 0.8.6 All 33 releases
desktop-mode / assets / css / window-chrome.css

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

1,946 lines 71.7 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 /* ---------------------------------------------------------------
211 * Window activity — the status ring.
212 *
213 * The leading mark of the title bar, in the position the app icon
214 * used to hold. That icon was a copy of the window's own dock tile a
215 * few hundred pixels below it, and a title bar has room for one mark
216 * of that size — better spent on something that changes.
217 *
218 * The ring is an `<os-save-status variant="ring" mode="icon">`, found
219 * by `[data-os-activity-indicator]` — the same public attribute a
220 * plugin uses to mount its own. The framework's ring is not a special
221 * case; it is the first subscriber.
222 *
223 * Four states, and only one of them fills:
224 *
225 * idle white outline, no glyph
226 * saving accent outline, breathing
227 * saved accent fill, white check
228 * failed open red outline, red bang
229 *
230 * Colour alone is not a distinction every user can make, which is why
231 * the two outcomes differ in SHAPE — filled versus open, check versus
232 * bang — and not only in hue.
233 *
234 * `Window._paintActivityIndicator()` also mirrors the phase onto the
235 * title bar as `data-os-activity` (absent while idle) so a desktop
236 * theme can react to window state without reaching into the
237 * component's shadow root.
238 */
239 .os-window__status {
240 --os-ui-save-status-size: var(--os-titlebar-activity-size, 16px);
241 /*
242 * At rest: a white ring. One value, in both title-bar states —
243 * the phase is what the ring reports, and dimming it on an
244 * unfocused window would make "idle" say two different things
245 * depending on which window you last clicked.
246 */
247 --os-ui-save-status-idle-color: var(--os-titlebar-activity-idle-color, #fff);
248 /*
249 * The RING colour, not the dot's background. Setting
250 * `--os-ui-save-status-bg` here instead is what once painted a
251 * solid accent fill inside the resting outline: that token is the
252 * dot's `background` on the component's base rule, and the ring
253 * only borrows it as a border. The ring has its own name for
254 * exactly this reason.
255 */
256 --os-ui-save-status-ring-color: var(--os-titlebar-activity-color, #2271b1);
257 --os-ui-save-status-saved-bg: var(--os-titlebar-activity-saved-color, #2271b1);
258 --os-ui-save-status-failed-bg: var(--os-titlebar-activity-failed-color, #d63638);
259 display: inline-flex;
260 align-items: center;
261 flex-shrink: 0;
262 }
263
264 /*
265 * The announcement. A glow is invisible to a screen reader, and
266 * "did that save?" is precisely the question that can't be answered
267 * by looking. `Window._paintActivityIndicator()` writes the outcome
268 * here; absolute positioning keeps it out of the title bar's flex
269 * flow so it contributes neither a box nor a `gap`.
270 */
271 .os-window__activity-status {
272 position: absolute;
273 width: 1px;
274 height: 1px;
275 margin: -1px;
276 padding: 0;
277 border: 0;
278 overflow: hidden;
279 clip-path: inset(50%);
280 white-space: nowrap;
281 }
282
283 /*
284 * Activity indicator slot — opt-in, and empty by default.
285 *
286 * The framework paints the glow above instead of putting an
287 * `<os-save-status>` in the title bar of its own accord. The slot
288 * survives for anything that DOES want a literal dot as well: give an
289 * `<os-save-status>` the `data-os-activity-indicator` attribute, drop
290 * it in a title-bar slot inside a `.os-window__activity` wrapper, and
291 * `Window._paintActivityIndicator()` drives its phase for you.
292 *
293 * The fixed width is what keeps the blink from shifting the title
294 * text sideways, and `--wp-admin-theme-color` is forwarded as `color`
295 * so the component's shadow-DOM `currentColor` references (the glow's
296 * box-shadow) resolve to the live accent.
297 */
298 .os-window__activity {
299 display: inline-flex;
300 align-items: center;
301 justify-content: center;
302 width: 14px;
303 height: 14px;
304 flex-shrink: 0;
305 margin-inline-start: 6px;
306 margin-inline-end: 4px;
307 color: var(--wp-admin-theme-color, #2271b1);
308 }
309
310 /* Window title text. */
311 .os-window__title {
312 flex: 1;
313 overflow: hidden;
314 text-overflow: ellipsis;
315 white-space: nowrap;
316 font-size: 14px;
317 font-weight: 500;
318 line-height: var(--os-titlebar-height);
319 }
320
321 /*
322 * Window control buttons container.
323 *
324 * The cluster is TRANSPARENT by default, which is the whole point: a
325 * themed title-bar texture runs edge to edge underneath it, and the
326 * buttons float on the artwork rather than sitting on a plate. Every
327 * property below resolves to exactly that when unset.
328 *
329 * A theme that wants a plate instead sets `--os-titlebar-
330 * controls-bg` (and optionally the TITLEBAR_CONTROLS texture slot,
331 * a radius, and some inline padding) — the classic "the controls live
332 * in their own well" look, without the framework picking it for
333 * everyone.
334 *
335 * `padding-block` stays 0 so the cluster never changes the title
336 * bar's height; only the inline padding is themable.
337 *
338 * @since 0.9.8
339 */
340 .os-window__controls {
341 display: flex;
342 gap: var( --os-titlebar-controls-gap, 4px );
343 align-items: center;
344 flex-shrink: 0;
345 padding-inline: var( --os-titlebar-controls-padding, 0 );
346 border-radius: var( --os-titlebar-controls-radius, 0 );
347 background-color: var( --os-titlebar-controls-bg, transparent );
348 background-image: var( --os-titlebar-controls-image, none );
349 background-repeat: var( --os-titlebar-controls-image-repeat, repeat );
350 background-size: var( --os-titlebar-controls-image-size, auto );
351 background-position: var( --os-titlebar-controls-image-position, center );
352 }
353
354 /*
355 * Same treatment for the Screen Options / Help cluster, so a theme
356 * that plates one can plate the other and keep them consistent.
357 * Falls through to the controls tokens, so setting the pair above is
358 * enough for both.
359 */
360 .os-window__screen-meta {
361 border-radius: var(
362 --os-titlebar-meta-radius,
363 var( --os-titlebar-controls-radius, 0 )
364 );
365 background-color: var( --os-titlebar-meta-bg, transparent );
366 background-image: var( --os-titlebar-meta-image, none );
367 background-repeat: var( --os-titlebar-meta-image-repeat, repeat );
368 background-size: var( --os-titlebar-meta-image-size, auto );
369 background-position: var( --os-titlebar-meta-image-position, center );
370 }
371
372 /*
373 * Layer-3 slot hosts.
374 *
375 * Most in-titlebar slots wrap canonical chrome elements without
376 * contributing to the flex layout themselves — `display: contents`
377 * removes the wrapper's box so the inner element (icon span with
378 * `flex-shrink: 0`, before/after spacers, etc.) keeps its
379 * original layout role.
380 *
381 * The `icon` slot is empty by default now — the app icon it used to
382 * carry duplicated the window's dock tile — so on most windows it
383 * contributes nothing at all. It keeps `display: contents` for the
384 * ones where a plugin or a desktop theme does render an icon into it.
385 *
386 * The `title` slot is the exception: it's the flex-grow region of
387 * the title bar (`flex: 1`). When a plugin replaces the default
388 * title with custom HTML or a render callback, we want the new
389 * content to inherit the same "fill the remaining horizontal
390 * space" behaviour — so the slot itself owns `flex: 1` and the
391 * default title element's own `flex: 1` cooperates inside the
392 * nested flex.
393 *
394 * before/after-titlebar slots are flex-column children of
395 * `.os-window` (siblings of the title bar). They're real
396 * boxes — plugins can paint backgrounds, padding, borders. Empty
397 * hosts collapse via `:empty` so they take no space until populated.
398 *
399 * @since 0.6.0
400 */
401 .os-window__slot--before-icon,
402 .os-window__slot--icon,
403 .os-window__slot--after-title,
404 .os-window__slot--before-controls,
405 .os-window__slot--after-controls {
406 display: contents;
407 }
408
409
410 .os-window__slot--title {
411 flex: 1;
412 min-width: 0;
413 display: flex;
414 align-items: center;
415 overflow: hidden;
416 }
417
418 .os-window__slot--before-titlebar,
419 .os-window__slot--after-titlebar {
420 display: block;
421 flex-shrink: 0;
422 }
423
424 .os-window__slot--before-titlebar:empty,
425 .os-window__slot--after-titlebar:empty {
426 display: none;
427 }
428
429 /* ---------------------------------------------------------------
430 * Window control buttons.
431 *
432 * Flat, icon-only buttons styled to feel at home in the WordPress
433 * admin: transparent by default, subtle tinted hover, focus ring in
434 * the active admin theme color, inline SVG icons inheriting
435 * currentColor so they adapt to focused / unfocused title bars.
436 *
437 * The close button gets a destructive red wash on hover only —
438 * never as a default state — so it reads as safe until interacted
439 * with, matching the WordPress admin's pattern of reserving color
440 * for semantic signal.
441 * --------------------------------------------------------------- */
442 /*
443 * Title-bar chrome buttons render as `<os-window-button>`.
444 * Shadow DOM can't reach across the window's focus class, so we
445 * drive the coloring via custom properties that inherit through
446 * the boundary. The component reads `--os-ui-btn-color`,
447 * `--os-ui-btn-bg-hover`, etc. and paints accordingly.
448 *
449 * Focused control glyphs are white at 70% — correct against the
450 * default focused title bar, which is the admin theme colour and so
451 * always a mid-to-dark fill, and invisible against a pale one. A
452 * theme could not reach them: these declarations land on the WINDOW
453 * element, so a `--os-ui-btn-color` set at the shell root loses to
454 * them, and the same names also drive buttons outside the title bar
455 * (pinned notes, the desk chrome) that a theme usually does not want
456 * to move in the same stroke.
457 *
458 * `--os-titlebar-btn-focused-*` mirrors the unfocused set
459 * below, name for name, so the two halves of the title bar are
460 * addressed the same way. Every one is UNDECLARED and falls back to
461 * the literal it replaced, so an unthemed shell paints exactly what
462 * it painted before.
463 *
464 * There is no `-danger-hover` twin: destructive red is semantic, not
465 * chrome, and both halves already resolve it through `--os-ui-danger`.
466 */
467 .os-window--focused {
468 --os-ui-btn-color: var( --os-titlebar-btn-focused-color, rgba( 255, 255, 255, 0.7 ) );
469 --os-ui-btn-color-hover: var( --os-titlebar-btn-focused-color-hover, #fff );
470 --os-ui-btn-bg-hover: var( --os-titlebar-btn-focused-bg-hover, rgba( 255, 255, 255, 0.18 ) );
471 --os-ui-btn-bg-active: var( --os-titlebar-btn-focused-bg-active, rgba( 255, 255, 255, 0.25 ) );
472 --os-ui-btn-outline: var( --os-titlebar-btn-focused-outline, rgba( 255, 255, 255, 0.65 ) );
473 --os-ui-btn-danger-hover: var( --os-ui-danger, #d63638 );
474 }
475
476 .os-window:not( .os-window--focused ) {
477 --os-ui-btn-color: var( --os-titlebar-btn-color, rgba( 0, 0, 0, 0.45 ) );
478 --os-ui-btn-color-hover: var( --os-titlebar-btn-color-hover, rgba( 0, 0, 0, 0.85 ) );
479 --os-ui-btn-bg-hover: var( --os-titlebar-btn-bg-hover, rgba( 0, 0, 0, 0.08 ) );
480 --os-ui-btn-bg-active: var( --os-titlebar-btn-bg-active, rgba( 0, 0, 0, 0.12 ) );
481 --os-ui-btn-outline: var( --wp-admin-theme-color, #2271b1 );
482 --os-ui-btn-danger-hover: var( --os-ui-danger, #d63638 );
483 }
484
485 /*
486 * Unfocused control glyphs under an active desktop theme.
487 *
488 * The rule above paints them BLACK at 45% — correct against the
489 * default light unfocused title bar (`#f0f0f1`), invisible against a
490 * dark themed one. They can't simply follow the palette either:
491 * these are title-bar chrome, so the colour they owe allegiance to is
492 * the title bar's own text colour, not the window body's.
493 *
494 * Deriving it with `color-mix` means a theme gets legible unfocused
495 * controls for free from the `--os-titlebar-color` it was
496 * already setting — no extra tokens to discover. The four
497 * `--os-titlebar-btn-*` overrides above still win when an
498 * author wants exact control.
499 *
500 * Scoped to `[data-os-desktop-theme]` on purpose: the
501 * default title-bar colour is `#50575e`, so applying this
502 * unconditionally would lighten the unfocused glyphs on every
503 * unthemed shell. No theme, no change.
504 */
505 .os-shell[ data-os-desktop-theme ]
506 .os-window:not( .os-window--focused ) {
507 --os-ui-btn-color: var(
508 --os-titlebar-btn-color,
509 color-mix( in srgb, var( --os-titlebar-color, #50575e ) 72%, transparent )
510 );
511 --os-ui-btn-color-hover: var(
512 --os-titlebar-btn-color-hover,
513 var( --os-titlebar-color, #50575e )
514 );
515 --os-ui-btn-bg-hover: var(
516 --os-titlebar-btn-bg-hover,
517 color-mix( in srgb, var( --os-titlebar-color, #50575e ) 18%, transparent )
518 );
519 --os-ui-btn-bg-active: var(
520 --os-titlebar-btn-bg-active,
521 color-mix( in srgb, var( --os-titlebar-color, #50575e ) 26%, transparent )
522 );
523 }
524
525 /* ---------------------------------------------------------------
526 * Title-bar actions menu (leading edge, before icon + title).
527 *
528 * Trigger is a standard `.os-window__btn` — inherits focused /
529 * unfocused coloring from the existing control-button rules. The only
530 * bespoke rule is margin: it sits at the leading edge so a small trailing
531 * gap separates it from the icon without affecting overall title-bar gap.
532 *
533 * Panel is an absolute dropdown anchored to the title bar (which is now
534 * position: relative). Visibility is driven by the `hidden` attribute on
535 * the element itself — no separate `--open` class to keep in sync with
536 * aria-expanded.
537 * --------------------------------------------------------------- */
538 /*
539 * ⋯ button sits between the screen-meta cluster and the window
540 * controls, as the "last page-level chrome" item. When present it
541 * takes over the divider duty: screen-meta drops its own trailing
542 * divider, and the controls container gains a leading one — so a
543 * single clean line always sits between page chrome and window
544 * chrome regardless of which combination is visible.
545 */
546 .os-window__titlebar:has(.os-window__menu-btn) .os-window__screen-meta {
547 margin-inline-end: 0;
548 padding-inline-end: 0;
549 border-inline-end: none;
550 }
551
552 .os-window__titlebar:has(.os-window__menu-btn) .os-window__controls {
553 margin-inline-start: 8px;
554 padding-inline-start: 8px;
555 /* Tokenized so a theme can retint it — or set `transparent` and
556 * let its own title-bar artwork carry the separation. */
557 border-inline-start: 1px solid
558 var( --os-titlebar-divider, rgba(255, 255, 255, 0.15) );
559 }
560
561 .os-window:not(.os-window--focused) .os-window__titlebar:has(.os-window__menu-btn) .os-window__controls {
562 border-inline-start-color: var(
563 --os-titlebar-divider-unfocused,
564 rgba(0, 0, 0, 0.1)
565 );
566 }
567
568 /*
569 * Menu popover lives inside the title bar's absolute-positioned
570 * coordinate system — positioning is caller-specific and stays
571 * outer. Background, border, items, checkbox styling all moved
572 * into `<os-menu>` / `<os-menu-item>`.
573 */
574 .os-window__menu-panel {
575 position: absolute;
576 top: calc( var( --os-titlebar-height ) + 2px );
577 inset-inline-end: 60px;
578 z-index: 2;
579 }
580
581 /*
582 * "Related" dropdown — opened by the related-entities title-bar
583 * button (`src/related-entities/`). The panel ALSO carries
584 * `.os-window__menu-panel` (positioning + the drag-tracker
585 * exclusion come from there); this class only layers the related-menu
586 * extras: an internal scroll cap for media-heavy posts and a sane
587 * minimum width.
588 */
589 .os-window__related-panel {
590 max-height: min( 60vh, 420px );
591 overflow-y: auto;
592 min-width: 180px;
593 }
594
595 /* Section header inside the Related dropdown ("Categories", "Media"). */
596 .os-window__related-group {
597 padding: 6px 12px 2px;
598 font-size: 11px;
599 font-weight: 600;
600 text-transform: uppercase;
601 letter-spacing: 0.04em;
602 opacity: 0.6;
603 color: var( --os-ui-fg );
604 }
605
606 .os-window__related-group:not(:first-child) {
607 margin-block-start: 4px;
608 border-block-start: 1px solid rgba( 128, 128, 128, 0.25 );
609 padding-block-start: 8px;
610 }
611
612 /* ---------------------------------------------------------------
613 * Screen Meta buttons (Screen Options / Help) in the title bar.
614 * Positioned right after the title text, visually separated from
615 * the close / maximize / minimize cluster by a subtle divider.
616 * Sized to meet WCAG 2.2 target size (24x24 minimum).
617 * --------------------------------------------------------------- */
618 .os-window__screen-meta {
619 display: flex;
620 gap: 4px;
621 align-items: center;
622 flex-shrink: 0;
623 margin-inline-end: 8px;
624 padding-inline-end: 8px;
625 border-inline-end: 1px solid
626 var( --os-titlebar-divider, rgba(255, 255, 255, 0.15) );
627 }
628
629 /* Hide the divider when there are no screen-meta buttons. */
630 .os-window__screen-meta:empty {
631 display: none;
632 }
633
634 .os-window__meta-btn {
635 display: flex;
636 align-items: center;
637 justify-content: center;
638 width: 28px;
639 height: 28px;
640 border: none;
641 border-radius: 6px;
642 cursor: pointer;
643 padding: 0;
644 background: transparent;
645 transition: background-color 0.15s ease, color 0.15s ease;
646 }
647
648 .os-window__meta-btn .dashicons {
649 font-size: 18px;
650 width: 18px;
651 height: 18px;
652 }
653 /* The flex-centring override for the dashicon glyph currently lives
654 * in `windows.css`. That placement was a cache workaround from when
655 * this file was an `@import` sub-sheet with no `?ver=` of its own;
656 * it is now a separately registered, filemtime-stamped handle, so
657 * the workaround no longer buys anything. Left where it is for now —
658 * relocating it is behaviour-neutral churn. */
659
660 /* Focused window: meta buttons visible.
661 *
662 * Screen-meta buttons are not `<os-window-button>` — they are plain
663 * buttons in the light DOM, so they paint themselves instead of
664 * reading the `--os-ui-btn-*` bridge. They sit in the same title bar
665 * against the same fill, so they take the same
666 * `--os-titlebar-btn-focused-*` tokens, each keeping its own
667 * literal as the fallback: unthemed they stay a touch dimmer at rest
668 * than the window controls (0.65 vs 0.7), themed they move together
669 * rather than one cluster going legible and the other staying white. */
670 .os-window--focused .os-window__meta-btn {
671 color: var( --os-titlebar-btn-focused-color, rgba(255, 255, 255, 0.65) );
672 }
673 .os-window--focused .os-window__meta-btn:hover {
674 color: var( --os-titlebar-btn-focused-color-hover, var( --os-ui-fg-on-accent, #fff ) );
675 background: var( --os-titlebar-btn-focused-bg-hover, rgba(255, 255, 255, 0.18) );
676 }
677 .os-window--focused .os-window__meta-btn:focus-visible {
678 color: var( --os-titlebar-btn-focused-color-hover, var( --os-ui-fg-on-accent, #fff ) );
679 background: var( --os-titlebar-btn-focused-bg-hover, rgba(255, 255, 255, 0.18) );
680 outline: 2px solid var( --os-titlebar-btn-focused-outline, rgba(255, 255, 255, 0.6) );
681 outline-offset: 1px;
682 }
683 .os-window--focused .os-window__meta-btn--active {
684 color: var( --os-titlebar-btn-focused-color-hover, var( --os-ui-fg-on-accent, #fff ) );
685 background: var( --os-titlebar-btn-focused-bg-active, rgba(255, 255, 255, 0.25) );
686 }
687
688 /* Unfocused window: divider and buttons adapt to light title bar. */
689 .os-window:not(.os-window--focused) .os-window__screen-meta {
690 border-inline-end-color: var(
691 --os-titlebar-divider-unfocused,
692 rgba(0, 0, 0, 0.1)
693 );
694 }
695 /*
696 * Screen Options / Help on an UNFOCUSED title bar. These read the same
697 * two tokens as the window controls beside them rather than their own
698 * literals — a black glyph is invisible on a dark title bar, and these
699 * buttons sit in the same strip as controls that were already themable.
700 * The literals are unchanged, so an unthemed bar looks as it always did.
701 */
702 .os-window:not(.os-window--focused) .os-window__meta-btn {
703 color: var( --os-titlebar-btn-color, rgba(0, 0, 0, 0.3) );
704 }
705 .os-window:not(.os-window--focused) .os-window__meta-btn:hover {
706 color: var( --os-titlebar-btn-color-hover, rgba(0, 0, 0, 0.6) );
707 background: var( --os-ui-hover, rgba(0, 0, 0, 0.08) );
708 }
709 .os-window:not(.os-window--focused) .os-window__meta-btn--active {
710 color: var( --os-titlebar-btn-color-hover, rgba(0, 0, 0, 0.6) );
711 background: rgba(0, 0, 0, 0.12);
712 }
713
714 /* ---------------------------------------------------------------
715 * Tab strip — submenu navigation rendered in the parent shell, just
716 * below the title bar. Each tab swaps the iframe URL in place; no
717 * new window opens. Horizontally scrollable on narrow windows so the
718 * same component works on tablet and mobile shells unchanged.
719 *
720 * These are TABS in the physical sense: the strip is a recessed
721 * track, and the active tab is a plate that rises out of it wearing
722 * the page's own fill, filleted into the floor at both bottom
723 * corners so tab and page read as one continuous surface. That joint
724 * is the whole design. The previous treatment — flat text with an
725 * accent underline, on a strip painted the same colour as the
726 * focused title bar — gave the sub-pages no container to belong to
727 * and no relationship to the page they navigate, which is why they
728 * read as loose text floating in the chrome.
729 *
730 * The page under an iframe window is always `#fff`: `chromeless.css`
731 * paints `body.os-chromeless` white whatever the admin colour scheme
732 * says. So "the page's own fill" is a colour the shell can name, and
733 * `--os-tabs-active-bg` names it.
734 * --------------------------------------------------------------- */
735 .os-window__tabs {
736 /*
737 * Corner radius and fillet size are the same measurement — the
738 * fillet is the *inverse* of the corner, so a mismatch reads as a
739 * kink where the tab meets the floor. One alias, read by both.
740 */
741 --_tab-radius: var( --os-tabs-radius, 8px );
742 /* Tab height, shared with the plate that has to sit exactly on it. */
743 --_tab-h: 30px;
744 /* Weight of the rail that traces the silhouette. */
745 --_tab-stroke: var( --os-tabs-rail-width, 2px );
746 /*
747 * How far each straight run of the rail overlaps the arc it hands
748 * over to. Purely an anti-seam allowance — see the mask sizes on
749 * the ring. It has to stay well under the radius or the overlap
750 * would reach past the arc it is covering for.
751 */
752 --_tab-seam: 1px;
753 position: relative;
754 display: flex;
755 /* Tabs sit ON the floor of the track, not centred in it. */
756 align-items: flex-end;
757 flex-shrink: 0;
758 gap: 2px;
759 /*
760 * Horizontal padding is a floor as well as a breathing space. The
761 * ring reaches one radius plus one stroke (10px at the shipped
762 * values) past the plate on each side, so a first or last tab
763 * under a smaller padding would have its outer corner clipped by
764 * the `overflow` rule below.
765 */
766 padding: 8px 12px 0;
767 background-color: var( --os-tabs-bg, var( --os-ui-surface-elevated, #f6f7f7 ) );
768 /* TABBAR texture slot — layered over the strip's own colour. */
769 background-image: var( --os-tabs-image, none );
770 background-repeat: var( --os-tabs-image-repeat, repeat );
771 background-size: var( --os-tabs-image-size, auto );
772 background-position: var( --os-tabs-image-position, center );
773 /*
774 * No bottom border. A hairline here would run straight through
775 * the joint between the active tab and its page, which is the one
776 * edge this design exists to erase.
777 */
778 overflow-x: auto;
779 overflow-y: hidden;
780 scrollbar-width: thin;
781 /*
782 * `overscroll-behavior-x: contain` keeps horizontal trackpad
783 * swipes inside the strip instead of triggering browser back.
784 */
785 overscroll-behavior-x: contain;
786 }
787
788 /*
789 * Unfocused, the track follows the title bar down instead of holding
790 * its lit colour.
791 *
792 * Focused, the bar and the track are the same Obsidian and the tab is
793 * the only thing lifting out of them: two surfaces plus the tab. Dim
794 * the bar alone and the strip becomes a third colour belonging to
795 * neither the chrome above it nor the page below, which is the seam
796 * this rule removes.
797 *
798 * The chain ends at `--os-tabs-bg`, so a theme that names only the one
799 * strip colour keeps it in both states.
800 */
801 .os-window:not( .os-window--focused ) .os-window__tabs {
802 background-color: var(
803 --os-tabs-bg-unfocused,
804 var( --os-tabs-bg, var( --os-ui-surface-elevated, #f6f7f7 ) )
805 );
806 }
807
808 /*
809 * The same strip on a NATIVE window, which is the same strip: one
810 * stylesheet, whatever is behind the window. What changes is only
811 * what the active tab is wearing, and it changes in tokens.
812 *
813 * The rule the whole design rests on is that the active tab wears the
814 * page's own fill and is filleted into it, so tab and content read as
815 * one surface. For an iframe window that fill is `#fff`, because
816 * `chromeless.css` paints every admin page inside a window white
817 * whatever the colour scheme says. A native window's page is its
818 * body, and `--os-window-bg` is what paints it — so that is the fill
819 * here, and the two move together by construction.
820 *
821 * The frost and the crown go with the white. They are what makes a
822 * bright plate read as a surface lifting out of dark chrome; on a
823 * native window the tab and the track are the same colour when the
824 * window is focused, and a crown on a tab you cannot see the edges of
825 * is decoration with nothing to decorate. The rail carries the
826 * silhouette on its own, which is the point: a native window has no
827 * value step to wear, so the line IS the tab.
828 *
829 * The label follows the fill. `--os-tabs-active-color` names the text
830 * on a white plate and resolves to near-black, which would be
831 * invisible here.
832 */
833 .os-window--native {
834 --os-tabs-active-bg: var( --os-window-bg, #fff );
835 --os-tabs-active-color: var( --os-ui-fg, #1d2327 );
836 --os-tabs-active-color-muted: var( --os-ui-fg-muted, #50575e );
837 --os-tabs-active-frost: none;
838 --os-tabs-active-crown: none;
839 }
840
841 /*
842 * Windows with no submenu still get a strip — `createWindowElement()`
843 * appends it unconditionally so `addExternalTab()` has somewhere to
844 * put a tab later. Empty, it must take no room at all: with vertical
845 * padding on the track, an empty strip would otherwise paint a bare
846 * band of track colour under every submenu-less window's title bar.
847 *
848 * `:has()` rather than `:empty`, because the strip is never empty any
849 * more — it always carries the plate. What makes a strip vacant is
850 * having no TABS in it.
851 */
852 .os-window__tabs:not( :has( .os-window__tab ) ) {
853 display: none;
854 }
855
856 /* ---------------------------------------------------------------
857 * The plate — the active tab's surface.
858 *
859 * It is one element that TRAVELS between tabs rather than a fill
860 * that switches off on one tab and on at the next. That distinction
861 * is the whole reason it exists: a fill that switches has to cross-
862 * fade a dark tab into a light one, and every frame in between is a
863 * muddy grey that belongs to neither. Nothing crossfades here. The
864 * surface simply moves, and the labels change colour underneath it.
865 *
866 * `tabs.ts` publishes the target geometry as `--_tab-plate-x` and
867 * `--_tab-plate-w` on this element; everything else is CSS.
868 * --------------------------------------------------------------- */
869 .os-window__tab-plate {
870 position: absolute;
871 bottom: 0;
872 left: 0;
873 height: var( --_tab-h );
874 width: var( --_tab-plate-w, 0 );
875 transform: translateX( var( --_tab-plate-x, 0 ) );
876 pointer-events: none;
877 transition:
878 transform var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) ),
879 width var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) ),
880 opacity 0.15s ease;
881 }
882
883 /*
884 * Until the first measurement lands, the plate has no business
885 * animating — it would slide in from the strip's left edge every
886 * time a window opens.
887 */
888 .os-window__tab-plate:not( [ data-placed ] ) {
889 transition: none;
890 }
891
892 /*
893 * The frosted face.
894 *
895 * The tint is TOP-WEIGHTED and reaches zero before the bottom of the
896 * plate, which is the load-bearing part: the joint below is the
897 * page's own colour, so anything still tinted down there draws a
898 * seam across the one edge this design exists to erase. Keep the
899 * gradient's last stop fully transparent and keep it above ~75%.
900 *
901 * The fallback is a flat `--os-tabs-active-bg`, so a stylesheet that
902 * loses the palette gets the plain white tab rather than nothing.
903 */
904 .os-window__tab-plate-fill {
905 position: absolute;
906 /*
907 * Overshoots the track's floor by one radius, and is then clipped
908 * by the strip's own `overflow`. Ending the face exactly ON the
909 * floor puts a paint boundary on the one edge that has to be
910 * invisible; pushing it past and cutting it means there is no edge
911 * there to resolve at all.
912 */
913 inset: 0 0 calc( var( --_tab-radius ) * -1 ) 0;
914 overflow: hidden;
915 border-radius: var( --_tab-radius ) var( --_tab-radius ) 0 0;
916 /*
917 * OPAQUE, and deliberately so — no `backdrop-filter`, no alpha.
918 *
919 * A translucent element with a backdrop filter is promoted to its
920 * own compositor layer, and the edge of that layer is a clip the
921 * compositor resolves against whatever is behind it. That leaves a
922 * faint grey hairline down the plate's sides and across its
923 * bottom. Everywhere else that would be a nuisance; here it draws
924 * a second, dimmer line a few pixels inside the real one and flatly
925 * contradicts what this design is claiming. The gradient below
926 * reproduces the frosted crown by eye with no alpha at all.
927 *
928 * `background-size` pins the gradient to the tab's true height so
929 * the overshoot underneath stays flat page-white.
930 */
931 background-color: var( --os-tabs-active-bg, #fff );
932 background-image: var( --os-tabs-active-frost, none );
933 background-size: 100% var( --_tab-h );
934 background-repeat: no-repeat;
935 }
936
937 /*
938 * The crown — Holomesh over the top of the plate, masked away before
939 * the joint. Holomesh is nine stacked gradients, so it cannot be
940 * faded by adding a colour stop; it needs its own layer and a mask.
941 *
942 * It is on exactly one tab at a time. That is what keeps the mesh an
943 * identity moment rather than wallpaper, and it is the same rule the
944 * rest of the kit follows (see `src/ui/holo.ts`).
945 */
946 .os-window__tab-plate-fill::before {
947 content: '';
948 position: absolute;
949 inset: 0;
950 background-image: var( --os-tabs-active-crown, none );
951 background-size: 210% 210%;
952 background-position: 20% 26%;
953 opacity: var( --os-tabs-active-crown-opacity, 0.5 );
954 -webkit-mask-image: linear-gradient(
955 180deg,
956 #000 0%,
957 rgba( 0, 0, 0, 0.55 ) 34%,
958 transparent 66%
959 );
960 mask-image: linear-gradient(
961 180deg,
962 #000 0%,
963 rgba( 0, 0, 0, 0.55 ) 34%,
964 transparent 66%
965 );
966 animation: os-tab-crown-drift 16s ease-in-out infinite alternate;
967 }
968
969 @keyframes os-tab-crown-drift {
970 from { background-position: 20% 26%; }
971 to { background-position: 72% 62%; }
972 }
973
974 /*
975 * The joint: two concave quarter-circles carrying the plate's fill
976 * out to the floor of the track. Without them the plate is a rounded
977 * rectangle NEAR the page; with them it is attached to it.
978 *
979 * Its own element rather than a pseudo on the face, so the face can
980 * clip its crown (`overflow: hidden`) without clipping the curve
981 * that does the attaching. Pure `--os-tabs-active-bg` — see the note
982 * on the tint above.
983 */
984 .os-window__tab-plate-joint {
985 position: absolute;
986 bottom: 0;
987 left: calc( var( --_tab-radius ) * -1 );
988 right: calc( var( --_tab-radius ) * -1 );
989 height: var( --_tab-radius );
990 background:
991 radial-gradient(
992 circle at 0 0,
993 transparent var( --_tab-radius ),
994 var( --os-tabs-active-bg, #fff ) var( --_tab-radius )
995 ) left bottom / var( --_tab-radius ) var( --_tab-radius ) no-repeat,
996 radial-gradient(
997 circle at 100% 0,
998 transparent var( --_tab-radius ),
999 var( --os-tabs-active-bg, #fff ) var( --_tab-radius )
1000 ) right bottom / var( --_tab-radius ) var( --_tab-radius ) no-repeat;
1001 }
1002
1003 /* ---------------------------------------------------------------
1004 * The rail — one continuous line around the whole silhouette.
1005 *
1006 * It runs the top edge of the page, curves up through the fillet,
1007 * traces the tab and comes back down. What it outlines is therefore
1008 * page-plus-tab as ONE shape, which is the same claim the joint makes
1009 * by omission, said out loud.
1010 *
1011 * Two rules govern every number below.
1012 *
1013 * 1. **The line lives on the DARK side of the boundary, everywhere.**
1014 * It hugs the white shape from outside and never paints on it. Get
1015 * this wrong on any one segment and the line steps sideways by its
1016 * own width at the tangent point where that segment meets the next
1017 * — which is exactly what a stroke that does not follow the shape
1018 * looks like. The convex tab corners are therefore annulus R→R+s
1019 * (outside the plate) while the concave fillets are R−s→R (inside
1020 * the fillet circle, whose white lies OUTSIDE it). Those look like
1021 * opposite conventions and are the same one.
1022 *
1023 * 2. **Every piece samples one mesh laid across the whole strip.**
1024 * `--_tab-strip-w` is why. Give the rail and the ring their own
1025 * backgrounds and you get two unrelated gradients meeting at a
1026 * visible join in the middle of the fillet.
1027 *
1028 * CSS cannot stroke a path, so the mesh is painted as a background and
1029 * a mask cuts it to the outline. Seven layers, one per segment, all
1030 * positioned off the box edges so the whole thing survives the plate
1031 * changing width mid-slide with no JS.
1032 * --------------------------------------------------------------- */
1033 .os-window__tabs::before {
1034 content: '';
1035 position: absolute;
1036 left: 0;
1037 right: 0;
1038 bottom: 0;
1039 height: var( --_tab-stroke );
1040 background-image: var( --os-tabs-rail, none );
1041 background-size: var( --_tab-strip-w, 100% ) 100%;
1042 background-repeat: no-repeat;
1043 pointer-events: none;
1044 /*
1045 * Two segments, stopping where each fillet begins. Run the rail
1046 * straight through and it draws a chord across the concave curve.
1047 */
1048 -webkit-mask-image: linear-gradient( #000 0 0 ), linear-gradient( #000 0 0 );
1049 mask-image: linear-gradient( #000 0 0 ), linear-gradient( #000 0 0 );
1050 /*
1051 * Each segment runs one `--_tab-seam` PAST where its fillet begins,
1052 * so the rail overlaps the arc rather than meeting it exactly —
1053 * the same anti-seam allowance the ring uses, for the same reason.
1054 */
1055 -webkit-mask-size:
1056 calc(
1057 var( --_tab-plate-x, 0px ) - var( --_tab-radius ) + var( --_tab-seam )
1058 ) 100%,
1059 calc(
1060 100% - var( --_tab-plate-x, 0px ) - var( --_tab-plate-w, 0px ) -
1061 var( --_tab-radius ) + var( --_tab-seam )
1062 ) 100%;
1063 mask-size:
1064 calc(
1065 var( --_tab-plate-x, 0px ) - var( --_tab-radius ) + var( --_tab-seam )
1066 ) 100%,
1067 calc(
1068 100% - var( --_tab-plate-x, 0px ) - var( --_tab-plate-w, 0px ) -
1069 var( --_tab-radius ) + var( --_tab-seam )
1070 ) 100%;
1071 -webkit-mask-position: left top, right top;
1072 mask-position: left top, right top;
1073 -webkit-mask-repeat: no-repeat;
1074 mask-repeat: no-repeat;
1075 transition:
1076 -webkit-mask-size var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) ),
1077 mask-size var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) );
1078 }
1079
1080 /* With no active tab there is no gap to leave, so the rail is whole. */
1081 .os-window__tabs[ data-tab-plate-empty ]::before {
1082 -webkit-mask-image: none;
1083 mask-image: none;
1084 }
1085
1086 /*
1087 * The tab's half of the line. The box is the plate grown by one
1088 * stroke upward and by radius-plus-stroke on each side, so the top
1089 * edge, both corners and both fillet arcs all have room to sit
1090 * OUTSIDE the white.
1091 */
1092 .os-window__tab-plate::after {
1093 --_ring-arc-out: radial-gradient(
1094 circle at 0 0,
1095 transparent calc( var( --_tab-radius ) - var( --_tab-stroke ) ),
1096 #000 calc( var( --_tab-radius ) - var( --_tab-stroke ) ),
1097 #000 var( --_tab-radius ),
1098 transparent var( --_tab-radius )
1099 );
1100 --_ring-arc-out-r: radial-gradient(
1101 circle at 100% 0,
1102 transparent calc( var( --_tab-radius ) - var( --_tab-stroke ) ),
1103 #000 calc( var( --_tab-radius ) - var( --_tab-stroke ) ),
1104 #000 var( --_tab-radius ),
1105 transparent var( --_tab-radius )
1106 );
1107 --_ring-corner-l: radial-gradient(
1108 circle at 100% 100%,
1109 transparent var( --_tab-radius ),
1110 #000 var( --_tab-radius ),
1111 #000 calc( var( --_tab-radius ) + var( --_tab-stroke ) ),
1112 transparent calc( var( --_tab-radius ) + var( --_tab-stroke ) )
1113 );
1114 --_ring-corner-r: radial-gradient(
1115 circle at 0 100%,
1116 transparent var( --_tab-radius ),
1117 #000 var( --_tab-radius ),
1118 #000 calc( var( --_tab-radius ) + var( --_tab-stroke ) ),
1119 transparent calc( var( --_tab-radius ) + var( --_tab-stroke ) )
1120 );
1121 --_ring-bar: linear-gradient( #000 0 0 );
1122
1123 content: '';
1124 position: absolute;
1125 inset:
1126 calc( var( --_tab-stroke ) * -1 )
1127 calc( ( var( --_tab-radius ) + var( --_tab-stroke ) ) * -1 )
1128 0;
1129 background-image: var( --os-tabs-rail, none );
1130 background-size: var( --_tab-strip-w, 100% ) 100%;
1131 background-position:
1132 calc(
1133 var( --_tab-radius ) + var( --_tab-stroke ) -
1134 var( --_tab-plate-x, 0px )
1135 )
1136 0;
1137 background-repeat: no-repeat;
1138 pointer-events: none;
1139
1140 -webkit-mask-image:
1141 var( --_ring-bar ), var( --_ring-corner-l ), var( --_ring-corner-r ),
1142 var( --_ring-bar ), var( --_ring-bar ),
1143 var( --_ring-arc-out ), var( --_ring-arc-out-r );
1144 mask-image:
1145 var( --_ring-bar ), var( --_ring-corner-l ), var( --_ring-corner-r ),
1146 var( --_ring-bar ), var( --_ring-bar ),
1147 var( --_ring-arc-out ), var( --_ring-arc-out-r );
1148 /*
1149 * The three straight runs are each grown by `--_tab-seam` at BOTH
1150 * ends, and shifted back by the same amount, so every one of them
1151 * overlaps the arc it hands over to instead of meeting it exactly.
1152 *
1153 * Two layers that abut on a shared boundary each contribute a
1154 * partial, antialiased alpha there, and the sum can fall short of
1155 * 1 — which prints as a faint hairline across the stroke at all
1156 * six tangent points. Overlapping cannot go wrong in the other
1157 * direction: mask alpha is clamped, so doubling it is still opaque
1158 * and the seam simply stops existing.
1159 *
1160 * The overlap stays on the dark side of the curve at every one of
1161 * those points, so it never bleeds onto the white.
1162 */
1163 -webkit-mask-size:
1164 calc( 100% - 4 * var( --_tab-radius ) - 2 * var( --_tab-stroke ) + 2 * var( --_tab-seam ) ) var( --_tab-stroke ),
1165 calc( var( --_tab-radius ) + var( --_tab-stroke ) ) calc( var( --_tab-radius ) + var( --_tab-stroke ) ),
1166 calc( var( --_tab-radius ) + var( --_tab-stroke ) ) calc( var( --_tab-radius ) + var( --_tab-stroke ) ),
1167 var( --_tab-stroke ) calc( 100% - 2 * var( --_tab-radius ) - var( --_tab-stroke ) + 2 * var( --_tab-seam ) ),
1168 var( --_tab-stroke ) calc( 100% - 2 * var( --_tab-radius ) - var( --_tab-stroke ) + 2 * var( --_tab-seam ) ),
1169 var( --_tab-radius ) var( --_tab-radius ),
1170 var( --_tab-radius ) var( --_tab-radius );
1171 mask-size:
1172 calc( 100% - 4 * var( --_tab-radius ) - 2 * var( --_tab-stroke ) + 2 * var( --_tab-seam ) ) var( --_tab-stroke ),
1173 calc( var( --_tab-radius ) + var( --_tab-stroke ) ) calc( var( --_tab-radius ) + var( --_tab-stroke ) ),
1174 calc( var( --_tab-radius ) + var( --_tab-stroke ) ) calc( var( --_tab-radius ) + var( --_tab-stroke ) ),
1175 var( --_tab-stroke ) calc( 100% - 2 * var( --_tab-radius ) - var( --_tab-stroke ) + 2 * var( --_tab-seam ) ),
1176 var( --_tab-stroke ) calc( 100% - 2 * var( --_tab-radius ) - var( --_tab-stroke ) + 2 * var( --_tab-seam ) ),
1177 var( --_tab-radius ) var( --_tab-radius ),
1178 var( --_tab-radius ) var( --_tab-radius );
1179 /*
1180 * Four-value syntax throughout, and it is load-bearing. A
1181 * percentage in `mask-position` resolves against the container
1182 * MINUS the layer's own size, so `calc(100% - 8px)` does not mean
1183 * "8px from the right edge" — it means "right-aligned, then pushed
1184 * left by 8px PLUS the layer's width". Every right-hand segment
1185 * lands a radius too far left that way. `right <offset>` is
1186 * measured from the edge and does not care how wide the layer is.
1187 */
1188 -webkit-mask-position:
1189 left calc( 2 * var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ) top 0,
1190 left var( --_tab-radius ) top 0,
1191 right var( --_tab-radius ) top 0,
1192 left var( --_tab-radius ) top calc( var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ),
1193 right var( --_tab-radius ) top calc( var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ),
1194 left var( --_tab-stroke ) bottom 0,
1195 right var( --_tab-stroke ) bottom 0;
1196 mask-position:
1197 left calc( 2 * var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ) top 0,
1198 left var( --_tab-radius ) top 0,
1199 right var( --_tab-radius ) top 0,
1200 left var( --_tab-radius ) top calc( var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ),
1201 right var( --_tab-radius ) top calc( var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ),
1202 left var( --_tab-stroke ) bottom 0,
1203 right var( --_tab-stroke ) bottom 0;
1204 -webkit-mask-repeat: no-repeat;
1205 mask-repeat: no-repeat;
1206
1207 /* Travels with the plate, so the mesh stays locked to the strip
1208 * for the whole slide instead of snapping on the first frame. */
1209 transition: background-position var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) );
1210 }
1211
1212 /*
1213 * No active tab to sit under — `syncActiveTab` can land on a URL that
1214 * matches nothing, and an external sub-tab deactivates every submenu
1215 * tab. The plate keeps its last geometry and fades, so re-activating
1216 * a tab does not read as the plate flying in from nowhere.
1217 */
1218 .os-window__tab-plate[ data-empty ] {
1219 opacity: 0;
1220 }
1221
1222 @media ( prefers-reduced-motion: reduce ) {
1223 .os-window__tab-plate,
1224 .os-window__tab-plate::after,
1225 .os-window__tabs::before {
1226 transition-duration: 1ms;
1227 }
1228 .os-window__tab-plate-fill::before {
1229 animation: none;
1230 }
1231 }
1232
1233 /*
1234 * Soft edge fades so overflowing tabs tell the user "scroll for more."
1235 *
1236 * Each fade is painted ONLY on an edge that is actually hiding a tab.
1237 * `observeTabOverflow()` in `src/window/tabs.ts` stamps `data-overflow`
1238 * with the physical edges currently covered — `left`, `right`, `both`,
1239 * or the attribute dropped when the strip fits — and re-measures on
1240 * scroll, on resize, and when tabs are added or removed.
1241 *
1242 * This used to be one unconditional mask on both ends. The reasoning
1243 * was that on a strip that fits, the faded ends land past the last tab
1244 * and so fade nothing. That held on the pre-brand light strip, where
1245 * the tab bar and the title bar above it were near enough the same
1246 * near-white that a mask over empty area was invisible. It stopped
1247 * holding on the station: the strip is Obsidian over a darker window
1248 * edge, so masking its ends to transparent punches two grey smudges
1249 * into every window's submenu, whether or not there is anything to
1250 * scroll to. A permanent affordance for a state that is usually false
1251 * is not an affordance — it is decoration that lies.
1252 */
1253 .os-window__tabs[ data-overflow='left' ] {
1254 mask-image: linear-gradient( to right, transparent 0, #000 16px );
1255 -webkit-mask-image: linear-gradient( to right, transparent 0, #000 16px );
1256 }
1257
1258 .os-window__tabs[ data-overflow='right' ] {
1259 mask-image: linear-gradient(
1260 to right,
1261 #000 calc(100% - 16px),
1262 transparent 100%
1263 );
1264 -webkit-mask-image: linear-gradient(
1265 to right,
1266 #000 calc(100% - 16px),
1267 transparent 100%
1268 );
1269 }
1270
1271 .os-window__tabs[ data-overflow='both' ] {
1272 mask-image: linear-gradient(
1273 to right,
1274 transparent 0,
1275 #000 16px,
1276 #000 calc(100% - 16px),
1277 transparent 100%
1278 );
1279 -webkit-mask-image: linear-gradient(
1280 to right,
1281 transparent 0,
1282 #000 16px,
1283 #000 calc(100% - 16px),
1284 transparent 100%
1285 );
1286 }
1287
1288 .os-window__tab {
1289 position: relative;
1290 flex-shrink: 0;
1291 display: inline-flex;
1292 align-items: center;
1293 height: 30px;
1294 padding: 0 14px;
1295 border: none;
1296 border-radius: var( --_tab-radius ) var( --_tab-radius ) 0 0;
1297 background: transparent;
1298 color: var( --os-tabs-color, var( --os-ui-fg-muted, #50575e ) );
1299 font: inherit;
1300 font-size: 12px;
1301 line-height: 1;
1302 cursor: pointer;
1303 white-space: nowrap;
1304 transition: color 0.15s ease, background-color 0.15s ease;
1305 }
1306
1307 .os-window__tab:hover {
1308 color: var(--wp-admin-theme-color, #2271b1);
1309 background: var( --os-ui-hover, rgba(0, 0, 0, 0.03) );
1310 }
1311
1312 .os-window__tab:focus-visible {
1313 outline: 2px solid var(--wp-admin-theme-color, #2271b1);
1314 outline-offset: -2px;
1315 }
1316
1317 /*
1318 * The active tab. It paints NO surface of its own — the plate does
1319 * that, and the plate is a sibling that slides. All this rule owns is
1320 * the label.
1321 *
1322 * No accent anywhere on it either. The shape already says "this one",
1323 * and the accent is spent: one Pulse divider in the dock, one focus
1324 * ring. A tab that is both a distinct surface AND coloured is saying
1325 * the same thing twice.
1326 */
1327 .os-window__tab--active {
1328 color: var( --os-tabs-active-color, #1d2327 );
1329 font-weight: 600;
1330 /*
1331 * Anything nested in an active tab (the external tab's detach and
1332 * close chips) is now sitting on a light fill inside dark chrome,
1333 * so the two tones it reads have to flip with the tab. Both are
1334 * re-pointed through names the palette owns rather than hardcoded
1335 * here, so a desktop theme retints the chips with the tab.
1336 */
1337 --os-ui-fg-muted: var( --os-tabs-active-color-muted, rgba(29, 35, 39, 0.6) );
1338 --os-ui-hover: color-mix(
1339 in srgb,
1340 var( --os-tabs-active-color, #1d2327 ) 8%,
1341 transparent
1342 );
1343 }
1344
1345 /* An active tab has the plate under it; a hover wash on top of that
1346 * would double-paint the surface. */
1347 .os-window__tab--active:hover {
1348 background: transparent;
1349 color: var( --os-tabs-active-color, #1d2327 );
1350 }
1351
1352 /*
1353 * External sub-tab. Same shape as a submenu tab, plus two inline
1354 * chips: detach (↗) and close (×). Chips are painted as spans
1355 * (rather than nested buttons — nested interactive elements are a
1356 * pain for a11y). The tab's click handler routes chip clicks via
1357 * the `data-tab-action` dataset attribute.
1358 */
1359 .os-window__tab--external {
1360 /* Slightly wider gap between the label and the chip cluster, and
1361 * more breathing room at the trailing edge so the chips aren't
1362 * flush against the tab boundary. */
1363 gap: 10px;
1364 padding-inline-end: 4px;
1365 }
1366
1367 .os-window__tab-label {
1368 max-width: 180px;
1369 overflow: hidden;
1370 text-overflow: ellipsis;
1371 white-space: nowrap;
1372 }
1373
1374 /* External-tab action chips moved to `<os-tab-chip>`. Spacing
1375 * between chips handled by a single host-level rule since the
1376 * component doesn't know about its siblings. */
1377 os-tab-chip + os-tab-chip {
1378 margin-inline-start: 4px;
1379 }
1380
1381 /* Window body: contains the iframe. */
1382 .os-window__body {
1383 flex: 1;
1384 position: relative;
1385 overflow: hidden;
1386 /*
1387 * WINDOW_BODY texture slot. Visible behind native window content
1388 * and behind any iframe whose page has a transparent background;
1389 * an opaque wp-admin page inside an iframe paints over it, which
1390 * is why a theme wanting texture everywhere reaches for DESKTOP
1391 * and the chrome slots instead.
1392 */
1393 background-image: var( --os-window-body-image, none );
1394 background-repeat: var( --os-window-body-image-repeat, repeat );
1395 background-size: var( --os-window-body-image-size, auto );
1396 background-position: var( --os-window-body-image-position, center );
1397 /*
1398 * Body typeface. `--os-ui-font` is the component kit's own token, so
1399 * one declaration here reaches every `<os-*>` element inside the
1400 * window: shadow DOM inherits `font-family` through the boundary.
1401 */
1402 font-family: var( --os-ui-font, inherit );
1403 }
1404
1405 /* Iframe fills the window body. */
1406 .os-window__iframe {
1407 width: 100%;
1408 height: 100%;
1409 border: none;
1410 display: block;
1411 background: var(--os-window-bg);
1412 opacity: 1;
1413 transition: opacity 0.25s ease;
1414 }
1415
1416 /*
1417 * Headings inside native window bodies.
1418 *
1419 * WordPress core's `wp-admin/css/common.css` styles headings with
1420 * BARE ELEMENT selectors — `h1 { color: #1d2327 }` and
1421 * `h2, h3 { color: #1d2327 }`. Native windows render in the parent
1422 * shell rather than in an iframe, so those rules reach straight into
1423 * our light-DOM window content: an `<h3>` in a `<os-card>` came out
1424 * near-black on a dark theme while every sibling paragraph correctly
1425 * followed the palette.
1426 *
1427 * The specificity here is doing real work. It has to sit BETWEEN two
1428 * other rules:
1429 *
1430 * core `h3` (0,0,1) must lose
1431 * THIS rule (0,0,1) wins on source order
1432 * `.os-my-wordpress__user-section h3` (0,1,1) must still win
1433 *
1434 * `:where()` contributes zero specificity, so the selector below
1435 * weighs the same as core's bare `h3` and beats it only because our
1436 * stylesheet prints later — while any of our own class-scoped heading
1437 * rules continue to override it. Raising this to a plain
1438 * `.os-window__body h3` would tie with those and break them
1439 * depending on file order.
1440 *
1441 * The `#1d2327` fallback is core's own value, so with no theme active
1442 * the computed colour is unchanged.
1443 */
1444 :where( .os-window__body ) :is( h1, h2, h3, h4, h5, h6 ) {
1445 color: var( --os-ui-fg, #1d2327 );
1446 }
1447
1448 /*
1449 * Same story, same fix, for the other bare-element rules core ships:
1450 *
1451 * a { color: #2271b1 }
1452 * code { background: #f0f0f1 }
1453 *
1454 * `code` is the worse of the two — a light chip background with
1455 * palette-coloured text on it inverts to unreadable the moment a
1456 * theme goes dark. Both fallbacks are core's own values, so the
1457 * default is unchanged.
1458 *
1459 * Form controls (`input`, `textarea`, `select`) are deliberately NOT
1460 * included: core styles those through attribute selectors that carry
1461 * real specificity, native windows overwhelmingly use the `<os-*>`
1462 * components instead, and a half-applied override there would look
1463 * worse than leaving them alone.
1464 */
1465 :where( .os-window__body ) a {
1466 color: var( --os-ui-accent, #2271b1 );
1467 }
1468
1469 :where( .os-window__body ) code {
1470 background: var( --os-ui-surface-sunken, #f0f0f1 );
1471 color: var( --os-ui-fg, inherit );
1472 }
1473
1474 /*
1475 * Raw form controls in native window bodies.
1476 *
1477 * Core's `forms.css` styles these through ATTRIBUTE selectors —
1478 * `input[type="search"], select, textarea { background-color: #fff;
1479 * color: #1e1e1e; border: 1px solid #949494 }` — which weighs
1480 * (0,1,1). That beats a plain class selector, so a control our own
1481 * CSS had already tokenized (My WordPress's search box reads
1482 * `background: var( --os-ui-surface, #fff )` and has done all along)
1483 * still came out white: core simply outranked it.
1484 *
1485 * Hence `.os-window__body` + `:is( input[type], … )`, which
1486 * weighs (0,2,1) and wins. `<os-*>` form components are untouched —
1487 * they live in shadow DOM, where none of this reaches.
1488 *
1489 * Fallbacks are core's own values, so an unthemed shell is unchanged.
1490 */
1491 .os-window__body
1492 :is(
1493 input[ type="text" ],
1494 input[ type="search" ],
1495 input[ type="email" ],
1496 input[ type="url" ],
1497 input[ type="tel" ],
1498 input[ type="number" ],
1499 input[ type="password" ],
1500 input[ type="date" ],
1501 input[ type="datetime-local" ],
1502 input[ type="month" ],
1503 input[ type="time" ],
1504 input[ type="week" ],
1505 select,
1506 textarea
1507 ) {
1508 background-color: var( --os-ui-surface, #fff );
1509 color: var( --os-ui-fg, #1e1e1e );
1510 border-color: var( --os-ui-border-strong, #949494 );
1511 }
1512
1513 .os-window__body :is( input, textarea )::placeholder {
1514 color: var( --os-ui-fg-muted, #646970 );
1515 }
1516
1517 /* Cross-window drag drop-overlay rules currently live in windows.css.
1518 * That was originally a cache workaround — this file was an `@import`
1519 * sub-sheet with no `?ver=` of its own, so edits here could be served
1520 * stale indefinitely. It is now a separately registered handle with
1521 * its own filemtime stamp, so the workaround is obsolete and those
1522 * rules could move back here. Left in place for now because moving
1523 * them is a behaviour-neutral churn better done on its own. */
1524
1525 /*
1526 * Loading-state overlay — painted by `src/window/dom.ts` into
1527 * every window's body and removed once the body's content reports
1528 * ready. The `<os-spinner>` inside is sized responsively against
1529 * the window's width via `clamp(96px, 14vw, 192px)` so a tiny
1530 * popover gets a small spinner and a maximized window gets a big
1531 * one. Placed above the body content (iframe / native render
1532 * output) and kept aria-hidden so the spinner's own SR-label is
1533 * the only loading announcement.
1534 *
1535 * The `--visible` modifier turns the overlay on. JS adds it ~120ms
1536 * into the load (`LOADING_OVERLAY_SHOW_DELAY_MS`), so a fast render
1537 * never paints a spinner. The delay cannot be a `transition-delay`
1538 * here: the overlay is appended into a body that already carries
1539 * `--loading`, so its first computed style is the visible one and the
1540 * transition never runs.
1541 */
1542 .os-window__loading {
1543 position: absolute;
1544 inset: 0;
1545 display: flex;
1546 align-items: center;
1547 justify-content: center;
1548 pointer-events: none;
1549 background: var(--os-window-bg);
1550 opacity: 0;
1551 /* Default (loaded → loading-removed) — fade out immediately. */
1552 transition: opacity 0.25s ease;
1553 /*
1554 * Above both reveal layers (z-index 2 and 3), so the spinner stays
1555 * readable for the whole load and the surface it is sitting on only
1556 * becomes visible once the spinner has faded out.
1557 */
1558 z-index: 4;
1559 }
1560
1561 /*
1562 * Content hidden while loading. The reveal surface is excluded
1563 * alongside the spinner overlay: it is chrome, not content, and fading
1564 * it to transparent would show the very content it exists to cover.
1565 */
1566 .os-window__body--loading > .os-window__iframe,
1567 .os-window__body--loading
1568 > :not(.os-window__loading):not(.os-window__reveal) {
1569 opacity: 0;
1570 transition: opacity 0.25s ease;
1571 }
1572
1573 .os-window__body--loading > .os-window__loading--visible {
1574 opacity: 1;
1575 }
1576
1577 /*
1578 * Content hand-off. `--loading` drops as soon as the content is ready,
1579 * but the overlay above it still needs 250ms to fade out. Without this
1580 * both layers are on screen at once, which reads as a flash.
1581 *
1582 * This modifier holds the content transparent for the length of that
1583 * fade, then fades it in. The delay in the shorthand does the holding.
1584 *
1585 * Only added when the spinner actually painted. A load that finishes
1586 * inside the show delay never reached `--visible`, so the shell drops
1587 * the overlay in the same tick instead.
1588 *
1589 * `--revealing` declares the same selector at the same specificity and
1590 * comes later in the file, so a window playing a reveal keeps its
1591 * content opaque under the reveal surface.
1592 */
1593 .os-window__body--loading-out > .os-window__iframe,
1594 .os-window__body--loading-out
1595 > :not(.os-window__loading):not(.os-window__reveal) {
1596 opacity: 1;
1597 transition: opacity 0.25s ease 0.25s;
1598 }
1599
1600 @media ( prefers-reduced-motion: reduce ) {
1601 .os-window__iframe,
1602 .os-window__loading,
1603 .os-window__body--loading > .os-window__iframe,
1604 .os-window__body--loading
1605 > :not(.os-window__loading):not(.os-window__reveal),
1606 .os-window__body--loading-out > .os-window__iframe,
1607 .os-window__body--loading-out
1608 > :not(.os-window__loading):not(.os-window__reveal) {
1609 transition: none;
1610 }
1611 }
1612
1613 /*
1614 * Window reveal — the opaque layers a window's content is uncovered
1615 * from once it reports ready. Painted for every window (of either
1616 * kind) from construction, animated away by `src/reveals/surface.ts`,
1617 * then removed.
1618 *
1619 * They are SIBLINGS of the iframe, never wrappers. `clip-path` is
1620 * animated on these elements alone, so the content below keeps its own
1621 * compositing layer, its hit-testing, and its stacking context — a
1622 * reveal cannot swallow a click or re-rasterize the page it is
1623 * uncovering. Native window content gets the identical treatment for
1624 * the same reason.
1625 *
1626 * `--os-window-reveal-surface` is a theme token, white by
1627 * default — the surface has to be opaque or there is nothing to reveal
1628 * from. The EDGE token is `transparent` by default instead, since an
1629 * edge is an accent rather than the effect itself. Either layer that
1630 * computes to no paint is skipped rather than animated. A def may
1631 * override the surface for itself with an inline `background` (see
1632 * `WindowRevealDef.surfaceColor`), which is how `obturator` gets its
1633 * near-black shutter blades regardless of the token.
1634 *
1635 * No `transition` here on purpose — the shape is driven entirely by
1636 * the Web Animations API, which needs a matched `from` / `to` pair a
1637 * CSS transition cannot express. Reduced motion is handled JS-side by
1638 * removing the layers instead of animating them.
1639 */
1640 .os-window__reveal {
1641 position: absolute;
1642 inset: 0;
1643 pointer-events: none;
1644 background: var( --os-window-reveal-surface, #fff );
1645 z-index: 3;
1646 }
1647
1648 /*
1649 * The reveal's leading edge. Same element shape, same keyframes, run
1650 * over a slightly longer duration so it trails the surface — the sliver
1651 * of it that is not yet covered by the surface IS the edge band. That
1652 * is why this needs no shape of its own, and why every reveal (built-in
1653 * or third-party) gets an edge that follows its geometry exactly.
1654 *
1655 * Behind the surface, still above the content.
1656 *
1657 * `--os-window-reveal-edge` is `transparent` by default, and
1658 * the shell drops the layer entirely while it computes that way — so
1659 * the default costs no element and no animation. Give the token a
1660 * colour (or a gradient) to turn the edge on across every reveal at
1661 * once. `--os-window-reveal-edge-thickness` tunes how wide
1662 * the band is; `edgeLag: 0` on a def opts a single reveal out.
1663 */
1664 .os-window__reveal--edge {
1665 background: var( --os-window-reveal-edge, transparent );
1666 z-index: 2;
1667 }
1668
1669 /*
1670 * A reveal that renders its own DOM (`WindowRevealDef.render`) paints
1671 * itself — an `<svg>`, a canvas, whatever it built. The surface token
1672 * must NOT apply underneath it: an opaque rectangle behind the
1673 * renderer's output is what the effect would end up uncovering, so the
1674 * animation would play against a flat colour and the real content
1675 * would only appear when the layer is finally removed.
1676 */
1677 .os-window__reveal--custom {
1678 background: none;
1679 }
1680
1681 /*
1682 * While a reveal plays, pin the content to full opacity with no
1683 * transition. Dropping `--loading` normally starts a 250 ms content
1684 * fade-in; a reveal running over that fade would show a
1685 * half-transparent strip along its leading edge. Beats the base
1686 * `.os-window__iframe` rule on specificity, so no
1687 * `!important` is needed.
1688 */
1689 .os-window__body--revealing
1690 > :not(.os-window__loading):not(.os-window__reveal) {
1691 opacity: 1;
1692 transition: none;
1693 }
1694
1695 /*
1696 * External sub-tab iframes stack in the same body as the primary
1697 * iframe. Only one is visible at a time (managed by `switchToTab`
1698 * via `style.display`). Absolute positioning so they don't push the
1699 * primary iframe around when added to the DOM; `display: none` on
1700 * inactive iframes is set inline by the JS.
1701 */
1702 .os-window__iframe--external {
1703 position: absolute;
1704 inset: 0;
1705 }
1706
1707 /*
1708 * Double-buffer twin for `Window.swapReload()` — the silent refresh
1709 * the editor-preview companion uses. The twin loads UNDERNEATH the
1710 * visible frame at full opacity: a normal, fully-rasterized paint
1711 * target, completely covered by the (opaque) old frame while it
1712 * loads. Deliberately not `opacity: 0`-on-top or
1713 * `visibility: hidden` — browsers defer rasterizing invisible
1714 * iframes, and revealing such a frame paints its blank background
1715 * before its raster lands (a white blink).
1716 *
1717 * Stacking: positioned elements paint above static ones regardless
1718 * of DOM order, so the buffer (absolute) would land on top of the
1719 * static primary frame — `--swap-front` elevates the OLD frame for
1720 * the duration of the swap instead. The swap itself is instant and
1721 * animation-free: removing the old frame exposes the ready-painted
1722 * twin in the same compositor frame. At no point is unpainted
1723 * content the only thing on screen.
1724 */
1725 .os-window__iframe--buffer {
1726 position: absolute;
1727 inset: 0;
1728 pointer-events: none;
1729 }
1730
1731 .os-window__iframe--swap-front {
1732 position: relative;
1733 z-index: 1;
1734 }
1735
1736 /*
1737 * Native window body — fills the window but lets its contents scroll
1738 * independently. Iframe bodies use `overflow: hidden` so the iframe
1739 * controls its own scroll; native bodies render real document content
1740 * that needs the parent to manage overflow.
1741 */
1742 .os-window__body--native {
1743 overflow: auto;
1744 color: var( --os-ui-fg, #1d2327 );
1745 /* background-COLOR, not the shorthand. This is a modifier on the
1746 * SAME element as `.os-window__body`, at the same
1747 * specificity and later in source order — the shorthand would
1748 * reset the WINDOW_BODY texture declared there, which is exactly
1749 * the surface native windows exist to show. */
1750 background-color: var(--os-window-bg);
1751 }
1752
1753 /*
1754 * Highlight rings. Toggled from JS by `Window.setHighlight()` —
1755 * used by plugins that need to point at a window from outside it
1756 * (e.g. a "connect to" dropdown that previews candidate windows on
1757 * hover). `--wp-window-highlight-color` is overridable per-instance
1758 * via `setHighlight( mode, { color } )` or globally via the
1759 * variable.
1760 */
1761 .wp-window {
1762 --wp-window-highlight-color: var(
1763 --wp-admin-theme-color, #2271b1
1764 );
1765 }
1766 .wp-window--highlight-preview {
1767 box-shadow: 0 0 0 3px var( --wp-window-highlight-color ),
1768 0 0 24px 4px var( --wp-window-highlight-color );
1769 transition: box-shadow 0.12s ease;
1770 z-index: 9999;
1771 }
1772 .wp-window--highlight-persistent {
1773 box-shadow: 0 0 0 2px var( --wp-window-highlight-color );
1774 transition: box-shadow 0.12s ease;
1775 }
1776
1777 /*
1778 * Slots for plugin-registered title-bar buttons. Empty by default
1779 * — `display: contents` hides them entirely from layout when no
1780 * plugin has registered a button for the window. When buttons ARE
1781 * present, they sit inline next to the title (left slot) or just
1782 * before the window controls (right slot).
1783 */
1784 .os-window__custom-buttons {
1785 display: contents;
1786 }
1787 .os-window__btn--custom {
1788 margin: 0 2px;
1789 }
1790 /*
1791 * Plugin-supplied icons render in the host's light DOM so the
1792 * global Dashicons stylesheet reaches them (shadow DOM doesn't
1793 * inherit page CSS).
1794 *
1795 * Dashicons render at their native 20×20 — that's the size the
1796 * font is hinted for and the only one where glyph metrics put
1797 * the visual centre on the geometric centre. Earlier we tried
1798 * down-scaling them to 14×14 to match the built-in chrome icons,
1799 * but font-size: 14 on a Dashicon shifts the glyph 1–2 pixels
1800 * off centre because the font's ascent/descent ratio doesn't
1801 * scale linearly. The 30×30 button has 5px of padding around a
1802 * 20×20 glyph — comfortable, and visually consistent with the
1803 * 14×14 chrome icons because both are flex-centred in the same
1804 * box.
1805 *
1806 * For inline-SVG icons that plugin authors ship without explicit
1807 * width/height, we DO clamp to 18×18 — those are the cases where
1808 * a bare `<svg>` would otherwise size to its viewBox or default
1809 * to 300×150 (the spec default) and overflow the button.
1810 */
1811 .os-window__btn--custom svg:not( [ width ] ):not( [ height ] ) {
1812 width: 18px;
1813 height: 18px;
1814 display: block;
1815 }
1816
1817 /*
1818 * Busy state for custom title-bar buttons — a gentle opacity pulse
1819 * while a round-trip is in flight (e.g. the editor-preview eye
1820 * waiting on the editor's autosave before opening the preview).
1821 * Driven by `aria-busy="true"` + this class, both set by the button's
1822 * render callback.
1823 */
1824 .os-window__btn--busy {
1825 animation: os-btn-busy-pulse 1s ease-in-out infinite;
1826 pointer-events: none;
1827 }
1828
1829 @keyframes os-btn-busy-pulse {
1830 0%,
1831 100% {
1832 opacity: 1;
1833 }
1834
1835 50% {
1836 opacity: 0.4;
1837 }
1838 }
1839
1840 @media (prefers-reduced-motion: reduce) {
1841 .os-window__btn--busy {
1842 animation: none;
1843 opacity: 0.6;
1844 }
1845 }
1846
1847 /*
1848 * Disabled state for custom title-bar buttons — visible but inert
1849 * (e.g. the editor-preview eye on an unsaved "Add New" screen, where
1850 * there is nothing to preview until the first save). The button keeps
1851 * pointer events so its explanatory tooltip/toast still works;
1852 * `aria-disabled` carries the semantics.
1853 */
1854 .os-window__btn--disabled {
1855 opacity: 0.4;
1856 cursor: default;
1857 }
1858
1859 /* ----------------------------------------------------------------
1860 * Custom-chrome marker — hides every framework-shipped titlebar
1861 * child while a plugin's chrome is mounted.
1862 *
1863 * The window gets the `os-window--custom-chrome` class the
1864 * moment `mountWindowChrome` succeeds (BEFORE the plugin's
1865 * `render()` runs). Default children carry the
1866 * `data-os-default-chrome` attribute stamped at element-
1867 * creation time. The combination is a load-bearing guarantee:
1868 * even if the plugin's render() doesn't clear `titlebar.innerHTML`,
1869 * even if a plugin's destroy() leaks the standard chrome back into
1870 * place, even mid-fade during a window close — the default chrome
1871 * NEVER becomes visible while the marker class is active.
1872 *
1873 * The class is removed only when the chrome is swapped to standard
1874 * (in `Window.remountWindowChrome`); during a window close it
1875 * persists until `element.remove()` runs in `onDone()`, which is
1876 * after the fade has reached opacity 0 — so the user never sees
1877 * the swap.
1878 *
1879 * @since 0.18.0
1880 * ---------------------------------------------------------------- */
1881
1882 .os-window--custom-chrome
1883 > .os-window__titlebar
1884 > [ data-os-default-chrome ] {
1885 display: none !important;
1886 }
1887
1888 /* ---------------------------------------------------------------
1889 * Reload button feedback.
1890 *
1891 * Click feedback is decoupled from the loading state:
1892 *
1893 * 1. On click, the icon performs a single 360° rotation with a
1894 * fast-start / slow-end ease curve — confirms the user's
1895 * gesture independently of how long the page takes to load.
1896 * The `--spinning` class is added by the click handler and
1897 * removed on `animationend` so a subsequent click restarts
1898 * the animation cleanly.
1899 *
1900 * 2. While the window body is in the `--loading` state (set by
1901 * `markContentLoading()` and cleared on the iframe's
1902 * `os-ready` postMessage), the button is dimmed and
1903 * non-interactive so a second click can't desync the
1904 * chromeless bridge's loading handshake.
1905 *
1906 * `:has()` is used instead of mirroring the `--loading` modifier
1907 * onto the window root because upstream's loading API only
1908 * decorates the body element. Browsers without :has() (very old)
1909 * fall back to a static dimmed button — feature, not bug.
1910 * --------------------------------------------------------------- */
1911
1912 .os-window:has( .os-window__body--loading )
1913 .os-window__btn--reload {
1914 pointer-events: none;
1915 opacity: 0.55;
1916 }
1917
1918 /*
1919 * Click feedback animation. The custom easing — `cubic-bezier( 0.05,
1920 * 0.7, 0.1, 1 )` — is a steeper-than-default ease-out: ~70% of the
1921 * rotation lands in the first 30% of the duration, then it decelerates.
1922 * Reads as "snap, then settle" rather than the linear glide of a
1923 * loading-spinner, reinforcing that this is a one-shot acknowledgement
1924 * of the gesture rather than progress through a long task. The `0.6s`
1925 * duration is short enough to never block the user but long enough
1926 * that the deceleration is legible.
1927 */
1928 .os-window__btn--reload.os-window__btn--spinning {
1929 animation: os-reload-spin-once 0.6s cubic-bezier( 0.05, 0.7, 0.1, 1 );
1930 }
1931
1932 @keyframes os-reload-spin-once {
1933 from {
1934 transform: rotate( 0deg );
1935 }
1936 to {
1937 transform: rotate( 360deg );
1938 }
1939 }
1940
1941 @media ( prefers-reduced-motion: reduce ) {
1942 .os-window__btn--reload.os-window__btn--spinning {
1943 animation: none;
1944 }
1945 }
1946