| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 2 | - * Desktop Mode — Window chrome. | |
| 2 | + * OpenStation — Window chrome. | |
| 3 | 3 | * |
| 4 | 4 | * Everything that renders the base window frame: title bar, icon + |
| 5 | 5 | * title text, control buttons, focused / unfocused colour variants, |
| 6 | 6 | * the ⋯ actions menu panel, screen-meta buttons, the tab strip (for |
| @@ -12,9 +12,9 @@ | ||
| 12 | 12 | * @since 6.9.0 |
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | 15 | /* Base window. */ |
| 16 | -.desktop-mode-window { | |
| 16 | +.os-window { | |
| 17 | 17 | /* |
| 18 | 18 | * border-box sizing is essential for pixel-perfect maximize. |
| 19 | 19 | * `Window.maximize()` sets inline `width = parent.clientWidth`, |
| 20 | 20 | * which is the area's inner width. Under content-box sizing, the |
| @@ -19,9 +19,9 @@ | ||
| 19 | 19 | * `Window.maximize()` sets inline `width = parent.clientWidth`, |
| 20 | 20 | * which is the area's inner width. Under content-box sizing, the |
| 21 | 21 | * window's 1px border would add 2 extra pixels to the rendered |
| 22 | 22 | * element, pushing its right + bottom borders past the |
| 23 | - * overflow-hidden boundary of `.desktop-mode-area` and making them | |
| 23 | + * overflow-hidden boundary of `.os-area` and making them | |
| 24 | 24 | * look clipped/offscreen. With border-box, width/height INCLUDE |
| 25 | 25 | * the border, so `width = clientWidth` fits exactly. |
| 26 | 26 | * |
| 27 | 27 | * WP admin CSS does set `*, *::before, *::after { box-sizing: |
| @@ -33,12 +33,30 @@ | ||
| 33 | 33 | box-sizing: border-box; |
| 34 | 34 | position: absolute; |
| 35 | 35 | display: flex; |
| 36 | 36 | flex-direction: column; |
| 37 | - background: var(--desktop-mode-window-bg); | |
| 38 | - border: 1px solid var(--desktop-mode-window-border); | |
| 39 | - border-radius: var(--desktop-mode-window-radius); | |
| 40 | - box-shadow: var(--desktop-mode-window-shadow); | |
| 37 | + background: var(--os-window-bg); | |
| 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); | |
| 44 | + border-radius: var(--os-window-radius); | |
| 45 | + box-shadow: var(--os-window-shadow); | |
| 46 | + /* | |
| 47 | + * Desktop-theme window frame (WINDOW_FRAME texture slot). All | |
| 48 | + * four properties default to their CSS initial value via the | |
| 49 | + * `var( …, fallback )` second argument, so with no theme active | |
| 50 | + * this costs nothing and the 1px border above stays in charge. | |
| 51 | + * A theme that sets `--os-window-border-image-source` | |
| 52 | + * takes the frame over entirely — border-image paints on top of | |
| 53 | + * (and visually replaces) the border. See docs/desktop-themes.md. | |
| 54 | + */ | |
| 55 | + border-image-source: var(--os-window-border-image-source, none); | |
| 56 | + border-image-slice: var(--os-window-border-image-slice, 100%); | |
| 57 | + border-image-width: var(--os-window-border-image-width, 1); | |
| 58 | + border-image-repeat: var(--os-window-border-image-repeat, stretch); | |
| 41 | 59 | overflow: hidden; |
| 42 | 60 | min-width: 320px; |
| 43 | 61 | min-height: 200px; |
| 44 | 62 | transform-origin: center center; |
| @@ -55,9 +73,9 @@ | ||
| 55 | 73 | * `filter`; keep it in the shared transition list so the |
| 56 | 74 | * treatment fades both ways as focus moves between windows. |
| 57 | 75 | * Duration is a custom property (default in `effects.css`) so |
| 58 | 76 | * the blur/darken ramp can be tuned without editing this list. */ |
| 59 | - filter var( --desktop-mode-fx-transition-duration, 0.5s ) ease-in-out; | |
| 77 | + filter var( --os-fx-transition-duration, 0.5s ) ease-in-out; | |
| 60 | 78 | } |
| 61 | 79 | |
| 62 | 80 | /* |
| 63 | 81 | * Suppress the left/top/width/height transition during drag or resize — |
| @@ -69,11 +87,11 @@ | ||
| 69 | 87 | * transition, leaving maximized / snapped windows ~250 ms behind the |
| 70 | 88 | * viewport the whole time. The class is added by |
| 71 | 89 | * `reflowStatefulWindows` and cleared ~140 ms after the last tick. |
| 72 | 90 | */ |
| 73 | -.desktop-mode-window--dragging, | |
| 74 | -.desktop-mode-window--resizing, | |
| 75 | -.desktop-mode-window--reflowing { | |
| 91 | +.os-window--dragging, | |
| 92 | +.os-window--resizing, | |
| 93 | +.os-window--reflowing { | |
| 76 | 94 | transition: none; |
| 77 | 95 | } |
| 78 | 96 | |
| 79 | 97 | /* |
| @@ -84,9 +102,9 @@ | ||
| 84 | 102 | * `--resizing` and the source-order ensures it wins. Kept short |
| 85 | 103 | * (90 ms) so the window still feels glued to the cursor — anything |
| 86 | 104 | * longer reads as lag. |
| 87 | 105 | */ |
| 88 | -.desktop-mode-window--snap-drag { | |
| 106 | +.os-window--snap-drag { | |
| 89 | 107 | transition: |
| 90 | 108 | left 0.09s ease-out, |
| 91 | 109 | top 0.09s ease-out, |
| 92 | 110 | width 0.09s ease-out, |
| @@ -93,14 +111,37 @@ | ||
| 93 | 111 | height 0.09s ease-out; |
| 94 | 112 | } |
| 95 | 113 | |
| 96 | 114 | /* Focused window gets elevated shadow and distinct title bar. */ |
| 97 | -.desktop-mode-window--focused { | |
| 98 | - box-shadow: var(--desktop-mode-window-shadow-focused); | |
| 115 | +.os-window--focused { | |
| 116 | + box-shadow: var(--os-window-shadow-focused); | |
| 117 | + /* | |
| 118 | + * WINDOW_FRAME_FOCUSED texture slot. Each property falls through | |
| 119 | + * the unfocused WINDOW_FRAME value before reaching its initial, so | |
| 120 | + * a theme shipping one frame gets it on both states and a theme | |
| 121 | + * shipping two gets a frame that lights up on focus — the same | |
| 122 | + * fallback chain TITLEBAR_FOCUSED uses. | |
| 123 | + */ | |
| 124 | + border-image-source: var( | |
| 125 | + --os-window-border-image-focused-source, | |
| 126 | + var(--os-window-border-image-source, none) | |
| 127 | + ); | |
| 128 | + border-image-slice: var( | |
| 129 | + --os-window-border-image-focused-slice, | |
| 130 | + var(--os-window-border-image-slice, 100%) | |
| 131 | + ); | |
| 132 | + border-image-width: var( | |
| 133 | + --os-window-border-image-focused-width, | |
| 134 | + var(--os-window-border-image-width, 1) | |
| 135 | + ); | |
| 136 | + border-image-repeat: var( | |
| 137 | + --os-window-border-image-focused-repeat, | |
| 138 | + var(--os-window-border-image-repeat, stretch) | |
| 139 | + ); | |
| 99 | 140 | } |
| 100 | 141 | |
| 101 | 142 | /* Title bar. */ |
| 102 | -.desktop-mode-window__titlebar { | |
| 143 | +.os-window__titlebar { | |
| 103 | 144 | position: relative; |
| 104 | 145 | /* Promote the titlebar into its own stacking context above the |
| 105 | 146 | * body. Without this, the body (which comes later in source order |
| 106 | 147 | * and inherits z-auto) wins document-order overlap battles — |
| @@ -109,25 +150,66 @@ | ||
| 109 | 150 | * paint over the titlebar's ⋯ menu popover. */ |
| 110 | 151 | z-index: 21; |
| 111 | 152 | display: flex; |
| 112 | 153 | align-items: center; |
| 113 | - height: var(--desktop-mode-titlebar-height); | |
| 114 | - padding: 0 8px; | |
| 115 | - background: var(--desktop-mode-titlebar-bg); | |
| 116 | - color: var(--desktop-mode-titlebar-color); | |
| 154 | + height: var(--os-titlebar-height); | |
| 155 | + padding: var(--os-titlebar-padding, 0 8px); | |
| 156 | + background-color: var(--os-titlebar-bg); | |
| 157 | + /* | |
| 158 | + * Desktop-theme title-bar texture (TITLEBAR slot). `none` when | |
| 159 | + * unset, so the background-color above is the whole story for | |
| 160 | + * an unthemed shell. The `-repeat` / `-size` companions are | |
| 161 | + * declared unconditionally with their CSS initial values as | |
| 162 | + * fallbacks — cheaper than a second selector and it keeps the | |
| 163 | + * shorthand from resetting them. | |
| 164 | + */ | |
| 165 | + background-image: var(--os-titlebar-image, none); | |
| 166 | + background-repeat: var(--os-titlebar-image-repeat, repeat); | |
| 167 | + background-size: var(--os-titlebar-image-size, auto); | |
| 168 | + background-position: var(--os-titlebar-image-position, center); | |
| 169 | + color: var(--os-titlebar-color); | |
| 170 | + /* | |
| 171 | + * Title bars can carry their own face — a display typeface here | |
| 172 | + * with a text face in the body is the classic desktop split. Two | |
| 173 | + * levels of fallback: the title-bar token, then the shell-wide | |
| 174 | + * one, then `inherit`, which is what an undeclared font-family | |
| 175 | + * would have computed to anyway. Unthemed shells are unchanged. | |
| 176 | + */ | |
| 177 | + font-family: var( | |
| 178 | + --os-titlebar-font, | |
| 179 | + var(--os-font, inherit) | |
| 180 | + ); | |
| 117 | 181 | cursor: default; |
| 118 | 182 | user-select: none; |
| 119 | 183 | flex-shrink: 0; |
| 120 | - 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 )); | |
| 121 | 194 | } |
| 122 | 195 | |
| 123 | -.desktop-mode-window--focused .desktop-mode-window__titlebar { | |
| 124 | - background: var(--desktop-mode-titlebar-bg-focused); | |
| 125 | - color: var(--desktop-mode-titlebar-color-focused); | |
| 196 | +.os-window--focused .os-window__titlebar { | |
| 197 | + background-color: var(--os-titlebar-bg-focused); | |
| 198 | + /* | |
| 199 | + * TITLEBAR_FOCUSED slot. Falls back through the unfocused | |
| 200 | + * TITLEBAR image before reaching `none`, so a theme that ships | |
| 201 | + * only one title-bar texture gets it on both states for free. | |
| 202 | + */ | |
| 203 | + background-image: var( | |
| 204 | + --os-titlebar-image-focused, | |
| 205 | + var(--os-titlebar-image, none) | |
| 206 | + ); | |
| 207 | + color: var(--os-titlebar-color-focused); | |
| 126 | 208 | } |
| 127 | 209 | |
| 128 | 210 | /* Window icon in title bar. */ |
| 129 | -.desktop-mode-window__icon { | |
| 211 | +.os-window__icon { | |
| 130 | 212 | font-size: 18px; |
| 131 | 213 | width: 18px; |
| 132 | 214 | height: 18px; |
| 133 | 215 | flex-shrink: 0; |
| @@ -132,17 +214,104 @@ | ||
| 132 | 214 | height: 18px; |
| 133 | 215 | flex-shrink: 0; |
| 134 | 216 | } |
| 135 | 217 | |
| 136 | -/* Activity indicator slot — sits between the icon and the title. | |
| 137 | - * Reserves a fixed width so the indicator's blink animation can't | |
| 138 | - * shift the title text horizontally. The inner `<wpd-save-status>` | |
| 139 | - * paints a 12px modem-style dot (always visible, accent-colored). | |
| 218 | +/* Letter-badge fallback (unrecognized icon values) — shrink the | |
| 219 | + * one-/two-letter monogram so it fits the 18px icon box. */ | |
| 220 | +.os-window__icon.os-icon-letter { | |
| 221 | + font-size: 9px; | |
| 222 | +} | |
| 223 | + | |
| 224 | +/* --------------------------------------------------------------- | |
| 225 | + * Window activity — the status ring. | |
| 140 | 226 | * |
| 141 | - * `--wp-admin-theme-color` is forwarded as `color` on the host so | |
| 142 | - * the inner shadow-DOM stylesheet's `currentColor` references | |
| 143 | - * (used in the box-shadow glow) resolve to the live accent. */ | |
| 144 | -.desktop-mode-window__activity { | |
| 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 | + */ | |
| 313 | +.os-window__activity { | |
| 145 | 314 | display: inline-flex; |
| 146 | 315 | align-items: center; |
| 147 | 316 | justify-content: center; |
| 148 | 317 | width: 14px; |
| @@ -152,28 +321,120 @@ | ||
| 152 | 321 | margin-inline-end: 4px; |
| 153 | 322 | color: var(--wp-admin-theme-color, #2271b1); |
| 154 | 323 | } |
| 155 | 324 | |
| 156 | -/* Window title text. */ | |
| 157 | -.desktop-mode-window__title { | |
| 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 | + */ | |
| 336 | +.os-window__title { | |
| 158 | 337 | flex: 1; |
| 159 | 338 | overflow: hidden; |
| 160 | 339 | text-overflow: ellipsis; |
| 161 | 340 | white-space: nowrap; |
| 162 | - font-size: 14px; | |
| 163 | - font-weight: 500; | |
| 164 | - line-height: var(--desktop-mode-titlebar-height); | |
| 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); | |
| 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); | |
| 165 | 352 | } |
| 166 | 353 | |
| 167 | -/* Window control buttons container. */ | |
| 168 | -.desktop-mode-window__controls { | |
| 354 | +/* | |
| 355 | + * Window control buttons container. | |
| 356 | + * | |
| 357 | + * The cluster is TRANSPARENT by default, which is the whole point: a | |
| 358 | + * themed title-bar texture runs edge to edge underneath it, and the | |
| 359 | + * buttons float on the artwork rather than sitting on a plate. Every | |
| 360 | + * property below resolves to exactly that when unset. | |
| 361 | + * | |
| 362 | + * A theme that wants a plate instead sets `--os-titlebar- | |
| 363 | + * controls-bg` (and optionally the TITLEBAR_CONTROLS texture slot, | |
| 364 | + * a radius, and some inline padding) — the classic "the controls live | |
| 365 | + * in their own well" look, without the framework picking it for | |
| 366 | + * everyone. | |
| 367 | + * | |
| 368 | + * `padding-block` stays 0 so the cluster never changes the title | |
| 369 | + * bar's height; only the inline padding is themable. | |
| 370 | + * | |
| 371 | + * @since 0.9.8 | |
| 372 | + */ | |
| 373 | +.os-window__controls { | |
| 169 | 374 | display: flex; |
| 170 | - gap: 4px; | |
| 375 | + /* `-1` puts the cluster at the leading edge of the bar. */ | |
| 376 | + order: var( --os-titlebar-controls-order, 0 ); | |
| 377 | + gap: var( --os-titlebar-controls-gap, 4px ); | |
| 171 | 378 | align-items: center; |
| 172 | 379 | flex-shrink: 0; |
| 380 | + padding-inline: var( --os-titlebar-controls-padding, 0 ); | |
| 381 | + border-radius: var( --os-titlebar-controls-radius, 0 ); | |
| 382 | + background-color: var( --os-titlebar-controls-bg, transparent ); | |
| 383 | + background-image: var( --os-titlebar-controls-image, none ); | |
| 384 | + background-repeat: var( --os-titlebar-controls-image-repeat, repeat ); | |
| 385 | + background-size: var( --os-titlebar-controls-image-size, auto ); | |
| 386 | + background-position: var( --os-titlebar-controls-image-position, center ); | |
| 173 | 387 | } |
| 174 | 388 | |
| 389 | +/* A window that asked for `appearance.controls.placement: 'left'`. */ | |
| 390 | +.os-window__controls--left { | |
| 391 | + order: -1; | |
| 392 | +} | |
| 393 | + | |
| 175 | 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 | +/* | |
| 419 | + * Same treatment for the Screen Options / Help cluster, so a theme | |
| 420 | + * that plates one can plate the other and keep them consistent. | |
| 421 | + * Falls through to the controls tokens, so setting the pair above is | |
| 422 | + * enough for both. | |
| 423 | + */ | |
| 424 | +.os-window__screen-meta { | |
| 425 | + border-radius: var( | |
| 426 | + --os-titlebar-meta-radius, | |
| 427 | + var( --os-titlebar-controls-radius, 0 ) | |
| 428 | + ); | |
| 429 | + background-color: var( --os-titlebar-meta-bg, transparent ); | |
| 430 | + background-image: var( --os-titlebar-meta-image, none ); | |
| 431 | + background-repeat: var( --os-titlebar-meta-image-repeat, repeat ); | |
| 432 | + background-size: var( --os-titlebar-meta-image-size, auto ); | |
| 433 | + background-position: var( --os-titlebar-meta-image-position, center ); | |
| 434 | +} | |
| 435 | + | |
| 436 | +/* | |
| 176 | 437 | * Layer-3 slot hosts. |
| 177 | 438 | * |
| 178 | 439 | * Most in-titlebar slots wrap canonical chrome elements without |
| 179 | 440 | * contributing to the flex layout themselves — `display: contents` |
| @@ -180,8 +441,13 @@ | ||
| 180 | 441 | * removes the wrapper's box so the inner element (icon span with |
| 181 | 442 | * `flex-shrink: 0`, before/after spacers, etc.) keeps its |
| 182 | 443 | * original layout role. |
| 183 | 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 | + * | |
| 184 | 450 | * The `title` slot is the exception: it's the flex-grow region of |
| 185 | 451 | * the title bar (`flex: 1`). When a plugin replaces the default |
| 186 | 452 | * title with custom HTML or a render callback, we want the new |
| 187 | 453 | * content to inherit the same "fill the remaining horizontal |
| @@ -189,23 +455,24 @@ | ||
| 189 | 455 | * default title element's own `flex: 1` cooperates inside the |
| 190 | 456 | * nested flex. |
| 191 | 457 | * |
| 192 | 458 | * before/after-titlebar slots are flex-column children of |
| 193 | - * `.desktop-mode-window` (siblings of the title bar). They're real | |
| 459 | + * `.os-window` (siblings of the title bar). They're real | |
| 194 | 460 | * boxes — plugins can paint backgrounds, padding, borders. Empty |
| 195 | 461 | * hosts collapse via `:empty` so they take no space until populated. |
| 196 | 462 | * |
| 197 | 463 | * @since 0.6.0 |
| 198 | 464 | */ |
| 199 | -.desktop-mode-window__slot--before-icon, | |
| 200 | -.desktop-mode-window__slot--icon, | |
| 201 | -.desktop-mode-window__slot--after-title, | |
| 202 | -.desktop-mode-window__slot--before-controls, | |
| 203 | -.desktop-mode-window__slot--after-controls { | |
| 465 | +.os-window__slot--before-icon, | |
| 466 | +.os-window__slot--icon, | |
| 467 | +.os-window__slot--after-title, | |
| 468 | +.os-window__slot--before-controls, | |
| 469 | +.os-window__slot--after-controls { | |
| 204 | 470 | display: contents; |
| 205 | 471 | } |
| 206 | 472 | |
| 207 | -.desktop-mode-window__slot--title { | |
| 473 | + | |
| 474 | +.os-window__slot--title { | |
| 208 | 475 | flex: 1; |
| 209 | 476 | min-width: 0; |
| 210 | 477 | display: flex; |
| 211 | 478 | align-items: center; |
| @@ -211,16 +478,16 @@ | ||
| 211 | 478 | align-items: center; |
| 212 | 479 | overflow: hidden; |
| 213 | 480 | } |
| 214 | 481 | |
| 215 | -.desktop-mode-window__slot--before-titlebar, | |
| 216 | -.desktop-mode-window__slot--after-titlebar { | |
| 482 | +.os-window__slot--before-titlebar, | |
| 483 | +.os-window__slot--after-titlebar { | |
| 217 | 484 | display: block; |
| 218 | 485 | flex-shrink: 0; |
| 219 | 486 | } |
| 220 | 487 | |
| 221 | -.desktop-mode-window__slot--before-titlebar:empty, | |
| 222 | -.desktop-mode-window__slot--after-titlebar:empty { | |
| 488 | +.os-window__slot--before-titlebar:empty, | |
| 489 | +.os-window__slot--after-titlebar:empty { | |
| 223 | 490 | display: none; |
| 224 | 491 | } |
| 225 | 492 | |
| 226 | 493 | /* --------------------------------------------------------------- |
| @@ -236,36 +503,98 @@ | ||
| 236 | 503 | * with, matching the WordPress admin's pattern of reserving color |
| 237 | 504 | * for semantic signal. |
| 238 | 505 | * --------------------------------------------------------------- */ |
| 239 | 506 | /* |
| 240 | - * Title-bar chrome buttons render as `<wpd-window-button>`. | |
| 507 | + * Title-bar chrome buttons render as `<os-window-button>`. | |
| 241 | 508 | * Shadow DOM can't reach across the window's focus class, so we |
| 242 | 509 | * drive the coloring via custom properties that inherit through |
| 243 | - * the boundary. The component reads `--wpd-btn-color`, | |
| 244 | - * `--wpd-btn-bg-hover`, etc. and paints accordingly. | |
| 510 | + * the boundary. The component reads `--os-ui-btn-color`, | |
| 511 | + * `--os-ui-btn-bg-hover`, etc. and paints accordingly. | |
| 512 | + * | |
| 513 | + * Focused control glyphs are white at 70% — correct against the | |
| 514 | + * default focused title bar, which is the admin theme colour and so | |
| 515 | + * always a mid-to-dark fill, and invisible against a pale one. A | |
| 516 | + * theme could not reach them: these declarations land on the WINDOW | |
| 517 | + * element, so a `--os-ui-btn-color` set at the shell root loses to | |
| 518 | + * them, and the same names also drive buttons outside the title bar | |
| 519 | + * (pinned notes, the desk chrome) that a theme usually does not want | |
| 520 | + * to move in the same stroke. | |
| 521 | + * | |
| 522 | + * `--os-titlebar-btn-focused-*` mirrors the unfocused set | |
| 523 | + * below, name for name, so the two halves of the title bar are | |
| 524 | + * addressed the same way. Every one is UNDECLARED and falls back to | |
| 525 | + * the literal it replaced, so an unthemed shell paints exactly what | |
| 526 | + * it painted before. | |
| 527 | + * | |
| 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. | |
| 245 | 532 | */ |
| 246 | -.desktop-mode-window--focused { | |
| 247 | - --wpd-btn-color: rgba( 255, 255, 255, 0.7 ); | |
| 248 | - --wpd-btn-color-hover: #fff; | |
| 249 | - --wpd-btn-bg-hover: rgba( 255, 255, 255, 0.18 ); | |
| 250 | - --wpd-btn-bg-active: rgba( 255, 255, 255, 0.25 ); | |
| 251 | - --wpd-btn-outline: rgba( 255, 255, 255, 0.65 ); | |
| 252 | - --wpd-btn-danger-hover: #d63638; | |
| 533 | +.os-window--focused { | |
| 534 | + --os-ui-btn-color: var( --os-titlebar-btn-focused-color, rgba( 255, 255, 255, 0.7 ) ); | |
| 535 | + --os-ui-btn-color-hover: var( --os-titlebar-btn-focused-color-hover, #fff ); | |
| 536 | + --os-ui-btn-bg-hover: var( --os-titlebar-btn-focused-bg-hover, rgba( 255, 255, 255, 0.18 ) ); | |
| 537 | + --os-ui-btn-bg-active: var( --os-titlebar-btn-focused-bg-active, rgba( 255, 255, 255, 0.25 ) ); | |
| 538 | + --os-ui-btn-outline: var( --os-titlebar-btn-focused-outline, rgba( 255, 255, 255, 0.65 ) ); | |
| 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 ) ); | |
| 253 | 541 | } |
| 254 | 542 | |
| 255 | -.desktop-mode-window:not( .desktop-mode-window--focused ) { | |
| 256 | - --wpd-btn-color: rgba( 0, 0, 0, 0.45 ); | |
| 257 | - --wpd-btn-color-hover: rgba( 0, 0, 0, 0.85 ); | |
| 258 | - --wpd-btn-bg-hover: rgba( 0, 0, 0, 0.08 ); | |
| 259 | - --wpd-btn-bg-active: rgba( 0, 0, 0, 0.12 ); | |
| 260 | - --wpd-btn-outline: var( --wp-admin-theme-color, #2271b1 ); | |
| 261 | - --wpd-btn-danger-hover: #d63638; | |
| 543 | +.os-window:not( .os-window--focused ) { | |
| 544 | + --os-ui-btn-color: var( --os-titlebar-btn-color, rgba( 0, 0, 0, 0.45 ) ); | |
| 545 | + --os-ui-btn-color-hover: var( --os-titlebar-btn-color-hover, rgba( 0, 0, 0, 0.85 ) ); | |
| 546 | + --os-ui-btn-bg-hover: var( --os-titlebar-btn-bg-hover, rgba( 0, 0, 0, 0.08 ) ); | |
| 547 | + --os-ui-btn-bg-active: var( --os-titlebar-btn-bg-active, rgba( 0, 0, 0, 0.12 ) ); | |
| 548 | + --os-ui-btn-outline: var( --wp-admin-theme-color, #2271b1 ); | |
| 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 ) ); | |
| 262 | 551 | } |
| 263 | 552 | |
| 553 | +/* | |
| 554 | + * Unfocused control glyphs under an active desktop theme. | |
| 555 | + * | |
| 556 | + * The rule above paints them BLACK at 45% — correct against the | |
| 557 | + * default light unfocused title bar (`#f0f0f1`), invisible against a | |
| 558 | + * dark themed one. They can't simply follow the palette either: | |
| 559 | + * these are title-bar chrome, so the colour they owe allegiance to is | |
| 560 | + * the title bar's own text colour, not the window body's. | |
| 561 | + * | |
| 562 | + * Deriving it with `color-mix` means a theme gets legible unfocused | |
| 563 | + * controls for free from the `--os-titlebar-color` it was | |
| 564 | + * already setting — no extra tokens to discover. The four | |
| 565 | + * `--os-titlebar-btn-*` overrides above still win when an | |
| 566 | + * author wants exact control. | |
| 567 | + * | |
| 568 | + * Scoped to `[data-os-desktop-theme]` on purpose: the | |
| 569 | + * default title-bar colour is `#50575e`, so applying this | |
| 570 | + * unconditionally would lighten the unfocused glyphs on every | |
| 571 | + * unthemed shell. No theme, no change. | |
| 572 | + */ | |
| 573 | +.os-shell[ data-os-desktop-theme ] | |
| 574 | + .os-window:not( .os-window--focused ) { | |
| 575 | + --os-ui-btn-color: var( | |
| 576 | + --os-titlebar-btn-color, | |
| 577 | + color-mix( in srgb, var( --os-titlebar-color, #50575e ) 72%, transparent ) | |
| 578 | + ); | |
| 579 | + --os-ui-btn-color-hover: var( | |
| 580 | + --os-titlebar-btn-color-hover, | |
| 581 | + var( --os-titlebar-color, #50575e ) | |
| 582 | + ); | |
| 583 | + --os-ui-btn-bg-hover: var( | |
| 584 | + --os-titlebar-btn-bg-hover, | |
| 585 | + color-mix( in srgb, var( --os-titlebar-color, #50575e ) 18%, transparent ) | |
| 586 | + ); | |
| 587 | + --os-ui-btn-bg-active: var( | |
| 588 | + --os-titlebar-btn-bg-active, | |
| 589 | + color-mix( in srgb, var( --os-titlebar-color, #50575e ) 26%, transparent ) | |
| 590 | + ); | |
| 591 | +} | |
| 592 | + | |
| 264 | 593 | /* --------------------------------------------------------------- |
| 265 | 594 | * Title-bar actions menu (leading edge, before icon + title). |
| 266 | 595 | * |
| 267 | - * Trigger is a standard `.desktop-mode-window__btn` — inherits focused / | |
| 596 | + * Trigger is a standard `.os-window__btn` — inherits focused / | |
| 268 | 597 | * unfocused coloring from the existing control-button rules. The only |
| 269 | 598 | * bespoke rule is margin: it sits at the leading edge so a small trailing |
| 270 | 599 | * gap separates it from the icon without affecting overall title-bar gap. |
| 271 | 600 | * |
| @@ -281,22 +610,28 @@ | ||
| 281 | 610 | * divider, and the controls container gains a leading one — so a |
| 282 | 611 | * single clean line always sits between page chrome and window |
| 283 | 612 | * chrome regardless of which combination is visible. |
| 284 | 613 | */ |
| 285 | -.desktop-mode-window__titlebar:has(.desktop-mode-window__menu-btn) .desktop-mode-window__screen-meta { | |
| 614 | +.os-window__titlebar:has(.os-window__menu-btn) .os-window__screen-meta { | |
| 286 | 615 | margin-inline-end: 0; |
| 287 | 616 | padding-inline-end: 0; |
| 288 | 617 | border-inline-end: none; |
| 289 | 618 | } |
| 290 | 619 | |
| 291 | -.desktop-mode-window__titlebar:has(.desktop-mode-window__menu-btn) .desktop-mode-window__controls { | |
| 620 | +.os-window__titlebar:has(.os-window__menu-btn) .os-window__controls { | |
| 292 | 621 | margin-inline-start: 8px; |
| 293 | 622 | padding-inline-start: 8px; |
| 294 | - border-inline-start: 1px solid rgba(255, 255, 255, 0.15); | |
| 623 | + /* Tokenized so a theme can retint it — or set `transparent` and | |
| 624 | + * let its own title-bar artwork carry the separation. */ | |
| 625 | + border-inline-start: 1px solid | |
| 626 | + var( --os-titlebar-divider, rgba(255, 255, 255, 0.15) ); | |
| 295 | 627 | } |
| 296 | 628 | |
| 297 | -.desktop-mode-window:not(.desktop-mode-window--focused) .desktop-mode-window__titlebar:has(.desktop-mode-window__menu-btn) .desktop-mode-window__controls { | |
| 298 | - border-inline-start-color: rgba(0, 0, 0, 0.1); | |
| 629 | +.os-window:not(.os-window--focused) .os-window__titlebar:has(.os-window__menu-btn) .os-window__controls { | |
| 630 | + border-inline-start-color: var( | |
| 631 | + --os-titlebar-divider-unfocused, | |
| 632 | + rgba(0, 0, 0, 0.1) | |
| 633 | + ); | |
| 299 | 634 | } |
| 300 | 635 | |
| 301 | 636 | /* |
| 302 | 637 | * Menu popover lives inside the title bar's absolute-positioned |
| @@ -301,17 +636,89 @@ | ||
| 301 | 636 | /* |
| 302 | 637 | * Menu popover lives inside the title bar's absolute-positioned |
| 303 | 638 | * coordinate system — positioning is caller-specific and stays |
| 304 | 639 | * outer. Background, border, items, checkbox styling all moved |
| 305 | - * into `<wpd-menu>` / `<wpd-menu-item>`. | |
| 640 | + * into `<os-menu>` / `<os-menu-item>`. | |
| 306 | 641 | */ |
| 307 | -.desktop-mode-window__menu-panel { | |
| 642 | +.os-window__menu-panel { | |
| 308 | 643 | position: absolute; |
| 309 | - top: calc( var( --desktop-mode-titlebar-height ) + 2px ); | |
| 644 | + top: calc( var( --os-titlebar-height ) + 2px ); | |
| 310 | 645 | inset-inline-end: 60px; |
| 311 | 646 | z-index: 2; |
| 312 | 647 | } |
| 313 | 648 | |
| 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 | +/* | |
| 691 | + * "Related" dropdown — opened by the related-entities title-bar | |
| 692 | + * button (`src/related-entities/`). The panel ALSO carries | |
| 693 | + * `.os-window__menu-panel` (positioning + the drag-tracker | |
| 694 | + * exclusion come from there); this class only layers the related-menu | |
| 695 | + * extras: an internal scroll cap for media-heavy posts and a sane | |
| 696 | + * minimum width. | |
| 697 | + */ | |
| 698 | +.os-window__related-panel { | |
| 699 | + max-height: min( 60vh, 420px ); | |
| 700 | + overflow-y: auto; | |
| 701 | + min-width: 180px; | |
| 702 | +} | |
| 703 | + | |
| 704 | +/* Section header inside the Related dropdown ("Categories", "Media"). */ | |
| 705 | +.os-window__related-group { | |
| 706 | + padding: 6px 12px 2px; | |
| 707 | + font-size: 11px; | |
| 708 | + font-weight: 600; | |
| 709 | + text-transform: uppercase; | |
| 710 | + letter-spacing: 0.04em; | |
| 711 | + opacity: 0.6; | |
| 712 | + color: var( --os-ui-fg ); | |
| 713 | +} | |
| 714 | + | |
| 715 | +.os-window__related-group:not(:first-child) { | |
| 716 | + margin-block-start: 4px; | |
| 717 | + border-block-start: 1px solid rgba( 128, 128, 128, 0.25 ); | |
| 718 | + padding-block-start: 8px; | |
| 719 | +} | |
| 720 | + | |
| 314 | 721 | /* --------------------------------------------------------------- |
| 315 | 722 | * Screen Meta buttons (Screen Options / Help) in the title bar. |
| 316 | 723 | * Positioned right after the title text, visually separated from |
| 317 | 724 | * the close / maximize / minimize cluster by a subtle divider. |
| @@ -316,9 +723,9 @@ | ||
| 316 | 723 | * Positioned right after the title text, visually separated from |
| 317 | 724 | * the close / maximize / minimize cluster by a subtle divider. |
| 318 | 725 | * Sized to meet WCAG 2.2 target size (24x24 minimum). |
| 319 | 726 | * --------------------------------------------------------------- */ |
| 320 | -.desktop-mode-window__screen-meta { | |
| 727 | +.os-window__screen-meta { | |
| 321 | 728 | display: flex; |
| 322 | 729 | gap: 4px; |
| 323 | 730 | align-items: center; |
| 324 | 731 | flex-shrink: 0; |
| @@ -323,77 +730,110 @@ | ||
| 323 | 730 | align-items: center; |
| 324 | 731 | flex-shrink: 0; |
| 325 | 732 | margin-inline-end: 8px; |
| 326 | 733 | padding-inline-end: 8px; |
| 327 | - border-inline-end: 1px solid rgba(255, 255, 255, 0.15); | |
| 734 | + border-inline-end: 1px solid | |
| 735 | + var( --os-titlebar-divider, rgba(255, 255, 255, 0.15) ); | |
| 328 | 736 | } |
| 329 | 737 | |
| 330 | 738 | /* Hide the divider when there are no screen-meta buttons. */ |
| 331 | -.desktop-mode-window__screen-meta:empty { | |
| 739 | +.os-window__screen-meta:empty { | |
| 332 | 740 | display: none; |
| 333 | 741 | } |
| 334 | 742 | |
| 335 | -.desktop-mode-window__meta-btn { | |
| 743 | +.os-window__meta-btn { | |
| 336 | 744 | display: flex; |
| 337 | 745 | align-items: center; |
| 338 | 746 | justify-content: center; |
| 339 | - width: 28px; | |
| 340 | - height: 28px; | |
| 747 | + width: var( --os-titlebar-btn-width, 28px ); | |
| 748 | + height: var( --os-titlebar-btn-height, 28px ); | |
| 341 | 749 | border: none; |
| 342 | - border-radius: 6px; | |
| 750 | + border-radius: var( --os-titlebar-meta-btn-radius, 6px ); | |
| 343 | 751 | cursor: pointer; |
| 344 | 752 | padding: 0; |
| 345 | - 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 ); | |
| 346 | 761 | transition: background-color 0.15s ease, color 0.15s ease; |
| 347 | 762 | } |
| 348 | 763 | |
| 349 | -.desktop-mode-window__meta-btn .dashicons { | |
| 350 | - font-size: 18px; | |
| 351 | - width: 18px; | |
| 352 | - height: 18px; | |
| 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 | + ); | |
| 353 | 770 | } |
| 354 | -/* The flex-centring override for the dashicon glyph lives in | |
| 355 | - * `windows.css` rather than here. `windows.css` is mtime-stamped | |
| 356 | - * (per `desktop_mode_register_assets` in includes/assets.php) so | |
| 357 | - * its URL changes whenever it does, but `@import`-ed sub-sheets | |
| 358 | - * like this one are fetched without a version query and the | |
| 359 | - * browser will serve a stale cached copy across edits to this | |
| 360 | - * file. Co-locating cache-sensitive overrides with the parent | |
| 361 | - * stylesheet is the rule of the road here. */ | |
| 362 | 771 | |
| 363 | -/* Focused window: meta buttons visible. */ | |
| 364 | -.desktop-mode-window--focused .desktop-mode-window__meta-btn { | |
| 365 | - color: rgba(255, 255, 255, 0.65); | |
| 772 | +.os-window__meta-btn .dashicons { | |
| 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 ); | |
| 366 | 776 | } |
| 367 | -.desktop-mode-window--focused .desktop-mode-window__meta-btn:hover { | |
| 368 | - color: #fff; | |
| 369 | - background: rgba(255, 255, 255, 0.18); | |
| 777 | +/* The flex-centring override for the dashicon glyph currently lives | |
| 778 | + * in `windows.css`. That placement was a cache workaround from when | |
| 779 | + * this file was an `@import` sub-sheet with no `?ver=` of its own; | |
| 780 | + * it is now a separately registered, filemtime-stamped handle, so | |
| 781 | + * the workaround no longer buys anything. Left where it is for now — | |
| 782 | + * relocating it is behaviour-neutral churn. */ | |
| 783 | + | |
| 784 | +/* Focused window: meta buttons visible. | |
| 785 | + * | |
| 786 | + * Screen-meta buttons are not `<os-window-button>` — they are plain | |
| 787 | + * buttons in the light DOM, so they paint themselves instead of | |
| 788 | + * reading the `--os-ui-btn-*` bridge. They sit in the same title bar | |
| 789 | + * against the same fill, so they take the same | |
| 790 | + * `--os-titlebar-btn-focused-*` tokens, each keeping its own | |
| 791 | + * literal as the fallback: unthemed they stay a touch dimmer at rest | |
| 792 | + * than the window controls (0.65 vs 0.7), themed they move together | |
| 793 | + * rather than one cluster going legible and the other staying white. */ | |
| 794 | +.os-window--focused .os-window__meta-btn { | |
| 795 | + color: var( --os-titlebar-btn-focused-color, rgba(255, 255, 255, 0.65) ); | |
| 370 | 796 | } |
| 371 | -.desktop-mode-window--focused .desktop-mode-window__meta-btn:focus-visible { | |
| 372 | - color: #fff; | |
| 373 | - background: rgba(255, 255, 255, 0.18); | |
| 374 | - outline: 2px solid rgba(255, 255, 255, 0.6); | |
| 797 | +.os-window--focused .os-window__meta-btn:hover { | |
| 798 | + color: var( --os-titlebar-btn-focused-color-hover, var( --os-ui-fg-on-accent, #fff ) ); | |
| 799 | + background-color: var( --os-titlebar-btn-focused-bg-hover, rgba(255, 255, 255, 0.18) ); | |
| 800 | +} | |
| 801 | +.os-window--focused .os-window__meta-btn:focus-visible { | |
| 802 | + color: var( --os-titlebar-btn-focused-color-hover, var( --os-ui-fg-on-accent, #fff ) ); | |
| 803 | + background-color: var( --os-titlebar-btn-focused-bg-hover, rgba(255, 255, 255, 0.18) ); | |
| 804 | + outline: 2px solid var( --os-titlebar-btn-focused-outline, rgba(255, 255, 255, 0.6) ); | |
| 375 | 805 | outline-offset: 1px; |
| 376 | 806 | } |
| 377 | -.desktop-mode-window--focused .desktop-mode-window__meta-btn--active { | |
| 378 | - color: #fff; | |
| 379 | - background: rgba(255, 255, 255, 0.25); | |
| 807 | +.os-window--focused .os-window__meta-btn--active { | |
| 808 | + color: var( --os-titlebar-btn-focused-color-hover, var( --os-ui-fg-on-accent, #fff ) ); | |
| 809 | + background-color: var( --os-titlebar-btn-focused-bg-active, rgba(255, 255, 255, 0.25) ); | |
| 380 | 810 | } |
| 381 | 811 | |
| 382 | 812 | /* Unfocused window: divider and buttons adapt to light title bar. */ |
| 383 | -.desktop-mode-window:not(.desktop-mode-window--focused) .desktop-mode-window__screen-meta { | |
| 384 | - border-inline-end-color: rgba(0, 0, 0, 0.1); | |
| 813 | +.os-window:not(.os-window--focused) .os-window__screen-meta { | |
| 814 | + border-inline-end-color: var( | |
| 815 | + --os-titlebar-divider-unfocused, | |
| 816 | + rgba(0, 0, 0, 0.1) | |
| 817 | + ); | |
| 385 | 818 | } |
| 386 | -.desktop-mode-window:not(.desktop-mode-window--focused) .desktop-mode-window__meta-btn { | |
| 387 | - color: rgba(0, 0, 0, 0.3); | |
| 819 | +/* | |
| 820 | + * Screen Options / Help on an UNFOCUSED title bar. These read the same | |
| 821 | + * two tokens as the window controls beside them rather than their own | |
| 822 | + * literals — a black glyph is invisible on a dark title bar, and these | |
| 823 | + * buttons sit in the same strip as controls that were already themable. | |
| 824 | + * The literals are unchanged, so an unthemed bar looks as it always did. | |
| 825 | + */ | |
| 826 | +.os-window:not(.os-window--focused) .os-window__meta-btn { | |
| 827 | + color: var( --os-titlebar-btn-color, rgba(0, 0, 0, 0.3) ); | |
| 388 | 828 | } |
| 389 | -.desktop-mode-window:not(.desktop-mode-window--focused) .desktop-mode-window__meta-btn:hover { | |
| 390 | - color: rgba(0, 0, 0, 0.6); | |
| 391 | - background: rgba(0, 0, 0, 0.08); | |
| 829 | +.os-window:not(.os-window--focused) .os-window__meta-btn:hover { | |
| 830 | + color: var( --os-titlebar-btn-color-hover, rgba(0, 0, 0, 0.6) ); | |
| 831 | + background-color: var( --os-ui-hover, rgba(0, 0, 0, 0.08) ); | |
| 392 | 832 | } |
| 393 | -.desktop-mode-window:not(.desktop-mode-window--focused) .desktop-mode-window__meta-btn--active { | |
| 394 | - color: rgba(0, 0, 0, 0.6); | |
| 395 | - background: rgba(0, 0, 0, 0.12); | |
| 833 | +.os-window:not(.os-window--focused) .os-window__meta-btn--active { | |
| 834 | + color: var( --os-titlebar-btn-color-hover, rgba(0, 0, 0, 0.6) ); | |
| 835 | + background-color: rgba(0, 0, 0, 0.12); | |
| 396 | 836 | } |
| 397 | 837 | |
| 398 | 838 | /* --------------------------------------------------------------- |
| 399 | 839 | * Tab strip — submenu navigation rendered in the parent shell, just |
| @@ -399,30 +839,571 @@ | ||
| 399 | 839 | * Tab strip — submenu navigation rendered in the parent shell, just |
| 400 | 840 | * below the title bar. Each tab swaps the iframe URL in place; no |
| 401 | 841 | * new window opens. Horizontally scrollable on narrow windows so the |
| 402 | 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. | |
| 403 | 858 | * --------------------------------------------------------------- */ |
| 404 | -.desktop-mode-window__tabs { | |
| 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; | |
| 405 | 878 | display: flex; |
| 879 | + /* Tabs sit ON the floor of the track, not centred in it. */ | |
| 880 | + align-items: flex-end; | |
| 406 | 881 | flex-shrink: 0; |
| 407 | - gap: 2px; | |
| 408 | - padding: 0 8px; | |
| 409 | - background: var(--desktop-mode-tabs-bg, #f6f7f7); | |
| 410 | - border-bottom: 1px solid var(--desktop-mode-window-border); | |
| 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 ); | |
| 891 | + background-color: var( --os-tabs-bg, var( --os-ui-surface-elevated, #f6f7f7 ) ); | |
| 892 | + /* TABBAR texture slot — layered over the strip's own colour. */ | |
| 893 | + background-image: var( --os-tabs-image, none ); | |
| 894 | + background-repeat: var( --os-tabs-image-repeat, repeat ); | |
| 895 | + background-size: var( --os-tabs-image-size, auto ); | |
| 896 | + background-position: var( --os-tabs-image-position, center ); | |
| 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 | + */ | |
| 411 | 902 | overflow-x: auto; |
| 412 | 903 | overflow-y: hidden; |
| 413 | 904 | scrollbar-width: thin; |
| 414 | 905 | /* |
| 415 | - * Soft edge fades so overflowing tabs tell the user "scroll for | |
| 416 | - * more." The mask is always applied — when the strip doesn't | |
| 417 | - * overflow, the faded ends are empty area beyond the last tab so | |
| 418 | - * the cosmetic is invisible. When it DOES overflow, the last- | |
| 419 | - * visible tab fades under the mask, creating the affordance. | |
| 420 | 906 | * `overscroll-behavior-x: contain` keeps horizontal trackpad |
| 421 | 907 | * swipes inside the strip instead of triggering browser back. |
| 422 | 908 | */ |
| 909 | + overscroll-behavior-x: contain; | |
| 910 | +} | |
| 911 | + | |
| 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 | + ); | |
| 423 | 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 | +/* | |
| 1366 | + * Soft edge fades so overflowing tabs tell the user "scroll for more." | |
| 1367 | + * | |
| 1368 | + * Each fade is painted ONLY on an edge that is actually hiding a tab. | |
| 1369 | + * `observeTabOverflow()` in `src/window/tabs.ts` stamps `data-overflow` | |
| 1370 | + * with the physical edges currently covered — `left`, `right`, `both`, | |
| 1371 | + * or the attribute dropped when the strip fits — and re-measures on | |
| 1372 | + * scroll, on resize, and when tabs are added or removed. | |
| 1373 | + * | |
| 1374 | + * This used to be one unconditional mask on both ends. The reasoning | |
| 1375 | + * was that on a strip that fits, the faded ends land past the last tab | |
| 1376 | + * and so fade nothing. That held on the pre-brand light strip, where | |
| 1377 | + * the tab bar and the title bar above it were near enough the same | |
| 1378 | + * near-white that a mask over empty area was invisible. It stopped | |
| 1379 | + * holding on the station: the strip is Obsidian over a darker window | |
| 1380 | + * edge, so masking its ends to transparent punches two grey smudges | |
| 1381 | + * into every window's submenu, whether or not there is anything to | |
| 1382 | + * scroll to. A permanent affordance for a state that is usually false | |
| 1383 | + * is not an affordance — it is decoration that lies. | |
| 1384 | + */ | |
| 1385 | +.os-window__tabs[ data-overflow='left' ] { | |
| 1386 | + mask-image: linear-gradient( to right, transparent 0, #000 16px ); | |
| 1387 | + -webkit-mask-image: linear-gradient( to right, transparent 0, #000 16px ); | |
| 1388 | +} | |
| 1389 | + | |
| 1390 | +.os-window__tabs[ data-overflow='right' ] { | |
| 1391 | + mask-image: linear-gradient( | |
| 424 | 1392 | to right, |
| 1393 | + #000 calc(100% - 16px), | |
| 1394 | + transparent 100% | |
| 1395 | + ); | |
| 1396 | + -webkit-mask-image: linear-gradient( | |
| 1397 | + to right, | |
| 1398 | + #000 calc(100% - 16px), | |
| 1399 | + transparent 100% | |
| 1400 | + ); | |
| 1401 | +} | |
| 1402 | + | |
| 1403 | +.os-window__tabs[ data-overflow='both' ] { | |
| 1404 | + mask-image: linear-gradient( | |
| 1405 | + to right, | |
| 425 | 1406 | transparent 0, |
| 426 | 1407 | #000 16px, |
| 427 | 1408 | #000 calc(100% - 16px), |
| 428 | 1409 | transparent 100% |
| @@ -433,53 +1414,87 @@ | ||
| 433 | 1414 | #000 16px, |
| 434 | 1415 | #000 calc(100% - 16px), |
| 435 | 1416 | transparent 100% |
| 436 | 1417 | ); |
| 437 | - overscroll-behavior-x: contain; | |
| 438 | 1418 | } |
| 439 | 1419 | |
| 440 | -/* | |
| 441 | - * When JS assigns the --has-overflow marker (future enhancement), the | |
| 442 | - * mask is stronger. For now the CSS-only mask is subtle enough to be | |
| 443 | - * invisible on non-overflowing strips — mask cut-offs land outside the | |
| 444 | - * flex content. | |
| 445 | - */ | |
| 446 | - | |
| 447 | -.desktop-mode-window__tab { | |
| 1420 | +.os-window__tab { | |
| 1421 | + position: relative; | |
| 448 | 1422 | flex-shrink: 0; |
| 449 | 1423 | display: inline-flex; |
| 450 | 1424 | align-items: center; |
| 451 | - height: 32px; | |
| 452 | - 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 ); | |
| 453 | 1428 | border: none; |
| 454 | - background: transparent; | |
| 455 | - color: var(--desktop-mode-tabs-color, #50575e); | |
| 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 ); | |
| 1437 | + color: var( --os-tabs-color, var( --os-ui-fg-muted, #50575e ) ); | |
| 456 | 1438 | font: inherit; |
| 457 | - font-size: 12px; | |
| 1439 | + font-size: var( --os-tabs-font-size, 12px ); | |
| 458 | 1440 | line-height: 1; |
| 459 | 1441 | cursor: pointer; |
| 460 | - border-bottom: 2px solid transparent; | |
| 461 | - margin-bottom: -1px; | |
| 462 | 1442 | white-space: nowrap; |
| 463 | - 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; | |
| 464 | 1444 | } |
| 465 | 1445 | |
| 466 | -.desktop-mode-window__tab:hover { | |
| 467 | - color: var(--wp-admin-theme-color, #2271b1); | |
| 468 | - background: rgba(0, 0, 0, 0.03); | |
| 1446 | +.os-window__tab:hover { | |
| 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) ) ); | |
| 469 | 1449 | } |
| 470 | 1450 | |
| 471 | -.desktop-mode-window__tab:focus-visible { | |
| 1451 | +.os-window__tab:focus-visible { | |
| 472 | 1452 | outline: 2px solid var(--wp-admin-theme-color, #2271b1); |
| 473 | 1453 | outline-offset: -2px; |
| 474 | 1454 | } |
| 475 | 1455 | |
| 476 | -.desktop-mode-window__tab--active { | |
| 477 | - color: var(--wp-admin-theme-color, #2271b1); | |
| 478 | - border-bottom-color: var(--wp-admin-theme-color, #2271b1); | |
| 479 | - font-weight: 600; | |
| 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 | + */ | |
| 1466 | +.os-window__tab--active { | |
| 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 | + ); | |
| 480 | 1488 | } |
| 481 | 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 | + | |
| 482 | 1497 | /* |
| 483 | 1498 | * External sub-tab. Same shape as a submenu tab, plus two inline |
| 484 | 1499 | * chips: detach (↗) and close (×). Chips are painted as spans |
| 485 | 1500 | * (rather than nested buttons — nested interactive elements are a |
| @@ -485,9 +1500,9 @@ | ||
| 485 | 1500 | * (rather than nested buttons — nested interactive elements are a |
| 486 | 1501 | * pain for a11y). The tab's click handler routes chip clicks via |
| 487 | 1502 | * the `data-tab-action` dataset attribute. |
| 488 | 1503 | */ |
| 489 | -.desktop-mode-window__tab--external { | |
| 1504 | +.os-window__tab--external { | |
| 490 | 1505 | /* Slightly wider gap between the label and the chip cluster, and |
| 491 | 1506 | * more breathing room at the trailing edge so the chips aren't |
| 492 | 1507 | * flush against the tab boundary. */ |
| 493 | 1508 | gap: 10px; |
| @@ -493,9 +1508,9 @@ | ||
| 493 | 1508 | gap: 10px; |
| 494 | 1509 | padding-inline-end: 4px; |
| 495 | 1510 | } |
| 496 | 1511 | |
| 497 | -.desktop-mode-window__tab-label { | |
| 1512 | +.os-window__tab-label { | |
| 498 | 1513 | max-width: 180px; |
| 499 | 1514 | overflow: hidden; |
| 500 | 1515 | text-overflow: ellipsis; |
| 501 | 1516 | white-space: nowrap; |
| @@ -500,44 +1515,183 @@ | ||
| 500 | 1515 | text-overflow: ellipsis; |
| 501 | 1516 | white-space: nowrap; |
| 502 | 1517 | } |
| 503 | 1518 | |
| 504 | -/* External-tab action chips moved to `<wpd-tab-chip>`. Spacing | |
| 1519 | +/* External-tab action chips moved to `<os-tab-chip>`. Spacing | |
| 505 | 1520 | * between chips handled by a single host-level rule since the |
| 506 | 1521 | * component doesn't know about its siblings. */ |
| 507 | -wpd-tab-chip + wpd-tab-chip { | |
| 1522 | +os-tab-chip + os-tab-chip { | |
| 508 | 1523 | margin-inline-start: 4px; |
| 509 | 1524 | } |
| 510 | 1525 | |
| 511 | 1526 | /* Window body: contains the iframe. */ |
| 512 | -.desktop-mode-window__body { | |
| 1527 | +.os-window__body { | |
| 513 | 1528 | flex: 1; |
| 514 | 1529 | position: relative; |
| 515 | 1530 | overflow: hidden; |
| 1531 | + /* | |
| 1532 | + * WINDOW_BODY texture slot. Visible behind native window content | |
| 1533 | + * and behind any iframe whose page has a transparent background; | |
| 1534 | + * an opaque wp-admin page inside an iframe paints over it, which | |
| 1535 | + * is why a theme wanting texture everywhere reaches for DESKTOP | |
| 1536 | + * and the chrome slots instead. | |
| 1537 | + */ | |
| 1538 | + background-image: var( --os-window-body-image, none ); | |
| 1539 | + background-repeat: var( --os-window-body-image-repeat, repeat ); | |
| 1540 | + background-size: var( --os-window-body-image-size, auto ); | |
| 1541 | + background-position: var( --os-window-body-image-position, center ); | |
| 1542 | + /* | |
| 1543 | + * Body typeface. `--os-ui-font` is the component kit's own token, so | |
| 1544 | + * one declaration here reaches every `<os-*>` element inside the | |
| 1545 | + * window: shadow DOM inherits `font-family` through the boundary. | |
| 1546 | + */ | |
| 1547 | + font-family: var( --os-ui-font, inherit ); | |
| 516 | 1548 | } |
| 517 | 1549 | |
| 518 | 1550 | /* Iframe fills the window body. */ |
| 519 | -.desktop-mode-window__iframe { | |
| 1551 | +.os-window__iframe { | |
| 520 | 1552 | width: 100%; |
| 521 | 1553 | height: 100%; |
| 522 | 1554 | border: none; |
| 523 | 1555 | display: block; |
| 524 | - background: var(--desktop-mode-window-bg); | |
| 1556 | + background: var(--os-window-bg); | |
| 525 | 1557 | opacity: 1; |
| 526 | 1558 | transition: opacity 0.25s ease; |
| 527 | 1559 | } |
| 528 | 1560 | |
| 529 | -/* Cross-window drag drop-overlay rules live in windows.css, NOT here. | |
| 530 | - * Putting them in this @import'd sub-sheet means browsers can serve a | |
| 531 | - * stale cached copy even when the parent windows.css has been cache- | |
| 532 | - * busted by mtime, leaving the drag stuck at the iframe boundary | |
| 533 | - * until the user manually clears their stylesheet cache. Keep them | |
| 534 | - * co-located with the @import declarations themselves. */ | |
| 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 | +} | |
| 535 | 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 ); | |
| 1579 | +} | |
| 1580 | + | |
| 536 | 1581 | /* |
| 1582 | + * Headings inside native window bodies. | |
| 1583 | + * | |
| 1584 | + * WordPress core's `wp-admin/css/common.css` styles headings with | |
| 1585 | + * BARE ELEMENT selectors — `h1 { color: #1d2327 }` and | |
| 1586 | + * `h2, h3 { color: #1d2327 }`. Native windows render in the parent | |
| 1587 | + * shell rather than in an iframe, so those rules reach straight into | |
| 1588 | + * our light-DOM window content: an `<h3>` in a `<os-card>` came out | |
| 1589 | + * near-black on a dark theme while every sibling paragraph correctly | |
| 1590 | + * followed the palette. | |
| 1591 | + * | |
| 1592 | + * The specificity here is doing real work. It has to sit BETWEEN two | |
| 1593 | + * other rules: | |
| 1594 | + * | |
| 1595 | + * core `h3` (0,0,1) must lose | |
| 1596 | + * THIS rule (0,0,1) wins on source order | |
| 1597 | + * `.os-my-wordpress__user-section h3` (0,1,1) must still win | |
| 1598 | + * | |
| 1599 | + * `:where()` contributes zero specificity, so the selector below | |
| 1600 | + * weighs the same as core's bare `h3` and beats it only because our | |
| 1601 | + * stylesheet prints later — while any of our own class-scoped heading | |
| 1602 | + * rules continue to override it. Raising this to a plain | |
| 1603 | + * `.os-window__body h3` would tie with those and break them | |
| 1604 | + * depending on file order. | |
| 1605 | + * | |
| 1606 | + * The `#1d2327` fallback is core's own value, so with no theme active | |
| 1607 | + * the computed colour is unchanged. | |
| 1608 | + */ | |
| 1609 | +:where( .os-window__body ) :is( h1, h2, h3, h4, h5, h6 ) { | |
| 1610 | + color: var( --os-ui-fg, #1d2327 ); | |
| 1611 | +} | |
| 1612 | + | |
| 1613 | +/* | |
| 1614 | + * Same story, same fix, for the other bare-element rules core ships: | |
| 1615 | + * | |
| 1616 | + * a { color: #2271b1 } | |
| 1617 | + * code { background: #f0f0f1 } | |
| 1618 | + * | |
| 1619 | + * `code` is the worse of the two — a light chip background with | |
| 1620 | + * palette-coloured text on it inverts to unreadable the moment a | |
| 1621 | + * theme goes dark. Both fallbacks are core's own values, so the | |
| 1622 | + * default is unchanged. | |
| 1623 | + * | |
| 1624 | + * Form controls (`input`, `textarea`, `select`) are deliberately NOT | |
| 1625 | + * included: core styles those through attribute selectors that carry | |
| 1626 | + * real specificity, native windows overwhelmingly use the `<os-*>` | |
| 1627 | + * components instead, and a half-applied override there would look | |
| 1628 | + * worse than leaving them alone. | |
| 1629 | + */ | |
| 1630 | +:where( .os-window__body ) a { | |
| 1631 | + color: var( --os-ui-accent, #2271b1 ); | |
| 1632 | +} | |
| 1633 | + | |
| 1634 | +:where( .os-window__body ) code { | |
| 1635 | + background: var( --os-ui-surface-sunken, #f0f0f1 ); | |
| 1636 | + color: var( --os-ui-fg, inherit ); | |
| 1637 | +} | |
| 1638 | + | |
| 1639 | +/* | |
| 1640 | + * Raw form controls in native window bodies. | |
| 1641 | + * | |
| 1642 | + * Core's `forms.css` styles these through ATTRIBUTE selectors — | |
| 1643 | + * `input[type="search"], select, textarea { background-color: #fff; | |
| 1644 | + * color: #1e1e1e; border: 1px solid #949494 }` — which weighs | |
| 1645 | + * (0,1,1). That beats a plain class selector, so a control our own | |
| 1646 | + * CSS had already tokenized (My WordPress's search box reads | |
| 1647 | + * `background: var( --os-ui-surface, #fff )` and has done all along) | |
| 1648 | + * still came out white: core simply outranked it. | |
| 1649 | + * | |
| 1650 | + * Hence `.os-window__body` + `:is( input[type], … )`, which | |
| 1651 | + * weighs (0,2,1) and wins. `<os-*>` form components are untouched — | |
| 1652 | + * they live in shadow DOM, where none of this reaches. | |
| 1653 | + * | |
| 1654 | + * Fallbacks are core's own values, so an unthemed shell is unchanged. | |
| 1655 | + */ | |
| 1656 | +.os-window__body | |
| 1657 | + :is( | |
| 1658 | + input[ type="text" ], | |
| 1659 | + input[ type="search" ], | |
| 1660 | + input[ type="email" ], | |
| 1661 | + input[ type="url" ], | |
| 1662 | + input[ type="tel" ], | |
| 1663 | + input[ type="number" ], | |
| 1664 | + input[ type="password" ], | |
| 1665 | + input[ type="date" ], | |
| 1666 | + input[ type="datetime-local" ], | |
| 1667 | + input[ type="month" ], | |
| 1668 | + input[ type="time" ], | |
| 1669 | + input[ type="week" ], | |
| 1670 | + select, | |
| 1671 | + textarea | |
| 1672 | + ) { | |
| 1673 | + background-color: var( --os-ui-surface, #fff ); | |
| 1674 | + color: var( --os-ui-fg, #1e1e1e ); | |
| 1675 | + border-color: var( --os-ui-border-strong, #949494 ); | |
| 1676 | +} | |
| 1677 | + | |
| 1678 | +.os-window__body :is( input, textarea )::placeholder { | |
| 1679 | + color: var( --os-ui-fg-muted, #646970 ); | |
| 1680 | +} | |
| 1681 | + | |
| 1682 | +/* Cross-window drag drop-overlay rules currently live in windows.css. | |
| 1683 | + * That was originally a cache workaround — this file was an `@import` | |
| 1684 | + * sub-sheet with no `?ver=` of its own, so edits here could be served | |
| 1685 | + * stale indefinitely. It is now a separately registered handle with | |
| 1686 | + * its own filemtime stamp, so the workaround is obsolete and those | |
| 1687 | + * rules could move back here. Left in place for now because moving | |
| 1688 | + * them is a behaviour-neutral churn better done on its own. */ | |
| 1689 | + | |
| 1690 | +/* | |
| 537 | 1691 | * Loading-state overlay — painted by `src/window/dom.ts` into |
| 538 | 1692 | * every window's body and removed once the body's content reports |
| 539 | - * ready. The `<wpd-spinner>` inside is sized responsively against | |
| 1693 | + * ready. The `<os-spinner>` inside is sized responsively against | |
| 540 | 1694 | * the window's width via `clamp(96px, 14vw, 192px)` so a tiny |
| 541 | 1695 | * popover gets a small spinner and a maximized window gets a big |
| 542 | 1696 | * one. Placed above the body content (iframe / native render |
| 543 | 1697 | * output) and kept aria-hidden so the spinner's own SR-label is |
| @@ -542,16 +1696,16 @@ | ||
| 542 | 1696 | * one. Placed above the body content (iframe / native render |
| 543 | 1697 | * output) and kept aria-hidden so the spinner's own SR-label is |
| 544 | 1698 | * the only loading announcement. |
| 545 | 1699 | * |
| 546 | - * `transition-delay` on the overlay's fade-in means a render | |
| 547 | - * that lands within ~120ms never paints the spinner — the | |
| 548 | - * overlay only becomes visible when there's actually a wait | |
| 549 | - * worth surfacing. This keeps the affordance from flashing on | |
| 550 | - * fast local-dev loads while still covering the slow production | |
| 551 | - * iframe boots that motivated it. | |
| 1700 | + * The `--visible` modifier turns the overlay on. JS adds it ~120ms | |
| 1701 | + * into the load (`LOADING_OVERLAY_SHOW_DELAY_MS`), so a fast render | |
| 1702 | + * never paints a spinner. The delay cannot be a `transition-delay` | |
| 1703 | + * here: the overlay is appended into a body that already carries | |
| 1704 | + * `--loading`, so its first computed style is the visible one and the | |
| 1705 | + * transition never runs. | |
| 552 | 1706 | */ |
| 553 | -.desktop-mode-window__loading { | |
| 1707 | +.os-window__loading { | |
| 554 | 1708 | position: absolute; |
| 555 | 1709 | inset: 0; |
| 556 | 1710 | display: flex; |
| 557 | 1711 | align-items: center; |
| @@ -556,40 +1710,155 @@ | ||
| 556 | 1710 | display: flex; |
| 557 | 1711 | align-items: center; |
| 558 | 1712 | justify-content: center; |
| 559 | 1713 | pointer-events: none; |
| 560 | - background: var(--desktop-mode-window-bg); | |
| 1714 | + background: var(--os-window-bg); | |
| 561 | 1715 | opacity: 0; |
| 562 | 1716 | /* Default (loaded → loading-removed) — fade out immediately. */ |
| 563 | 1717 | transition: opacity 0.25s ease; |
| 564 | - z-index: 1; | |
| 1718 | + /* | |
| 1719 | + * Above both reveal layers (z-index 2 and 3), so the spinner stays | |
| 1720 | + * readable for the whole load and the surface it is sitting on only | |
| 1721 | + * becomes visible once the spinner has faded out. | |
| 1722 | + */ | |
| 1723 | + z-index: 4; | |
| 565 | 1724 | } |
| 566 | 1725 | |
| 567 | -.desktop-mode-window__body--loading > .desktop-mode-window__iframe, | |
| 568 | -.desktop-mode-window__body--loading > :not(.desktop-mode-window__loading) { | |
| 1726 | +/* | |
| 1727 | + * Content hidden while loading. The reveal surface is excluded | |
| 1728 | + * alongside the spinner overlay: it is chrome, not content, and fading | |
| 1729 | + * it to transparent would show the very content it exists to cover. | |
| 1730 | + */ | |
| 1731 | +.os-window__body--loading > .os-window__iframe, | |
| 1732 | +.os-window__body--loading | |
| 1733 | + > :not(.os-window__loading):not(.os-window__reveal) { | |
| 569 | 1734 | opacity: 0; |
| 570 | 1735 | transition: opacity 0.25s ease; |
| 571 | 1736 | } |
| 572 | 1737 | |
| 573 | -.desktop-mode-window__body--loading > .desktop-mode-window__loading { | |
| 1738 | +.os-window__body--loading > .os-window__loading--visible { | |
| 574 | 1739 | opacity: 1; |
| 575 | - /* Entry transition with a small delay — loads that finish in | |
| 576 | - * under ~120ms never paint the spinner, so fast local-dev / | |
| 577 | - * hot-cache fires don't flash. Slow production iframe boots | |
| 578 | - * still see the spinner. */ | |
| 579 | - transition-delay: 0.12s; | |
| 580 | 1740 | } |
| 581 | 1741 | |
| 1742 | +/* | |
| 1743 | + * Content hand-off. `--loading` drops as soon as the content is ready, | |
| 1744 | + * but the overlay above it still needs 250ms to fade out. Without this | |
| 1745 | + * both layers are on screen at once, which reads as a flash. | |
| 1746 | + * | |
| 1747 | + * This modifier holds the content transparent for the length of that | |
| 1748 | + * fade, then fades it in. The delay in the shorthand does the holding. | |
| 1749 | + * | |
| 1750 | + * Only added when the spinner actually painted. A load that finishes | |
| 1751 | + * inside the show delay never reached `--visible`, so the shell drops | |
| 1752 | + * the overlay in the same tick instead. | |
| 1753 | + * | |
| 1754 | + * `--revealing` declares the same selector at the same specificity and | |
| 1755 | + * comes later in the file, so a window playing a reveal keeps its | |
| 1756 | + * content opaque under the reveal surface. | |
| 1757 | + */ | |
| 1758 | +.os-window__body--loading-out > .os-window__iframe, | |
| 1759 | +.os-window__body--loading-out | |
| 1760 | + > :not(.os-window__loading):not(.os-window__reveal) { | |
| 1761 | + opacity: 1; | |
| 1762 | + transition: opacity 0.25s ease 0.25s; | |
| 1763 | +} | |
| 1764 | + | |
| 582 | 1765 | @media ( prefers-reduced-motion: reduce ) { |
| 583 | - .desktop-mode-window__iframe, | |
| 584 | - .desktop-mode-window__loading, | |
| 585 | - .desktop-mode-window__body--loading > .desktop-mode-window__iframe, | |
| 586 | - .desktop-mode-window__body--loading > :not(.desktop-mode-window__loading) { | |
| 1766 | + .os-window__iframe, | |
| 1767 | + .os-window__loading, | |
| 1768 | + .os-window__body--loading > .os-window__iframe, | |
| 1769 | + .os-window__body--loading | |
| 1770 | + > :not(.os-window__loading):not(.os-window__reveal), | |
| 1771 | + .os-window__body--loading-out > .os-window__iframe, | |
| 1772 | + .os-window__body--loading-out | |
| 1773 | + > :not(.os-window__loading):not(.os-window__reveal) { | |
| 587 | 1774 | transition: none; |
| 588 | 1775 | } |
| 589 | 1776 | } |
| 590 | 1777 | |
| 591 | 1778 | /* |
| 1779 | + * Window reveal — the opaque layers a window's content is uncovered | |
| 1780 | + * from once it reports ready. Painted for every window (of either | |
| 1781 | + * kind) from construction, animated away by `src/reveals/surface.ts`, | |
| 1782 | + * then removed. | |
| 1783 | + * | |
| 1784 | + * They are SIBLINGS of the iframe, never wrappers. `clip-path` is | |
| 1785 | + * animated on these elements alone, so the content below keeps its own | |
| 1786 | + * compositing layer, its hit-testing, and its stacking context — a | |
| 1787 | + * reveal cannot swallow a click or re-rasterize the page it is | |
| 1788 | + * uncovering. Native window content gets the identical treatment for | |
| 1789 | + * the same reason. | |
| 1790 | + * | |
| 1791 | + * `--os-window-reveal-surface` is a theme token, white by | |
| 1792 | + * default — the surface has to be opaque or there is nothing to reveal | |
| 1793 | + * from. The EDGE token is `transparent` by default instead, since an | |
| 1794 | + * edge is an accent rather than the effect itself. Either layer that | |
| 1795 | + * computes to no paint is skipped rather than animated. A def may | |
| 1796 | + * override the surface for itself with an inline `background` (see | |
| 1797 | + * `WindowRevealDef.surfaceColor`), which is how `obturator` gets its | |
| 1798 | + * near-black shutter blades regardless of the token. | |
| 1799 | + * | |
| 1800 | + * No `transition` here on purpose — the shape is driven entirely by | |
| 1801 | + * the Web Animations API, which needs a matched `from` / `to` pair a | |
| 1802 | + * CSS transition cannot express. Reduced motion is handled JS-side by | |
| 1803 | + * removing the layers instead of animating them. | |
| 1804 | + */ | |
| 1805 | +.os-window__reveal { | |
| 1806 | + position: absolute; | |
| 1807 | + inset: 0; | |
| 1808 | + pointer-events: none; | |
| 1809 | + background: var( --os-window-reveal-surface, #fff ); | |
| 1810 | + z-index: 3; | |
| 1811 | +} | |
| 1812 | + | |
| 1813 | +/* | |
| 1814 | + * The reveal's leading edge. Same element shape, same keyframes, run | |
| 1815 | + * over a slightly longer duration so it trails the surface — the sliver | |
| 1816 | + * of it that is not yet covered by the surface IS the edge band. That | |
| 1817 | + * is why this needs no shape of its own, and why every reveal (built-in | |
| 1818 | + * or third-party) gets an edge that follows its geometry exactly. | |
| 1819 | + * | |
| 1820 | + * Behind the surface, still above the content. | |
| 1821 | + * | |
| 1822 | + * `--os-window-reveal-edge` is `transparent` by default, and | |
| 1823 | + * the shell drops the layer entirely while it computes that way — so | |
| 1824 | + * the default costs no element and no animation. Give the token a | |
| 1825 | + * colour (or a gradient) to turn the edge on across every reveal at | |
| 1826 | + * once. `--os-window-reveal-edge-thickness` tunes how wide | |
| 1827 | + * the band is; `edgeLag: 0` on a def opts a single reveal out. | |
| 1828 | + */ | |
| 1829 | +.os-window__reveal--edge { | |
| 1830 | + background: var( --os-window-reveal-edge, transparent ); | |
| 1831 | + z-index: 2; | |
| 1832 | +} | |
| 1833 | + | |
| 1834 | +/* | |
| 1835 | + * A reveal that renders its own DOM (`WindowRevealDef.render`) paints | |
| 1836 | + * itself — an `<svg>`, a canvas, whatever it built. The surface token | |
| 1837 | + * must NOT apply underneath it: an opaque rectangle behind the | |
| 1838 | + * renderer's output is what the effect would end up uncovering, so the | |
| 1839 | + * animation would play against a flat colour and the real content | |
| 1840 | + * would only appear when the layer is finally removed. | |
| 1841 | + */ | |
| 1842 | +.os-window__reveal--custom { | |
| 1843 | + background: none; | |
| 1844 | +} | |
| 1845 | + | |
| 1846 | +/* | |
| 1847 | + * While a reveal plays, pin the content to full opacity with no | |
| 1848 | + * transition. Dropping `--loading` normally starts a 250 ms content | |
| 1849 | + * fade-in; a reveal running over that fade would show a | |
| 1850 | + * half-transparent strip along its leading edge. Beats the base | |
| 1851 | + * `.os-window__iframe` rule on specificity, so no | |
| 1852 | + * `!important` is needed. | |
| 1853 | + */ | |
| 1854 | +.os-window__body--revealing | |
| 1855 | + > :not(.os-window__loading):not(.os-window__reveal) { | |
| 1856 | + opacity: 1; | |
| 1857 | + transition: none; | |
| 1858 | +} | |
| 1859 | + | |
| 1860 | +/* | |
| 592 | 1861 | * External sub-tab iframes stack in the same body as the primary |
| 593 | 1862 | * iframe. Only one is visible at a time (managed by `switchToTab` |
| 594 | 1863 | * via `style.display`). Absolute positioning so they don't push the |
| 595 | 1864 | * primary iframe around when added to the DOM; `display: none` on |
| @@ -594,23 +1863,57 @@ | ||
| 594 | 1863 | * via `style.display`). Absolute positioning so they don't push the |
| 595 | 1864 | * primary iframe around when added to the DOM; `display: none` on |
| 596 | 1865 | * inactive iframes is set inline by the JS. |
| 597 | 1866 | */ |
| 598 | -.desktop-mode-window__iframe--external { | |
| 1867 | +.os-window__iframe--external { | |
| 599 | 1868 | position: absolute; |
| 600 | 1869 | inset: 0; |
| 601 | 1870 | } |
| 602 | 1871 | |
| 603 | 1872 | /* |
| 1873 | + * Double-buffer twin for `Window.swapReload()` — the silent refresh | |
| 1874 | + * the editor-preview companion uses. The twin loads UNDERNEATH the | |
| 1875 | + * visible frame at full opacity: a normal, fully-rasterized paint | |
| 1876 | + * target, completely covered by the (opaque) old frame while it | |
| 1877 | + * loads. Deliberately not `opacity: 0`-on-top or | |
| 1878 | + * `visibility: hidden` — browsers defer rasterizing invisible | |
| 1879 | + * iframes, and revealing such a frame paints its blank background | |
| 1880 | + * before its raster lands (a white blink). | |
| 1881 | + * | |
| 1882 | + * Stacking: positioned elements paint above static ones regardless | |
| 1883 | + * of DOM order, so the buffer (absolute) would land on top of the | |
| 1884 | + * static primary frame — `--swap-front` elevates the OLD frame for | |
| 1885 | + * the duration of the swap instead. The swap itself is instant and | |
| 1886 | + * animation-free: removing the old frame exposes the ready-painted | |
| 1887 | + * twin in the same compositor frame. At no point is unpainted | |
| 1888 | + * content the only thing on screen. | |
| 1889 | + */ | |
| 1890 | +.os-window__iframe--buffer { | |
| 1891 | + position: absolute; | |
| 1892 | + inset: 0; | |
| 1893 | + pointer-events: none; | |
| 1894 | +} | |
| 1895 | + | |
| 1896 | +.os-window__iframe--swap-front { | |
| 1897 | + position: relative; | |
| 1898 | + z-index: 1; | |
| 1899 | +} | |
| 1900 | + | |
| 1901 | +/* | |
| 604 | 1902 | * Native window body — fills the window but lets its contents scroll |
| 605 | 1903 | * independently. Iframe bodies use `overflow: hidden` so the iframe |
| 606 | 1904 | * controls its own scroll; native bodies render real document content |
| 607 | 1905 | * that needs the parent to manage overflow. |
| 608 | 1906 | */ |
| 609 | -.desktop-mode-window__body--native { | |
| 1907 | +.os-window__body--native { | |
| 610 | 1908 | overflow: auto; |
| 611 | - color: #1d2327; | |
| 612 | - background: var(--desktop-mode-window-bg); | |
| 1909 | + color: var( --os-ui-fg, #1d2327 ); | |
| 1910 | + /* background-COLOR, not the shorthand. This is a modifier on the | |
| 1911 | + * SAME element as `.os-window__body`, at the same | |
| 1912 | + * specificity and later in source order — the shorthand would | |
| 1913 | + * reset the WINDOW_BODY texture declared there, which is exactly | |
| 1914 | + * the surface native windows exist to show. */ | |
| 1915 | + background-color: var(--os-window-bg); | |
| 613 | 1916 | } |
| 614 | 1917 | |
| 615 | 1918 | /* |
| 616 | 1919 | * Highlight rings. Toggled from JS by `Window.setHighlight()` — |
| @@ -642,12 +1945,12 @@ | ||
| 642 | 1945 | * plugin has registered a button for the window. When buttons ARE |
| 643 | 1946 | * present, they sit inline next to the title (left slot) or just |
| 644 | 1947 | * before the window controls (right slot). |
| 645 | 1948 | */ |
| 646 | -.desktop-mode-window__custom-buttons { | |
| 1949 | +.os-window__custom-buttons { | |
| 647 | 1950 | display: contents; |
| 648 | 1951 | } |
| 649 | -.desktop-mode-window__btn--custom { | |
| 1952 | +.os-window__btn--custom { | |
| 650 | 1953 | margin: 0 2px; |
| 651 | 1954 | } |
| 652 | 1955 | /* |
| 653 | 1956 | * Plugin-supplied icons render in the host's light DOM so the |
| @@ -669,22 +1972,64 @@ | ||
| 669 | 1972 | * width/height, we DO clamp to 18×18 — those are the cases where |
| 670 | 1973 | * a bare `<svg>` would otherwise size to its viewBox or default |
| 671 | 1974 | * to 300×150 (the spec default) and overflow the button. |
| 672 | 1975 | */ |
| 673 | -.desktop-mode-window__btn--custom svg:not( [ width ] ):not( [ height ] ) { | |
| 1976 | +.os-window__btn--custom svg:not( [ width ] ):not( [ height ] ) { | |
| 674 | 1977 | width: 18px; |
| 675 | 1978 | height: 18px; |
| 676 | 1979 | display: block; |
| 677 | 1980 | } |
| 678 | 1981 | |
| 1982 | +/* | |
| 1983 | + * Busy state for custom title-bar buttons — a gentle opacity pulse | |
| 1984 | + * while a round-trip is in flight (e.g. the editor-preview eye | |
| 1985 | + * waiting on the editor's autosave before opening the preview). | |
| 1986 | + * Driven by `aria-busy="true"` + this class, both set by the button's | |
| 1987 | + * render callback. | |
| 1988 | + */ | |
| 1989 | +.os-window__btn--busy { | |
| 1990 | + animation: os-btn-busy-pulse 1s ease-in-out infinite; | |
| 1991 | + pointer-events: none; | |
| 1992 | +} | |
| 1993 | + | |
| 1994 | +@keyframes os-btn-busy-pulse { | |
| 1995 | + 0%, | |
| 1996 | + 100% { | |
| 1997 | + opacity: 1; | |
| 1998 | + } | |
| 1999 | + | |
| 2000 | + 50% { | |
| 2001 | + opacity: 0.4; | |
| 2002 | + } | |
| 2003 | +} | |
| 2004 | + | |
| 2005 | +@media (prefers-reduced-motion: reduce) { | |
| 2006 | + .os-window__btn--busy { | |
| 2007 | + animation: none; | |
| 2008 | + opacity: 0.6; | |
| 2009 | + } | |
| 2010 | +} | |
| 2011 | + | |
| 2012 | +/* | |
| 2013 | + * Disabled state for custom title-bar buttons — visible but inert | |
| 2014 | + * (e.g. the editor-preview eye on an unsaved "Add New" screen, where | |
| 2015 | + * there is nothing to preview until the first save). The button keeps | |
| 2016 | + * pointer events so its explanatory tooltip/toast still works; | |
| 2017 | + * `aria-disabled` carries the semantics. | |
| 2018 | + */ | |
| 2019 | +.os-window__btn--disabled { | |
| 2020 | + opacity: 0.4; | |
| 2021 | + cursor: default; | |
| 2022 | +} | |
| 2023 | + | |
| 679 | 2024 | /* ---------------------------------------------------------------- |
| 680 | 2025 | * Custom-chrome marker — hides every framework-shipped titlebar |
| 681 | 2026 | * child while a plugin's chrome is mounted. |
| 682 | 2027 | * |
| 683 | - * The window gets the `desktop-mode-window--custom-chrome` class the | |
| 2028 | + * The window gets the `os-window--custom-chrome` class the | |
| 684 | 2029 | * moment `mountWindowChrome` succeeds (BEFORE the plugin's |
| 685 | 2030 | * `render()` runs). Default children carry the |
| 686 | - * `data-desktop-mode-default-chrome` attribute stamped at element- | |
| 2031 | + * `data-os-default-chrome` attribute stamped at element- | |
| 687 | 2032 | * creation time. The combination is a load-bearing guarantee: |
| 688 | 2033 | * even if the plugin's render() doesn't clear `titlebar.innerHTML`, |
| 689 | 2034 | * even if a plugin's destroy() leaks the standard chrome back into |
| 690 | 2035 | * place, even mid-fade during a window close — the default chrome |
| @@ -698,11 +2043,11 @@ | ||
| 698 | 2043 | * |
| 699 | 2044 | * @since 0.18.0 |
| 700 | 2045 | * ---------------------------------------------------------------- */ |
| 701 | 2046 | |
| 702 | -.desktop-mode-window--custom-chrome | |
| 703 | - > .desktop-mode-window__titlebar | |
| 704 | - > [ data-desktop-mode-default-chrome ] { | |
| 2047 | +.os-window--custom-chrome | |
| 2048 | + > .os-window__titlebar | |
| 2049 | + > [ data-os-default-chrome ] { | |
| 705 | 2050 | display: none !important; |
| 706 | 2051 | } |
| 707 | 2052 | |
| 708 | 2053 | /* --------------------------------------------------------------- |
| @@ -718,9 +2063,9 @@ | ||
| 718 | 2063 | * the animation cleanly. |
| 719 | 2064 | * |
| 720 | 2065 | * 2. While the window body is in the `--loading` state (set by |
| 721 | 2066 | * `markContentLoading()` and cleared on the iframe's |
| 722 | - * `desktop-mode-ready` postMessage), the button is dimmed and | |
| 2067 | + * `os-ready` postMessage), the button is dimmed and | |
| 723 | 2068 | * non-interactive so a second click can't desync the |
| 724 | 2069 | * chromeless bridge's loading handshake. |
| 725 | 2070 | * |
| 726 | 2071 | * `:has()` is used instead of mirroring the `--loading` modifier |
| @@ -728,10 +2073,10 @@ | ||
| 728 | 2073 | * decorates the body element. Browsers without :has() (very old) |
| 729 | 2074 | * fall back to a static dimmed button — feature, not bug. |
| 730 | 2075 | * --------------------------------------------------------------- */ |
| 731 | 2076 | |
| 732 | -.desktop-mode-window:has( .desktop-mode-window__body--loading ) | |
| 733 | - .desktop-mode-window__btn--reload { | |
| 2077 | +.os-window:has( .os-window__body--loading ) | |
| 2078 | + .os-window__btn--reload { | |
| 734 | 2079 | pointer-events: none; |
| 735 | 2080 | opacity: 0.55; |
| 736 | 2081 | } |
| 737 | 2082 | |
| @@ -744,13 +2089,13 @@ | ||
| 744 | 2089 | * of the gesture rather than progress through a long task. The `0.6s` |
| 745 | 2090 | * duration is short enough to never block the user but long enough |
| 746 | 2091 | * that the deceleration is legible. |
| 747 | 2092 | */ |
| 748 | -.desktop-mode-window__btn--reload.desktop-mode-window__btn--spinning { | |
| 749 | - animation: wpd-reload-spin-once 0.6s cubic-bezier( 0.05, 0.7, 0.1, 1 ); | |
| 2093 | +.os-window__btn--reload.os-window__btn--spinning { | |
| 2094 | + animation: os-reload-spin-once 0.6s cubic-bezier( 0.05, 0.7, 0.1, 1 ); | |
| 750 | 2095 | } |
| 751 | 2096 | |
| 752 | -@keyframes wpd-reload-spin-once { | |
| 2097 | +@keyframes os-reload-spin-once { | |
| 753 | 2098 | from { |
| 754 | 2099 | transform: rotate( 0deg ); |
| 755 | 2100 | } |
| 756 | 2101 | to { |
| @@ -758,8 +2103,8 @@ | ||
| 758 | 2103 | } |
| 759 | 2104 | } |
| 760 | 2105 | |
| 761 | 2106 | @media ( prefers-reduced-motion: reduce ) { |
| 762 | - .desktop-mode-window__btn--reload.desktop-mode-window__btn--spinning { | |
| 2107 | + .os-window__btn--reload.os-window__btn--spinning { | |
| 763 | 2108 | animation: none; |
| 764 | 2109 | } |
| 765 | 2110 | } |