PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.10
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.10
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 0.8.9 0.8.8 0.8.7 All 34 releases
← All changes | assets/css/window-chrome.css +629 -17 1.0.11.1.10 View file →
@@ -206,16 +206,96 @@
206 206 .os-window__icon.os-icon-letter {
207 207 font-size: 9px;
208 208 }
209 209
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).
210 +/* ---------------------------------------------------------------
211 + * Window activity — the status ring.
214 212 *
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. */
213 + * The leading mark of the title bar, in the position the app icon
214 + * used to hold. That icon was a copy of the window's own dock tile a
215 + * few hundred pixels below it, and a title bar has room for one mark
216 + * of that size — better spent on something that changes.
217 + *
218 + * The ring is an `<os-save-status variant="ring" mode="icon">`, found
219 + * by `[data-os-activity-indicator]` — the same public attribute a
220 + * plugin uses to mount its own. The framework's ring is not a special
221 + * case; it is the first subscriber.
222 + *
223 + * Four states, and only one of them fills:
224 + *
225 + * idle white outline, no glyph
226 + * saving accent outline, breathing
227 + * saved accent fill, white check
228 + * failed open red outline, red bang
229 + *
230 + * Colour alone is not a distinction every user can make, which is why
231 + * the two outcomes differ in SHAPE — filled versus open, check versus
232 + * bang — and not only in hue.
233 + *
234 + * `Window._paintActivityIndicator()` also mirrors the phase onto the
235 + * title bar as `data-os-activity` (absent while idle) so a desktop
236 + * theme can react to window state without reaching into the
237 + * component's shadow root.
238 + */
239 +.os-window__status {
240 + --os-ui-save-status-size: var(--os-titlebar-activity-size, 16px);
241 + /*
242 + * At rest: a white ring. One value, in both title-bar states —
243 + * the phase is what the ring reports, and dimming it on an
244 + * unfocused window would make "idle" say two different things
245 + * depending on which window you last clicked.
246 + */
247 + --os-ui-save-status-idle-color: var(--os-titlebar-activity-idle-color, #fff);
248 + /*
249 + * The RING colour, not the dot's background. Setting
250 + * `--os-ui-save-status-bg` here instead is what once painted a
251 + * solid accent fill inside the resting outline: that token is the
252 + * dot's `background` on the component's base rule, and the ring
253 + * only borrows it as a border. The ring has its own name for
254 + * exactly this reason.
255 + */
256 + --os-ui-save-status-ring-color: var(--os-titlebar-activity-color, #2271b1);
257 + --os-ui-save-status-saved-bg: var(--os-titlebar-activity-saved-color, #2271b1);
258 + --os-ui-save-status-failed-bg: var(--os-titlebar-activity-failed-color, #d63638);
259 + display: inline-flex;
260 + align-items: center;
261 + flex-shrink: 0;
262 +}
263 +
264 +/*
265 + * The announcement. A glow is invisible to a screen reader, and
266 + * "did that save?" is precisely the question that can't be answered
267 + * by looking. `Window._paintActivityIndicator()` writes the outcome
268 + * here; absolute positioning keeps it out of the title bar's flex
269 + * flow so it contributes neither a box nor a `gap`.
270 + */
271 +.os-window__activity-status {
272 + position: absolute;
273 + width: 1px;
274 + height: 1px;
275 + margin: -1px;
276 + padding: 0;
277 + border: 0;
278 + overflow: hidden;
279 + clip-path: inset(50%);
280 + white-space: nowrap;
281 +}
282 +
283 +/*
284 + * Activity indicator slot — opt-in, and empty by default.
285 + *
286 + * The framework paints the glow above instead of putting an
287 + * `<os-save-status>` in the title bar of its own accord. The slot
288 + * survives for anything that DOES want a literal dot as well: give an
289 + * `<os-save-status>` the `data-os-activity-indicator` attribute, drop
290 + * it in a title-bar slot inside a `.os-window__activity` wrapper, and
291 + * `Window._paintActivityIndicator()` drives its phase for you.
292 + *
293 + * The fixed width is what keeps the blink from shifting the title
294 + * text sideways, and `--wp-admin-theme-color` is forwarded as `color`
295 + * so the component's shadow-DOM `currentColor` references (the glow's
296 + * box-shadow) resolve to the live accent.
297 + */
218 298 .os-window__activity {
219 299 display: inline-flex;
220 300 align-items: center;
221 301 justify-content: center;
@@ -297,8 +377,13 @@
297 377 * removes the wrapper's box so the inner element (icon span with
298 378 * `flex-shrink: 0`, before/after spacers, etc.) keeps its
299 379 * original layout role.
300 380 *
381 + * The `icon` slot is empty by default now — the app icon it used to
382 + * carry duplicated the window's dock tile — so on most windows it
383 + * contributes nothing at all. It keeps `display: contents` for the
384 + * ones where a plugin or a desktop theme does render an icon into it.
385 + *
301 386 * The `title` slot is the exception: it's the flex-grow region of
302 387 * the title bar (`flex: 1`). When a plugin replaces the default
303 388 * title with custom HTML or a render callback, we want the new
304 389 * content to inherit the same "fill the remaining horizontal
@@ -320,8 +405,9 @@
320 405 .os-window__slot--after-controls {
321 406 display: contents;
322 407 }
323 408
409 +
324 410 .os-window__slot--title {
325 411 flex: 1;
326 412 min-width: 0;
327 413 display: flex;
@@ -365,9 +451,9 @@
365 451 * always a mid-to-dark fill, and invisible against a pale one. A
366 452 * theme could not reach them: these declarations land on the WINDOW
367 453 * element, so a `--os-ui-btn-color` set at the shell root loses to
368 454 * 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
455 + * (pinned notes, the desk chrome) that a theme usually does not want
370 456 * to move in the same stroke.
371 457 *
372 458 * `--os-titlebar-btn-focused-*` mirrors the unfocused set
373 459 * below, name for name, so the two halves of the title bar are
@@ -629,14 +715,56 @@
629 715 * Tab strip — submenu navigation rendered in the parent shell, just
630 716 * below the title bar. Each tab swaps the iframe URL in place; no
631 717 * new window opens. Horizontally scrollable on narrow windows so the
632 718 * same component works on tablet and mobile shells unchanged.
719 + *
720 + * These are TABS in the physical sense: the strip is a recessed
721 + * track, and the active tab is a plate that rises out of it wearing
722 + * the page's own fill, filleted into the floor at both bottom
723 + * corners so tab and page read as one continuous surface. That joint
724 + * is the whole design. The previous treatment — flat text with an
725 + * accent underline, on a strip painted the same colour as the
726 + * focused title bar — gave the sub-pages no container to belong to
727 + * and no relationship to the page they navigate, which is why they
728 + * read as loose text floating in the chrome.
729 + *
730 + * The page under an iframe window is always `#fff`: `chromeless.css`
731 + * paints `body.os-chromeless` white whatever the admin colour scheme
732 + * says. So "the page's own fill" is a colour the shell can name, and
733 + * `--os-tabs-active-bg` names it.
633 734 * --------------------------------------------------------------- */
634 735 .os-window__tabs {
736 + /*
737 + * Corner radius and fillet size are the same measurement — the
738 + * fillet is the *inverse* of the corner, so a mismatch reads as a
739 + * kink where the tab meets the floor. One alias, read by both.
740 + */
741 + --_tab-radius: var( --os-tabs-radius, 8px );
742 + /* Tab height, shared with the plate that has to sit exactly on it. */
743 + --_tab-h: 30px;
744 + /* Weight of the rail that traces the silhouette. */
745 + --_tab-stroke: var( --os-tabs-rail-width, 2px );
746 + /*
747 + * How far each straight run of the rail overlaps the arc it hands
748 + * over to. Purely an anti-seam allowance — see the mask sizes on
749 + * the ring. It has to stay well under the radius or the overlap
750 + * would reach past the arc it is covering for.
751 + */
752 + --_tab-seam: 1px;
753 + position: relative;
635 754 display: flex;
755 + /* Tabs sit ON the floor of the track, not centred in it. */
756 + align-items: flex-end;
636 757 flex-shrink: 0;
637 758 gap: 2px;
638 - padding: 0 8px;
759 + /*
760 + * Horizontal padding is a floor as well as a breathing space. The
761 + * ring reaches one radius plus one stroke (10px at the shipped
762 + * values) past the plate on each side, so a first or last tab
763 + * under a smaller padding would have its outer corner clipped by
764 + * the `overflow` rule below.
765 + */
766 + padding: 8px 12px 0;
639 767 background-color: var( --os-tabs-bg, var( --os-ui-surface-elevated, #f6f7f7 ) );
640 768 /* TABBAR texture slot — layered over the strip's own colour. */
641 769 background-image: var( --os-tabs-image, none );
642 770 background-repeat: var( --os-tabs-image-repeat, repeat );
@@ -641,9 +769,13 @@
641 769 background-image: var( --os-tabs-image, none );
642 770 background-repeat: var( --os-tabs-image-repeat, repeat );
643 771 background-size: var( --os-tabs-image-size, auto );
644 772 background-position: var( --os-tabs-image-position, center );
645 - border-bottom: 1px solid var(--os-window-border);
773 + /*
774 + * No bottom border. A hairline here would run straight through
775 + * the joint between the active tab and its page, which is the one
776 + * edge this design exists to erase.
777 + */
646 778 overflow-x: auto;
647 779 overflow-y: hidden;
648 780 scrollbar-width: thin;
649 781 /*
@@ -653,8 +785,459 @@
653 785 overscroll-behavior-x: contain;
654 786 }
655 787
656 788 /*
789 + * Unfocused, the track follows the title bar down instead of holding
790 + * its lit colour.
791 + *
792 + * Focused, the bar and the track are the same Obsidian and the tab is
793 + * the only thing lifting out of them: two surfaces plus the tab. Dim
794 + * the bar alone and the strip becomes a third colour belonging to
795 + * neither the chrome above it nor the page below, which is the seam
796 + * this rule removes.
797 + *
798 + * The chain ends at `--os-tabs-bg`, so a theme that names only the one
799 + * strip colour keeps it in both states.
800 + */
801 +.os-window:not( .os-window--focused ) .os-window__tabs {
802 + background-color: var(
803 + --os-tabs-bg-unfocused,
804 + var( --os-tabs-bg, var( --os-ui-surface-elevated, #f6f7f7 ) )
805 + );
806 +}
807 +
808 +/*
809 + * The same strip on a NATIVE window, which is the same strip: one
810 + * stylesheet, whatever is behind the window. What changes is only
811 + * what the active tab is wearing, and it changes in tokens.
812 + *
813 + * The rule the whole design rests on is that the active tab wears the
814 + * page's own fill and is filleted into it, so tab and content read as
815 + * one surface. For an iframe window that fill is `#fff`, because
816 + * `chromeless.css` paints every admin page inside a window white
817 + * whatever the colour scheme says. A native window's page is its
818 + * body, and `--os-window-bg` is what paints it — so that is the fill
819 + * here, and the two move together by construction.
820 + *
821 + * The frost and the crown go with the white. They are what makes a
822 + * bright plate read as a surface lifting out of dark chrome; on a
823 + * native window the tab and the track are the same colour when the
824 + * window is focused, and a crown on a tab you cannot see the edges of
825 + * is decoration with nothing to decorate. The rail carries the
826 + * silhouette on its own, which is the point: a native window has no
827 + * value step to wear, so the line IS the tab.
828 + *
829 + * The label follows the fill. `--os-tabs-active-color` names the text
830 + * on a white plate and resolves to near-black, which would be
831 + * invisible here.
832 + */
833 +.os-window--native {
834 + --os-tabs-active-bg: var( --os-window-bg, #fff );
835 + --os-tabs-active-color: var( --os-ui-fg, #1d2327 );
836 + --os-tabs-active-color-muted: var( --os-ui-fg-muted, #50575e );
837 + --os-tabs-active-frost: none;
838 + --os-tabs-active-crown: none;
839 +}
840 +
841 +/*
842 + * Windows with no submenu still get a strip — `createWindowElement()`
843 + * appends it unconditionally so `addExternalTab()` has somewhere to
844 + * put a tab later. Empty, it must take no room at all: with vertical
845 + * padding on the track, an empty strip would otherwise paint a bare
846 + * band of track colour under every submenu-less window's title bar.
847 + *
848 + * `:has()` rather than `:empty`, because the strip is never empty any
849 + * more — it always carries the plate. What makes a strip vacant is
850 + * having no TABS in it.
851 + */
852 +.os-window__tabs:not( :has( .os-window__tab ) ) {
853 + display: none;
854 +}
855 +
856 +/* ---------------------------------------------------------------
857 + * The plate — the active tab's surface.
858 + *
859 + * It is one element that TRAVELS between tabs rather than a fill
860 + * that switches off on one tab and on at the next. That distinction
861 + * is the whole reason it exists: a fill that switches has to cross-
862 + * fade a dark tab into a light one, and every frame in between is a
863 + * muddy grey that belongs to neither. Nothing crossfades here. The
864 + * surface simply moves, and the labels change colour underneath it.
865 + *
866 + * `tabs.ts` publishes the target geometry as `--_tab-plate-x` and
867 + * `--_tab-plate-w` on this element; everything else is CSS.
868 + * --------------------------------------------------------------- */
869 +.os-window__tab-plate {
870 + position: absolute;
871 + bottom: 0;
872 + left: 0;
873 + height: var( --_tab-h );
874 + width: var( --_tab-plate-w, 0 );
875 + transform: translateX( var( --_tab-plate-x, 0 ) );
876 + pointer-events: none;
877 + transition:
878 + transform var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) ),
879 + width var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) ),
880 + opacity 0.15s ease;
881 +}
882 +
883 +/*
884 + * Until the first measurement lands, the plate has no business
885 + * animating — it would slide in from the strip's left edge every
886 + * time a window opens.
887 + */
888 +.os-window__tab-plate:not( [ data-placed ] ) {
889 + transition: none;
890 +}
891 +
892 +/*
893 + * The frosted face.
894 + *
895 + * The tint is TOP-WEIGHTED and reaches zero before the bottom of the
896 + * plate, which is the load-bearing part: the joint below is the
897 + * page's own colour, so anything still tinted down there draws a
898 + * seam across the one edge this design exists to erase. Keep the
899 + * gradient's last stop fully transparent and keep it above ~75%.
900 + *
901 + * The fallback is a flat `--os-tabs-active-bg`, so a stylesheet that
902 + * loses the palette gets the plain white tab rather than nothing.
903 + */
904 +.os-window__tab-plate-fill {
905 + position: absolute;
906 + /*
907 + * Overshoots the track's floor by one radius, and is then clipped
908 + * by the strip's own `overflow`. Ending the face exactly ON the
909 + * floor puts a paint boundary on the one edge that has to be
910 + * invisible; pushing it past and cutting it means there is no edge
911 + * there to resolve at all.
912 + */
913 + inset: 0 0 calc( var( --_tab-radius ) * -1 ) 0;
914 + overflow: hidden;
915 + border-radius: var( --_tab-radius ) var( --_tab-radius ) 0 0;
916 + /*
917 + * OPAQUE, and deliberately so — no `backdrop-filter`, no alpha.
918 + *
919 + * A translucent element with a backdrop filter is promoted to its
920 + * own compositor layer, and the edge of that layer is a clip the
921 + * compositor resolves against whatever is behind it. That leaves a
922 + * faint grey hairline down the plate's sides and across its
923 + * bottom. Everywhere else that would be a nuisance; here it draws
924 + * a second, dimmer line a few pixels inside the real one and flatly
925 + * contradicts what this design is claiming. The gradient below
926 + * reproduces the frosted crown by eye with no alpha at all.
927 + *
928 + * `background-size` pins the gradient to the tab's true height so
929 + * the overshoot underneath stays flat page-white.
930 + */
931 + background-color: var( --os-tabs-active-bg, #fff );
932 + background-image: var( --os-tabs-active-frost, none );
933 + background-size: 100% var( --_tab-h );
934 + background-repeat: no-repeat;
935 +}
936 +
937 +/*
938 + * The crown — Holomesh over the top of the plate, masked away before
939 + * the joint. Holomesh is nine stacked gradients, so it cannot be
940 + * faded by adding a colour stop; it needs its own layer and a mask.
941 + *
942 + * It is on exactly one tab at a time. That is what keeps the mesh an
943 + * identity moment rather than wallpaper, and it is the same rule the
944 + * rest of the kit follows (see `src/ui/holo.ts`).
945 + */
946 +.os-window__tab-plate-fill::before {
947 + content: '';
948 + position: absolute;
949 + inset: 0;
950 + background-image: var( --os-tabs-active-crown, none );
951 + background-size: 210% 210%;
952 + background-position: 20% 26%;
953 + opacity: var( --os-tabs-active-crown-opacity, 0.5 );
954 + -webkit-mask-image: linear-gradient(
955 + 180deg,
956 + #000 0%,
957 + rgba( 0, 0, 0, 0.55 ) 34%,
958 + transparent 66%
959 + );
960 + mask-image: linear-gradient(
961 + 180deg,
962 + #000 0%,
963 + rgba( 0, 0, 0, 0.55 ) 34%,
964 + transparent 66%
965 + );
966 + animation: os-tab-crown-drift 16s ease-in-out infinite alternate;
967 +}
968 +
969 +@keyframes os-tab-crown-drift {
970 + from { background-position: 20% 26%; }
971 + to { background-position: 72% 62%; }
972 +}
973 +
974 +/*
975 + * The joint: two concave quarter-circles carrying the plate's fill
976 + * out to the floor of the track. Without them the plate is a rounded
977 + * rectangle NEAR the page; with them it is attached to it.
978 + *
979 + * Its own element rather than a pseudo on the face, so the face can
980 + * clip its crown (`overflow: hidden`) without clipping the curve
981 + * that does the attaching. Pure `--os-tabs-active-bg` — see the note
982 + * on the tint above.
983 + */
984 +.os-window__tab-plate-joint {
985 + position: absolute;
986 + bottom: 0;
987 + left: calc( var( --_tab-radius ) * -1 );
988 + right: calc( var( --_tab-radius ) * -1 );
989 + height: var( --_tab-radius );
990 + background:
991 + radial-gradient(
992 + circle at 0 0,
993 + transparent var( --_tab-radius ),
994 + var( --os-tabs-active-bg, #fff ) var( --_tab-radius )
995 + ) left bottom / var( --_tab-radius ) var( --_tab-radius ) no-repeat,
996 + radial-gradient(
997 + circle at 100% 0,
998 + transparent var( --_tab-radius ),
999 + var( --os-tabs-active-bg, #fff ) var( --_tab-radius )
1000 + ) right bottom / var( --_tab-radius ) var( --_tab-radius ) no-repeat;
1001 +}
1002 +
1003 +/* ---------------------------------------------------------------
1004 + * The rail — one continuous line around the whole silhouette.
1005 + *
1006 + * It runs the top edge of the page, curves up through the fillet,
1007 + * traces the tab and comes back down. What it outlines is therefore
1008 + * page-plus-tab as ONE shape, which is the same claim the joint makes
1009 + * by omission, said out loud.
1010 + *
1011 + * Two rules govern every number below.
1012 + *
1013 + * 1. **The line lives on the DARK side of the boundary, everywhere.**
1014 + * It hugs the white shape from outside and never paints on it. Get
1015 + * this wrong on any one segment and the line steps sideways by its
1016 + * own width at the tangent point where that segment meets the next
1017 + * — which is exactly what a stroke that does not follow the shape
1018 + * looks like. The convex tab corners are therefore annulus R→R+s
1019 + * (outside the plate) while the concave fillets are R−s→R (inside
1020 + * the fillet circle, whose white lies OUTSIDE it). Those look like
1021 + * opposite conventions and are the same one.
1022 + *
1023 + * 2. **Every piece samples one mesh laid across the whole strip.**
1024 + * `--_tab-strip-w` is why. Give the rail and the ring their own
1025 + * backgrounds and you get two unrelated gradients meeting at a
1026 + * visible join in the middle of the fillet.
1027 + *
1028 + * CSS cannot stroke a path, so the mesh is painted as a background and
1029 + * a mask cuts it to the outline. Seven layers, one per segment, all
1030 + * positioned off the box edges so the whole thing survives the plate
1031 + * changing width mid-slide with no JS.
1032 + * --------------------------------------------------------------- */
1033 +.os-window__tabs::before {
1034 + content: '';
1035 + position: absolute;
1036 + left: 0;
1037 + right: 0;
1038 + bottom: 0;
1039 + height: var( --_tab-stroke );
1040 + background-image: var( --os-tabs-rail, none );
1041 + /* The rail's colour is derived from the accent; how much of it
1042 + shows is a literal the palette answers. 1 is the earlier look. */
1043 + opacity: var( --os-tabs-rail-opacity, 1 );
1044 + background-size: var( --_tab-strip-w, 100% ) 100%;
1045 + background-repeat: no-repeat;
1046 + pointer-events: none;
1047 + /*
1048 + * Two segments, stopping where each fillet begins. Run the rail
1049 + * straight through and it draws a chord across the concave curve.
1050 + */
1051 + -webkit-mask-image: linear-gradient( #000 0 0 ), linear-gradient( #000 0 0 );
1052 + mask-image: linear-gradient( #000 0 0 ), linear-gradient( #000 0 0 );
1053 + /*
1054 + * Each segment runs one `--_tab-seam` PAST where its fillet begins,
1055 + * so the rail overlaps the arc rather than meeting it exactly —
1056 + * the same anti-seam allowance the ring uses, for the same reason.
1057 + */
1058 + -webkit-mask-size:
1059 + calc(
1060 + var( --_tab-plate-x, 0px ) - var( --_tab-radius ) + var( --_tab-seam )
1061 + ) 100%,
1062 + calc(
1063 + 100% - var( --_tab-plate-x, 0px ) - var( --_tab-plate-w, 0px ) -
1064 + var( --_tab-radius ) + var( --_tab-seam )
1065 + ) 100%;
1066 + mask-size:
1067 + calc(
1068 + var( --_tab-plate-x, 0px ) - var( --_tab-radius ) + var( --_tab-seam )
1069 + ) 100%,
1070 + calc(
1071 + 100% - var( --_tab-plate-x, 0px ) - var( --_tab-plate-w, 0px ) -
1072 + var( --_tab-radius ) + var( --_tab-seam )
1073 + ) 100%;
1074 + -webkit-mask-position: left top, right top;
1075 + mask-position: left top, right top;
1076 + -webkit-mask-repeat: no-repeat;
1077 + mask-repeat: no-repeat;
1078 + transition:
1079 + -webkit-mask-size var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) ),
1080 + mask-size var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) );
1081 +}
1082 +
1083 +/* With no active tab there is no gap to leave, so the rail is whole. */
1084 +.os-window__tabs[ data-tab-plate-empty ]::before {
1085 + -webkit-mask-image: none;
1086 + mask-image: none;
1087 +}
1088 +
1089 +/*
1090 + * The tab's half of the line. The box is the plate grown by one
1091 + * stroke upward and by radius-plus-stroke on each side, so the top
1092 + * edge, both corners and both fillet arcs all have room to sit
1093 + * OUTSIDE the white.
1094 + */
1095 +.os-window__tab-plate::after {
1096 + --_ring-arc-out: radial-gradient(
1097 + circle at 0 0,
1098 + transparent calc( var( --_tab-radius ) - var( --_tab-stroke ) ),
1099 + #000 calc( var( --_tab-radius ) - var( --_tab-stroke ) ),
1100 + #000 var( --_tab-radius ),
1101 + transparent var( --_tab-radius )
1102 + );
1103 + --_ring-arc-out-r: radial-gradient(
1104 + circle at 100% 0,
1105 + transparent calc( var( --_tab-radius ) - var( --_tab-stroke ) ),
1106 + #000 calc( var( --_tab-radius ) - var( --_tab-stroke ) ),
1107 + #000 var( --_tab-radius ),
1108 + transparent var( --_tab-radius )
1109 + );
1110 + --_ring-corner-l: radial-gradient(
1111 + circle at 100% 100%,
1112 + transparent var( --_tab-radius ),
1113 + #000 var( --_tab-radius ),
1114 + #000 calc( var( --_tab-radius ) + var( --_tab-stroke ) ),
1115 + transparent calc( var( --_tab-radius ) + var( --_tab-stroke ) )
1116 + );
1117 + --_ring-corner-r: radial-gradient(
1118 + circle at 0 100%,
1119 + transparent var( --_tab-radius ),
1120 + #000 var( --_tab-radius ),
1121 + #000 calc( var( --_tab-radius ) + var( --_tab-stroke ) ),
1122 + transparent calc( var( --_tab-radius ) + var( --_tab-stroke ) )
1123 + );
1124 + --_ring-bar: linear-gradient( #000 0 0 );
1125 +
1126 + content: '';
1127 + position: absolute;
1128 + inset:
1129 + calc( var( --_tab-stroke ) * -1 )
1130 + calc( ( var( --_tab-radius ) + var( --_tab-stroke ) ) * -1 )
1131 + 0;
1132 + background-image: var( --os-tabs-rail, none );
1133 + /* The rail's colour is derived from the accent; how much of it
1134 + shows is a literal the palette answers. 1 is the earlier look. */
1135 + opacity: var( --os-tabs-rail-opacity, 1 );
1136 + background-size: var( --_tab-strip-w, 100% ) 100%;
1137 + background-position:
1138 + calc(
1139 + var( --_tab-radius ) + var( --_tab-stroke ) -
1140 + var( --_tab-plate-x, 0px )
1141 + )
1142 + 0;
1143 + background-repeat: no-repeat;
1144 + pointer-events: none;
1145 +
1146 + -webkit-mask-image:
1147 + var( --_ring-bar ), var( --_ring-corner-l ), var( --_ring-corner-r ),
1148 + var( --_ring-bar ), var( --_ring-bar ),
1149 + var( --_ring-arc-out ), var( --_ring-arc-out-r );
1150 + mask-image:
1151 + var( --_ring-bar ), var( --_ring-corner-l ), var( --_ring-corner-r ),
1152 + var( --_ring-bar ), var( --_ring-bar ),
1153 + var( --_ring-arc-out ), var( --_ring-arc-out-r );
1154 + /*
1155 + * The three straight runs are each grown by `--_tab-seam` at BOTH
1156 + * ends, and shifted back by the same amount, so every one of them
1157 + * overlaps the arc it hands over to instead of meeting it exactly.
1158 + *
1159 + * Two layers that abut on a shared boundary each contribute a
1160 + * partial, antialiased alpha there, and the sum can fall short of
1161 + * 1 — which prints as a faint hairline across the stroke at all
1162 + * six tangent points. Overlapping cannot go wrong in the other
1163 + * direction: mask alpha is clamped, so doubling it is still opaque
1164 + * and the seam simply stops existing.
1165 + *
1166 + * The overlap stays on the dark side of the curve at every one of
1167 + * those points, so it never bleeds onto the white.
1168 + */
1169 + -webkit-mask-size:
1170 + calc( 100% - 4 * var( --_tab-radius ) - 2 * var( --_tab-stroke ) + 2 * var( --_tab-seam ) ) var( --_tab-stroke ),
1171 + calc( var( --_tab-radius ) + var( --_tab-stroke ) ) calc( var( --_tab-radius ) + var( --_tab-stroke ) ),
1172 + calc( var( --_tab-radius ) + var( --_tab-stroke ) ) calc( var( --_tab-radius ) + var( --_tab-stroke ) ),
1173 + var( --_tab-stroke ) calc( 100% - 2 * var( --_tab-radius ) - var( --_tab-stroke ) + 2 * var( --_tab-seam ) ),
1174 + var( --_tab-stroke ) calc( 100% - 2 * var( --_tab-radius ) - var( --_tab-stroke ) + 2 * var( --_tab-seam ) ),
1175 + var( --_tab-radius ) var( --_tab-radius ),
1176 + var( --_tab-radius ) var( --_tab-radius );
1177 + mask-size:
1178 + calc( 100% - 4 * var( --_tab-radius ) - 2 * var( --_tab-stroke ) + 2 * var( --_tab-seam ) ) var( --_tab-stroke ),
1179 + calc( var( --_tab-radius ) + var( --_tab-stroke ) ) calc( var( --_tab-radius ) + var( --_tab-stroke ) ),
1180 + calc( var( --_tab-radius ) + var( --_tab-stroke ) ) calc( var( --_tab-radius ) + var( --_tab-stroke ) ),
1181 + var( --_tab-stroke ) calc( 100% - 2 * var( --_tab-radius ) - var( --_tab-stroke ) + 2 * var( --_tab-seam ) ),
1182 + var( --_tab-stroke ) calc( 100% - 2 * var( --_tab-radius ) - var( --_tab-stroke ) + 2 * var( --_tab-seam ) ),
1183 + var( --_tab-radius ) var( --_tab-radius ),
1184 + var( --_tab-radius ) var( --_tab-radius );
1185 + /*
1186 + * Four-value syntax throughout, and it is load-bearing. A
1187 + * percentage in `mask-position` resolves against the container
1188 + * MINUS the layer's own size, so `calc(100% - 8px)` does not mean
1189 + * "8px from the right edge" — it means "right-aligned, then pushed
1190 + * left by 8px PLUS the layer's width". Every right-hand segment
1191 + * lands a radius too far left that way. `right <offset>` is
1192 + * measured from the edge and does not care how wide the layer is.
1193 + */
1194 + -webkit-mask-position:
1195 + left calc( 2 * var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ) top 0,
1196 + left var( --_tab-radius ) top 0,
1197 + right var( --_tab-radius ) top 0,
1198 + left var( --_tab-radius ) top calc( var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ),
1199 + right var( --_tab-radius ) top calc( var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ),
1200 + left var( --_tab-stroke ) bottom 0,
1201 + right var( --_tab-stroke ) bottom 0;
1202 + mask-position:
1203 + left calc( 2 * var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ) top 0,
1204 + left var( --_tab-radius ) top 0,
1205 + right var( --_tab-radius ) top 0,
1206 + left var( --_tab-radius ) top calc( var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ),
1207 + right var( --_tab-radius ) top calc( var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ),
1208 + left var( --_tab-stroke ) bottom 0,
1209 + right var( --_tab-stroke ) bottom 0;
1210 + -webkit-mask-repeat: no-repeat;
1211 + mask-repeat: no-repeat;
1212 +
1213 + /* Travels with the plate, so the mesh stays locked to the strip
1214 + * for the whole slide instead of snapping on the first frame. */
1215 + transition: background-position var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) );
1216 +}
1217 +
1218 +/*
1219 + * No active tab to sit under — `syncActiveTab` can land on a URL that
1220 + * matches nothing, and an external sub-tab deactivates every submenu
1221 + * tab. The plate keeps its last geometry and fades, so re-activating
1222 + * a tab does not read as the plate flying in from nowhere.
1223 + */
1224 +.os-window__tab-plate[ data-empty ] {
1225 + opacity: 0;
1226 +}
1227 +
1228 +@media ( prefers-reduced-motion: reduce ) {
1229 + .os-window__tab-plate,
1230 + .os-window__tab-plate::after,
1231 + .os-window__tabs::before {
1232 + transition-duration: 1ms;
1233 + }
1234 + .os-window__tab-plate-fill::before {
1235 + animation: none;
1236 + }
1237 +}
1238 +
1239 +/*
657 1240 * Soft edge fades so overflowing tabs tell the user "scroll for more."
658 1241 *
659 1242 * Each fade is painted ONLY on an edge that is actually hiding a tab.
660 1243 * `observeTabOverflow()` in `src/window/tabs.ts` stamps `data-overflow`
@@ -708,14 +1291,16 @@
708 1291 );
709 1292 }
710 1293
711 1294 .os-window__tab {
1295 + position: relative;
712 1296 flex-shrink: 0;
713 1297 display: inline-flex;
714 1298 align-items: center;
715 - height: 32px;
716 - padding: 0 12px;
1299 + height: 30px;
1300 + padding: 0 14px;
717 1301 border: none;
1302 + border-radius: var( --_tab-radius ) var( --_tab-radius ) 0 0;
718 1303 background: transparent;
719 1304 color: var( --os-tabs-color, var( --os-ui-fg-muted, #50575e ) );
720 1305 font: inherit;
721 1306 font-size: 12px;
@@ -720,12 +1305,10 @@
720 1305 font: inherit;
721 1306 font-size: 12px;
722 1307 line-height: 1;
723 1308 cursor: pointer;
724 - border-bottom: 2px solid transparent;
725 - margin-bottom: -1px;
726 1309 white-space: nowrap;
727 - transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
1310 + transition: color 0.15s ease, background-color 0.15s ease;
728 1311 }
729 1312
730 1313 .os-window__tab:hover {
731 1314 color: var(--wp-admin-theme-color, #2271b1);
@@ -736,12 +1319,41 @@
736 1319 outline: 2px solid var(--wp-admin-theme-color, #2271b1);
737 1320 outline-offset: -2px;
738 1321 }
739 1322
1323 +/*
1324 + * The active tab. It paints NO surface of its own — the plate does
1325 + * that, and the plate is a sibling that slides. All this rule owns is
1326 + * the label.
1327 + *
1328 + * No accent anywhere on it either. The shape already says "this one",
1329 + * and the accent is spent: one Pulse divider in the dock, one focus
1330 + * ring. A tab that is both a distinct surface AND coloured is saying
1331 + * the same thing twice.
1332 + */
740 1333 .os-window__tab--active {
741 - color: var(--wp-admin-theme-color, #2271b1);
742 - border-bottom-color: var(--wp-admin-theme-color, #2271b1);
1334 + color: var( --os-tabs-active-color, #1d2327 );
743 1335 font-weight: 600;
1336 + /*
1337 + * Anything nested in an active tab (the external tab's detach and
1338 + * close chips) is now sitting on a light fill inside dark chrome,
1339 + * so the two tones it reads have to flip with the tab. Both are
1340 + * re-pointed through names the palette owns rather than hardcoded
1341 + * here, so a desktop theme retints the chips with the tab.
1342 + */
1343 + --os-ui-fg-muted: var( --os-tabs-active-color-muted, rgba(29, 35, 39, 0.6) );
1344 + --os-ui-hover: color-mix(
1345 + in srgb,
1346 + var( --os-tabs-active-color, #1d2327 ) 8%,
1347 + transparent
1348 + );
1349 +}
1350 +
1351 +/* An active tab has the plate under it; a hover wash on top of that
1352 + * would double-paint the surface. */
1353 +.os-window__tab--active:hover {
1354 + background: transparent;
1355 + color: var( --os-tabs-active-color, #1d2327 );
744 1356 }
745 1357
746 1358 /*
747 1359 * External sub-tab. Same shape as a submenu tab, plus two inline