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