/** * 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; } /* Activity indicator slot — sits between the icon and the title. * Reserves a fixed width so the indicator's blink animation can't * shift the title text horizontally. The inner `` * paints a 12px modem-style dot (always visible, accent-colored). * * `--wp-admin-theme-color` is forwarded as `color` on the host so * the inner shadow-DOM stylesheet's `currentColor` references * (used in the box-shadow glow) resolve to the live accent. */ .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 `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 ``. * 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 * (sticky 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-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 `` — 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. * --------------------------------------------------------------- */ .os-window__tabs { display: flex; flex-shrink: 0; gap: 2px; padding: 0 8px; 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 ); border-bottom: 1px solid var(--os-window-border); 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; } /* * 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 { flex-shrink: 0; display: inline-flex; align-items: center; height: 32px; padding: 0 12px; border: none; background: transparent; color: var( --os-tabs-color, var( --os-ui-fg-muted, #50575e ) ); font: inherit; font-size: 12px; line-height: 1; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap; transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease; } .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; } .os-window__tab--active { color: var(--wp-admin-theme-color, #2271b1); border-bottom-color: var(--wp-admin-theme-color, #2271b1); font-weight: 600; } /* * External sub-tab. Same shape as a submenu tab, plus two inline * chips: detach (↗) and close (×). Chips are painted as spans * (rather than nested buttons — nested interactive elements are a * pain for a11y). The tab's click handler routes chip clicks via * the `data-tab-action` dataset attribute. */ .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 ``. 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 `` 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 `

` in a `` 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 `` * 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. `` 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 `` 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 ``, 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 `` 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; } }