/** * 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); /* * The border width is a token so a WINDOW_FRAME texture can be * thicker than a hairline: border-image paints in the border area, * and content starts inside it, so a 4px bevel needs a 4px border. */ border: var(--os-window-border-width, 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: var(--os-titlebar-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: var(--os-titlebar-gap, 8px); /* * Title-bar controls are sized here rather than in * ``, because the same component also draws * buttons outside the title bar that a theme does not mean to resize. */ --os-ui-btn-width: var(--os-titlebar-btn-width, 30px); --os-ui-btn-height: var(--os-titlebar-btn-height, 30px); --os-ui-btn-icon-size: var(--os-titlebar-btn-icon-size, 14px); --os-ui-btn-press-transform: var(--os-titlebar-btn-press-transform, scale( 0.9 )); } .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); /* `none` for an era whose title bar had no such mark. */ display: var(--os-titlebar-activity-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. * * Every knob a classic title bar needs: size, weight and alignment of * the text, and a plate behind it for a bar whose artwork runs under * the title (stripes that stop either side of it). A title centred on * the whole bar rather than on the space the controls leave it is * `position: absolute` with `left: 50%` and a `-50%` translate, all * from tokens; the bar is the positioned ancestor. Unset, every * property resolves to what this rule always said. */ .os-window__title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--os-titlebar-font-size, 14px); font-weight: var(--os-titlebar-font-weight, 500); letter-spacing: var(--os-titlebar-letter-spacing, normal); text-align: var(--os-titlebar-title-align, start); line-height: var(--os-titlebar-height); position: var(--os-titlebar-title-position, static); left: var(--os-titlebar-title-left, auto); translate: var(--os-titlebar-title-translate, none); max-width: var(--os-titlebar-title-max-width, none); padding-inline: var(--os-titlebar-title-padding, 0); background: var(--os-titlebar-title-bg, transparent); } /* * 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; /* `-1` puts the cluster at the leading edge of the bar. */ order: var( --os-titlebar-controls-order, 0 ); 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 ); } /* A window that asked for `appearance.controls.placement: 'left'`. */ .os-window__controls--left { order: -1; } /* * Per-control order and visibility, for a theme drawing an era whose * title bar had a different set: a close box first and nothing else, * or three buttons with a gap before the last. Hiding a control takes * a way to reach that action off the bar, so it is the theme's * explicit choice and never a default. */ .os-window__controls .os-window__btn--minimize { order: var( --os-titlebar-minimize-order, 0 ); display: var( --os-titlebar-minimize-display, inline-flex ); } .os-window__controls .os-window__btn--maximize { order: var( --os-titlebar-maximize-order, 0 ); display: var( --os-titlebar-maximize-display, inline-flex ); } .os-window__controls .os-window__btn--focus-tab { order: var( --os-titlebar-fullscreen-order, 0 ); display: var( --os-titlebar-fullscreen-display, inline-flex ); } .os-window__controls .os-window__btn--close { order: var( --os-titlebar-close-order, 0 ); margin-inline-start: var( --os-titlebar-close-gap, 0 ); } /* * 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. * * The close button's hover is the danger red in both halves, through * `--os-ui-danger`. `--os-titlebar-close-bg-hover` and * `-color-hover` re-point it for a title bar whose era drew its close * button another way, without moving every other danger surface. */ .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-titlebar-close-bg-hover, var( --os-ui-danger, #d63638 ) ); --os-ui-btn-danger-color-hover: var( --os-titlebar-close-color-hover, var( --os-ui-fg-on-accent, #fff ) ); } .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-titlebar-close-bg-hover, var( --os-ui-danger, #d63638 ) ); --os-ui-btn-danger-color-hover: var( --os-titlebar-close-color-hover, var( --os-ui-fg-on-accent, #fff ) ); } /* * 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; } /* * "Copy link" confirms in its own row: the label becomes "Link copied" * with a tick that pops in, then the row reverts and the menu closes * unless it is still hovered. The tick sits in the slotted label, so it * is styled here rather than in the menu's shadow root. Transform and * opacity only. * * The tick wears the row's own ink, not the accent: it is feedback, not * the thing you are on, and the words already say what happened (see * "The accent marks state; it never decorates" in AGENTS.md). */ .os-window__menu-copied-icon { /* Hangs in the gutter so "Link copied" stays aligned with the other labels. Out of flow, so the label's overflow clip, whose box is not this icon's containing block, does not cut it off. */ position: absolute; translate: -28px -1px; animation: os-menu-copied-pop 0.24s cubic-bezier( 0.34, 1.56, 0.64, 1 ) both; } .os-window__menu-copied-icon:dir( rtl ) { translate: 28px -1px; } @keyframes os-menu-copied-pop { from { opacity: 0; transform: scale( 0.4 ); } to { opacity: 1; transform: scale( 1 ); } } @media ( prefers-reduced-motion: reduce ) { .os-window__menu-copied-icon { animation: none; } } /* * "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: var( --os-titlebar-btn-width, 28px ); height: var( --os-titlebar-btn-height, 28px ); border: none; border-radius: var( --os-titlebar-meta-btn-radius, 6px ); cursor: pointer; padding: 0; /* The same face as the window controls beside them, when a theme * gives those one (TITLEBAR_BUTTON). Every state below changes the * colour only, so the face survives hover and press. */ background-color: var( --os-ui-btn-bg, transparent ); background-image: var( --os-ui-btn-bg-image, none ); background-repeat: var( --os-ui-btn-bg-image-repeat, no-repeat ); background-size: var( --os-ui-btn-bg-image-size, auto ); background-position: var( --os-ui-btn-bg-image-position, center ); transition: background-color 0.15s ease, color 0.15s ease; } .os-window__meta-btn:active, .os-window__meta-btn--active { background-image: var( --os-ui-btn-bg-image-pressed, var( --os-ui-btn-bg-image, none ) ); } .os-window__meta-btn .dashicons { font-size: var( --os-titlebar-meta-icon-size, 18px ); width: var( --os-titlebar-meta-icon-size, 18px ); height: var( --os-titlebar-meta-icon-size, 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-color: 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-color: 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-color: 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-color: 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-color: 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: var( --os-tabs-tab-height, 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: var( --os-tabs-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: var( --os-tabs-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 { /* `none` for a theme whose tabs draw their own faces. */ display: var( --os-tabs-plate-display, block ); 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 ); /* The rail's colour is derived from the accent; how much of it shows is a literal the palette answers. 1 is the earlier look. */ opacity: var( --os-tabs-rail-opacity, 1 ); 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 ); /* The rail's colour is derived from the accent; how much of it shows is a literal the palette answers. 1 is the earlier look. */ opacity: var( --os-tabs-rail-opacity, 1 ); 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: var( --_tab-h ); margin: var( --os-tabs-tab-margin, 0 ); padding: var( --os-tabs-tab-padding, 0 14px ); border: none; border-radius: var( --_tab-radius ) var( --_tab-radius ) 0 0; /* * A tab paints no face of its own by default: the plate is the * surface. A theme that draws every tab as a key gives each one a * face and an edge here, and turns the plate off. */ background: var( --os-tabs-tab-bg, transparent ); box-shadow: var( --os-tabs-tab-shadow, none ); color: var( --os-tabs-color, var( --os-ui-fg-muted, #50575e ) ); font: inherit; font-size: var( --os-tabs-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( --os-tabs-hover-color, var(--wp-admin-theme-color, #2271b1) ); background: var( --os-tabs-hover-bg, 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 { /* Above its neighbours, for a theme whose active tab grows over them. */ z-index: 1; height: var( --os-tabs-active-height, var( --_tab-h ) ); margin: var( --os-tabs-active-margin, var( --os-tabs-tab-margin, 0 ) ); background: var( --os-tabs-active-tab-bg, transparent ); box-shadow: var( --os-tabs-active-shadow, none ); color: var( --os-tabs-active-color, #1d2327 ); font-weight: var( --os-tabs-active-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: var( --os-tabs-active-tab-bg, 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; } /* * A window with tabs can sit its page in a panel the tabs stand on, * for a theme that draws its tabs as keys: padding and edges on the * body, and a frame around the page. The defaults leave the page * flush under the strip. A buffer iframe is placed absolutely, so it * keeps to the same padding. */ .os-window:has( .os-window__tab ) > .os-window__body { padding: var( --os-tabs-panel-padding, 0 ); box-shadow: var( --os-tabs-panel-shadow, none ); } .os-window:has( .os-window__tab ) > .os-window__body > .os-window__iframe { box-shadow: var( --os-tabs-page-shadow, none ); } .os-window:has( .os-window__tab ) > .os-window__body > .os-window__iframe--buffer { inset: var( --os-tabs-panel-padding, 0 ); } /* * 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; } }