PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.13
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.13
1.1.13 1.1.12 1.1.11 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 All 37 releases
desktop-mode / assets / css / window-chrome.css

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

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