# desktop-mode/1.1.1/assets/css/window-chrome.css

OpenStation: Desktop Windows, Dock &amp; Virtual Desktops for WP Admin, version 1.1.1. 1,946 lines.

- Page: https://pluginprobe.com/plugins/desktop-mode/1.1.1/code/assets/css/window-chrome.css
- Raw: https://pluginprobe.com/plugins/desktop-mode/1.1.1/raw/assets/css/window-chrome.css
- Modified: 2026-08-14T15:57:42+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/desktop-mode/1.1.1/code/assets/css/window-chrome.css#L10-L20`.

```css
/**
 * OpenStation — Window chrome.
 *
 * Everything that renders the base window frame: title bar, icon +
 * title text, control buttons, focused / unfocused colour variants,
 * the ⋯ actions menu panel, screen-meta buttons, the tab strip (for
 * submenu + external tabs), the window body, the primary iframe, and
 * the native-body variant. State-driven rules (dragging / resizing /
 * maximized / fullscreen / overview / minimized / closing) live in
 * sibling files loaded via `windows.css`.
 *
 * @since 6.9.0
 */

/* Base window. */
.os-window {
	/*
	 * border-box sizing is essential for pixel-perfect maximize.
	 * `Window.maximize()` sets inline `width = parent.clientWidth`,
	 * which is the area's inner width. Under content-box sizing, the
	 * window's 1px border would add 2 extra pixels to the rendered
	 * element, pushing its right + bottom borders past the
	 * overflow-hidden boundary of `.os-area` and making them
	 * look clipped/offscreen. With border-box, width/height INCLUDE
	 * the border, so `width = clientWidth` fits exactly.
	 *
	 * WP admin CSS does set `*, *::before, *::after { box-sizing:
	 * border-box }` globally, but setting it explicitly here
	 * insulates us from stylesheets or embeds that might reset the
	 * universal rule — the correctness of maximize geometry is too
	 * important to depend on ambient CSS.
	 */
	box-sizing: border-box;
	position: absolute;
	display: flex;
	flex-direction: column;
	background: var(--os-window-bg);
	border: 1px solid var(--os-window-border);
	border-radius: var(--os-window-radius);
	box-shadow: var(--os-window-shadow);
	/*
	 * Desktop-theme window frame (WINDOW_FRAME texture slot). All
	 * four properties default to their CSS initial value via the
	 * `var( …, fallback )` second argument, so with no theme active
	 * this costs nothing and the 1px border above stays in charge.
	 * A theme that sets `--os-window-border-image-source`
	 * takes the frame over entirely — border-image paints on top of
	 * (and visually replaces) the border. See docs/desktop-themes.md.
	 */
	border-image-source: var(--os-window-border-image-source, none);
	border-image-slice: var(--os-window-border-image-slice, 100%);
	border-image-width: var(--os-window-border-image-width, 1);
	border-image-repeat: var(--os-window-border-image-repeat, stretch);
	overflow: hidden;
	min-width: 320px;
	min-height: 200px;
	transform-origin: center center;
	transition:
		left 0.25s cubic-bezier(0.2, 0, 0.2, 1),
		top 0.25s cubic-bezier(0.2, 0, 0.2, 1),
		width 0.25s cubic-bezier(0.2, 0, 0.2, 1),
		height 0.25s cubic-bezier(0.2, 0, 0.2, 1),
		border-radius 0.25s ease,
		transform 0.2s ease,
		opacity 0.2s ease,
		box-shadow 0.2s ease,
		/* Unfocus effects (e.g. `--fx-darken` / `--fx-frost`) toggle
		 * `filter`; keep it in the shared transition list so the
		 * treatment fades both ways as focus moves between windows.
		 * Duration is a custom property (default in `effects.css`) so
		 * the blur/darken ramp can be tuned without editing this list. */
		filter var( --os-fx-transition-duration, 0.5s ) ease-in-out;
}

/*
 * Suppress the left/top/width/height transition during drag or resize —
 * otherwise every pointer move lerps and the window lags the cursor.
 *
 * Also during a viewport reflow (`--reflowing`) — when the browser
 * window is being dragged smaller, the ResizeObserver fires many
 * times per second and each style write would restart the 250 ms
 * transition, leaving maximized / snapped windows ~250 ms behind the
 * viewport the whole time. The class is added by
 * `reflowStatefulWindows` and cleared ~140 ms after the last tick.
 */
.os-window--dragging,
.os-window--resizing,
.os-window--reflowing {
	transition: none;
}

/*
 * Snap-to-grid drag/resize: re-enable a SHORT transition so each
 * cell-to-cell jump animates smoothly instead of teleporting. The
 * `--snap-drag` class is added by the window class only when snap
 * is on at drag/resize start; it composes with `--dragging` /
 * `--resizing` and the source-order ensures it wins. Kept short
 * (90 ms) so the window still feels glued to the cursor — anything
 * longer reads as lag.
 */
.os-window--snap-drag {
	transition:
		left 0.09s ease-out,
		top 0.09s ease-out,
		width 0.09s ease-out,
		height 0.09s ease-out;
}

/* Focused window gets elevated shadow and distinct title bar. */
.os-window--focused {
	box-shadow: var(--os-window-shadow-focused);
	/*
	 * WINDOW_FRAME_FOCUSED texture slot. Each property falls through
	 * the unfocused WINDOW_FRAME value before reaching its initial, so
	 * a theme shipping one frame gets it on both states and a theme
	 * shipping two gets a frame that lights up on focus — the same
	 * fallback chain TITLEBAR_FOCUSED uses.
	 */
	border-image-source: var(
		--os-window-border-image-focused-source,
		var(--os-window-border-image-source, none)
	);
	border-image-slice: var(
		--os-window-border-image-focused-slice,
		var(--os-window-border-image-slice, 100%)
	);
	border-image-width: var(
		--os-window-border-image-focused-width,
		var(--os-window-border-image-width, 1)
	);
	border-image-repeat: var(
		--os-window-border-image-focused-repeat,
		var(--os-window-border-image-repeat, stretch)
	);
}

/* Title bar. */
.os-window__titlebar {
	position: relative;
	/* Promote the titlebar into its own stacking context above the
	 * body. Without this, the body (which comes later in source order
	 * and inherits z-auto) wins document-order overlap battles —
	 * notably any sticky-positioned content inside the body (e.g. the
	 * native Posts table's sticky header at z-index 20–40) would
	 * paint over the titlebar's ⋯ menu popover. */
	z-index: 21;
	display: flex;
	align-items: center;
	height: var(--os-titlebar-height);
	padding: 0 8px;
	background-color: var(--os-titlebar-bg);
	/*
	 * Desktop-theme title-bar texture (TITLEBAR slot). `none` when
	 * unset, so the background-color above is the whole story for
	 * an unthemed shell. The `-repeat` / `-size` companions are
	 * declared unconditionally with their CSS initial values as
	 * fallbacks — cheaper than a second selector and it keeps the
	 * shorthand from resetting them.
	 */
	background-image: var(--os-titlebar-image, none);
	background-repeat: var(--os-titlebar-image-repeat, repeat);
	background-size: var(--os-titlebar-image-size, auto);
	background-position: var(--os-titlebar-image-position, center);
	color: var(--os-titlebar-color);
	/*
	 * Title bars can carry their own face — a display typeface here
	 * with a text face in the body is the classic desktop split. Two
	 * levels of fallback: the title-bar token, then the shell-wide
	 * one, then `inherit`, which is what an undeclared font-family
	 * would have computed to anyway. Unthemed shells are unchanged.
	 */
	font-family: var(
		--os-titlebar-font,
		var(--os-font, inherit)
	);
	cursor: default;
	user-select: none;
	flex-shrink: 0;
	gap: 8px;
}

.os-window--focused .os-window__titlebar {
	background-color: var(--os-titlebar-bg-focused);
	/*
	 * TITLEBAR_FOCUSED slot. Falls back through the unfocused
	 * TITLEBAR image before reaching `none`, so a theme that ships
	 * only one title-bar texture gets it on both states for free.
	 */
	background-image: var(
		--os-titlebar-image-focused,
		var(--os-titlebar-image, none)
	);
	color: var(--os-titlebar-color-focused);
}

/* Window icon in title bar. */
.os-window__icon {
	font-size: 18px;
	width: 18px;
	height: 18px;
	flex-shrink: 0;
}

/* Letter-badge fallback (unrecognized icon values) — shrink the
 * one-/two-letter monogram so it fits the 18px icon box. */
.os-window__icon.os-icon-letter {
	font-size: 9px;
}

/* ---------------------------------------------------------------
 * Window activity — the status ring.
 *
 * The leading mark of the title bar, in the position the app icon
 * used to hold. That icon was a copy of the window's own dock tile a
 * few hundred pixels below it, and a title bar has room for one mark
 * of that size — better spent on something that changes.
 *
 * The ring is an `<os-save-status variant="ring" mode="icon">`, found
 * by `[data-os-activity-indicator]` — the same public attribute a
 * plugin uses to mount its own. The framework's ring is not a special
 * case; it is the first subscriber.
 *
 * Four states, and only one of them fills:
 *
 *   idle    white outline, no glyph
 *   saving  accent outline, breathing
 *   saved   accent fill, white check
 *   failed  open red outline, red bang
 *
 * Colour alone is not a distinction every user can make, which is why
 * the two outcomes differ in SHAPE — filled versus open, check versus
 * bang — and not only in hue.
 *
 * `Window._paintActivityIndicator()` also mirrors the phase onto the
 * title bar as `data-os-activity` (absent while idle) so a desktop
 * theme can react to window state without reaching into the
 * component's shadow root.
 */
.os-window__status {
	--os-ui-save-status-size: var(--os-titlebar-activity-size, 16px);
	/*
	 * At rest: a white ring. One value, in both title-bar states —
	 * the phase is what the ring reports, and dimming it on an
	 * unfocused window would make "idle" say two different things
	 * depending on which window you last clicked.
	 */
	--os-ui-save-status-idle-color: var(--os-titlebar-activity-idle-color, #fff);
	/*
	 * The RING colour, not the dot's background. Setting
	 * `--os-ui-save-status-bg` here instead is what once painted a
	 * solid accent fill inside the resting outline: that token is the
	 * dot's `background` on the component's base rule, and the ring
	 * only borrows it as a border. The ring has its own name for
	 * exactly this reason.
	 */
	--os-ui-save-status-ring-color: var(--os-titlebar-activity-color, #2271b1);
	--os-ui-save-status-saved-bg: var(--os-titlebar-activity-saved-color, #2271b1);
	--os-ui-save-status-failed-bg: var(--os-titlebar-activity-failed-color, #d63638);
	display: inline-flex;
	align-items: center;
	flex-shrink: 0;
}

/*
 * The announcement. A glow is invisible to a screen reader, and
 * "did that save?" is precisely the question that can't be answered
 * by looking. `Window._paintActivityIndicator()` writes the outcome
 * here; absolute positioning keeps it out of the title bar's flex
 * flow so it contributes neither a box nor a `gap`.
 */
.os-window__activity-status {
	position: absolute;
	width: 1px;
	height: 1px;
	margin: -1px;
	padding: 0;
	border: 0;
	overflow: hidden;
	clip-path: inset(50%);
	white-space: nowrap;
}

/*
 * Activity indicator slot — opt-in, and empty by default.
 *
 * The framework paints the glow above instead of putting an
 * `<os-save-status>` in the title bar of its own accord. The slot
 * survives for anything that DOES want a literal dot as well: give an
 * `<os-save-status>` the `data-os-activity-indicator` attribute, drop
 * it in a title-bar slot inside a `.os-window__activity` wrapper, and
 * `Window._paintActivityIndicator()` drives its phase for you.
 *
 * The fixed width is what keeps the blink from shifting the title
 * text sideways, and `--wp-admin-theme-color` is forwarded as `color`
 * so the component's shadow-DOM `currentColor` references (the glow's
 * box-shadow) resolve to the live accent.
 */
.os-window__activity {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	width: 14px;
	height: 14px;
	flex-shrink: 0;
	margin-inline-start: 6px;
	margin-inline-end: 4px;
	color: var(--wp-admin-theme-color, #2271b1);
}

/* Window title text. */
.os-window__title {
	flex: 1;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
	font-size: 14px;
	font-weight: 500;
	line-height: var(--os-titlebar-height);
}

/*
 * Window control buttons container.
 *
 * The cluster is TRANSPARENT by default, which is the whole point: a
 * themed title-bar texture runs edge to edge underneath it, and the
 * buttons float on the artwork rather than sitting on a plate. Every
 * property below resolves to exactly that when unset.
 *
 * A theme that wants a plate instead sets `--os-titlebar-
 * controls-bg` (and optionally the TITLEBAR_CONTROLS texture slot,
 * a radius, and some inline padding) — the classic "the controls live
 * in their own well" look, without the framework picking it for
 * everyone.
 *
 * `padding-block` stays 0 so the cluster never changes the title
 * bar's height; only the inline padding is themable.
 *
 * @since 0.9.8
 */
.os-window__controls {
	display: flex;
	gap: var( --os-titlebar-controls-gap, 4px );
	align-items: center;
	flex-shrink: 0;
	padding-inline: var( --os-titlebar-controls-padding, 0 );
	border-radius: var( --os-titlebar-controls-radius, 0 );
	background-color: var( --os-titlebar-controls-bg, transparent );
	background-image: var( --os-titlebar-controls-image, none );
	background-repeat: var( --os-titlebar-controls-image-repeat, repeat );
	background-size: var( --os-titlebar-controls-image-size, auto );
	background-position: var( --os-titlebar-controls-image-position, center );
}

/*
 * Same treatment for the Screen Options / Help cluster, so a theme
 * that plates one can plate the other and keep them consistent.
 * Falls through to the controls tokens, so setting the pair above is
 * enough for both.
 */
.os-window__screen-meta {
	border-radius: var(
		--os-titlebar-meta-radius,
		var( --os-titlebar-controls-radius, 0 )
	);
	background-color: var( --os-titlebar-meta-bg, transparent );
	background-image: var( --os-titlebar-meta-image, none );
	background-repeat: var( --os-titlebar-meta-image-repeat, repeat );
	background-size: var( --os-titlebar-meta-image-size, auto );
	background-position: var( --os-titlebar-meta-image-position, center );
}

/*
 * Layer-3 slot hosts.
 *
 * Most in-titlebar slots wrap canonical chrome elements without
 * contributing to the flex layout themselves — `display: contents`
 * removes the wrapper's box so the inner element (icon span with
 * `flex-shrink: 0`, before/after spacers, etc.) keeps its
 * original layout role.
 *
 * The `icon` slot is empty by default now — the app icon it used to
 * carry duplicated the window's dock tile — so on most windows it
 * contributes nothing at all. It keeps `display: contents` for the
 * ones where a plugin or a desktop theme does render an icon into it.
 *
 * The `title` slot is the exception: it's the flex-grow region of
 * the title bar (`flex: 1`). When a plugin replaces the default
 * title with custom HTML or a render callback, we want the new
 * content to inherit the same "fill the remaining horizontal
 * space" behaviour — so the slot itself owns `flex: 1` and the
 * default title element's own `flex: 1` cooperates inside the
 * nested flex.
 *
 * before/after-titlebar slots are flex-column children of
 * `.os-window` (siblings of the title bar). They're real
 * boxes — plugins can paint backgrounds, padding, borders. Empty
 * hosts collapse via `:empty` so they take no space until populated.
 *
 * @since 0.6.0
 */
.os-window__slot--before-icon,
.os-window__slot--icon,
.os-window__slot--after-title,
.os-window__slot--before-controls,
.os-window__slot--after-controls {
	display: contents;
}


.os-window__slot--title {
	flex: 1;
	min-width: 0;
	display: flex;
	align-items: center;
	overflow: hidden;
}

.os-window__slot--before-titlebar,
.os-window__slot--after-titlebar {
	display: block;
	flex-shrink: 0;
}

.os-window__slot--before-titlebar:empty,
.os-window__slot--after-titlebar:empty {
	display: none;
}

/* ---------------------------------------------------------------
 * Window control buttons.
 *
 * Flat, icon-only buttons styled to feel at home in the WordPress
 * admin: transparent by default, subtle tinted hover, focus ring in
 * the active admin theme color, inline SVG icons inheriting
 * currentColor so they adapt to focused / unfocused title bars.
 *
 * The close button gets a destructive red wash on hover only —
 * never as a default state — so it reads as safe until interacted
 * with, matching the WordPress admin's pattern of reserving color
 * for semantic signal.
 * --------------------------------------------------------------- */
/*
 * Title-bar chrome buttons render as `<os-window-button>`.
 * Shadow DOM can't reach across the window's focus class, so we
 * drive the coloring via custom properties that inherit through
 * the boundary. The component reads `--os-ui-btn-color`,
 * `--os-ui-btn-bg-hover`, etc. and paints accordingly.
 *
 * Focused control glyphs are white at 70% — correct against the
 * default focused title bar, which is the admin theme colour and so
 * always a mid-to-dark fill, and invisible against a pale one. A
 * theme could not reach them: these declarations land on the WINDOW
 * element, so a `--os-ui-btn-color` set at the shell root loses to
 * them, and the same names also drive buttons outside the title bar
 * (pinned notes, the desk chrome) that a theme usually does not want
 * to move in the same stroke.
 *
 * `--os-titlebar-btn-focused-*` mirrors the unfocused set
 * below, name for name, so the two halves of the title bar are
 * addressed the same way. Every one is UNDECLARED and falls back to
 * the literal it replaced, so an unthemed shell paints exactly what
 * it painted before.
 *
 * There is no `-danger-hover` twin: destructive red is semantic, not
 * chrome, and both halves already resolve it through `--os-ui-danger`.
 */
.os-window--focused {
	--os-ui-btn-color: var( --os-titlebar-btn-focused-color, rgba( 255, 255, 255, 0.7 ) );
	--os-ui-btn-color-hover: var( --os-titlebar-btn-focused-color-hover, #fff );
	--os-ui-btn-bg-hover: var( --os-titlebar-btn-focused-bg-hover, rgba( 255, 255, 255, 0.18 ) );
	--os-ui-btn-bg-active: var( --os-titlebar-btn-focused-bg-active, rgba( 255, 255, 255, 0.25 ) );
	--os-ui-btn-outline: var( --os-titlebar-btn-focused-outline, rgba( 255, 255, 255, 0.65 ) );
	--os-ui-btn-danger-hover: var( --os-ui-danger, #d63638 );
}

.os-window:not( .os-window--focused ) {
	--os-ui-btn-color: var( --os-titlebar-btn-color, rgba( 0, 0, 0, 0.45 ) );
	--os-ui-btn-color-hover: var( --os-titlebar-btn-color-hover, rgba( 0, 0, 0, 0.85 ) );
	--os-ui-btn-bg-hover: var( --os-titlebar-btn-bg-hover, rgba( 0, 0, 0, 0.08 ) );
	--os-ui-btn-bg-active: var( --os-titlebar-btn-bg-active, rgba( 0, 0, 0, 0.12 ) );
	--os-ui-btn-outline: var( --wp-admin-theme-color, #2271b1 );
	--os-ui-btn-danger-hover: var( --os-ui-danger, #d63638 );
}

/*
 * Unfocused control glyphs under an active desktop theme.
 *
 * The rule above paints them BLACK at 45% — correct against the
 * default light unfocused title bar (`#f0f0f1`), invisible against a
 * dark themed one. They can't simply follow the palette either:
 * these are title-bar chrome, so the colour they owe allegiance to is
 * the title bar's own text colour, not the window body's.
 *
 * Deriving it with `color-mix` means a theme gets legible unfocused
 * controls for free from the `--os-titlebar-color` it was
 * already setting — no extra tokens to discover. The four
 * `--os-titlebar-btn-*` overrides above still win when an
 * author wants exact control.
 *
 * Scoped to `[data-os-desktop-theme]` on purpose: the
 * default title-bar colour is `#50575e`, so applying this
 * unconditionally would lighten the unfocused glyphs on every
 * unthemed shell. No theme, no change.
 */
.os-shell[ data-os-desktop-theme ]
	.os-window:not( .os-window--focused ) {
	--os-ui-btn-color: var(
		--os-titlebar-btn-color,
		color-mix( in srgb, var( --os-titlebar-color, #50575e ) 72%, transparent )
	);
	--os-ui-btn-color-hover: var(
		--os-titlebar-btn-color-hover,
		var( --os-titlebar-color, #50575e )
	);
	--os-ui-btn-bg-hover: var(
		--os-titlebar-btn-bg-hover,
		color-mix( in srgb, var( --os-titlebar-color, #50575e ) 18%, transparent )
	);
	--os-ui-btn-bg-active: var(
		--os-titlebar-btn-bg-active,
		color-mix( in srgb, var( --os-titlebar-color, #50575e ) 26%, transparent )
	);
}

/* ---------------------------------------------------------------
 * Title-bar actions menu (leading edge, before icon + title).
 *
 * Trigger is a standard `.os-window__btn` — inherits focused /
 * unfocused coloring from the existing control-button rules. The only
 * bespoke rule is margin: it sits at the leading edge so a small trailing
 * gap separates it from the icon without affecting overall title-bar gap.
 *
 * Panel is an absolute dropdown anchored to the title bar (which is now
 * position: relative). Visibility is driven by the `hidden` attribute on
 * the element itself — no separate `--open` class to keep in sync with
 * aria-expanded.
 * --------------------------------------------------------------- */
/*
 * ⋯ button sits between the screen-meta cluster and the window
 * controls, as the "last page-level chrome" item. When present it
 * takes over the divider duty: screen-meta drops its own trailing
 * divider, and the controls container gains a leading one — so a
 * single clean line always sits between page chrome and window
 * chrome regardless of which combination is visible.
 */
.os-window__titlebar:has(.os-window__menu-btn) .os-window__screen-meta {
	margin-inline-end: 0;
	padding-inline-end: 0;
	border-inline-end: none;
}

.os-window__titlebar:has(.os-window__menu-btn) .os-window__controls {
	margin-inline-start: 8px;
	padding-inline-start: 8px;
	/* Tokenized so a theme can retint it — or set `transparent` and
	 * let its own title-bar artwork carry the separation. */
	border-inline-start: 1px solid
		var( --os-titlebar-divider, rgba(255, 255, 255, 0.15) );
}

.os-window:not(.os-window--focused) .os-window__titlebar:has(.os-window__menu-btn) .os-window__controls {
	border-inline-start-color: var(
		--os-titlebar-divider-unfocused,
		rgba(0, 0, 0, 0.1)
	);
}

/*
 * Menu popover lives inside the title bar's absolute-positioned
 * coordinate system — positioning is caller-specific and stays
 * outer. Background, border, items, checkbox styling all moved
 * into `<os-menu>` / `<os-menu-item>`.
 */
.os-window__menu-panel {
	position: absolute;
	top: calc( var( --os-titlebar-height ) + 2px );
	inset-inline-end: 60px;
	z-index: 2;
}

/*
 * "Related" dropdown — opened by the related-entities title-bar
 * button (`src/related-entities/`). The panel ALSO carries
 * `.os-window__menu-panel` (positioning + the drag-tracker
 * exclusion come from there); this class only layers the related-menu
 * extras: an internal scroll cap for media-heavy posts and a sane
 * minimum width.
 */
.os-window__related-panel {
	max-height: min( 60vh, 420px );
	overflow-y: auto;
	min-width: 180px;
}

/* Section header inside the Related dropdown ("Categories", "Media"). */
.os-window__related-group {
	padding: 6px 12px 2px;
	font-size: 11px;
	font-weight: 600;
	text-transform: uppercase;
	letter-spacing: 0.04em;
	opacity: 0.6;
	color: var( --os-ui-fg );
}

.os-window__related-group:not(:first-child) {
	margin-block-start: 4px;
	border-block-start: 1px solid rgba( 128, 128, 128, 0.25 );
	padding-block-start: 8px;
}

/* ---------------------------------------------------------------
 * Screen Meta buttons (Screen Options / Help) in the title bar.
 * Positioned right after the title text, visually separated from
 * the close / maximize / minimize cluster by a subtle divider.
 * Sized to meet WCAG 2.2 target size (24x24 minimum).
 * --------------------------------------------------------------- */
.os-window__screen-meta {
	display: flex;
	gap: 4px;
	align-items: center;
	flex-shrink: 0;
	margin-inline-end: 8px;
	padding-inline-end: 8px;
	border-inline-end: 1px solid
		var( --os-titlebar-divider, rgba(255, 255, 255, 0.15) );
}

/* Hide the divider when there are no screen-meta buttons. */
.os-window__screen-meta:empty {
	display: none;
}

.os-window__meta-btn {
	display: flex;
	align-items: center;
	justify-content: center;
	width: 28px;
	height: 28px;
	border: none;
	border-radius: 6px;
	cursor: pointer;
	padding: 0;
	background: transparent;
	transition: background-color 0.15s ease, color 0.15s ease;
}

.os-window__meta-btn .dashicons {
	font-size: 18px;
	width: 18px;
	height: 18px;
}
/* The flex-centring override for the dashicon glyph currently lives
 * in `windows.css`. That placement was a cache workaround from when
 * this file was an `@import` sub-sheet with no `?ver=` of its own;
 * it is now a separately registered, filemtime-stamped handle, so
 * the workaround no longer buys anything. Left where it is for now —
 * relocating it is behaviour-neutral churn. */

/* Focused window: meta buttons visible.
 *
 * Screen-meta buttons are not `<os-window-button>` — they are plain
 * buttons in the light DOM, so they paint themselves instead of
 * reading the `--os-ui-btn-*` bridge. They sit in the same title bar
 * against the same fill, so they take the same
 * `--os-titlebar-btn-focused-*` tokens, each keeping its own
 * literal as the fallback: unthemed they stay a touch dimmer at rest
 * than the window controls (0.65 vs 0.7), themed they move together
 * rather than one cluster going legible and the other staying white. */
.os-window--focused .os-window__meta-btn {
	color: var( --os-titlebar-btn-focused-color, rgba(255, 255, 255, 0.65) );
}
.os-window--focused .os-window__meta-btn:hover {
	color: var( --os-titlebar-btn-focused-color-hover, var( --os-ui-fg-on-accent, #fff ) );
	background: var( --os-titlebar-btn-focused-bg-hover, rgba(255, 255, 255, 0.18) );
}
.os-window--focused .os-window__meta-btn:focus-visible {
	color: var( --os-titlebar-btn-focused-color-hover, var( --os-ui-fg-on-accent, #fff ) );
	background: var( --os-titlebar-btn-focused-bg-hover, rgba(255, 255, 255, 0.18) );
	outline: 2px solid var( --os-titlebar-btn-focused-outline, rgba(255, 255, 255, 0.6) );
	outline-offset: 1px;
}
.os-window--focused .os-window__meta-btn--active {
	color: var( --os-titlebar-btn-focused-color-hover, var( --os-ui-fg-on-accent, #fff ) );
	background: var( --os-titlebar-btn-focused-bg-active, rgba(255, 255, 255, 0.25) );
}

/* Unfocused window: divider and buttons adapt to light title bar. */
.os-window:not(.os-window--focused) .os-window__screen-meta {
	border-inline-end-color: var(
		--os-titlebar-divider-unfocused,
		rgba(0, 0, 0, 0.1)
	);
}
/*
 * Screen Options / Help on an UNFOCUSED title bar. These read the same
 * two tokens as the window controls beside them rather than their own
 * literals — a black glyph is invisible on a dark title bar, and these
 * buttons sit in the same strip as controls that were already themable.
 * The literals are unchanged, so an unthemed bar looks as it always did.
 */
.os-window:not(.os-window--focused) .os-window__meta-btn {
	color: var( --os-titlebar-btn-color, rgba(0, 0, 0, 0.3) );
}
.os-window:not(.os-window--focused) .os-window__meta-btn:hover {
	color: var( --os-titlebar-btn-color-hover, rgba(0, 0, 0, 0.6) );
	background: var( --os-ui-hover, rgba(0, 0, 0, 0.08) );
}
.os-window:not(.os-window--focused) .os-window__meta-btn--active {
	color: var( --os-titlebar-btn-color-hover, rgba(0, 0, 0, 0.6) );
	background: rgba(0, 0, 0, 0.12);
}

/* ---------------------------------------------------------------
 * Tab strip — submenu navigation rendered in the parent shell, just
 * below the title bar. Each tab swaps the iframe URL in place; no
 * new window opens. Horizontally scrollable on narrow windows so the
 * same component works on tablet and mobile shells unchanged.
 *
 * These are TABS in the physical sense: the strip is a recessed
 * track, and the active tab is a plate that rises out of it wearing
 * the page's own fill, filleted into the floor at both bottom
 * corners so tab and page read as one continuous surface. That joint
 * is the whole design. The previous treatment — flat text with an
 * accent underline, on a strip painted the same colour as the
 * focused title bar — gave the sub-pages no container to belong to
 * and no relationship to the page they navigate, which is why they
 * read as loose text floating in the chrome.
 *
 * The page under an iframe window is always `#fff`: `chromeless.css`
 * paints `body.os-chromeless` white whatever the admin colour scheme
 * says. So "the page's own fill" is a colour the shell can name, and
 * `--os-tabs-active-bg` names it.
 * --------------------------------------------------------------- */
.os-window__tabs {
	/*
	 * Corner radius and fillet size are the same measurement — the
	 * fillet is the *inverse* of the corner, so a mismatch reads as a
	 * kink where the tab meets the floor. One alias, read by both.
	 */
	--_tab-radius: var( --os-tabs-radius, 8px );
	/* Tab height, shared with the plate that has to sit exactly on it. */
	--_tab-h: 30px;
	/* Weight of the rail that traces the silhouette. */
	--_tab-stroke: var( --os-tabs-rail-width, 2px );
	/*
	 * How far each straight run of the rail overlaps the arc it hands
	 * over to. Purely an anti-seam allowance — see the mask sizes on
	 * the ring. It has to stay well under the radius or the overlap
	 * would reach past the arc it is covering for.
	 */
	--_tab-seam: 1px;
	position: relative;
	display: flex;
	/* Tabs sit ON the floor of the track, not centred in it. */
	align-items: flex-end;
	flex-shrink: 0;
	gap: 2px;
	/*
	 * Horizontal padding is a floor as well as a breathing space. The
	 * ring reaches one radius plus one stroke (10px at the shipped
	 * values) past the plate on each side, so a first or last tab
	 * under a smaller padding would have its outer corner clipped by
	 * the `overflow` rule below.
	 */
	padding: 8px 12px 0;
	background-color: var( --os-tabs-bg, var( --os-ui-surface-elevated, #f6f7f7 ) );
	/* TABBAR texture slot — layered over the strip's own colour. */
	background-image: var( --os-tabs-image, none );
	background-repeat: var( --os-tabs-image-repeat, repeat );
	background-size: var( --os-tabs-image-size, auto );
	background-position: var( --os-tabs-image-position, center );
	/*
	 * No bottom border. A hairline here would run straight through
	 * the joint between the active tab and its page, which is the one
	 * edge this design exists to erase.
	 */
	overflow-x: auto;
	overflow-y: hidden;
	scrollbar-width: thin;
	/*
	 * `overscroll-behavior-x: contain` keeps horizontal trackpad
	 * swipes inside the strip instead of triggering browser back.
	 */
	overscroll-behavior-x: contain;
}

/*
 * Unfocused, the track follows the title bar down instead of holding
 * its lit colour.
 *
 * Focused, the bar and the track are the same Obsidian and the tab is
 * the only thing lifting out of them: two surfaces plus the tab. Dim
 * the bar alone and the strip becomes a third colour belonging to
 * neither the chrome above it nor the page below, which is the seam
 * this rule removes.
 *
 * The chain ends at `--os-tabs-bg`, so a theme that names only the one
 * strip colour keeps it in both states.
 */
.os-window:not( .os-window--focused ) .os-window__tabs {
	background-color: var(
		--os-tabs-bg-unfocused,
		var( --os-tabs-bg, var( --os-ui-surface-elevated, #f6f7f7 ) )
	);
}

/*
 * The same strip on a NATIVE window, which is the same strip: one
 * stylesheet, whatever is behind the window. What changes is only
 * what the active tab is wearing, and it changes in tokens.
 *
 * The rule the whole design rests on is that the active tab wears the
 * page's own fill and is filleted into it, so tab and content read as
 * one surface. For an iframe window that fill is `#fff`, because
 * `chromeless.css` paints every admin page inside a window white
 * whatever the colour scheme says. A native window's page is its
 * body, and `--os-window-bg` is what paints it — so that is the fill
 * here, and the two move together by construction.
 *
 * The frost and the crown go with the white. They are what makes a
 * bright plate read as a surface lifting out of dark chrome; on a
 * native window the tab and the track are the same colour when the
 * window is focused, and a crown on a tab you cannot see the edges of
 * is decoration with nothing to decorate. The rail carries the
 * silhouette on its own, which is the point: a native window has no
 * value step to wear, so the line IS the tab.
 *
 * The label follows the fill. `--os-tabs-active-color` names the text
 * on a white plate and resolves to near-black, which would be
 * invisible here.
 */
.os-window--native {
	--os-tabs-active-bg: var( --os-window-bg, #fff );
	--os-tabs-active-color: var( --os-ui-fg, #1d2327 );
	--os-tabs-active-color-muted: var( --os-ui-fg-muted, #50575e );
	--os-tabs-active-frost: none;
	--os-tabs-active-crown: none;
}

/*
 * Windows with no submenu still get a strip — `createWindowElement()`
 * appends it unconditionally so `addExternalTab()` has somewhere to
 * put a tab later. Empty, it must take no room at all: with vertical
 * padding on the track, an empty strip would otherwise paint a bare
 * band of track colour under every submenu-less window's title bar.
 *
 * `:has()` rather than `:empty`, because the strip is never empty any
 * more — it always carries the plate. What makes a strip vacant is
 * having no TABS in it.
 */
.os-window__tabs:not( :has( .os-window__tab ) ) {
	display: none;
}

/* ---------------------------------------------------------------
 * The plate — the active tab's surface.
 *
 * It is one element that TRAVELS between tabs rather than a fill
 * that switches off on one tab and on at the next. That distinction
 * is the whole reason it exists: a fill that switches has to cross-
 * fade a dark tab into a light one, and every frame in between is a
 * muddy grey that belongs to neither. Nothing crossfades here. The
 * surface simply moves, and the labels change colour underneath it.
 *
 * `tabs.ts` publishes the target geometry as `--_tab-plate-x` and
 * `--_tab-plate-w` on this element; everything else is CSS.
 * --------------------------------------------------------------- */
.os-window__tab-plate {
	position: absolute;
	bottom: 0;
	left: 0;
	height: var( --_tab-h );
	width: var( --_tab-plate-w, 0 );
	transform: translateX( var( --_tab-plate-x, 0 ) );
	pointer-events: none;
	transition:
		transform var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) ),
		width var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) ),
		opacity 0.15s ease;
}

/*
 * Until the first measurement lands, the plate has no business
 * animating — it would slide in from the strip's left edge every
 * time a window opens.
 */
.os-window__tab-plate:not( [ data-placed ] ) {
	transition: none;
}

/*
 * The frosted face.
 *
 * The tint is TOP-WEIGHTED and reaches zero before the bottom of the
 * plate, which is the load-bearing part: the joint below is the
 * page's own colour, so anything still tinted down there draws a
 * seam across the one edge this design exists to erase. Keep the
 * gradient's last stop fully transparent and keep it above ~75%.
 *
 * The fallback is a flat `--os-tabs-active-bg`, so a stylesheet that
 * loses the palette gets the plain white tab rather than nothing.
 */
.os-window__tab-plate-fill {
	position: absolute;
	/*
	 * Overshoots the track's floor by one radius, and is then clipped
	 * by the strip's own `overflow`. Ending the face exactly ON the
	 * floor puts a paint boundary on the one edge that has to be
	 * invisible; pushing it past and cutting it means there is no edge
	 * there to resolve at all.
	 */
	inset: 0 0 calc( var( --_tab-radius ) * -1 ) 0;
	overflow: hidden;
	border-radius: var( --_tab-radius ) var( --_tab-radius ) 0 0;
	/*
	 * OPAQUE, and deliberately so — no `backdrop-filter`, no alpha.
	 *
	 * A translucent element with a backdrop filter is promoted to its
	 * own compositor layer, and the edge of that layer is a clip the
	 * compositor resolves against whatever is behind it. That leaves a
	 * faint grey hairline down the plate's sides and across its
	 * bottom. Everywhere else that would be a nuisance; here it draws
	 * a second, dimmer line a few pixels inside the real one and flatly
	 * contradicts what this design is claiming. The gradient below
	 * reproduces the frosted crown by eye with no alpha at all.
	 *
	 * `background-size` pins the gradient to the tab's true height so
	 * the overshoot underneath stays flat page-white.
	 */
	background-color: var( --os-tabs-active-bg, #fff );
	background-image: var( --os-tabs-active-frost, none );
	background-size: 100% var( --_tab-h );
	background-repeat: no-repeat;
}

/*
 * The crown — Holomesh over the top of the plate, masked away before
 * the joint. Holomesh is nine stacked gradients, so it cannot be
 * faded by adding a colour stop; it needs its own layer and a mask.
 *
 * It is on exactly one tab at a time. That is what keeps the mesh an
 * identity moment rather than wallpaper, and it is the same rule the
 * rest of the kit follows (see `src/ui/holo.ts`).
 */
.os-window__tab-plate-fill::before {
	content: '';
	position: absolute;
	inset: 0;
	background-image: var( --os-tabs-active-crown, none );
	background-size: 210% 210%;
	background-position: 20% 26%;
	opacity: var( --os-tabs-active-crown-opacity, 0.5 );
	-webkit-mask-image: linear-gradient(
		180deg,
		#000 0%,
		rgba( 0, 0, 0, 0.55 ) 34%,
		transparent 66%
	);
	mask-image: linear-gradient(
		180deg,
		#000 0%,
		rgba( 0, 0, 0, 0.55 ) 34%,
		transparent 66%
	);
	animation: os-tab-crown-drift 16s ease-in-out infinite alternate;
}

@keyframes os-tab-crown-drift {
	from { background-position: 20% 26%; }
	to   { background-position: 72% 62%; }
}

/*
 * The joint: two concave quarter-circles carrying the plate's fill
 * out to the floor of the track. Without them the plate is a rounded
 * rectangle NEAR the page; with them it is attached to it.
 *
 * Its own element rather than a pseudo on the face, so the face can
 * clip its crown (`overflow: hidden`) without clipping the curve
 * that does the attaching. Pure `--os-tabs-active-bg` — see the note
 * on the tint above.
 */
.os-window__tab-plate-joint {
	position: absolute;
	bottom: 0;
	left: calc( var( --_tab-radius ) * -1 );
	right: calc( var( --_tab-radius ) * -1 );
	height: var( --_tab-radius );
	background:
		radial-gradient(
			circle at 0 0,
			transparent var( --_tab-radius ),
			var( --os-tabs-active-bg, #fff ) var( --_tab-radius )
		) left bottom / var( --_tab-radius ) var( --_tab-radius ) no-repeat,
		radial-gradient(
			circle at 100% 0,
			transparent var( --_tab-radius ),
			var( --os-tabs-active-bg, #fff ) var( --_tab-radius )
		) right bottom / var( --_tab-radius ) var( --_tab-radius ) no-repeat;
}

/* ---------------------------------------------------------------
 * The rail — one continuous line around the whole silhouette.
 *
 * It runs the top edge of the page, curves up through the fillet,
 * traces the tab and comes back down. What it outlines is therefore
 * page-plus-tab as ONE shape, which is the same claim the joint makes
 * by omission, said out loud.
 *
 * Two rules govern every number below.
 *
 * 1. **The line lives on the DARK side of the boundary, everywhere.**
 *    It hugs the white shape from outside and never paints on it. Get
 *    this wrong on any one segment and the line steps sideways by its
 *    own width at the tangent point where that segment meets the next
 *    — which is exactly what a stroke that does not follow the shape
 *    looks like. The convex tab corners are therefore annulus R→R+s
 *    (outside the plate) while the concave fillets are R−s→R (inside
 *    the fillet circle, whose white lies OUTSIDE it). Those look like
 *    opposite conventions and are the same one.
 *
 * 2. **Every piece samples one mesh laid across the whole strip.**
 *    `--_tab-strip-w` is why. Give the rail and the ring their own
 *    backgrounds and you get two unrelated gradients meeting at a
 *    visible join in the middle of the fillet.
 *
 * CSS cannot stroke a path, so the mesh is painted as a background and
 * a mask cuts it to the outline. Seven layers, one per segment, all
 * positioned off the box edges so the whole thing survives the plate
 * changing width mid-slide with no JS.
 * --------------------------------------------------------------- */
.os-window__tabs::before {
	content: '';
	position: absolute;
	left: 0;
	right: 0;
	bottom: 0;
	height: var( --_tab-stroke );
	background-image: var( --os-tabs-rail, none );
	background-size: var( --_tab-strip-w, 100% ) 100%;
	background-repeat: no-repeat;
	pointer-events: none;
	/*
	 * Two segments, stopping where each fillet begins. Run the rail
	 * straight through and it draws a chord across the concave curve.
	 */
	-webkit-mask-image: linear-gradient( #000 0 0 ), linear-gradient( #000 0 0 );
	mask-image: linear-gradient( #000 0 0 ), linear-gradient( #000 0 0 );
	/*
	 * Each segment runs one `--_tab-seam` PAST where its fillet begins,
	 * so the rail overlaps the arc rather than meeting it exactly —
	 * the same anti-seam allowance the ring uses, for the same reason.
	 */
	-webkit-mask-size:
		calc(
			var( --_tab-plate-x, 0px ) - var( --_tab-radius ) + var( --_tab-seam )
		) 100%,
		calc(
			100% - var( --_tab-plate-x, 0px ) - var( --_tab-plate-w, 0px ) -
				var( --_tab-radius ) + var( --_tab-seam )
		) 100%;
	mask-size:
		calc(
			var( --_tab-plate-x, 0px ) - var( --_tab-radius ) + var( --_tab-seam )
		) 100%,
		calc(
			100% - var( --_tab-plate-x, 0px ) - var( --_tab-plate-w, 0px ) -
				var( --_tab-radius ) + var( --_tab-seam )
		) 100%;
	-webkit-mask-position: left top, right top;
	mask-position: left top, right top;
	-webkit-mask-repeat: no-repeat;
	mask-repeat: no-repeat;
	transition:
		-webkit-mask-size var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) ),
		mask-size var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) );
}

/* With no active tab there is no gap to leave, so the rail is whole. */
.os-window__tabs[ data-tab-plate-empty ]::before {
	-webkit-mask-image: none;
	mask-image: none;
}

/*
 * The tab's half of the line. The box is the plate grown by one
 * stroke upward and by radius-plus-stroke on each side, so the top
 * edge, both corners and both fillet arcs all have room to sit
 * OUTSIDE the white.
 */
.os-window__tab-plate::after {
	--_ring-arc-out: radial-gradient(
		circle at 0 0,
		transparent calc( var( --_tab-radius ) - var( --_tab-stroke ) ),
		#000 calc( var( --_tab-radius ) - var( --_tab-stroke ) ),
		#000 var( --_tab-radius ),
		transparent var( --_tab-radius )
	);
	--_ring-arc-out-r: radial-gradient(
		circle at 100% 0,
		transparent calc( var( --_tab-radius ) - var( --_tab-stroke ) ),
		#000 calc( var( --_tab-radius ) - var( --_tab-stroke ) ),
		#000 var( --_tab-radius ),
		transparent var( --_tab-radius )
	);
	--_ring-corner-l: radial-gradient(
		circle at 100% 100%,
		transparent var( --_tab-radius ),
		#000 var( --_tab-radius ),
		#000 calc( var( --_tab-radius ) + var( --_tab-stroke ) ),
		transparent calc( var( --_tab-radius ) + var( --_tab-stroke ) )
	);
	--_ring-corner-r: radial-gradient(
		circle at 0 100%,
		transparent var( --_tab-radius ),
		#000 var( --_tab-radius ),
		#000 calc( var( --_tab-radius ) + var( --_tab-stroke ) ),
		transparent calc( var( --_tab-radius ) + var( --_tab-stroke ) )
	);
	--_ring-bar: linear-gradient( #000 0 0 );

	content: '';
	position: absolute;
	inset:
		calc( var( --_tab-stroke ) * -1 )
		calc( ( var( --_tab-radius ) + var( --_tab-stroke ) ) * -1 )
		0;
	background-image: var( --os-tabs-rail, none );
	background-size: var( --_tab-strip-w, 100% ) 100%;
	background-position:
		calc(
			var( --_tab-radius ) + var( --_tab-stroke ) -
				var( --_tab-plate-x, 0px )
		)
		0;
	background-repeat: no-repeat;
	pointer-events: none;

	-webkit-mask-image:
		var( --_ring-bar ), var( --_ring-corner-l ), var( --_ring-corner-r ),
		var( --_ring-bar ), var( --_ring-bar ),
		var( --_ring-arc-out ), var( --_ring-arc-out-r );
	mask-image:
		var( --_ring-bar ), var( --_ring-corner-l ), var( --_ring-corner-r ),
		var( --_ring-bar ), var( --_ring-bar ),
		var( --_ring-arc-out ), var( --_ring-arc-out-r );
	/*
	 * The three straight runs are each grown by `--_tab-seam` at BOTH
	 * ends, and shifted back by the same amount, so every one of them
	 * overlaps the arc it hands over to instead of meeting it exactly.
	 *
	 * Two layers that abut on a shared boundary each contribute a
	 * partial, antialiased alpha there, and the sum can fall short of
	 * 1 — which prints as a faint hairline across the stroke at all
	 * six tangent points. Overlapping cannot go wrong in the other
	 * direction: mask alpha is clamped, so doubling it is still opaque
	 * and the seam simply stops existing.
	 *
	 * The overlap stays on the dark side of the curve at every one of
	 * those points, so it never bleeds onto the white.
	 */
	-webkit-mask-size:
		calc( 100% - 4 * var( --_tab-radius ) - 2 * var( --_tab-stroke ) + 2 * var( --_tab-seam ) ) var( --_tab-stroke ),
		calc( var( --_tab-radius ) + var( --_tab-stroke ) ) calc( var( --_tab-radius ) + var( --_tab-stroke ) ),
		calc( var( --_tab-radius ) + var( --_tab-stroke ) ) calc( var( --_tab-radius ) + var( --_tab-stroke ) ),
		var( --_tab-stroke ) calc( 100% - 2 * var( --_tab-radius ) - var( --_tab-stroke ) + 2 * var( --_tab-seam ) ),
		var( --_tab-stroke ) calc( 100% - 2 * var( --_tab-radius ) - var( --_tab-stroke ) + 2 * var( --_tab-seam ) ),
		var( --_tab-radius ) var( --_tab-radius ),
		var( --_tab-radius ) var( --_tab-radius );
	mask-size:
		calc( 100% - 4 * var( --_tab-radius ) - 2 * var( --_tab-stroke ) + 2 * var( --_tab-seam ) ) var( --_tab-stroke ),
		calc( var( --_tab-radius ) + var( --_tab-stroke ) ) calc( var( --_tab-radius ) + var( --_tab-stroke ) ),
		calc( var( --_tab-radius ) + var( --_tab-stroke ) ) calc( var( --_tab-radius ) + var( --_tab-stroke ) ),
		var( --_tab-stroke ) calc( 100% - 2 * var( --_tab-radius ) - var( --_tab-stroke ) + 2 * var( --_tab-seam ) ),
		var( --_tab-stroke ) calc( 100% - 2 * var( --_tab-radius ) - var( --_tab-stroke ) + 2 * var( --_tab-seam ) ),
		var( --_tab-radius ) var( --_tab-radius ),
		var( --_tab-radius ) var( --_tab-radius );
	/*
	 * Four-value syntax throughout, and it is load-bearing. A
	 * percentage in `mask-position` resolves against the container
	 * MINUS the layer's own size, so `calc(100% - 8px)` does not mean
	 * "8px from the right edge" — it means "right-aligned, then pushed
	 * left by 8px PLUS the layer's width". Every right-hand segment
	 * lands a radius too far left that way. `right <offset>` is
	 * measured from the edge and does not care how wide the layer is.
	 */
	-webkit-mask-position:
		left calc( 2 * var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ) top 0,
		left var( --_tab-radius ) top 0,
		right var( --_tab-radius ) top 0,
		left var( --_tab-radius ) top calc( var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ),
		right var( --_tab-radius ) top calc( var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ),
		left var( --_tab-stroke ) bottom 0,
		right var( --_tab-stroke ) bottom 0;
	mask-position:
		left calc( 2 * var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ) top 0,
		left var( --_tab-radius ) top 0,
		right var( --_tab-radius ) top 0,
		left var( --_tab-radius ) top calc( var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ),
		right var( --_tab-radius ) top calc( var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ),
		left var( --_tab-stroke ) bottom 0,
		right var( --_tab-stroke ) bottom 0;
	-webkit-mask-repeat: no-repeat;
	mask-repeat: no-repeat;

	/* Travels with the plate, so the mesh stays locked to the strip
	 * for the whole slide instead of snapping on the first frame. */
	transition: background-position var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) );
}

/*
 * No active tab to sit under — `syncActiveTab` can land on a URL that
 * matches nothing, and an external sub-tab deactivates every submenu
 * tab. The plate keeps its last geometry and fades, so re-activating
 * a tab does not read as the plate flying in from nowhere.
 */
.os-window__tab-plate[ data-empty ] {
	opacity: 0;
}

@media ( prefers-reduced-motion: reduce ) {
	.os-window__tab-plate,
	.os-window__tab-plate::after,
	.os-window__tabs::before {
		transition-duration: 1ms;
	}
	.os-window__tab-plate-fill::before {
		animation: none;
	}
}

/*
 * Soft edge fades so overflowing tabs tell the user "scroll for more."
 *
 * Each fade is painted ONLY on an edge that is actually hiding a tab.
 * `observeTabOverflow()` in `src/window/tabs.ts` stamps `data-overflow`
 * with the physical edges currently covered — `left`, `right`, `both`,
 * or the attribute dropped when the strip fits — and re-measures on
 * scroll, on resize, and when tabs are added or removed.
 *
 * This used to be one unconditional mask on both ends. The reasoning
 * was that on a strip that fits, the faded ends land past the last tab
 * and so fade nothing. That held on the pre-brand light strip, where
 * the tab bar and the title bar above it were near enough the same
 * near-white that a mask over empty area was invisible. It stopped
 * holding on the station: the strip is Obsidian over a darker window
 * edge, so masking its ends to transparent punches two grey smudges
 * into every window's submenu, whether or not there is anything to
 * scroll to. A permanent affordance for a state that is usually false
 * is not an affordance — it is decoration that lies.
 */
.os-window__tabs[ data-overflow='left' ] {
	mask-image: linear-gradient( to right, transparent 0, #000 16px );
	-webkit-mask-image: linear-gradient( to right, transparent 0, #000 16px );
}

.os-window__tabs[ data-overflow='right' ] {
	mask-image: linear-gradient(
		to right,
		#000 calc(100% - 16px),
		transparent 100%
	);
	-webkit-mask-image: linear-gradient(
		to right,
		#000 calc(100% - 16px),
		transparent 100%
	);
}

.os-window__tabs[ data-overflow='both' ] {
	mask-image: linear-gradient(
		to right,
		transparent 0,
		#000 16px,
		#000 calc(100% - 16px),
		transparent 100%
	);
	-webkit-mask-image: linear-gradient(
		to right,
		transparent 0,
		#000 16px,
		#000 calc(100% - 16px),
		transparent 100%
	);
}

.os-window__tab {
	position: relative;
	flex-shrink: 0;
	display: inline-flex;
	align-items: center;
	height: 30px;
	padding: 0 14px;
	border: none;
	border-radius: var( --_tab-radius ) var( --_tab-radius ) 0 0;
	background: transparent;
	color: var( --os-tabs-color, var( --os-ui-fg-muted, #50575e ) );
	font: inherit;
	font-size: 12px;
	line-height: 1;
	cursor: pointer;
	white-space: nowrap;
	transition: color 0.15s ease, background-color 0.15s ease;
}

.os-window__tab:hover {
	color: var(--wp-admin-theme-color, #2271b1);
	background: var( --os-ui-hover, rgba(0, 0, 0, 0.03) );
}

.os-window__tab:focus-visible {
	outline: 2px solid var(--wp-admin-theme-color, #2271b1);
	outline-offset: -2px;
}

/*
 * The active tab. It paints NO surface of its own — the plate does
 * that, and the plate is a sibling that slides. All this rule owns is
 * the label.
 *
 * No accent anywhere on it either. The shape already says "this one",
 * and the accent is spent: one Pulse divider in the dock, one focus
 * ring. A tab that is both a distinct surface AND coloured is saying
 * the same thing twice.
 */
.os-window__tab--active {
	color: var( --os-tabs-active-color, #1d2327 );
	font-weight: 600;
	/*
	 * Anything nested in an active tab (the external tab's detach and
	 * close chips) is now sitting on a light fill inside dark chrome,
	 * so the two tones it reads have to flip with the tab. Both are
	 * re-pointed through names the palette owns rather than hardcoded
	 * here, so a desktop theme retints the chips with the tab.
	 */
	--os-ui-fg-muted: var( --os-tabs-active-color-muted, rgba(29, 35, 39, 0.6) );
	--os-ui-hover: color-mix(
		in srgb,
		var( --os-tabs-active-color, #1d2327 ) 8%,
		transparent
	);
}

/* An active tab has the plate under it; a hover wash on top of that
 * would double-paint the surface. */
.os-window__tab--active:hover {
	background: transparent;
	color: var( --os-tabs-active-color, #1d2327 );
}

/*
 * External sub-tab. Same shape as a submenu tab, plus two inline
 * chips: detach (↗) and close (×). Chips are painted as spans
 * (rather than nested buttons — nested interactive elements are a
 * pain for a11y). The tab's click handler routes chip clicks via
 * the `data-tab-action` dataset attribute.
 */
.os-window__tab--external {
	/* Slightly wider gap between the label and the chip cluster, and
	 * more breathing room at the trailing edge so the chips aren't
	 * flush against the tab boundary. */
	gap: 10px;
	padding-inline-end: 4px;
}

.os-window__tab-label {
	max-width: 180px;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}

/* External-tab action chips moved to `<os-tab-chip>`. Spacing
 * between chips handled by a single host-level rule since the
 * component doesn't know about its siblings. */
os-tab-chip + os-tab-chip {
	margin-inline-start: 4px;
}

/* Window body: contains the iframe. */
.os-window__body {
	flex: 1;
	position: relative;
	overflow: hidden;
	/*
	 * WINDOW_BODY texture slot. Visible behind native window content
	 * and behind any iframe whose page has a transparent background;
	 * an opaque wp-admin page inside an iframe paints over it, which
	 * is why a theme wanting texture everywhere reaches for DESKTOP
	 * and the chrome slots instead.
	 */
	background-image: var( --os-window-body-image, none );
	background-repeat: var( --os-window-body-image-repeat, repeat );
	background-size: var( --os-window-body-image-size, auto );
	background-position: var( --os-window-body-image-position, center );
	/*
	 * Body typeface. `--os-ui-font` is the component kit's own token, so
	 * one declaration here reaches every `<os-*>` element inside the
	 * window: shadow DOM inherits `font-family` through the boundary.
	 */
	font-family: var( --os-ui-font, inherit );
}

/* Iframe fills the window body. */
.os-window__iframe {
	width: 100%;
	height: 100%;
	border: none;
	display: block;
	background: var(--os-window-bg);
	opacity: 1;
	transition: opacity 0.25s ease;
}

/*
 * Headings inside native window bodies.
 *
 * WordPress core's `wp-admin/css/common.css` styles headings with
 * BARE ELEMENT selectors — `h1 { color: #1d2327 }` and
 * `h2, h3 { color: #1d2327 }`. Native windows render in the parent
 * shell rather than in an iframe, so those rules reach straight into
 * our light-DOM window content: an `<h3>` in a `<os-card>` came out
 * near-black on a dark theme while every sibling paragraph correctly
 * followed the palette.
 *
 * The specificity here is doing real work. It has to sit BETWEEN two
 * other rules:
 *
 *   core `h3`                                        (0,0,1)  must lose
 *   THIS rule                                        (0,0,1)  wins on source order
 *   `.os-my-wordpress__user-section h3`    (0,1,1)  must still win
 *
 * `:where()` contributes zero specificity, so the selector below
 * weighs the same as core's bare `h3` and beats it only because our
 * stylesheet prints later — while any of our own class-scoped heading
 * rules continue to override it. Raising this to a plain
 * `.os-window__body h3` would tie with those and break them
 * depending on file order.
 *
 * The `#1d2327` fallback is core's own value, so with no theme active
 * the computed colour is unchanged.
 */
:where( .os-window__body ) :is( h1, h2, h3, h4, h5, h6 ) {
	color: var( --os-ui-fg, #1d2327 );
}

/*
 * Same story, same fix, for the other bare-element rules core ships:
 *
 *   a    { color: #2271b1 }
 *   code { background: #f0f0f1 }
 *
 * `code` is the worse of the two — a light chip background with
 * palette-coloured text on it inverts to unreadable the moment a
 * theme goes dark. Both fallbacks are core's own values, so the
 * default is unchanged.
 *
 * Form controls (`input`, `textarea`, `select`) are deliberately NOT
 * included: core styles those through attribute selectors that carry
 * real specificity, native windows overwhelmingly use the `<os-*>`
 * components instead, and a half-applied override there would look
 * worse than leaving them alone.
 */
:where( .os-window__body ) a {
	color: var( --os-ui-accent, #2271b1 );
}

:where( .os-window__body ) code {
	background: var( --os-ui-surface-sunken, #f0f0f1 );
	color: var( --os-ui-fg, inherit );
}

/*
 * Raw form controls in native window bodies.
 *
 * Core's `forms.css` styles these through ATTRIBUTE selectors —
 * `input[type="search"], select, textarea { background-color: #fff;
 * color: #1e1e1e; border: 1px solid #949494 }` — which weighs
 * (0,1,1). That beats a plain class selector, so a control our own
 * CSS had already tokenized (My WordPress's search box reads
 * `background: var( --os-ui-surface, #fff )` and has done all along)
 * still came out white: core simply outranked it.
 *
 * Hence `.os-window__body` + `:is( input[type], … )`, which
 * weighs (0,2,1) and wins. `<os-*>` form components are untouched —
 * they live in shadow DOM, where none of this reaches.
 *
 * Fallbacks are core's own values, so an unthemed shell is unchanged.
 */
.os-window__body
	:is(
		input[ type="text" ],
		input[ type="search" ],
		input[ type="email" ],
		input[ type="url" ],
		input[ type="tel" ],
		input[ type="number" ],
		input[ type="password" ],
		input[ type="date" ],
		input[ type="datetime-local" ],
		input[ type="month" ],
		input[ type="time" ],
		input[ type="week" ],
		select,
		textarea
	) {
	background-color: var( --os-ui-surface, #fff );
	color: var( --os-ui-fg, #1e1e1e );
	border-color: var( --os-ui-border-strong, #949494 );
}

.os-window__body :is( input, textarea )::placeholder {
	color: var( --os-ui-fg-muted, #646970 );
}

/* Cross-window drag drop-overlay rules currently live in windows.css.
 * That was originally a cache workaround — this file was an `@import`
 * sub-sheet with no `?ver=` of its own, so edits here could be served
 * stale indefinitely. It is now a separately registered handle with
 * its own filemtime stamp, so the workaround is obsolete and those
 * rules could move back here. Left in place for now because moving
 * them is a behaviour-neutral churn better done on its own. */

/*
 * Loading-state overlay — painted by `src/window/dom.ts` into
 * every window's body and removed once the body's content reports
 * ready. The `<os-spinner>` inside is sized responsively against
 * the window's width via `clamp(96px, 14vw, 192px)` so a tiny
 * popover gets a small spinner and a maximized window gets a big
 * one. Placed above the body content (iframe / native render
 * output) and kept aria-hidden so the spinner's own SR-label is
 * the only loading announcement.
 *
 * The `--visible` modifier turns the overlay on. JS adds it ~120ms
 * into the load (`LOADING_OVERLAY_SHOW_DELAY_MS`), so a fast render
 * never paints a spinner. The delay cannot be a `transition-delay`
 * here: the overlay is appended into a body that already carries
 * `--loading`, so its first computed style is the visible one and the
 * transition never runs.
 */
.os-window__loading {
	position: absolute;
	inset: 0;
	display: flex;
	align-items: center;
	justify-content: center;
	pointer-events: none;
	background: var(--os-window-bg);
	opacity: 0;
	/* Default (loaded → loading-removed) — fade out immediately. */
	transition: opacity 0.25s ease;
	/*
	 * Above both reveal layers (z-index 2 and 3), so the spinner stays
	 * readable for the whole load and the surface it is sitting on only
	 * becomes visible once the spinner has faded out.
	 */
	z-index: 4;
}

/*
 * Content hidden while loading. The reveal surface is excluded
 * alongside the spinner overlay: it is chrome, not content, and fading
 * it to transparent would show the very content it exists to cover.
 */
.os-window__body--loading > .os-window__iframe,
.os-window__body--loading
	> :not(.os-window__loading):not(.os-window__reveal) {
	opacity: 0;
	transition: opacity 0.25s ease;
}

.os-window__body--loading > .os-window__loading--visible {
	opacity: 1;
}

/*
 * Content hand-off. `--loading` drops as soon as the content is ready,
 * but the overlay above it still needs 250ms to fade out. Without this
 * both layers are on screen at once, which reads as a flash.
 *
 * This modifier holds the content transparent for the length of that
 * fade, then fades it in. The delay in the shorthand does the holding.
 *
 * Only added when the spinner actually painted. A load that finishes
 * inside the show delay never reached `--visible`, so the shell drops
 * the overlay in the same tick instead.
 *
 * `--revealing` declares the same selector at the same specificity and
 * comes later in the file, so a window playing a reveal keeps its
 * content opaque under the reveal surface.
 */
.os-window__body--loading-out > .os-window__iframe,
.os-window__body--loading-out
	> :not(.os-window__loading):not(.os-window__reveal) {
	opacity: 1;
	transition: opacity 0.25s ease 0.25s;
}

@media ( prefers-reduced-motion: reduce ) {
	.os-window__iframe,
	.os-window__loading,
	.os-window__body--loading > .os-window__iframe,
	.os-window__body--loading
		> :not(.os-window__loading):not(.os-window__reveal),
	.os-window__body--loading-out > .os-window__iframe,
	.os-window__body--loading-out
		> :not(.os-window__loading):not(.os-window__reveal) {
		transition: none;
	}
}

/*
 * Window reveal — the opaque layers a window's content is uncovered
 * from once it reports ready. Painted for every window (of either
 * kind) from construction, animated away by `src/reveals/surface.ts`,
 * then removed.
 *
 * They are SIBLINGS of the iframe, never wrappers. `clip-path` is
 * animated on these elements alone, so the content below keeps its own
 * compositing layer, its hit-testing, and its stacking context — a
 * reveal cannot swallow a click or re-rasterize the page it is
 * uncovering. Native window content gets the identical treatment for
 * the same reason.
 *
 * `--os-window-reveal-surface` is a theme token, white by
 * default — the surface has to be opaque or there is nothing to reveal
 * from. The EDGE token is `transparent` by default instead, since an
 * edge is an accent rather than the effect itself. Either layer that
 * computes to no paint is skipped rather than animated. A def may
 * override the surface for itself with an inline `background` (see
 * `WindowRevealDef.surfaceColor`), which is how `obturator` gets its
 * near-black shutter blades regardless of the token.
 *
 * No `transition` here on purpose — the shape is driven entirely by
 * the Web Animations API, which needs a matched `from` / `to` pair a
 * CSS transition cannot express. Reduced motion is handled JS-side by
 * removing the layers instead of animating them.
 */
.os-window__reveal {
	position: absolute;
	inset: 0;
	pointer-events: none;
	background: var( --os-window-reveal-surface, #fff );
	z-index: 3;
}

/*
 * The reveal's leading edge. Same element shape, same keyframes, run
 * over a slightly longer duration so it trails the surface — the sliver
 * of it that is not yet covered by the surface IS the edge band. That
 * is why this needs no shape of its own, and why every reveal (built-in
 * or third-party) gets an edge that follows its geometry exactly.
 *
 * Behind the surface, still above the content.
 *
 * `--os-window-reveal-edge` is `transparent` by default, and
 * the shell drops the layer entirely while it computes that way — so
 * the default costs no element and no animation. Give the token a
 * colour (or a gradient) to turn the edge on across every reveal at
 * once. `--os-window-reveal-edge-thickness` tunes how wide
 * the band is; `edgeLag: 0` on a def opts a single reveal out.
 */
.os-window__reveal--edge {
	background: var( --os-window-reveal-edge, transparent );
	z-index: 2;
}

/*
 * A reveal that renders its own DOM (`WindowRevealDef.render`) paints
 * itself — an `<svg>`, a canvas, whatever it built. The surface token
 * must NOT apply underneath it: an opaque rectangle behind the
 * renderer's output is what the effect would end up uncovering, so the
 * animation would play against a flat colour and the real content
 * would only appear when the layer is finally removed.
 */
.os-window__reveal--custom {
	background: none;
}

/*
 * While a reveal plays, pin the content to full opacity with no
 * transition. Dropping `--loading` normally starts a 250 ms content
 * fade-in; a reveal running over that fade would show a
 * half-transparent strip along its leading edge. Beats the base
 * `.os-window__iframe` rule on specificity, so no
 * `!important` is needed.
 */
.os-window__body--revealing
	> :not(.os-window__loading):not(.os-window__reveal) {
	opacity: 1;
	transition: none;
}

/*
 * External sub-tab iframes stack in the same body as the primary
 * iframe. Only one is visible at a time (managed by `switchToTab`
 * via `style.display`). Absolute positioning so they don't push the
 * primary iframe around when added to the DOM; `display: none` on
 * inactive iframes is set inline by the JS.
 */
.os-window__iframe--external {
	position: absolute;
	inset: 0;
}

/*
 * Double-buffer twin for `Window.swapReload()` — the silent refresh
 * the editor-preview companion uses. The twin loads UNDERNEATH the
 * visible frame at full opacity: a normal, fully-rasterized paint
 * target, completely covered by the (opaque) old frame while it
 * loads. Deliberately not `opacity: 0`-on-top or
 * `visibility: hidden` — browsers defer rasterizing invisible
 * iframes, and revealing such a frame paints its blank background
 * before its raster lands (a white blink).
 *
 * Stacking: positioned elements paint above static ones regardless
 * of DOM order, so the buffer (absolute) would land on top of the
 * static primary frame — `--swap-front` elevates the OLD frame for
 * the duration of the swap instead. The swap itself is instant and
 * animation-free: removing the old frame exposes the ready-painted
 * twin in the same compositor frame. At no point is unpainted
 * content the only thing on screen.
 */
.os-window__iframe--buffer {
	position: absolute;
	inset: 0;
	pointer-events: none;
}

.os-window__iframe--swap-front {
	position: relative;
	z-index: 1;
}

/*
 * Native window body — fills the window but lets its contents scroll
 * independently. Iframe bodies use `overflow: hidden` so the iframe
 * controls its own scroll; native bodies render real document content
 * that needs the parent to manage overflow.
 */
.os-window__body--native {
	overflow: auto;
	color: var( --os-ui-fg, #1d2327 );
	/* background-COLOR, not the shorthand. This is a modifier on the
	 * SAME element as `.os-window__body`, at the same
	 * specificity and later in source order — the shorthand would
	 * reset the WINDOW_BODY texture declared there, which is exactly
	 * the surface native windows exist to show. */
	background-color: var(--os-window-bg);
}

/*
 * Highlight rings. Toggled from JS by `Window.setHighlight()` —
 * used by plugins that need to point at a window from outside it
 * (e.g. a "connect to" dropdown that previews candidate windows on
 * hover). `--wp-window-highlight-color` is overridable per-instance
 * via `setHighlight( mode, { color } )` or globally via the
 * variable.
 */
.wp-window {
	--wp-window-highlight-color: var(
		--wp-admin-theme-color, #2271b1
	);
}
.wp-window--highlight-preview {
	box-shadow: 0 0 0 3px var( --wp-window-highlight-color ),
		0 0 24px 4px var( --wp-window-highlight-color );
	transition: box-shadow 0.12s ease;
	z-index: 9999;
}
.wp-window--highlight-persistent {
	box-shadow: 0 0 0 2px var( --wp-window-highlight-color );
	transition: box-shadow 0.12s ease;
}

/*
 * Slots for plugin-registered title-bar buttons. Empty by default
 * — `display: contents` hides them entirely from layout when no
 * plugin has registered a button for the window. When buttons ARE
 * present, they sit inline next to the title (left slot) or just
 * before the window controls (right slot).
 */
.os-window__custom-buttons {
	display: contents;
}
.os-window__btn--custom {
	margin: 0 2px;
}
/*
 * Plugin-supplied icons render in the host's light DOM so the
 * global Dashicons stylesheet reaches them (shadow DOM doesn't
 * inherit page CSS).
 *
 * Dashicons render at their native 20×20 — that's the size the
 * font is hinted for and the only one where glyph metrics put
 * the visual centre on the geometric centre. Earlier we tried
 * down-scaling them to 14×14 to match the built-in chrome icons,
 * but font-size: 14 on a Dashicon shifts the glyph 1–2 pixels
 * off centre because the font's ascent/descent ratio doesn't
 * scale linearly. The 30×30 button has 5px of padding around a
 * 20×20 glyph — comfortable, and visually consistent with the
 * 14×14 chrome icons because both are flex-centred in the same
 * box.
 *
 * For inline-SVG icons that plugin authors ship without explicit
 * width/height, we DO clamp to 18×18 — those are the cases where
 * a bare `<svg>` would otherwise size to its viewBox or default
 * to 300×150 (the spec default) and overflow the button.
 */
.os-window__btn--custom svg:not( [ width ] ):not( [ height ] ) {
	width: 18px;
	height: 18px;
	display: block;
}

/*
 * Busy state for custom title-bar buttons — a gentle opacity pulse
 * while a round-trip is in flight (e.g. the editor-preview eye
 * waiting on the editor's autosave before opening the preview).
 * Driven by `aria-busy="true"` + this class, both set by the button's
 * render callback.
 */
.os-window__btn--busy {
	animation: os-btn-busy-pulse 1s ease-in-out infinite;
	pointer-events: none;
}

@keyframes os-btn-busy-pulse {
	0%,
	100% {
		opacity: 1;
	}

	50% {
		opacity: 0.4;
	}
}

@media (prefers-reduced-motion: reduce) {
	.os-window__btn--busy {
		animation: none;
		opacity: 0.6;
	}
}

/*
 * Disabled state for custom title-bar buttons — visible but inert
 * (e.g. the editor-preview eye on an unsaved "Add New" screen, where
 * there is nothing to preview until the first save). The button keeps
 * pointer events so its explanatory tooltip/toast still works;
 * `aria-disabled` carries the semantics.
 */
.os-window__btn--disabled {
	opacity: 0.4;
	cursor: default;
}

/* ----------------------------------------------------------------
 * Custom-chrome marker — hides every framework-shipped titlebar
 * child while a plugin's chrome is mounted.
 *
 * The window gets the `os-window--custom-chrome` class the
 * moment `mountWindowChrome` succeeds (BEFORE the plugin's
 * `render()` runs). Default children carry the
 * `data-os-default-chrome` attribute stamped at element-
 * creation time. The combination is a load-bearing guarantee:
 * even if the plugin's render() doesn't clear `titlebar.innerHTML`,
 * even if a plugin's destroy() leaks the standard chrome back into
 * place, even mid-fade during a window close — the default chrome
 * NEVER becomes visible while the marker class is active.
 *
 * The class is removed only when the chrome is swapped to standard
 * (in `Window.remountWindowChrome`); during a window close it
 * persists until `element.remove()` runs in `onDone()`, which is
 * after the fade has reached opacity 0 — so the user never sees
 * the swap.
 *
 * @since 0.18.0
 * ---------------------------------------------------------------- */

.os-window--custom-chrome
	> .os-window__titlebar
	> [ data-os-default-chrome ] {
	display: none !important;
}

/* ---------------------------------------------------------------
 * Reload button feedback.
 *
 * Click feedback is decoupled from the loading state:
 *
 *   1. On click, the icon performs a single 360° rotation with a
 *      fast-start / slow-end ease curve — confirms the user's
 *      gesture independently of how long the page takes to load.
 *      The `--spinning` class is added by the click handler and
 *      removed on `animationend` so a subsequent click restarts
 *      the animation cleanly.
 *
 *   2. While the window body is in the `--loading` state (set by
 *      `markContentLoading()` and cleared on the iframe's
 *      `os-ready` postMessage), the button is dimmed and
 *      non-interactive so a second click can't desync the
 *      chromeless bridge's loading handshake.
 *
 * `:has()` is used instead of mirroring the `--loading` modifier
 * onto the window root because upstream's loading API only
 * decorates the body element. Browsers without :has() (very old)
 * fall back to a static dimmed button — feature, not bug.
 * --------------------------------------------------------------- */

.os-window:has( .os-window__body--loading )
	.os-window__btn--reload {
	pointer-events: none;
	opacity: 0.55;
}

/*
 * Click feedback animation. The custom easing — `cubic-bezier( 0.05,
 * 0.7, 0.1, 1 )` — is a steeper-than-default ease-out: ~70% of the
 * rotation lands in the first 30% of the duration, then it decelerates.
 * Reads as "snap, then settle" rather than the linear glide of a
 * loading-spinner, reinforcing that this is a one-shot acknowledgement
 * of the gesture rather than progress through a long task. The `0.6s`
 * duration is short enough to never block the user but long enough
 * that the deceleration is legible.
 */
.os-window__btn--reload.os-window__btn--spinning {
	animation: os-reload-spin-once 0.6s cubic-bezier( 0.05, 0.7, 0.1, 1 );
}

@keyframes os-reload-spin-once {
	from {
		transform: rotate( 0deg );
	}
	to {
		transform: rotate( 360deg );
	}
}

@media ( prefers-reduced-motion: reduce ) {
	.os-window__btn--reload.os-window__btn--spinning {
		animation: none;
	}
}

```
