PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.13
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.13
1.1.13 1.1.12 1.1.11 1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 All 37 releases
← All changes | assets/css/window-chrome.css +816 -45 1.0.1 → 1.1.13 View file →
@@ -34,9 +34,14 @@
34 34 position: absolute;
35 35 display: flex;
36 36 flex-direction: column;
37 37 background: var(--os-window-bg);
38 - border: 1px solid var(--os-window-border);
38 + /*
39 + * The border width is a token so a WINDOW_FRAME texture can be
40 + * thicker than a hairline: border-image paints in the border area,
41 + * and content starts inside it, so a 4px bevel needs a 4px border.
42 + */
43 + border: var(--os-window-border-width, 1px) solid var(--os-window-border);
39 44 border-radius: var(--os-window-radius);
40 45 box-shadow: var(--os-window-shadow);
41 46 /*
42 47 * Desktop-theme window frame (WINDOW_FRAME texture slot). All
@@ -146,9 +151,9 @@
146 151 z-index: 21;
147 152 display: flex;
148 153 align-items: center;
149 154 height: var(--os-titlebar-height);
150 - padding: 0 8px;
155 + padding: var(--os-titlebar-padding, 0 8px);
151 156 background-color: var(--os-titlebar-bg);
152 157 /*
153 158 * Desktop-theme title-bar texture (TITLEBAR slot). `none` when
154 159 * unset, so the background-color above is the whole story for
@@ -175,9 +180,18 @@
175 180 );
176 181 cursor: default;
177 182 user-select: none;
178 183 flex-shrink: 0;
179 - gap: 8px;
184 + gap: var(--os-titlebar-gap, 8px);
185 + /*
186 + * Title-bar controls are sized here rather than in
187 + * `<os-window-button>`, because the same component also draws
188 + * buttons outside the title bar that a theme does not mean to resize.
189 + */
190 + --os-ui-btn-width: var(--os-titlebar-btn-width, 30px);
191 + --os-ui-btn-height: var(--os-titlebar-btn-height, 30px);
192 + --os-ui-btn-icon-size: var(--os-titlebar-btn-icon-size, 14px);
193 + --os-ui-btn-press-transform: var(--os-titlebar-btn-press-transform, scale( 0.9 ));
180 194 }
181 195
182 196 .os-window--focused .os-window__titlebar {
183 197 background-color: var(--os-titlebar-bg-focused);
@@ -206,16 +220,97 @@
206 220 .os-window__icon.os-icon-letter {
207 221 font-size: 9px;
208 222 }
209 223
210 -/* Activity indicator slot — sits between the icon and the title.
211 - * Reserves a fixed width so the indicator's blink animation can't
212 - * shift the title text horizontally. The inner `<os-save-status>`
213 - * paints a 12px modem-style dot (always visible, accent-colored).
224 +/* ---------------------------------------------------------------
225 + * Window activity — the status ring.
214 226 *
215 - * `--wp-admin-theme-color` is forwarded as `color` on the host so
216 - * the inner shadow-DOM stylesheet's `currentColor` references
217 - * (used in the box-shadow glow) resolve to the live accent. */
227 + * The leading mark of the title bar, in the position the app icon
228 + * used to hold. That icon was a copy of the window's own dock tile a
229 + * few hundred pixels below it, and a title bar has room for one mark
230 + * of that size — better spent on something that changes.
231 + *
232 + * The ring is an `<os-save-status variant="ring" mode="icon">`, found
233 + * by `[data-os-activity-indicator]` — the same public attribute a
234 + * plugin uses to mount its own. The framework's ring is not a special
235 + * case; it is the first subscriber.
236 + *
237 + * Four states, and only one of them fills:
238 + *
239 + * idle white outline, no glyph
240 + * saving accent outline, breathing
241 + * saved accent fill, white check
242 + * failed open red outline, red bang
243 + *
244 + * Colour alone is not a distinction every user can make, which is why
245 + * the two outcomes differ in SHAPE — filled versus open, check versus
246 + * bang — and not only in hue.
247 + *
248 + * `Window._paintActivityIndicator()` also mirrors the phase onto the
249 + * title bar as `data-os-activity` (absent while idle) so a desktop
250 + * theme can react to window state without reaching into the
251 + * component's shadow root.
252 + */
253 +.os-window__status {
254 + --os-ui-save-status-size: var(--os-titlebar-activity-size, 16px);
255 + /*
256 + * At rest: a white ring. One value, in both title-bar states —
257 + * the phase is what the ring reports, and dimming it on an
258 + * unfocused window would make "idle" say two different things
259 + * depending on which window you last clicked.
260 + */
261 + --os-ui-save-status-idle-color: var(--os-titlebar-activity-idle-color, #fff);
262 + /*
263 + * The RING colour, not the dot's background. Setting
264 + * `--os-ui-save-status-bg` here instead is what once painted a
265 + * solid accent fill inside the resting outline: that token is the
266 + * dot's `background` on the component's base rule, and the ring
267 + * only borrows it as a border. The ring has its own name for
268 + * exactly this reason.
269 + */
270 + --os-ui-save-status-ring-color: var(--os-titlebar-activity-color, #2271b1);
271 + --os-ui-save-status-saved-bg: var(--os-titlebar-activity-saved-color, #2271b1);
272 + --os-ui-save-status-failed-bg: var(--os-titlebar-activity-failed-color, #d63638);
273 + /* `none` for an era whose title bar had no such mark. */
274 + display: var(--os-titlebar-activity-display, inline-flex);
275 + align-items: center;
276 + flex-shrink: 0;
277 +}
278 +
279 +/*
280 + * The announcement. A glow is invisible to a screen reader, and
281 + * "did that save?" is precisely the question that can't be answered
282 + * by looking. `Window._paintActivityIndicator()` writes the outcome
283 + * here; absolute positioning keeps it out of the title bar's flex
284 + * flow so it contributes neither a box nor a `gap`.
285 + */
286 +.os-window__activity-status {
287 + position: absolute;
288 + width: 1px;
289 + height: 1px;
290 + margin: -1px;
291 + padding: 0;
292 + border: 0;
293 + overflow: hidden;
294 + clip-path: inset(50%);
295 + white-space: nowrap;
296 +}
297 +
298 +/*
299 + * Activity indicator slot — opt-in, and empty by default.
300 + *
301 + * The framework paints the glow above instead of putting an
302 + * `<os-save-status>` in the title bar of its own accord. The slot
303 + * survives for anything that DOES want a literal dot as well: give an
304 + * `<os-save-status>` the `data-os-activity-indicator` attribute, drop
305 + * it in a title-bar slot inside a `.os-window__activity` wrapper, and
306 + * `Window._paintActivityIndicator()` drives its phase for you.
307 + *
308 + * The fixed width is what keeps the blink from shifting the title
309 + * text sideways, and `--wp-admin-theme-color` is forwarded as `color`
310 + * so the component's shadow-DOM `currentColor` references (the glow's
311 + * box-shadow) resolve to the live accent.
312 + */
218 313 .os-window__activity {
219 314 display: inline-flex;
220 315 align-items: center;
221 316 justify-content: center;
@@ -226,17 +321,35 @@
226 321 margin-inline-end: 4px;
227 322 color: var(--wp-admin-theme-color, #2271b1);
228 323 }
229 324
230 -/* Window title text. */
325 +/*
326 + * Window title text.
327 + *
328 + * Every knob a classic title bar needs: size, weight and alignment of
329 + * the text, and a plate behind it for a bar whose artwork runs under
330 + * the title (stripes that stop either side of it). A title centred on
331 + * the whole bar rather than on the space the controls leave it is
332 + * `position: absolute` with `left: 50%` and a `-50%` translate, all
333 + * from tokens; the bar is the positioned ancestor. Unset, every
334 + * property resolves to what this rule always said.
335 + */
231 336 .os-window__title {
232 337 flex: 1;
233 338 overflow: hidden;
234 339 text-overflow: ellipsis;
235 340 white-space: nowrap;
236 - font-size: 14px;
237 - font-weight: 500;
341 + font-size: var(--os-titlebar-font-size, 14px);
342 + font-weight: var(--os-titlebar-font-weight, 500);
343 + letter-spacing: var(--os-titlebar-letter-spacing, normal);
344 + text-align: var(--os-titlebar-title-align, start);
238 345 line-height: var(--os-titlebar-height);
346 + position: var(--os-titlebar-title-position, static);
347 + left: var(--os-titlebar-title-left, auto);
348 + translate: var(--os-titlebar-title-translate, none);
349 + max-width: var(--os-titlebar-title-max-width, none);
350 + padding-inline: var(--os-titlebar-title-padding, 0);
351 + background: var(--os-titlebar-title-bg, transparent);
239 352 }
240 353
241 354 /*
242 355 * Window control buttons container.
@@ -258,8 +371,10 @@
258 371 * @since 0.9.8
259 372 */
260 373 .os-window__controls {
261 374 display: flex;
375 + /* `-1` puts the cluster at the leading edge of the bar. */
376 + order: var( --os-titlebar-controls-order, 0 );
262 377 gap: var( --os-titlebar-controls-gap, 4px );
263 378 align-items: center;
264 379 flex-shrink: 0;
265 380 padding-inline: var( --os-titlebar-controls-padding, 0 );
@@ -270,9 +385,38 @@
270 385 background-size: var( --os-titlebar-controls-image-size, auto );
271 386 background-position: var( --os-titlebar-controls-image-position, center );
272 387 }
273 388
389 +/* A window that asked for `appearance.controls.placement: 'left'`. */
390 +.os-window__controls--left {
391 + order: -1;
392 +}
393 +
274 394 /*
395 + * Per-control order and visibility, for a theme drawing an era whose
396 + * title bar had a different set: a close box first and nothing else,
397 + * or three buttons with a gap before the last. Hiding a control takes
398 + * a way to reach that action off the bar, so it is the theme's
399 + * explicit choice and never a default.
400 + */
401 +.os-window__controls .os-window__btn--minimize {
402 + order: var( --os-titlebar-minimize-order, 0 );
403 + display: var( --os-titlebar-minimize-display, inline-flex );
404 +}
405 +.os-window__controls .os-window__btn--maximize {
406 + order: var( --os-titlebar-maximize-order, 0 );
407 + display: var( --os-titlebar-maximize-display, inline-flex );
408 +}
409 +.os-window__controls .os-window__btn--focus-tab {
410 + order: var( --os-titlebar-fullscreen-order, 0 );
411 + display: var( --os-titlebar-fullscreen-display, inline-flex );
412 +}
413 +.os-window__controls .os-window__btn--close {
414 + order: var( --os-titlebar-close-order, 0 );
415 + margin-inline-start: var( --os-titlebar-close-gap, 0 );
416 +}
417 +
418 +/*
275 419 * Same treatment for the Screen Options / Help cluster, so a theme
276 420 * that plates one can plate the other and keep them consistent.
277 421 * Falls through to the controls tokens, so setting the pair above is
278 422 * enough for both.
@@ -297,8 +441,13 @@
297 441 * removes the wrapper's box so the inner element (icon span with
298 442 * `flex-shrink: 0`, before/after spacers, etc.) keeps its
299 443 * original layout role.
300 444 *
445 + * The `icon` slot is empty by default now — the app icon it used to
446 + * carry duplicated the window's dock tile — so on most windows it
447 + * contributes nothing at all. It keeps `display: contents` for the
448 + * ones where a plugin or a desktop theme does render an icon into it.
449 + *
301 450 * The `title` slot is the exception: it's the flex-grow region of
302 451 * the title bar (`flex: 1`). When a plugin replaces the default
303 452 * title with custom HTML or a render callback, we want the new
304 453 * content to inherit the same "fill the remaining horizontal
@@ -320,8 +469,9 @@
320 469 .os-window__slot--after-controls {
321 470 display: contents;
322 471 }
323 472
473 +
324 474 .os-window__slot--title {
325 475 flex: 1;
326 476 min-width: 0;
327 477 display: flex;
@@ -365,9 +515,9 @@
365 515 * always a mid-to-dark fill, and invisible against a pale one. A
366 516 * theme could not reach them: these declarations land on the WINDOW
367 517 * element, so a `--os-ui-btn-color` set at the shell root loses to
368 518 * them, and the same names also drive buttons outside the title bar
369 - * (sticky notes, the desk chrome) that a theme usually does not want
519 + * (pinned notes, the desk chrome) that a theme usually does not want
370 520 * to move in the same stroke.
371 521 *
372 522 * `--os-titlebar-btn-focused-*` mirrors the unfocused set
373 523 * below, name for name, so the two halves of the title bar are
@@ -374,10 +524,12 @@
374 524 * addressed the same way. Every one is UNDECLARED and falls back to
375 525 * the literal it replaced, so an unthemed shell paints exactly what
376 526 * it painted before.
377 527 *
378 - * There is no `-danger-hover` twin: destructive red is semantic, not
379 - * chrome, and both halves already resolve it through `--os-ui-danger`.
528 + * The close button's hover is the danger red in both halves, through
529 + * `--os-ui-danger`. `--os-titlebar-close-bg-hover` and
530 + * `-color-hover` re-point it for a title bar whose era drew its close
531 + * button another way, without moving every other danger surface.
380 532 */
381 533 .os-window--focused {
382 534 --os-ui-btn-color: var( --os-titlebar-btn-focused-color, rgba( 255, 255, 255, 0.7 ) );
383 535 --os-ui-btn-color-hover: var( --os-titlebar-btn-focused-color-hover, #fff );
@@ -383,9 +535,10 @@
383 535 --os-ui-btn-color-hover: var( --os-titlebar-btn-focused-color-hover, #fff );
384 536 --os-ui-btn-bg-hover: var( --os-titlebar-btn-focused-bg-hover, rgba( 255, 255, 255, 0.18 ) );
385 537 --os-ui-btn-bg-active: var( --os-titlebar-btn-focused-bg-active, rgba( 255, 255, 255, 0.25 ) );
386 538 --os-ui-btn-outline: var( --os-titlebar-btn-focused-outline, rgba( 255, 255, 255, 0.65 ) );
387 - --os-ui-btn-danger-hover: var( --os-ui-danger, #d63638 );
539 + --os-ui-btn-danger-hover: var( --os-titlebar-close-bg-hover, var( --os-ui-danger, #d63638 ) );
540 + --os-ui-btn-danger-color-hover: var( --os-titlebar-close-color-hover, var( --os-ui-fg-on-accent, #fff ) );
388 541 }
389 542
390 543 .os-window:not( .os-window--focused ) {
391 544 --os-ui-btn-color: var( --os-titlebar-btn-color, rgba( 0, 0, 0, 0.45 ) );
@@ -392,9 +545,10 @@
392 545 --os-ui-btn-color-hover: var( --os-titlebar-btn-color-hover, rgba( 0, 0, 0, 0.85 ) );
393 546 --os-ui-btn-bg-hover: var( --os-titlebar-btn-bg-hover, rgba( 0, 0, 0, 0.08 ) );
394 547 --os-ui-btn-bg-active: var( --os-titlebar-btn-bg-active, rgba( 0, 0, 0, 0.12 ) );
395 548 --os-ui-btn-outline: var( --wp-admin-theme-color, #2271b1 );
396 - --os-ui-btn-danger-hover: var( --os-ui-danger, #d63638 );
549 + --os-ui-btn-danger-hover: var( --os-titlebar-close-bg-hover, var( --os-ui-danger, #d63638 ) );
550 + --os-ui-btn-danger-color-hover: var( --os-titlebar-close-color-hover, var( --os-ui-fg-on-accent, #fff ) );
397 551 }
398 552
399 553 /*
400 554 * Unfocused control glyphs under an active desktop theme.
@@ -492,8 +646,49 @@
492 646 z-index: 2;
493 647 }
494 648
495 649 /*
650 + * "Copy link" confirms in its own row: the label becomes "Link copied"
651 + * with a tick that pops in, then the row reverts and the menu closes
652 + * unless it is still hovered. The tick sits in the slotted label, so it
653 + * is styled here rather than in the menu's shadow root. Transform and
654 + * opacity only.
655 + *
656 + * The tick wears the row's own ink, not the accent: it is feedback, not
657 + * the thing you are on, and the words already say what happened (see
658 + * "The accent marks state; it never decorates" in AGENTS.md).
659 + */
660 +.os-window__menu-copied-icon {
661 + /* Hangs in the gutter so "Link copied" stays aligned with the
662 + other labels. Out of flow, so the label's overflow clip, whose
663 + box is not this icon's containing block, does not cut it off. */
664 + position: absolute;
665 + translate: -28px -1px;
666 + animation: os-menu-copied-pop 0.24s cubic-bezier( 0.34, 1.56, 0.64, 1 ) both;
667 +}
668 +
669 +.os-window__menu-copied-icon:dir( rtl ) {
670 + translate: 28px -1px;
671 +}
672 +
673 +@keyframes os-menu-copied-pop {
674 + from {
675 + opacity: 0;
676 + transform: scale( 0.4 );
677 + }
678 + to {
679 + opacity: 1;
680 + transform: scale( 1 );
681 + }
682 +}
683 +
684 +@media ( prefers-reduced-motion: reduce ) {
685 + .os-window__menu-copied-icon {
686 + animation: none;
687 + }
688 +}
689 +
690 +/*
496 691 * "Related" dropdown — opened by the related-entities title-bar
497 692 * button (`src/related-entities/`). The panel ALSO carries
498 693 * `.os-window__menu-panel` (positioning + the drag-tracker
499 694 * exclusion come from there); this class only layers the related-menu
@@ -548,22 +743,37 @@
548 743 .os-window__meta-btn {
549 744 display: flex;
550 745 align-items: center;
551 746 justify-content: center;
552 - width: 28px;
553 - height: 28px;
747 + width: var( --os-titlebar-btn-width, 28px );
748 + height: var( --os-titlebar-btn-height, 28px );
554 749 border: none;
555 - border-radius: 6px;
750 + border-radius: var( --os-titlebar-meta-btn-radius, 6px );
556 751 cursor: pointer;
557 752 padding: 0;
558 - background: transparent;
753 + /* The same face as the window controls beside them, when a theme
754 + * gives those one (TITLEBAR_BUTTON). Every state below changes the
755 + * colour only, so the face survives hover and press. */
756 + background-color: var( --os-ui-btn-bg, transparent );
757 + background-image: var( --os-ui-btn-bg-image, none );
758 + background-repeat: var( --os-ui-btn-bg-image-repeat, no-repeat );
759 + background-size: var( --os-ui-btn-bg-image-size, auto );
760 + background-position: var( --os-ui-btn-bg-image-position, center );
559 761 transition: background-color 0.15s ease, color 0.15s ease;
560 762 }
561 763
764 +.os-window__meta-btn:active,
765 +.os-window__meta-btn--active {
766 + background-image: var(
767 + --os-ui-btn-bg-image-pressed,
768 + var( --os-ui-btn-bg-image, none )
769 + );
770 +}
771 +
562 772 .os-window__meta-btn .dashicons {
563 - font-size: 18px;
564 - width: 18px;
565 - height: 18px;
773 + font-size: var( --os-titlebar-meta-icon-size, 18px );
774 + width: var( --os-titlebar-meta-icon-size, 18px );
775 + height: var( --os-titlebar-meta-icon-size, 18px );
566 776 }
567 777 /* The flex-centring override for the dashicon glyph currently lives
568 778 * in `windows.css`. That placement was a cache workaround from when
569 779 * this file was an `@import` sub-sheet with no `?ver=` of its own;
@@ -585,19 +795,19 @@
585 795 color: var( --os-titlebar-btn-focused-color, rgba(255, 255, 255, 0.65) );
586 796 }
587 797 .os-window--focused .os-window__meta-btn:hover {
588 798 color: var( --os-titlebar-btn-focused-color-hover, var( --os-ui-fg-on-accent, #fff ) );
589 - background: var( --os-titlebar-btn-focused-bg-hover, rgba(255, 255, 255, 0.18) );
799 + background-color: var( --os-titlebar-btn-focused-bg-hover, rgba(255, 255, 255, 0.18) );
590 800 }
591 801 .os-window--focused .os-window__meta-btn:focus-visible {
592 802 color: var( --os-titlebar-btn-focused-color-hover, var( --os-ui-fg-on-accent, #fff ) );
593 - background: var( --os-titlebar-btn-focused-bg-hover, rgba(255, 255, 255, 0.18) );
803 + background-color: var( --os-titlebar-btn-focused-bg-hover, rgba(255, 255, 255, 0.18) );
594 804 outline: 2px solid var( --os-titlebar-btn-focused-outline, rgba(255, 255, 255, 0.6) );
595 805 outline-offset: 1px;
596 806 }
597 807 .os-window--focused .os-window__meta-btn--active {
598 808 color: var( --os-titlebar-btn-focused-color-hover, var( --os-ui-fg-on-accent, #fff ) );
599 - background: var( --os-titlebar-btn-focused-bg-active, rgba(255, 255, 255, 0.25) );
809 + background-color: var( --os-titlebar-btn-focused-bg-active, rgba(255, 255, 255, 0.25) );
600 810 }
601 811
602 812 /* Unfocused window: divider and buttons adapt to light title bar. */
603 813 .os-window:not(.os-window--focused) .os-window__screen-meta {
@@ -617,13 +827,13 @@
617 827 color: var( --os-titlebar-btn-color, rgba(0, 0, 0, 0.3) );
618 828 }
619 829 .os-window:not(.os-window--focused) .os-window__meta-btn:hover {
620 830 color: var( --os-titlebar-btn-color-hover, rgba(0, 0, 0, 0.6) );
621 - background: var( --os-ui-hover, rgba(0, 0, 0, 0.08) );
831 + background-color: var( --os-ui-hover, rgba(0, 0, 0, 0.08) );
622 832 }
623 833 .os-window:not(.os-window--focused) .os-window__meta-btn--active {
624 834 color: var( --os-titlebar-btn-color-hover, rgba(0, 0, 0, 0.6) );
625 - background: rgba(0, 0, 0, 0.12);
835 + background-color: rgba(0, 0, 0, 0.12);
626 836 }
627 837
628 838 /* ---------------------------------------------------------------
629 839 * Tab strip — submenu navigation rendered in the parent shell, just
@@ -629,14 +839,56 @@
629 839 * Tab strip — submenu navigation rendered in the parent shell, just
630 840 * below the title bar. Each tab swaps the iframe URL in place; no
631 841 * new window opens. Horizontally scrollable on narrow windows so the
632 842 * same component works on tablet and mobile shells unchanged.
843 + *
844 + * These are TABS in the physical sense: the strip is a recessed
845 + * track, and the active tab is a plate that rises out of it wearing
846 + * the page's own fill, filleted into the floor at both bottom
847 + * corners so tab and page read as one continuous surface. That joint
848 + * is the whole design. The previous treatment — flat text with an
849 + * accent underline, on a strip painted the same colour as the
850 + * focused title bar — gave the sub-pages no container to belong to
851 + * and no relationship to the page they navigate, which is why they
852 + * read as loose text floating in the chrome.
853 + *
854 + * The page under an iframe window is always `#fff`: `chromeless.css`
855 + * paints `body.os-chromeless` white whatever the admin colour scheme
856 + * says. So "the page's own fill" is a colour the shell can name, and
857 + * `--os-tabs-active-bg` names it.
633 858 * --------------------------------------------------------------- */
634 859 .os-window__tabs {
860 + /*
861 + * Corner radius and fillet size are the same measurement — the
862 + * fillet is the *inverse* of the corner, so a mismatch reads as a
863 + * kink where the tab meets the floor. One alias, read by both.
864 + */
865 + --_tab-radius: var( --os-tabs-radius, 8px );
866 + /* Tab height, shared with the plate that has to sit exactly on it. */
867 + --_tab-h: var( --os-tabs-tab-height, 30px );
868 + /* Weight of the rail that traces the silhouette. */
869 + --_tab-stroke: var( --os-tabs-rail-width, 2px );
870 + /*
871 + * How far each straight run of the rail overlaps the arc it hands
872 + * over to. Purely an anti-seam allowance — see the mask sizes on
873 + * the ring. It has to stay well under the radius or the overlap
874 + * would reach past the arc it is covering for.
875 + */
876 + --_tab-seam: 1px;
877 + position: relative;
635 878 display: flex;
879 + /* Tabs sit ON the floor of the track, not centred in it. */
880 + align-items: flex-end;
636 881 flex-shrink: 0;
637 - gap: 2px;
638 - padding: 0 8px;
882 + gap: var( --os-tabs-gap, 2px );
883 + /*
884 + * Horizontal padding is a floor as well as a breathing space. The
885 + * ring reaches one radius plus one stroke (10px at the shipped
886 + * values) past the plate on each side, so a first or last tab
887 + * under a smaller padding would have its outer corner clipped by
888 + * the `overflow` rule below.
889 + */
890 + padding: var( --os-tabs-padding, 8px 12px 0 );
639 891 background-color: var( --os-tabs-bg, var( --os-ui-surface-elevated, #f6f7f7 ) );
640 892 /* TABBAR texture slot — layered over the strip's own colour. */
641 893 background-image: var( --os-tabs-image, none );
642 894 background-repeat: var( --os-tabs-image-repeat, repeat );
@@ -641,9 +893,13 @@
641 893 background-image: var( --os-tabs-image, none );
642 894 background-repeat: var( --os-tabs-image-repeat, repeat );
643 895 background-size: var( --os-tabs-image-size, auto );
644 896 background-position: var( --os-tabs-image-position, center );
645 - border-bottom: 1px solid var(--os-window-border);
897 + /*
898 + * No bottom border. A hairline here would run straight through
899 + * the joint between the active tab and its page, which is the one
900 + * edge this design exists to erase.
901 + */
646 902 overflow-x: auto;
647 903 overflow-y: hidden;
648 904 scrollbar-width: thin;
649 905 /*
@@ -653,8 +909,461 @@
653 909 overscroll-behavior-x: contain;
654 910 }
655 911
656 912 /*
913 + * Unfocused, the track follows the title bar down instead of holding
914 + * its lit colour.
915 + *
916 + * Focused, the bar and the track are the same Obsidian and the tab is
917 + * the only thing lifting out of them: two surfaces plus the tab. Dim
918 + * the bar alone and the strip becomes a third colour belonging to
919 + * neither the chrome above it nor the page below, which is the seam
920 + * this rule removes.
921 + *
922 + * The chain ends at `--os-tabs-bg`, so a theme that names only the one
923 + * strip colour keeps it in both states.
924 + */
925 +.os-window:not( .os-window--focused ) .os-window__tabs {
926 + background-color: var(
927 + --os-tabs-bg-unfocused,
928 + var( --os-tabs-bg, var( --os-ui-surface-elevated, #f6f7f7 ) )
929 + );
930 +}
931 +
932 +/*
933 + * The same strip on a NATIVE window, which is the same strip: one
934 + * stylesheet, whatever is behind the window. What changes is only
935 + * what the active tab is wearing, and it changes in tokens.
936 + *
937 + * The rule the whole design rests on is that the active tab wears the
938 + * page's own fill and is filleted into it, so tab and content read as
939 + * one surface. For an iframe window that fill is `#fff`, because
940 + * `chromeless.css` paints every admin page inside a window white
941 + * whatever the colour scheme says. A native window's page is its
942 + * body, and `--os-window-bg` is what paints it — so that is the fill
943 + * here, and the two move together by construction.
944 + *
945 + * The frost and the crown go with the white. They are what makes a
946 + * bright plate read as a surface lifting out of dark chrome; on a
947 + * native window the tab and the track are the same colour when the
948 + * window is focused, and a crown on a tab you cannot see the edges of
949 + * is decoration with nothing to decorate. The rail carries the
950 + * silhouette on its own, which is the point: a native window has no
951 + * value step to wear, so the line IS the tab.
952 + *
953 + * The label follows the fill. `--os-tabs-active-color` names the text
954 + * on a white plate and resolves to near-black, which would be
955 + * invisible here.
956 + */
957 +.os-window--native {
958 + --os-tabs-active-bg: var( --os-window-bg, #fff );
959 + --os-tabs-active-color: var( --os-ui-fg, #1d2327 );
960 + --os-tabs-active-color-muted: var( --os-ui-fg-muted, #50575e );
961 + --os-tabs-active-frost: none;
962 + --os-tabs-active-crown: none;
963 +}
964 +
965 +/*
966 + * Windows with no submenu still get a strip — `createWindowElement()`
967 + * appends it unconditionally so `addExternalTab()` has somewhere to
968 + * put a tab later. Empty, it must take no room at all: with vertical
969 + * padding on the track, an empty strip would otherwise paint a bare
970 + * band of track colour under every submenu-less window's title bar.
971 + *
972 + * `:has()` rather than `:empty`, because the strip is never empty any
973 + * more — it always carries the plate. What makes a strip vacant is
974 + * having no TABS in it.
975 + */
976 +.os-window__tabs:not( :has( .os-window__tab ) ) {
977 + display: none;
978 +}
979 +
980 +/* ---------------------------------------------------------------
981 + * The plate — the active tab's surface.
982 + *
983 + * It is one element that TRAVELS between tabs rather than a fill
984 + * that switches off on one tab and on at the next. That distinction
985 + * is the whole reason it exists: a fill that switches has to cross-
986 + * fade a dark tab into a light one, and every frame in between is a
987 + * muddy grey that belongs to neither. Nothing crossfades here. The
988 + * surface simply moves, and the labels change colour underneath it.
989 + *
990 + * `tabs.ts` publishes the target geometry as `--_tab-plate-x` and
991 + * `--_tab-plate-w` on this element; everything else is CSS.
992 + * --------------------------------------------------------------- */
993 +.os-window__tab-plate {
994 + /* `none` for a theme whose tabs draw their own faces. */
995 + display: var( --os-tabs-plate-display, block );
996 + position: absolute;
997 + bottom: 0;
998 + left: 0;
999 + height: var( --_tab-h );
1000 + width: var( --_tab-plate-w, 0 );
1001 + transform: translateX( var( --_tab-plate-x, 0 ) );
1002 + pointer-events: none;
1003 + transition:
1004 + transform var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) ),
1005 + width var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) ),
1006 + opacity 0.15s ease;
1007 +}
1008 +
1009 +/*
1010 + * Until the first measurement lands, the plate has no business
1011 + * animating — it would slide in from the strip's left edge every
1012 + * time a window opens.
1013 + */
1014 +.os-window__tab-plate:not( [ data-placed ] ) {
1015 + transition: none;
1016 +}
1017 +
1018 +/*
1019 + * The frosted face.
1020 + *
1021 + * The tint is TOP-WEIGHTED and reaches zero before the bottom of the
1022 + * plate, which is the load-bearing part: the joint below is the
1023 + * page's own colour, so anything still tinted down there draws a
1024 + * seam across the one edge this design exists to erase. Keep the
1025 + * gradient's last stop fully transparent and keep it above ~75%.
1026 + *
1027 + * The fallback is a flat `--os-tabs-active-bg`, so a stylesheet that
1028 + * loses the palette gets the plain white tab rather than nothing.
1029 + */
1030 +.os-window__tab-plate-fill {
1031 + position: absolute;
1032 + /*
1033 + * Overshoots the track's floor by one radius, and is then clipped
1034 + * by the strip's own `overflow`. Ending the face exactly ON the
1035 + * floor puts a paint boundary on the one edge that has to be
1036 + * invisible; pushing it past and cutting it means there is no edge
1037 + * there to resolve at all.
1038 + */
1039 + inset: 0 0 calc( var( --_tab-radius ) * -1 ) 0;
1040 + overflow: hidden;
1041 + border-radius: var( --_tab-radius ) var( --_tab-radius ) 0 0;
1042 + /*
1043 + * OPAQUE, and deliberately so — no `backdrop-filter`, no alpha.
1044 + *
1045 + * A translucent element with a backdrop filter is promoted to its
1046 + * own compositor layer, and the edge of that layer is a clip the
1047 + * compositor resolves against whatever is behind it. That leaves a
1048 + * faint grey hairline down the plate's sides and across its
1049 + * bottom. Everywhere else that would be a nuisance; here it draws
1050 + * a second, dimmer line a few pixels inside the real one and flatly
1051 + * contradicts what this design is claiming. The gradient below
1052 + * reproduces the frosted crown by eye with no alpha at all.
1053 + *
1054 + * `background-size` pins the gradient to the tab's true height so
1055 + * the overshoot underneath stays flat page-white.
1056 + */
1057 + background-color: var( --os-tabs-active-bg, #fff );
1058 + background-image: var( --os-tabs-active-frost, none );
1059 + background-size: 100% var( --_tab-h );
1060 + background-repeat: no-repeat;
1061 +}
1062 +
1063 +/*
1064 + * The crown — Holomesh over the top of the plate, masked away before
1065 + * the joint. Holomesh is nine stacked gradients, so it cannot be
1066 + * faded by adding a colour stop; it needs its own layer and a mask.
1067 + *
1068 + * It is on exactly one tab at a time. That is what keeps the mesh an
1069 + * identity moment rather than wallpaper, and it is the same rule the
1070 + * rest of the kit follows (see `src/ui/holo.ts`).
1071 + */
1072 +.os-window__tab-plate-fill::before {
1073 + content: '';
1074 + position: absolute;
1075 + inset: 0;
1076 + background-image: var( --os-tabs-active-crown, none );
1077 + background-size: 210% 210%;
1078 + background-position: 20% 26%;
1079 + opacity: var( --os-tabs-active-crown-opacity, 0.5 );
1080 + -webkit-mask-image: linear-gradient(
1081 + 180deg,
1082 + #000 0%,
1083 + rgba( 0, 0, 0, 0.55 ) 34%,
1084 + transparent 66%
1085 + );
1086 + mask-image: linear-gradient(
1087 + 180deg,
1088 + #000 0%,
1089 + rgba( 0, 0, 0, 0.55 ) 34%,
1090 + transparent 66%
1091 + );
1092 + animation: os-tab-crown-drift 16s ease-in-out infinite alternate;
1093 +}
1094 +
1095 +@keyframes os-tab-crown-drift {
1096 + from { background-position: 20% 26%; }
1097 + to { background-position: 72% 62%; }
1098 +}
1099 +
1100 +/*
1101 + * The joint: two concave quarter-circles carrying the plate's fill
1102 + * out to the floor of the track. Without them the plate is a rounded
1103 + * rectangle NEAR the page; with them it is attached to it.
1104 + *
1105 + * Its own element rather than a pseudo on the face, so the face can
1106 + * clip its crown (`overflow: hidden`) without clipping the curve
1107 + * that does the attaching. Pure `--os-tabs-active-bg` — see the note
1108 + * on the tint above.
1109 + */
1110 +.os-window__tab-plate-joint {
1111 + position: absolute;
1112 + bottom: 0;
1113 + left: calc( var( --_tab-radius ) * -1 );
1114 + right: calc( var( --_tab-radius ) * -1 );
1115 + height: var( --_tab-radius );
1116 + background:
1117 + radial-gradient(
1118 + circle at 0 0,
1119 + transparent var( --_tab-radius ),
1120 + var( --os-tabs-active-bg, #fff ) var( --_tab-radius )
1121 + ) left bottom / var( --_tab-radius ) var( --_tab-radius ) no-repeat,
1122 + radial-gradient(
1123 + circle at 100% 0,
1124 + transparent var( --_tab-radius ),
1125 + var( --os-tabs-active-bg, #fff ) var( --_tab-radius )
1126 + ) right bottom / var( --_tab-radius ) var( --_tab-radius ) no-repeat;
1127 +}
1128 +
1129 +/* ---------------------------------------------------------------
1130 + * The rail — one continuous line around the whole silhouette.
1131 + *
1132 + * It runs the top edge of the page, curves up through the fillet,
1133 + * traces the tab and comes back down. What it outlines is therefore
1134 + * page-plus-tab as ONE shape, which is the same claim the joint makes
1135 + * by omission, said out loud.
1136 + *
1137 + * Two rules govern every number below.
1138 + *
1139 + * 1. **The line lives on the DARK side of the boundary, everywhere.**
1140 + * It hugs the white shape from outside and never paints on it. Get
1141 + * this wrong on any one segment and the line steps sideways by its
1142 + * own width at the tangent point where that segment meets the next
1143 + * — which is exactly what a stroke that does not follow the shape
1144 + * looks like. The convex tab corners are therefore annulus R→R+s
1145 + * (outside the plate) while the concave fillets are R−s→R (inside
1146 + * the fillet circle, whose white lies OUTSIDE it). Those look like
1147 + * opposite conventions and are the same one.
1148 + *
1149 + * 2. **Every piece samples one mesh laid across the whole strip.**
1150 + * `--_tab-strip-w` is why. Give the rail and the ring their own
1151 + * backgrounds and you get two unrelated gradients meeting at a
1152 + * visible join in the middle of the fillet.
1153 + *
1154 + * CSS cannot stroke a path, so the mesh is painted as a background and
1155 + * a mask cuts it to the outline. Seven layers, one per segment, all
1156 + * positioned off the box edges so the whole thing survives the plate
1157 + * changing width mid-slide with no JS.
1158 + * --------------------------------------------------------------- */
1159 +.os-window__tabs::before {
1160 + content: '';
1161 + position: absolute;
1162 + left: 0;
1163 + right: 0;
1164 + bottom: 0;
1165 + height: var( --_tab-stroke );
1166 + background-image: var( --os-tabs-rail, none );
1167 + /* The rail's colour is derived from the accent; how much of it
1168 + shows is a literal the palette answers. 1 is the earlier look. */
1169 + opacity: var( --os-tabs-rail-opacity, 1 );
1170 + background-size: var( --_tab-strip-w, 100% ) 100%;
1171 + background-repeat: no-repeat;
1172 + pointer-events: none;
1173 + /*
1174 + * Two segments, stopping where each fillet begins. Run the rail
1175 + * straight through and it draws a chord across the concave curve.
1176 + */
1177 + -webkit-mask-image: linear-gradient( #000 0 0 ), linear-gradient( #000 0 0 );
1178 + mask-image: linear-gradient( #000 0 0 ), linear-gradient( #000 0 0 );
1179 + /*
1180 + * Each segment runs one `--_tab-seam` PAST where its fillet begins,
1181 + * so the rail overlaps the arc rather than meeting it exactly —
1182 + * the same anti-seam allowance the ring uses, for the same reason.
1183 + */
1184 + -webkit-mask-size:
1185 + calc(
1186 + var( --_tab-plate-x, 0px ) - var( --_tab-radius ) + var( --_tab-seam )
1187 + ) 100%,
1188 + calc(
1189 + 100% - var( --_tab-plate-x, 0px ) - var( --_tab-plate-w, 0px ) -
1190 + var( --_tab-radius ) + var( --_tab-seam )
1191 + ) 100%;
1192 + mask-size:
1193 + calc(
1194 + var( --_tab-plate-x, 0px ) - var( --_tab-radius ) + var( --_tab-seam )
1195 + ) 100%,
1196 + calc(
1197 + 100% - var( --_tab-plate-x, 0px ) - var( --_tab-plate-w, 0px ) -
1198 + var( --_tab-radius ) + var( --_tab-seam )
1199 + ) 100%;
1200 + -webkit-mask-position: left top, right top;
1201 + mask-position: left top, right top;
1202 + -webkit-mask-repeat: no-repeat;
1203 + mask-repeat: no-repeat;
1204 + transition:
1205 + -webkit-mask-size var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) ),
1206 + mask-size var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) );
1207 +}
1208 +
1209 +/* With no active tab there is no gap to leave, so the rail is whole. */
1210 +.os-window__tabs[ data-tab-plate-empty ]::before {
1211 + -webkit-mask-image: none;
1212 + mask-image: none;
1213 +}
1214 +
1215 +/*
1216 + * The tab's half of the line. The box is the plate grown by one
1217 + * stroke upward and by radius-plus-stroke on each side, so the top
1218 + * edge, both corners and both fillet arcs all have room to sit
1219 + * OUTSIDE the white.
1220 + */
1221 +.os-window__tab-plate::after {
1222 + --_ring-arc-out: radial-gradient(
1223 + circle at 0 0,
1224 + transparent calc( var( --_tab-radius ) - var( --_tab-stroke ) ),
1225 + #000 calc( var( --_tab-radius ) - var( --_tab-stroke ) ),
1226 + #000 var( --_tab-radius ),
1227 + transparent var( --_tab-radius )
1228 + );
1229 + --_ring-arc-out-r: radial-gradient(
1230 + circle at 100% 0,
1231 + transparent calc( var( --_tab-radius ) - var( --_tab-stroke ) ),
1232 + #000 calc( var( --_tab-radius ) - var( --_tab-stroke ) ),
1233 + #000 var( --_tab-radius ),
1234 + transparent var( --_tab-radius )
1235 + );
1236 + --_ring-corner-l: radial-gradient(
1237 + circle at 100% 100%,
1238 + transparent var( --_tab-radius ),
1239 + #000 var( --_tab-radius ),
1240 + #000 calc( var( --_tab-radius ) + var( --_tab-stroke ) ),
1241 + transparent calc( var( --_tab-radius ) + var( --_tab-stroke ) )
1242 + );
1243 + --_ring-corner-r: radial-gradient(
1244 + circle at 0 100%,
1245 + transparent var( --_tab-radius ),
1246 + #000 var( --_tab-radius ),
1247 + #000 calc( var( --_tab-radius ) + var( --_tab-stroke ) ),
1248 + transparent calc( var( --_tab-radius ) + var( --_tab-stroke ) )
1249 + );
1250 + --_ring-bar: linear-gradient( #000 0 0 );
1251 +
1252 + content: '';
1253 + position: absolute;
1254 + inset:
1255 + calc( var( --_tab-stroke ) * -1 )
1256 + calc( ( var( --_tab-radius ) + var( --_tab-stroke ) ) * -1 )
1257 + 0;
1258 + background-image: var( --os-tabs-rail, none );
1259 + /* The rail's colour is derived from the accent; how much of it
1260 + shows is a literal the palette answers. 1 is the earlier look. */
1261 + opacity: var( --os-tabs-rail-opacity, 1 );
1262 + background-size: var( --_tab-strip-w, 100% ) 100%;
1263 + background-position:
1264 + calc(
1265 + var( --_tab-radius ) + var( --_tab-stroke ) -
1266 + var( --_tab-plate-x, 0px )
1267 + )
1268 + 0;
1269 + background-repeat: no-repeat;
1270 + pointer-events: none;
1271 +
1272 + -webkit-mask-image:
1273 + var( --_ring-bar ), var( --_ring-corner-l ), var( --_ring-corner-r ),
1274 + var( --_ring-bar ), var( --_ring-bar ),
1275 + var( --_ring-arc-out ), var( --_ring-arc-out-r );
1276 + mask-image:
1277 + var( --_ring-bar ), var( --_ring-corner-l ), var( --_ring-corner-r ),
1278 + var( --_ring-bar ), var( --_ring-bar ),
1279 + var( --_ring-arc-out ), var( --_ring-arc-out-r );
1280 + /*
1281 + * The three straight runs are each grown by `--_tab-seam` at BOTH
1282 + * ends, and shifted back by the same amount, so every one of them
1283 + * overlaps the arc it hands over to instead of meeting it exactly.
1284 + *
1285 + * Two layers that abut on a shared boundary each contribute a
1286 + * partial, antialiased alpha there, and the sum can fall short of
1287 + * 1 — which prints as a faint hairline across the stroke at all
1288 + * six tangent points. Overlapping cannot go wrong in the other
1289 + * direction: mask alpha is clamped, so doubling it is still opaque
1290 + * and the seam simply stops existing.
1291 + *
1292 + * The overlap stays on the dark side of the curve at every one of
1293 + * those points, so it never bleeds onto the white.
1294 + */
1295 + -webkit-mask-size:
1296 + calc( 100% - 4 * var( --_tab-radius ) - 2 * var( --_tab-stroke ) + 2 * var( --_tab-seam ) ) var( --_tab-stroke ),
1297 + calc( var( --_tab-radius ) + var( --_tab-stroke ) ) calc( var( --_tab-radius ) + var( --_tab-stroke ) ),
1298 + calc( var( --_tab-radius ) + var( --_tab-stroke ) ) calc( var( --_tab-radius ) + var( --_tab-stroke ) ),
1299 + var( --_tab-stroke ) calc( 100% - 2 * var( --_tab-radius ) - var( --_tab-stroke ) + 2 * var( --_tab-seam ) ),
1300 + var( --_tab-stroke ) calc( 100% - 2 * var( --_tab-radius ) - var( --_tab-stroke ) + 2 * var( --_tab-seam ) ),
1301 + var( --_tab-radius ) var( --_tab-radius ),
1302 + var( --_tab-radius ) var( --_tab-radius );
1303 + mask-size:
1304 + calc( 100% - 4 * var( --_tab-radius ) - 2 * var( --_tab-stroke ) + 2 * var( --_tab-seam ) ) var( --_tab-stroke ),
1305 + calc( var( --_tab-radius ) + var( --_tab-stroke ) ) calc( var( --_tab-radius ) + var( --_tab-stroke ) ),
1306 + calc( var( --_tab-radius ) + var( --_tab-stroke ) ) calc( var( --_tab-radius ) + var( --_tab-stroke ) ),
1307 + var( --_tab-stroke ) calc( 100% - 2 * var( --_tab-radius ) - var( --_tab-stroke ) + 2 * var( --_tab-seam ) ),
1308 + var( --_tab-stroke ) calc( 100% - 2 * var( --_tab-radius ) - var( --_tab-stroke ) + 2 * var( --_tab-seam ) ),
1309 + var( --_tab-radius ) var( --_tab-radius ),
1310 + var( --_tab-radius ) var( --_tab-radius );
1311 + /*
1312 + * Four-value syntax throughout, and it is load-bearing. A
1313 + * percentage in `mask-position` resolves against the container
1314 + * MINUS the layer's own size, so `calc(100% - 8px)` does not mean
1315 + * "8px from the right edge" — it means "right-aligned, then pushed
1316 + * left by 8px PLUS the layer's width". Every right-hand segment
1317 + * lands a radius too far left that way. `right <offset>` is
1318 + * measured from the edge and does not care how wide the layer is.
1319 + */
1320 + -webkit-mask-position:
1321 + left calc( 2 * var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ) top 0,
1322 + left var( --_tab-radius ) top 0,
1323 + right var( --_tab-radius ) top 0,
1324 + left var( --_tab-radius ) top calc( var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ),
1325 + right var( --_tab-radius ) top calc( var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ),
1326 + left var( --_tab-stroke ) bottom 0,
1327 + right var( --_tab-stroke ) bottom 0;
1328 + mask-position:
1329 + left calc( 2 * var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ) top 0,
1330 + left var( --_tab-radius ) top 0,
1331 + right var( --_tab-radius ) top 0,
1332 + left var( --_tab-radius ) top calc( var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ),
1333 + right var( --_tab-radius ) top calc( var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ),
1334 + left var( --_tab-stroke ) bottom 0,
1335 + right var( --_tab-stroke ) bottom 0;
1336 + -webkit-mask-repeat: no-repeat;
1337 + mask-repeat: no-repeat;
1338 +
1339 + /* Travels with the plate, so the mesh stays locked to the strip
1340 + * for the whole slide instead of snapping on the first frame. */
1341 + transition: background-position var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) );
1342 +}
1343 +
1344 +/*
1345 + * No active tab to sit under — `syncActiveTab` can land on a URL that
1346 + * matches nothing, and an external sub-tab deactivates every submenu
1347 + * tab. The plate keeps its last geometry and fades, so re-activating
1348 + * a tab does not read as the plate flying in from nowhere.
1349 + */
1350 +.os-window__tab-plate[ data-empty ] {
1351 + opacity: 0;
1352 +}
1353 +
1354 +@media ( prefers-reduced-motion: reduce ) {
1355 + .os-window__tab-plate,
1356 + .os-window__tab-plate::after,
1357 + .os-window__tabs::before {
1358 + transition-duration: 1ms;
1359 + }
1360 + .os-window__tab-plate-fill::before {
1361 + animation: none;
1362 + }
1363 +}
1364 +
1365 +/*
657 1366 * Soft edge fades so overflowing tabs tell the user "scroll for more."
658 1367 *
659 1368 * Each fade is painted ONLY on an edge that is actually hiding a tab.
660 1369 * `observeTabOverflow()` in `src/window/tabs.ts` stamps `data-overflow`
@@ -708,29 +1417,36 @@
708 1417 );
709 1418 }
710 1419
711 1420 .os-window__tab {
1421 + position: relative;
712 1422 flex-shrink: 0;
713 1423 display: inline-flex;
714 1424 align-items: center;
715 - height: 32px;
716 - padding: 0 12px;
1425 + height: var( --_tab-h );
1426 + margin: var( --os-tabs-tab-margin, 0 );
1427 + padding: var( --os-tabs-tab-padding, 0 14px );
717 1428 border: none;
718 - background: transparent;
1429 + border-radius: var( --_tab-radius ) var( --_tab-radius ) 0 0;
1430 + /*
1431 + * A tab paints no face of its own by default: the plate is the
1432 + * surface. A theme that draws every tab as a key gives each one a
1433 + * face and an edge here, and turns the plate off.
1434 + */
1435 + background: var( --os-tabs-tab-bg, transparent );
1436 + box-shadow: var( --os-tabs-tab-shadow, none );
719 1437 color: var( --os-tabs-color, var( --os-ui-fg-muted, #50575e ) );
720 1438 font: inherit;
721 - font-size: 12px;
1439 + font-size: var( --os-tabs-font-size, 12px );
722 1440 line-height: 1;
723 1441 cursor: pointer;
724 - border-bottom: 2px solid transparent;
725 - margin-bottom: -1px;
726 1442 white-space: nowrap;
727 - transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
1443 + transition: color 0.15s ease, background-color 0.15s ease;
728 1444 }
729 1445
730 1446 .os-window__tab:hover {
731 - color: var(--wp-admin-theme-color, #2271b1);
732 - background: var( --os-ui-hover, rgba(0, 0, 0, 0.03) );
1447 + color: var( --os-tabs-hover-color, var(--wp-admin-theme-color, #2271b1) );
1448 + background: var( --os-tabs-hover-bg, var( --os-ui-hover, rgba(0, 0, 0, 0.03) ) );
733 1449 }
734 1450
735 1451 .os-window__tab:focus-visible {
736 1452 outline: 2px solid var(--wp-admin-theme-color, #2271b1);
@@ -736,14 +1452,49 @@
736 1452 outline: 2px solid var(--wp-admin-theme-color, #2271b1);
737 1453 outline-offset: -2px;
738 1454 }
739 1455
1456 +/*
1457 + * The active tab. It paints NO surface of its own — the plate does
1458 + * that, and the plate is a sibling that slides. All this rule owns is
1459 + * the label.
1460 + *
1461 + * No accent anywhere on it either. The shape already says "this one",
1462 + * and the accent is spent: one Pulse divider in the dock, one focus
1463 + * ring. A tab that is both a distinct surface AND coloured is saying
1464 + * the same thing twice.
1465 + */
740 1466 .os-window__tab--active {
741 - color: var(--wp-admin-theme-color, #2271b1);
742 - border-bottom-color: var(--wp-admin-theme-color, #2271b1);
743 - font-weight: 600;
1467 + /* Above its neighbours, for a theme whose active tab grows over them. */
1468 + z-index: 1;
1469 + height: var( --os-tabs-active-height, var( --_tab-h ) );
1470 + margin: var( --os-tabs-active-margin, var( --os-tabs-tab-margin, 0 ) );
1471 + background: var( --os-tabs-active-tab-bg, transparent );
1472 + box-shadow: var( --os-tabs-active-shadow, none );
1473 + color: var( --os-tabs-active-color, #1d2327 );
1474 + font-weight: var( --os-tabs-active-weight, 600 );
1475 + /*
1476 + * Anything nested in an active tab (the external tab's detach and
1477 + * close chips) is now sitting on a light fill inside dark chrome,
1478 + * so the two tones it reads have to flip with the tab. Both are
1479 + * re-pointed through names the palette owns rather than hardcoded
1480 + * here, so a desktop theme retints the chips with the tab.
1481 + */
1482 + --os-ui-fg-muted: var( --os-tabs-active-color-muted, rgba(29, 35, 39, 0.6) );
1483 + --os-ui-hover: color-mix(
1484 + in srgb,
1485 + var( --os-tabs-active-color, #1d2327 ) 8%,
1486 + transparent
1487 + );
744 1488 }
745 1489
1490 +/* An active tab has the plate under it; a hover wash on top of that
1491 + * would double-paint the surface. */
1492 +.os-window__tab--active:hover {
1493 + background: var( --os-tabs-active-tab-bg, transparent );
1494 + color: var( --os-tabs-active-color, #1d2327 );
1495 +}
1496 +
746 1497 /*
747 1498 * External sub-tab. Same shape as a submenu tab, plus two inline
748 1499 * chips: detach (↗) and close (×). Chips are painted as spans
749 1500 * (rather than nested buttons — nested interactive elements are a
@@ -804,8 +1555,28 @@
804 1555 display: block;
805 1556 background: var(--os-window-bg);
806 1557 opacity: 1;
807 1558 transition: opacity 0.25s ease;
1559 +}
1560 +
1561 +/*
1562 + * A window with tabs can sit its page in a panel the tabs stand on,
1563 + * for a theme that draws its tabs as keys: padding and edges on the
1564 + * body, and a frame around the page. The defaults leave the page
1565 + * flush under the strip. A buffer iframe is placed absolutely, so it
1566 + * keeps to the same padding.
1567 + */
1568 +.os-window:has( .os-window__tab ) > .os-window__body {
1569 + padding: var( --os-tabs-panel-padding, 0 );
1570 + box-shadow: var( --os-tabs-panel-shadow, none );
1571 +}
1572 +
1573 +.os-window:has( .os-window__tab ) > .os-window__body > .os-window__iframe {
1574 + box-shadow: var( --os-tabs-page-shadow, none );
1575 +}
1576 +
1577 +.os-window:has( .os-window__tab ) > .os-window__body > .os-window__iframe--buffer {
1578 + inset: var( --os-tabs-panel-padding, 0 );
808 1579 }
809 1580
810 1581 /*
811 1582 * Headings inside native window bodies.