/** * 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 ``, 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 * `` in the title bar of its own accord. The slot * survives for anything that DOES want a literal dot as well: give an * `` 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 ``. * 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-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. * * 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 ` 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 ``. 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; } }