| @@ -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 |