| @@ -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,25 @@ | ||
| 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 | + border: 1px solid var(--os-window-border); | |
| 39 | + border-radius: var(--os-window-radius); | |
| 40 | + box-shadow: var(--os-window-shadow); | |
| 41 | + /* | |
| 42 | + * Desktop-theme window frame (WINDOW_FRAME texture slot). All | |
| 43 | + * four properties default to their CSS initial value via the | |
| 44 | + * `var( …, fallback )` second argument, so with no theme active | |
| 45 | + * this costs nothing and the 1px border above stays in charge. | |
| 46 | + * A theme that sets `--os-window-border-image-source` | |
| 47 | + * takes the frame over entirely — border-image paints on top of | |
| 48 | + * (and visually replaces) the border. See docs/desktop-themes.md. | |
| 49 | + */ | |
| 50 | + border-image-source: var(--os-window-border-image-source, none); | |
| 51 | + border-image-slice: var(--os-window-border-image-slice, 100%); | |
| 52 | + border-image-width: var(--os-window-border-image-width, 1); | |
| 53 | + border-image-repeat: var(--os-window-border-image-repeat, stretch); | |
| 41 | 54 | overflow: hidden; |
| 42 | 55 | min-width: 320px; |
| 43 | 56 | min-height: 200px; |
| 44 | 57 | transform-origin: center center; |
| @@ -49,9 +62,15 @@ | ||
| 49 | 62 | height 0.25s cubic-bezier(0.2, 0, 0.2, 1), |
| 50 | 63 | border-radius 0.25s ease, |
| 51 | 64 | transform 0.2s ease, |
| 52 | 65 | opacity 0.2s ease, |
| 53 | - box-shadow 0.2s ease; | |
| 66 | + box-shadow 0.2s ease, | |
| 67 | + /* Unfocus effects (e.g. `--fx-darken` / `--fx-frost`) toggle | |
| 68 | + * `filter`; keep it in the shared transition list so the | |
| 69 | + * treatment fades both ways as focus moves between windows. | |
| 70 | + * Duration is a custom property (default in `effects.css`) so | |
| 71 | + * the blur/darken ramp can be tuned without editing this list. */ | |
| 72 | + filter var( --os-fx-transition-duration, 0.5s ) ease-in-out; | |
| 54 | 73 | } |
| 55 | 74 | |
| 56 | 75 | /* |
| 57 | 76 | * Suppress the left/top/width/height transition during drag or resize — |
| @@ -63,11 +82,11 @@ | ||
| 63 | 82 | * transition, leaving maximized / snapped windows ~250 ms behind the |
| 64 | 83 | * viewport the whole time. The class is added by |
| 65 | 84 | * `reflowStatefulWindows` and cleared ~140 ms after the last tick. |
| 66 | 85 | */ |
| 67 | -.desktop-mode-window--dragging, | |
| 68 | -.desktop-mode-window--resizing, | |
| 69 | -.desktop-mode-window--reflowing { | |
| 86 | +.os-window--dragging, | |
| 87 | +.os-window--resizing, | |
| 88 | +.os-window--reflowing { | |
| 70 | 89 | transition: none; |
| 71 | 90 | } |
| 72 | 91 | |
| 73 | 92 | /* |
| @@ -78,9 +97,9 @@ | ||
| 78 | 97 | * `--resizing` and the source-order ensures it wins. Kept short |
| 79 | 98 | * (90 ms) so the window still feels glued to the cursor — anything |
| 80 | 99 | * longer reads as lag. |
| 81 | 100 | */ |
| 82 | -.desktop-mode-window--snap-drag { | |
| 101 | +.os-window--snap-drag { | |
| 83 | 102 | transition: |
| 84 | 103 | left 0.09s ease-out, |
| 85 | 104 | top 0.09s ease-out, |
| 86 | 105 | width 0.09s ease-out, |
| @@ -87,14 +106,37 @@ | ||
| 87 | 106 | height 0.09s ease-out; |
| 88 | 107 | } |
| 89 | 108 | |
| 90 | 109 | /* Focused window gets elevated shadow and distinct title bar. */ |
| 91 | -.desktop-mode-window--focused { | |
| 92 | - box-shadow: var(--desktop-mode-window-shadow-focused); | |
| 110 | +.os-window--focused { | |
| 111 | + box-shadow: var(--os-window-shadow-focused); | |
| 112 | + /* | |
| 113 | + * WINDOW_FRAME_FOCUSED texture slot. Each property falls through | |
| 114 | + * the unfocused WINDOW_FRAME value before reaching its initial, so | |
| 115 | + * a theme shipping one frame gets it on both states and a theme | |
| 116 | + * shipping two gets a frame that lights up on focus — the same | |
| 117 | + * fallback chain TITLEBAR_FOCUSED uses. | |
| 118 | + */ | |
| 119 | + border-image-source: var( | |
| 120 | + --os-window-border-image-focused-source, | |
| 121 | + var(--os-window-border-image-source, none) | |
| 122 | + ); | |
| 123 | + border-image-slice: var( | |
| 124 | + --os-window-border-image-focused-slice, | |
| 125 | + var(--os-window-border-image-slice, 100%) | |
| 126 | + ); | |
| 127 | + border-image-width: var( | |
| 128 | + --os-window-border-image-focused-width, | |
| 129 | + var(--os-window-border-image-width, 1) | |
| 130 | + ); | |
| 131 | + border-image-repeat: var( | |
| 132 | + --os-window-border-image-focused-repeat, | |
| 133 | + var(--os-window-border-image-repeat, stretch) | |
| 134 | + ); | |
| 93 | 135 | } |
| 94 | 136 | |
| 95 | 137 | /* Title bar. */ |
| 96 | -.desktop-mode-window__titlebar { | |
| 138 | +.os-window__titlebar { | |
| 97 | 139 | position: relative; |
| 98 | 140 | /* Promote the titlebar into its own stacking context above the |
| 99 | 141 | * body. Without this, the body (which comes later in source order |
| 100 | 142 | * and inherits z-auto) wins document-order overlap battles — |
| @@ -103,12 +145,35 @@ | ||
| 103 | 145 | * paint over the titlebar's ⋯ menu popover. */ |
| 104 | 146 | z-index: 21; |
| 105 | 147 | display: flex; |
| 106 | 148 | align-items: center; |
| 107 | - height: var(--desktop-mode-titlebar-height); | |
| 149 | + height: var(--os-titlebar-height); | |
| 108 | 150 | padding: 0 8px; |
| 109 | - background: var(--desktop-mode-titlebar-bg); | |
| 110 | - color: var(--desktop-mode-titlebar-color); | |
| 151 | + background-color: var(--os-titlebar-bg); | |
| 152 | + /* | |
| 153 | + * Desktop-theme title-bar texture (TITLEBAR slot). `none` when | |
| 154 | + * unset, so the background-color above is the whole story for | |
| 155 | + * an unthemed shell. The `-repeat` / `-size` companions are | |
| 156 | + * declared unconditionally with their CSS initial values as | |
| 157 | + * fallbacks — cheaper than a second selector and it keeps the | |
| 158 | + * shorthand from resetting them. | |
| 159 | + */ | |
| 160 | + background-image: var(--os-titlebar-image, none); | |
| 161 | + background-repeat: var(--os-titlebar-image-repeat, repeat); | |
| 162 | + background-size: var(--os-titlebar-image-size, auto); | |
| 163 | + background-position: var(--os-titlebar-image-position, center); | |
| 164 | + color: var(--os-titlebar-color); | |
| 165 | + /* | |
| 166 | + * Title bars can carry their own face — a display typeface here | |
| 167 | + * with a text face in the body is the classic desktop split. Two | |
| 168 | + * levels of fallback: the title-bar token, then the shell-wide | |
| 169 | + * one, then `inherit`, which is what an undeclared font-family | |
| 170 | + * would have computed to anyway. Unthemed shells are unchanged. | |
| 171 | + */ | |
| 172 | + font-family: var( | |
| 173 | + --os-titlebar-font, | |
| 174 | + var(--os-font, inherit) | |
| 175 | + ); | |
| 111 | 176 | cursor: default; |
| 112 | 177 | user-select: none; |
| 113 | 178 | flex-shrink: 0; |
| 114 | 179 | gap: 8px; |
| @@ -113,15 +178,24 @@ | ||
| 113 | 178 | flex-shrink: 0; |
| 114 | 179 | gap: 8px; |
| 115 | 180 | } |
| 116 | 181 | |
| 117 | -.desktop-mode-window--focused .desktop-mode-window__titlebar { | |
| 118 | - background: var(--desktop-mode-titlebar-bg-focused); | |
| 119 | - color: var(--desktop-mode-titlebar-color-focused); | |
| 182 | +.os-window--focused .os-window__titlebar { | |
| 183 | + background-color: var(--os-titlebar-bg-focused); | |
| 184 | + /* | |
| 185 | + * TITLEBAR_FOCUSED slot. Falls back through the unfocused | |
| 186 | + * TITLEBAR image before reaching `none`, so a theme that ships | |
| 187 | + * only one title-bar texture gets it on both states for free. | |
| 188 | + */ | |
| 189 | + background-image: var( | |
| 190 | + --os-titlebar-image-focused, | |
| 191 | + var(--os-titlebar-image, none) | |
| 192 | + ); | |
| 193 | + color: var(--os-titlebar-color-focused); | |
| 120 | 194 | } |
| 121 | 195 | |
| 122 | 196 | /* Window icon in title bar. */ |
| 123 | -.desktop-mode-window__icon { | |
| 197 | +.os-window__icon { | |
| 124 | 198 | font-size: 18px; |
| 125 | 199 | width: 18px; |
| 126 | 200 | height: 18px; |
| 127 | 201 | flex-shrink: 0; |
| @@ -126,19 +200,105 @@ | ||
| 126 | 200 | height: 18px; |
| 127 | 201 | flex-shrink: 0; |
| 128 | 202 | } |
| 129 | 203 | |
| 130 | -/* Activity indicator slot — sits between the icon and the title. | |
| 131 | - * Reserves a fixed width so the indicator's blink animation can't | |
| 132 | - * shift the title text horizontally. The inner `<wpd-save-status>` | |
| 133 | - * paints a 12px modem-style dot (always visible, accent-colored). | |
| 204 | +/* Letter-badge fallback (unrecognized icon values) — shrink the | |
| 205 | + * one-/two-letter monogram so it fits the 18px icon box. */ | |
| 206 | +.os-window__icon.os-icon-letter { | |
| 207 | + font-size: 9px; | |
| 208 | +} | |
| 209 | + | |
| 210 | +/* --------------------------------------------------------------- | |
| 211 | + * Window activity — the status ring. | |
| 134 | 212 | * |
| 135 | - * `--wp-admin-theme-color` is forwarded as `color` on the host so | |
| 136 | - * the inner shadow-DOM stylesheet's `currentColor` references | |
| 137 | - * (used in the box-shadow glow) resolve to the live accent. */ | |
| 138 | -.desktop-mode-window__activity { | |
| 213 | + * The leading mark of the title bar, in the position the app icon | |
| 214 | + * used to hold. That icon was a copy of the window's own dock tile a | |
| 215 | + * few hundred pixels below it, and a title bar has room for one mark | |
| 216 | + * of that size — better spent on something that changes. | |
| 217 | + * | |
| 218 | + * The ring is an `<os-save-status variant="ring" mode="icon">`, found | |
| 219 | + * by `[data-os-activity-indicator]` — the same public attribute a | |
| 220 | + * plugin uses to mount its own. The framework's ring is not a special | |
| 221 | + * case; it is the first subscriber. | |
| 222 | + * | |
| 223 | + * Four states, and only one of them fills: | |
| 224 | + * | |
| 225 | + * idle white outline, no glyph | |
| 226 | + * saving accent outline, breathing | |
| 227 | + * saved accent fill, white check | |
| 228 | + * failed open red outline, red bang | |
| 229 | + * | |
| 230 | + * Colour alone is not a distinction every user can make, which is why | |
| 231 | + * the two outcomes differ in SHAPE — filled versus open, check versus | |
| 232 | + * bang — and not only in hue. | |
| 233 | + * | |
| 234 | + * `Window._paintActivityIndicator()` also mirrors the phase onto the | |
| 235 | + * title bar as `data-os-activity` (absent while idle) so a desktop | |
| 236 | + * theme can react to window state without reaching into the | |
| 237 | + * component's shadow root. | |
| 238 | + */ | |
| 239 | +.os-window__status { | |
| 240 | + --os-ui-save-status-size: var(--os-titlebar-activity-size, 16px); | |
| 241 | + /* | |
| 242 | + * At rest: a white ring. One value, in both title-bar states — | |
| 243 | + * the phase is what the ring reports, and dimming it on an | |
| 244 | + * unfocused window would make "idle" say two different things | |
| 245 | + * depending on which window you last clicked. | |
| 246 | + */ | |
| 247 | + --os-ui-save-status-idle-color: var(--os-titlebar-activity-idle-color, #fff); | |
| 248 | + /* | |
| 249 | + * The RING colour, not the dot's background. Setting | |
| 250 | + * `--os-ui-save-status-bg` here instead is what once painted a | |
| 251 | + * solid accent fill inside the resting outline: that token is the | |
| 252 | + * dot's `background` on the component's base rule, and the ring | |
| 253 | + * only borrows it as a border. The ring has its own name for | |
| 254 | + * exactly this reason. | |
| 255 | + */ | |
| 256 | + --os-ui-save-status-ring-color: var(--os-titlebar-activity-color, #2271b1); | |
| 257 | + --os-ui-save-status-saved-bg: var(--os-titlebar-activity-saved-color, #2271b1); | |
| 258 | + --os-ui-save-status-failed-bg: var(--os-titlebar-activity-failed-color, #d63638); | |
| 139 | 259 | display: inline-flex; |
| 140 | 260 | align-items: center; |
| 261 | + flex-shrink: 0; | |
| 262 | +} | |
| 263 | + | |
| 264 | +/* | |
| 265 | + * The announcement. A glow is invisible to a screen reader, and | |
| 266 | + * "did that save?" is precisely the question that can't be answered | |
| 267 | + * by looking. `Window._paintActivityIndicator()` writes the outcome | |
| 268 | + * here; absolute positioning keeps it out of the title bar's flex | |
| 269 | + * flow so it contributes neither a box nor a `gap`. | |
| 270 | + */ | |
| 271 | +.os-window__activity-status { | |
| 272 | + position: absolute; | |
| 273 | + width: 1px; | |
| 274 | + height: 1px; | |
| 275 | + margin: -1px; | |
| 276 | + padding: 0; | |
| 277 | + border: 0; | |
| 278 | + overflow: hidden; | |
| 279 | + clip-path: inset(50%); | |
| 280 | + white-space: nowrap; | |
| 281 | +} | |
| 282 | + | |
| 283 | +/* | |
| 284 | + * Activity indicator slot — opt-in, and empty by default. | |
| 285 | + * | |
| 286 | + * The framework paints the glow above instead of putting an | |
| 287 | + * `<os-save-status>` in the title bar of its own accord. The slot | |
| 288 | + * survives for anything that DOES want a literal dot as well: give an | |
| 289 | + * `<os-save-status>` the `data-os-activity-indicator` attribute, drop | |
| 290 | + * it in a title-bar slot inside a `.os-window__activity` wrapper, and | |
| 291 | + * `Window._paintActivityIndicator()` drives its phase for you. | |
| 292 | + * | |
| 293 | + * The fixed width is what keeps the blink from shifting the title | |
| 294 | + * text sideways, and `--wp-admin-theme-color` is forwarded as `color` | |
| 295 | + * so the component's shadow-DOM `currentColor` references (the glow's | |
| 296 | + * box-shadow) resolve to the live accent. | |
| 297 | + */ | |
| 298 | +.os-window__activity { | |
| 299 | + display: inline-flex; | |
| 300 | + align-items: center; | |
| 141 | 301 | justify-content: center; |
| 142 | 302 | width: 14px; |
| 143 | 303 | height: 14px; |
| 144 | 304 | flex-shrink: 0; |
| @@ -147,9 +307,9 @@ | ||
| 147 | 307 | color: var(--wp-admin-theme-color, #2271b1); |
| 148 | 308 | } |
| 149 | 309 | |
| 150 | 310 | /* Window title text. */ |
| 151 | -.desktop-mode-window__title { | |
| 311 | +.os-window__title { | |
| 152 | 312 | flex: 1; |
| 153 | 313 | overflow: hidden; |
| 154 | 314 | text-overflow: ellipsis; |
| 155 | 315 | white-space: nowrap; |
| @@ -154,20 +314,63 @@ | ||
| 154 | 314 | text-overflow: ellipsis; |
| 155 | 315 | white-space: nowrap; |
| 156 | 316 | font-size: 14px; |
| 157 | 317 | font-weight: 500; |
| 158 | - line-height: var(--desktop-mode-titlebar-height); | |
| 318 | + line-height: var(--os-titlebar-height); | |
| 159 | 319 | } |
| 160 | 320 | |
| 161 | -/* Window control buttons container. */ | |
| 162 | -.desktop-mode-window__controls { | |
| 321 | +/* | |
| 322 | + * Window control buttons container. | |
| 323 | + * | |
| 324 | + * The cluster is TRANSPARENT by default, which is the whole point: a | |
| 325 | + * themed title-bar texture runs edge to edge underneath it, and the | |
| 326 | + * buttons float on the artwork rather than sitting on a plate. Every | |
| 327 | + * property below resolves to exactly that when unset. | |
| 328 | + * | |
| 329 | + * A theme that wants a plate instead sets `--os-titlebar- | |
| 330 | + * controls-bg` (and optionally the TITLEBAR_CONTROLS texture slot, | |
| 331 | + * a radius, and some inline padding) — the classic "the controls live | |
| 332 | + * in their own well" look, without the framework picking it for | |
| 333 | + * everyone. | |
| 334 | + * | |
| 335 | + * `padding-block` stays 0 so the cluster never changes the title | |
| 336 | + * bar's height; only the inline padding is themable. | |
| 337 | + * | |
| 338 | + * @since 0.9.8 | |
| 339 | + */ | |
| 340 | +.os-window__controls { | |
| 163 | 341 | display: flex; |
| 164 | - gap: 4px; | |
| 342 | + gap: var( --os-titlebar-controls-gap, 4px ); | |
| 165 | 343 | align-items: center; |
| 166 | 344 | flex-shrink: 0; |
| 345 | + padding-inline: var( --os-titlebar-controls-padding, 0 ); | |
| 346 | + border-radius: var( --os-titlebar-controls-radius, 0 ); | |
| 347 | + background-color: var( --os-titlebar-controls-bg, transparent ); | |
| 348 | + background-image: var( --os-titlebar-controls-image, none ); | |
| 349 | + background-repeat: var( --os-titlebar-controls-image-repeat, repeat ); | |
| 350 | + background-size: var( --os-titlebar-controls-image-size, auto ); | |
| 351 | + background-position: var( --os-titlebar-controls-image-position, center ); | |
| 167 | 352 | } |
| 168 | 353 | |
| 169 | 354 | /* |
| 355 | + * Same treatment for the Screen Options / Help cluster, so a theme | |
| 356 | + * that plates one can plate the other and keep them consistent. | |
| 357 | + * Falls through to the controls tokens, so setting the pair above is | |
| 358 | + * enough for both. | |
| 359 | + */ | |
| 360 | +.os-window__screen-meta { | |
| 361 | + border-radius: var( | |
| 362 | + --os-titlebar-meta-radius, | |
| 363 | + var( --os-titlebar-controls-radius, 0 ) | |
| 364 | + ); | |
| 365 | + background-color: var( --os-titlebar-meta-bg, transparent ); | |
| 366 | + background-image: var( --os-titlebar-meta-image, none ); | |
| 367 | + background-repeat: var( --os-titlebar-meta-image-repeat, repeat ); | |
| 368 | + background-size: var( --os-titlebar-meta-image-size, auto ); | |
| 369 | + background-position: var( --os-titlebar-meta-image-position, center ); | |
| 370 | +} | |
| 371 | + | |
| 372 | +/* | |
| 170 | 373 | * Layer-3 slot hosts. |
| 171 | 374 | * |
| 172 | 375 | * Most in-titlebar slots wrap canonical chrome elements without |
| 173 | 376 | * contributing to the flex layout themselves — `display: contents` |
| @@ -174,8 +377,13 @@ | ||
| 174 | 377 | * removes the wrapper's box so the inner element (icon span with |
| 175 | 378 | * `flex-shrink: 0`, before/after spacers, etc.) keeps its |
| 176 | 379 | * original layout role. |
| 177 | 380 | * |
| 381 | + * The `icon` slot is empty by default now — the app icon it used to | |
| 382 | + * carry duplicated the window's dock tile — so on most windows it | |
| 383 | + * contributes nothing at all. It keeps `display: contents` for the | |
| 384 | + * ones where a plugin or a desktop theme does render an icon into it. | |
| 385 | + * | |
| 178 | 386 | * The `title` slot is the exception: it's the flex-grow region of |
| 179 | 387 | * the title bar (`flex: 1`). When a plugin replaces the default |
| 180 | 388 | * title with custom HTML or a render callback, we want the new |
| 181 | 389 | * content to inherit the same "fill the remaining horizontal |
| @@ -183,23 +391,24 @@ | ||
| 183 | 391 | * default title element's own `flex: 1` cooperates inside the |
| 184 | 392 | * nested flex. |
| 185 | 393 | * |
| 186 | 394 | * before/after-titlebar slots are flex-column children of |
| 187 | - * `.desktop-mode-window` (siblings of the title bar). They're real | |
| 395 | + * `.os-window` (siblings of the title bar). They're real | |
| 188 | 396 | * boxes — plugins can paint backgrounds, padding, borders. Empty |
| 189 | 397 | * hosts collapse via `:empty` so they take no space until populated. |
| 190 | 398 | * |
| 191 | 399 | * @since 0.6.0 |
| 192 | 400 | */ |
| 193 | -.desktop-mode-window__slot--before-icon, | |
| 194 | -.desktop-mode-window__slot--icon, | |
| 195 | -.desktop-mode-window__slot--after-title, | |
| 196 | -.desktop-mode-window__slot--before-controls, | |
| 197 | -.desktop-mode-window__slot--after-controls { | |
| 401 | +.os-window__slot--before-icon, | |
| 402 | +.os-window__slot--icon, | |
| 403 | +.os-window__slot--after-title, | |
| 404 | +.os-window__slot--before-controls, | |
| 405 | +.os-window__slot--after-controls { | |
| 198 | 406 | display: contents; |
| 199 | 407 | } |
| 200 | 408 | |
| 201 | -.desktop-mode-window__slot--title { | |
| 409 | + | |
| 410 | +.os-window__slot--title { | |
| 202 | 411 | flex: 1; |
| 203 | 412 | min-width: 0; |
| 204 | 413 | display: flex; |
| 205 | 414 | align-items: center; |
| @@ -205,16 +414,16 @@ | ||
| 205 | 414 | align-items: center; |
| 206 | 415 | overflow: hidden; |
| 207 | 416 | } |
| 208 | 417 | |
| 209 | -.desktop-mode-window__slot--before-titlebar, | |
| 210 | -.desktop-mode-window__slot--after-titlebar { | |
| 418 | +.os-window__slot--before-titlebar, | |
| 419 | +.os-window__slot--after-titlebar { | |
| 211 | 420 | display: block; |
| 212 | 421 | flex-shrink: 0; |
| 213 | 422 | } |
| 214 | 423 | |
| 215 | -.desktop-mode-window__slot--before-titlebar:empty, | |
| 216 | -.desktop-mode-window__slot--after-titlebar:empty { | |
| 424 | +.os-window__slot--before-titlebar:empty, | |
| 425 | +.os-window__slot--after-titlebar:empty { | |
| 217 | 426 | display: none; |
| 218 | 427 | } |
| 219 | 428 | |
| 220 | 429 | /* --------------------------------------------------------------- |
| @@ -230,36 +439,94 @@ | ||
| 230 | 439 | * with, matching the WordPress admin's pattern of reserving color |
| 231 | 440 | * for semantic signal. |
| 232 | 441 | * --------------------------------------------------------------- */ |
| 233 | 442 | /* |
| 234 | - * Title-bar chrome buttons render as `<wpd-window-button>`. | |
| 443 | + * Title-bar chrome buttons render as `<os-window-button>`. | |
| 235 | 444 | * Shadow DOM can't reach across the window's focus class, so we |
| 236 | 445 | * drive the coloring via custom properties that inherit through |
| 237 | - * the boundary. The component reads `--wpd-btn-color`, | |
| 238 | - * `--wpd-btn-bg-hover`, etc. and paints accordingly. | |
| 446 | + * the boundary. The component reads `--os-ui-btn-color`, | |
| 447 | + * `--os-ui-btn-bg-hover`, etc. and paints accordingly. | |
| 448 | + * | |
| 449 | + * Focused control glyphs are white at 70% — correct against the | |
| 450 | + * default focused title bar, which is the admin theme colour and so | |
| 451 | + * always a mid-to-dark fill, and invisible against a pale one. A | |
| 452 | + * theme could not reach them: these declarations land on the WINDOW | |
| 453 | + * element, so a `--os-ui-btn-color` set at the shell root loses to | |
| 454 | + * them, and the same names also drive buttons outside the title bar | |
| 455 | + * (pinned notes, the desk chrome) that a theme usually does not want | |
| 456 | + * to move in the same stroke. | |
| 457 | + * | |
| 458 | + * `--os-titlebar-btn-focused-*` mirrors the unfocused set | |
| 459 | + * below, name for name, so the two halves of the title bar are | |
| 460 | + * addressed the same way. Every one is UNDECLARED and falls back to | |
| 461 | + * the literal it replaced, so an unthemed shell paints exactly what | |
| 462 | + * it painted before. | |
| 463 | + * | |
| 464 | + * There is no `-danger-hover` twin: destructive red is semantic, not | |
| 465 | + * chrome, and both halves already resolve it through `--os-ui-danger`. | |
| 239 | 466 | */ |
| 240 | -.desktop-mode-window--focused { | |
| 241 | - --wpd-btn-color: rgba( 255, 255, 255, 0.7 ); | |
| 242 | - --wpd-btn-color-hover: #fff; | |
| 243 | - --wpd-btn-bg-hover: rgba( 255, 255, 255, 0.18 ); | |
| 244 | - --wpd-btn-bg-active: rgba( 255, 255, 255, 0.25 ); | |
| 245 | - --wpd-btn-outline: rgba( 255, 255, 255, 0.65 ); | |
| 246 | - --wpd-btn-danger-hover: #d63638; | |
| 467 | +.os-window--focused { | |
| 468 | + --os-ui-btn-color: var( --os-titlebar-btn-focused-color, rgba( 255, 255, 255, 0.7 ) ); | |
| 469 | + --os-ui-btn-color-hover: var( --os-titlebar-btn-focused-color-hover, #fff ); | |
| 470 | + --os-ui-btn-bg-hover: var( --os-titlebar-btn-focused-bg-hover, rgba( 255, 255, 255, 0.18 ) ); | |
| 471 | + --os-ui-btn-bg-active: var( --os-titlebar-btn-focused-bg-active, rgba( 255, 255, 255, 0.25 ) ); | |
| 472 | + --os-ui-btn-outline: var( --os-titlebar-btn-focused-outline, rgba( 255, 255, 255, 0.65 ) ); | |
| 473 | + --os-ui-btn-danger-hover: var( --os-ui-danger, #d63638 ); | |
| 247 | 474 | } |
| 248 | 475 | |
| 249 | -.desktop-mode-window:not( .desktop-mode-window--focused ) { | |
| 250 | - --wpd-btn-color: rgba( 0, 0, 0, 0.45 ); | |
| 251 | - --wpd-btn-color-hover: rgba( 0, 0, 0, 0.85 ); | |
| 252 | - --wpd-btn-bg-hover: rgba( 0, 0, 0, 0.08 ); | |
| 253 | - --wpd-btn-bg-active: rgba( 0, 0, 0, 0.12 ); | |
| 254 | - --wpd-btn-outline: var( --wp-admin-theme-color, #2271b1 ); | |
| 255 | - --wpd-btn-danger-hover: #d63638; | |
| 476 | +.os-window:not( .os-window--focused ) { | |
| 477 | + --os-ui-btn-color: var( --os-titlebar-btn-color, rgba( 0, 0, 0, 0.45 ) ); | |
| 478 | + --os-ui-btn-color-hover: var( --os-titlebar-btn-color-hover, rgba( 0, 0, 0, 0.85 ) ); | |
| 479 | + --os-ui-btn-bg-hover: var( --os-titlebar-btn-bg-hover, rgba( 0, 0, 0, 0.08 ) ); | |
| 480 | + --os-ui-btn-bg-active: var( --os-titlebar-btn-bg-active, rgba( 0, 0, 0, 0.12 ) ); | |
| 481 | + --os-ui-btn-outline: var( --wp-admin-theme-color, #2271b1 ); | |
| 482 | + --os-ui-btn-danger-hover: var( --os-ui-danger, #d63638 ); | |
| 256 | 483 | } |
| 257 | 484 | |
| 485 | +/* | |
| 486 | + * Unfocused control glyphs under an active desktop theme. | |
| 487 | + * | |
| 488 | + * The rule above paints them BLACK at 45% — correct against the | |
| 489 | + * default light unfocused title bar (`#f0f0f1`), invisible against a | |
| 490 | + * dark themed one. They can't simply follow the palette either: | |
| 491 | + * these are title-bar chrome, so the colour they owe allegiance to is | |
| 492 | + * the title bar's own text colour, not the window body's. | |
| 493 | + * | |
| 494 | + * Deriving it with `color-mix` means a theme gets legible unfocused | |
| 495 | + * controls for free from the `--os-titlebar-color` it was | |
| 496 | + * already setting — no extra tokens to discover. The four | |
| 497 | + * `--os-titlebar-btn-*` overrides above still win when an | |
| 498 | + * author wants exact control. | |
| 499 | + * | |
| 500 | + * Scoped to `[data-os-desktop-theme]` on purpose: the | |
| 501 | + * default title-bar colour is `#50575e`, so applying this | |
| 502 | + * unconditionally would lighten the unfocused glyphs on every | |
| 503 | + * unthemed shell. No theme, no change. | |
| 504 | + */ | |
| 505 | +.os-shell[ data-os-desktop-theme ] | |
| 506 | + .os-window:not( .os-window--focused ) { | |
| 507 | + --os-ui-btn-color: var( | |
| 508 | + --os-titlebar-btn-color, | |
| 509 | + color-mix( in srgb, var( --os-titlebar-color, #50575e ) 72%, transparent ) | |
| 510 | + ); | |
| 511 | + --os-ui-btn-color-hover: var( | |
| 512 | + --os-titlebar-btn-color-hover, | |
| 513 | + var( --os-titlebar-color, #50575e ) | |
| 514 | + ); | |
| 515 | + --os-ui-btn-bg-hover: var( | |
| 516 | + --os-titlebar-btn-bg-hover, | |
| 517 | + color-mix( in srgb, var( --os-titlebar-color, #50575e ) 18%, transparent ) | |
| 518 | + ); | |
| 519 | + --os-ui-btn-bg-active: var( | |
| 520 | + --os-titlebar-btn-bg-active, | |
| 521 | + color-mix( in srgb, var( --os-titlebar-color, #50575e ) 26%, transparent ) | |
| 522 | + ); | |
| 523 | +} | |
| 524 | + | |
| 258 | 525 | /* --------------------------------------------------------------- |
| 259 | 526 | * Title-bar actions menu (leading edge, before icon + title). |
| 260 | 527 | * |
| 261 | - * Trigger is a standard `.desktop-mode-window__btn` — inherits focused / | |
| 528 | + * Trigger is a standard `.os-window__btn` — inherits focused / | |
| 262 | 529 | * unfocused coloring from the existing control-button rules. The only |
| 263 | 530 | * bespoke rule is margin: it sits at the leading edge so a small trailing |
| 264 | 531 | * gap separates it from the icon without affecting overall title-bar gap. |
| 265 | 532 | * |
| @@ -275,22 +542,28 @@ | ||
| 275 | 542 | * divider, and the controls container gains a leading one — so a |
| 276 | 543 | * single clean line always sits between page chrome and window |
| 277 | 544 | * chrome regardless of which combination is visible. |
| 278 | 545 | */ |
| 279 | -.desktop-mode-window__titlebar:has(.desktop-mode-window__menu-btn) .desktop-mode-window__screen-meta { | |
| 546 | +.os-window__titlebar:has(.os-window__menu-btn) .os-window__screen-meta { | |
| 280 | 547 | margin-inline-end: 0; |
| 281 | 548 | padding-inline-end: 0; |
| 282 | 549 | border-inline-end: none; |
| 283 | 550 | } |
| 284 | 551 | |
| 285 | -.desktop-mode-window__titlebar:has(.desktop-mode-window__menu-btn) .desktop-mode-window__controls { | |
| 552 | +.os-window__titlebar:has(.os-window__menu-btn) .os-window__controls { | |
| 286 | 553 | margin-inline-start: 8px; |
| 287 | 554 | padding-inline-start: 8px; |
| 288 | - border-inline-start: 1px solid rgba(255, 255, 255, 0.15); | |
| 555 | + /* Tokenized so a theme can retint it — or set `transparent` and | |
| 556 | + * let its own title-bar artwork carry the separation. */ | |
| 557 | + border-inline-start: 1px solid | |
| 558 | + var( --os-titlebar-divider, rgba(255, 255, 255, 0.15) ); | |
| 289 | 559 | } |
| 290 | 560 | |
| 291 | -.desktop-mode-window:not(.desktop-mode-window--focused) .desktop-mode-window__titlebar:has(.desktop-mode-window__menu-btn) .desktop-mode-window__controls { | |
| 292 | - border-inline-start-color: rgba(0, 0, 0, 0.1); | |
| 561 | +.os-window:not(.os-window--focused) .os-window__titlebar:has(.os-window__menu-btn) .os-window__controls { | |
| 562 | + border-inline-start-color: var( | |
| 563 | + --os-titlebar-divider-unfocused, | |
| 564 | + rgba(0, 0, 0, 0.1) | |
| 565 | + ); | |
| 293 | 566 | } |
| 294 | 567 | |
| 295 | 568 | /* |
| 296 | 569 | * Menu popover lives inside the title bar's absolute-positioned |
| @@ -295,17 +568,48 @@ | ||
| 295 | 568 | /* |
| 296 | 569 | * Menu popover lives inside the title bar's absolute-positioned |
| 297 | 570 | * coordinate system — positioning is caller-specific and stays |
| 298 | 571 | * outer. Background, border, items, checkbox styling all moved |
| 299 | - * into `<wpd-menu>` / `<wpd-menu-item>`. | |
| 572 | + * into `<os-menu>` / `<os-menu-item>`. | |
| 300 | 573 | */ |
| 301 | -.desktop-mode-window__menu-panel { | |
| 574 | +.os-window__menu-panel { | |
| 302 | 575 | position: absolute; |
| 303 | - top: calc( var( --desktop-mode-titlebar-height ) + 2px ); | |
| 576 | + top: calc( var( --os-titlebar-height ) + 2px ); | |
| 304 | 577 | inset-inline-end: 60px; |
| 305 | 578 | z-index: 2; |
| 306 | 579 | } |
| 307 | 580 | |
| 581 | +/* | |
| 582 | + * "Related" dropdown — opened by the related-entities title-bar | |
| 583 | + * button (`src/related-entities/`). The panel ALSO carries | |
| 584 | + * `.os-window__menu-panel` (positioning + the drag-tracker | |
| 585 | + * exclusion come from there); this class only layers the related-menu | |
| 586 | + * extras: an internal scroll cap for media-heavy posts and a sane | |
| 587 | + * minimum width. | |
| 588 | + */ | |
| 589 | +.os-window__related-panel { | |
| 590 | + max-height: min( 60vh, 420px ); | |
| 591 | + overflow-y: auto; | |
| 592 | + min-width: 180px; | |
| 593 | +} | |
| 594 | + | |
| 595 | +/* Section header inside the Related dropdown ("Categories", "Media"). */ | |
| 596 | +.os-window__related-group { | |
| 597 | + padding: 6px 12px 2px; | |
| 598 | + font-size: 11px; | |
| 599 | + font-weight: 600; | |
| 600 | + text-transform: uppercase; | |
| 601 | + letter-spacing: 0.04em; | |
| 602 | + opacity: 0.6; | |
| 603 | + color: var( --os-ui-fg ); | |
| 604 | +} | |
| 605 | + | |
| 606 | +.os-window__related-group:not(:first-child) { | |
| 607 | + margin-block-start: 4px; | |
| 608 | + border-block-start: 1px solid rgba( 128, 128, 128, 0.25 ); | |
| 609 | + padding-block-start: 8px; | |
| 610 | +} | |
| 611 | + | |
| 308 | 612 | /* --------------------------------------------------------------- |
| 309 | 613 | * Screen Meta buttons (Screen Options / Help) in the title bar. |
| 310 | 614 | * Positioned right after the title text, visually separated from |
| 311 | 615 | * the close / maximize / minimize cluster by a subtle divider. |
| @@ -310,9 +614,9 @@ | ||
| 310 | 614 | * Positioned right after the title text, visually separated from |
| 311 | 615 | * the close / maximize / minimize cluster by a subtle divider. |
| 312 | 616 | * Sized to meet WCAG 2.2 target size (24x24 minimum). |
| 313 | 617 | * --------------------------------------------------------------- */ |
| 314 | -.desktop-mode-window__screen-meta { | |
| 618 | +.os-window__screen-meta { | |
| 315 | 619 | display: flex; |
| 316 | 620 | gap: 4px; |
| 317 | 621 | align-items: center; |
| 318 | 622 | flex-shrink: 0; |
| @@ -317,17 +621,18 @@ | ||
| 317 | 621 | align-items: center; |
| 318 | 622 | flex-shrink: 0; |
| 319 | 623 | margin-inline-end: 8px; |
| 320 | 624 | padding-inline-end: 8px; |
| 321 | - border-inline-end: 1px solid rgba(255, 255, 255, 0.15); | |
| 625 | + border-inline-end: 1px solid | |
| 626 | + var( --os-titlebar-divider, rgba(255, 255, 255, 0.15) ); | |
| 322 | 627 | } |
| 323 | 628 | |
| 324 | 629 | /* Hide the divider when there are no screen-meta buttons. */ |
| 325 | -.desktop-mode-window__screen-meta:empty { | |
| 630 | +.os-window__screen-meta:empty { | |
| 326 | 631 | display: none; |
| 327 | 632 | } |
| 328 | 633 | |
| 329 | -.desktop-mode-window__meta-btn { | |
| 634 | +.os-window__meta-btn { | |
| 330 | 635 | display: flex; |
| 331 | 636 | align-items: center; |
| 332 | 637 | justify-content: center; |
| 333 | 638 | width: 28px; |
| @@ -339,54 +644,71 @@ | ||
| 339 | 644 | background: transparent; |
| 340 | 645 | transition: background-color 0.15s ease, color 0.15s ease; |
| 341 | 646 | } |
| 342 | 647 | |
| 343 | -.desktop-mode-window__meta-btn .dashicons { | |
| 648 | +.os-window__meta-btn .dashicons { | |
| 344 | 649 | font-size: 18px; |
| 345 | 650 | width: 18px; |
| 346 | 651 | height: 18px; |
| 347 | 652 | } |
| 348 | -/* The flex-centring override for the dashicon glyph lives in | |
| 349 | - * `windows.css` rather than here. `windows.css` is mtime-stamped | |
| 350 | - * (per `desktop_mode_register_assets` in includes/assets.php) so | |
| 351 | - * its URL changes whenever it does, but `@import`-ed sub-sheets | |
| 352 | - * like this one are fetched without a version query and the | |
| 353 | - * browser will serve a stale cached copy across edits to this | |
| 354 | - * file. Co-locating cache-sensitive overrides with the parent | |
| 355 | - * stylesheet is the rule of the road here. */ | |
| 653 | +/* The flex-centring override for the dashicon glyph currently lives | |
| 654 | + * in `windows.css`. That placement was a cache workaround from when | |
| 655 | + * this file was an `@import` sub-sheet with no `?ver=` of its own; | |
| 656 | + * it is now a separately registered, filemtime-stamped handle, so | |
| 657 | + * the workaround no longer buys anything. Left where it is for now — | |
| 658 | + * relocating it is behaviour-neutral churn. */ | |
| 356 | 659 | |
| 357 | -/* Focused window: meta buttons visible. */ | |
| 358 | -.desktop-mode-window--focused .desktop-mode-window__meta-btn { | |
| 359 | - color: rgba(255, 255, 255, 0.65); | |
| 660 | +/* Focused window: meta buttons visible. | |
| 661 | + * | |
| 662 | + * Screen-meta buttons are not `<os-window-button>` — they are plain | |
| 663 | + * buttons in the light DOM, so they paint themselves instead of | |
| 664 | + * reading the `--os-ui-btn-*` bridge. They sit in the same title bar | |
| 665 | + * against the same fill, so they take the same | |
| 666 | + * `--os-titlebar-btn-focused-*` tokens, each keeping its own | |
| 667 | + * literal as the fallback: unthemed they stay a touch dimmer at rest | |
| 668 | + * than the window controls (0.65 vs 0.7), themed they move together | |
| 669 | + * rather than one cluster going legible and the other staying white. */ | |
| 670 | +.os-window--focused .os-window__meta-btn { | |
| 671 | + color: var( --os-titlebar-btn-focused-color, rgba(255, 255, 255, 0.65) ); | |
| 360 | 672 | } |
| 361 | -.desktop-mode-window--focused .desktop-mode-window__meta-btn:hover { | |
| 362 | - color: #fff; | |
| 363 | - background: rgba(255, 255, 255, 0.18); | |
| 673 | +.os-window--focused .os-window__meta-btn:hover { | |
| 674 | + color: var( --os-titlebar-btn-focused-color-hover, var( --os-ui-fg-on-accent, #fff ) ); | |
| 675 | + background: var( --os-titlebar-btn-focused-bg-hover, rgba(255, 255, 255, 0.18) ); | |
| 364 | 676 | } |
| 365 | -.desktop-mode-window--focused .desktop-mode-window__meta-btn:focus-visible { | |
| 366 | - color: #fff; | |
| 367 | - background: rgba(255, 255, 255, 0.18); | |
| 368 | - outline: 2px solid rgba(255, 255, 255, 0.6); | |
| 677 | +.os-window--focused .os-window__meta-btn:focus-visible { | |
| 678 | + color: var( --os-titlebar-btn-focused-color-hover, var( --os-ui-fg-on-accent, #fff ) ); | |
| 679 | + background: var( --os-titlebar-btn-focused-bg-hover, rgba(255, 255, 255, 0.18) ); | |
| 680 | + outline: 2px solid var( --os-titlebar-btn-focused-outline, rgba(255, 255, 255, 0.6) ); | |
| 369 | 681 | outline-offset: 1px; |
| 370 | 682 | } |
| 371 | -.desktop-mode-window--focused .desktop-mode-window__meta-btn--active { | |
| 372 | - color: #fff; | |
| 373 | - background: rgba(255, 255, 255, 0.25); | |
| 683 | +.os-window--focused .os-window__meta-btn--active { | |
| 684 | + color: var( --os-titlebar-btn-focused-color-hover, var( --os-ui-fg-on-accent, #fff ) ); | |
| 685 | + background: var( --os-titlebar-btn-focused-bg-active, rgba(255, 255, 255, 0.25) ); | |
| 374 | 686 | } |
| 375 | 687 | |
| 376 | 688 | /* Unfocused window: divider and buttons adapt to light title bar. */ |
| 377 | -.desktop-mode-window:not(.desktop-mode-window--focused) .desktop-mode-window__screen-meta { | |
| 378 | - border-inline-end-color: rgba(0, 0, 0, 0.1); | |
| 689 | +.os-window:not(.os-window--focused) .os-window__screen-meta { | |
| 690 | + border-inline-end-color: var( | |
| 691 | + --os-titlebar-divider-unfocused, | |
| 692 | + rgba(0, 0, 0, 0.1) | |
| 693 | + ); | |
| 379 | 694 | } |
| 380 | -.desktop-mode-window:not(.desktop-mode-window--focused) .desktop-mode-window__meta-btn { | |
| 381 | - color: rgba(0, 0, 0, 0.3); | |
| 695 | +/* | |
| 696 | + * Screen Options / Help on an UNFOCUSED title bar. These read the same | |
| 697 | + * two tokens as the window controls beside them rather than their own | |
| 698 | + * literals — a black glyph is invisible on a dark title bar, and these | |
| 699 | + * buttons sit in the same strip as controls that were already themable. | |
| 700 | + * The literals are unchanged, so an unthemed bar looks as it always did. | |
| 701 | + */ | |
| 702 | +.os-window:not(.os-window--focused) .os-window__meta-btn { | |
| 703 | + color: var( --os-titlebar-btn-color, rgba(0, 0, 0, 0.3) ); | |
| 382 | 704 | } |
| 383 | -.desktop-mode-window:not(.desktop-mode-window--focused) .desktop-mode-window__meta-btn:hover { | |
| 384 | - color: rgba(0, 0, 0, 0.6); | |
| 385 | - background: rgba(0, 0, 0, 0.08); | |
| 705 | +.os-window:not(.os-window--focused) .os-window__meta-btn:hover { | |
| 706 | + color: var( --os-titlebar-btn-color-hover, rgba(0, 0, 0, 0.6) ); | |
| 707 | + background: var( --os-ui-hover, rgba(0, 0, 0, 0.08) ); | |
| 386 | 708 | } |
| 387 | -.desktop-mode-window:not(.desktop-mode-window--focused) .desktop-mode-window__meta-btn--active { | |
| 388 | - color: rgba(0, 0, 0, 0.6); | |
| 709 | +.os-window:not(.os-window--focused) .os-window__meta-btn--active { | |
| 710 | + color: var( --os-titlebar-btn-color-hover, rgba(0, 0, 0, 0.6) ); | |
| 389 | 711 | background: rgba(0, 0, 0, 0.12); |
| 390 | 712 | } |
| 391 | 713 | |
| 392 | 714 | /* --------------------------------------------------------------- |
| @@ -393,30 +715,569 @@ | ||
| 393 | 715 | * Tab strip — submenu navigation rendered in the parent shell, just |
| 394 | 716 | * below the title bar. Each tab swaps the iframe URL in place; no |
| 395 | 717 | * new window opens. Horizontally scrollable on narrow windows so the |
| 396 | 718 | * same component works on tablet and mobile shells unchanged. |
| 719 | + * | |
| 720 | + * These are TABS in the physical sense: the strip is a recessed | |
| 721 | + * track, and the active tab is a plate that rises out of it wearing | |
| 722 | + * the page's own fill, filleted into the floor at both bottom | |
| 723 | + * corners so tab and page read as one continuous surface. That joint | |
| 724 | + * is the whole design. The previous treatment — flat text with an | |
| 725 | + * accent underline, on a strip painted the same colour as the | |
| 726 | + * focused title bar — gave the sub-pages no container to belong to | |
| 727 | + * and no relationship to the page they navigate, which is why they | |
| 728 | + * read as loose text floating in the chrome. | |
| 729 | + * | |
| 730 | + * The page under an iframe window is always `#fff`: `chromeless.css` | |
| 731 | + * paints `body.os-chromeless` white whatever the admin colour scheme | |
| 732 | + * says. So "the page's own fill" is a colour the shell can name, and | |
| 733 | + * `--os-tabs-active-bg` names it. | |
| 397 | 734 | * --------------------------------------------------------------- */ |
| 398 | -.desktop-mode-window__tabs { | |
| 735 | +.os-window__tabs { | |
| 736 | + /* | |
| 737 | + * Corner radius and fillet size are the same measurement — the | |
| 738 | + * fillet is the *inverse* of the corner, so a mismatch reads as a | |
| 739 | + * kink where the tab meets the floor. One alias, read by both. | |
| 740 | + */ | |
| 741 | + --_tab-radius: var( --os-tabs-radius, 8px ); | |
| 742 | + /* Tab height, shared with the plate that has to sit exactly on it. */ | |
| 743 | + --_tab-h: 30px; | |
| 744 | + /* Weight of the rail that traces the silhouette. */ | |
| 745 | + --_tab-stroke: var( --os-tabs-rail-width, 2px ); | |
| 746 | + /* | |
| 747 | + * How far each straight run of the rail overlaps the arc it hands | |
| 748 | + * over to. Purely an anti-seam allowance — see the mask sizes on | |
| 749 | + * the ring. It has to stay well under the radius or the overlap | |
| 750 | + * would reach past the arc it is covering for. | |
| 751 | + */ | |
| 752 | + --_tab-seam: 1px; | |
| 753 | + position: relative; | |
| 399 | 754 | display: flex; |
| 755 | + /* Tabs sit ON the floor of the track, not centred in it. */ | |
| 756 | + align-items: flex-end; | |
| 400 | 757 | flex-shrink: 0; |
| 401 | 758 | gap: 2px; |
| 402 | - padding: 0 8px; | |
| 403 | - background: var(--desktop-mode-tabs-bg, #f6f7f7); | |
| 404 | - border-bottom: 1px solid var(--desktop-mode-window-border); | |
| 759 | + /* | |
| 760 | + * Horizontal padding is a floor as well as a breathing space. The | |
| 761 | + * ring reaches one radius plus one stroke (10px at the shipped | |
| 762 | + * values) past the plate on each side, so a first or last tab | |
| 763 | + * under a smaller padding would have its outer corner clipped by | |
| 764 | + * the `overflow` rule below. | |
| 765 | + */ | |
| 766 | + padding: 8px 12px 0; | |
| 767 | + background-color: var( --os-tabs-bg, var( --os-ui-surface-elevated, #f6f7f7 ) ); | |
| 768 | + /* TABBAR texture slot — layered over the strip's own colour. */ | |
| 769 | + background-image: var( --os-tabs-image, none ); | |
| 770 | + background-repeat: var( --os-tabs-image-repeat, repeat ); | |
| 771 | + background-size: var( --os-tabs-image-size, auto ); | |
| 772 | + background-position: var( --os-tabs-image-position, center ); | |
| 773 | + /* | |
| 774 | + * No bottom border. A hairline here would run straight through | |
| 775 | + * the joint between the active tab and its page, which is the one | |
| 776 | + * edge this design exists to erase. | |
| 777 | + */ | |
| 405 | 778 | overflow-x: auto; |
| 406 | 779 | overflow-y: hidden; |
| 407 | 780 | scrollbar-width: thin; |
| 408 | 781 | /* |
| 409 | - * Soft edge fades so overflowing tabs tell the user "scroll for | |
| 410 | - * more." The mask is always applied — when the strip doesn't | |
| 411 | - * overflow, the faded ends are empty area beyond the last tab so | |
| 412 | - * the cosmetic is invisible. When it DOES overflow, the last- | |
| 413 | - * visible tab fades under the mask, creating the affordance. | |
| 414 | 782 | * `overscroll-behavior-x: contain` keeps horizontal trackpad |
| 415 | 783 | * swipes inside the strip instead of triggering browser back. |
| 416 | 784 | */ |
| 785 | + overscroll-behavior-x: contain; | |
| 786 | +} | |
| 787 | + | |
| 788 | +/* | |
| 789 | + * Unfocused, the track follows the title bar down instead of holding | |
| 790 | + * its lit colour. | |
| 791 | + * | |
| 792 | + * Focused, the bar and the track are the same Obsidian and the tab is | |
| 793 | + * the only thing lifting out of them: two surfaces plus the tab. Dim | |
| 794 | + * the bar alone and the strip becomes a third colour belonging to | |
| 795 | + * neither the chrome above it nor the page below, which is the seam | |
| 796 | + * this rule removes. | |
| 797 | + * | |
| 798 | + * The chain ends at `--os-tabs-bg`, so a theme that names only the one | |
| 799 | + * strip colour keeps it in both states. | |
| 800 | + */ | |
| 801 | +.os-window:not( .os-window--focused ) .os-window__tabs { | |
| 802 | + background-color: var( | |
| 803 | + --os-tabs-bg-unfocused, | |
| 804 | + var( --os-tabs-bg, var( --os-ui-surface-elevated, #f6f7f7 ) ) | |
| 805 | + ); | |
| 806 | +} | |
| 807 | + | |
| 808 | +/* | |
| 809 | + * The same strip on a NATIVE window, which is the same strip: one | |
| 810 | + * stylesheet, whatever is behind the window. What changes is only | |
| 811 | + * what the active tab is wearing, and it changes in tokens. | |
| 812 | + * | |
| 813 | + * The rule the whole design rests on is that the active tab wears the | |
| 814 | + * page's own fill and is filleted into it, so tab and content read as | |
| 815 | + * one surface. For an iframe window that fill is `#fff`, because | |
| 816 | + * `chromeless.css` paints every admin page inside a window white | |
| 817 | + * whatever the colour scheme says. A native window's page is its | |
| 818 | + * body, and `--os-window-bg` is what paints it — so that is the fill | |
| 819 | + * here, and the two move together by construction. | |
| 820 | + * | |
| 821 | + * The frost and the crown go with the white. They are what makes a | |
| 822 | + * bright plate read as a surface lifting out of dark chrome; on a | |
| 823 | + * native window the tab and the track are the same colour when the | |
| 824 | + * window is focused, and a crown on a tab you cannot see the edges of | |
| 825 | + * is decoration with nothing to decorate. The rail carries the | |
| 826 | + * silhouette on its own, which is the point: a native window has no | |
| 827 | + * value step to wear, so the line IS the tab. | |
| 828 | + * | |
| 829 | + * The label follows the fill. `--os-tabs-active-color` names the text | |
| 830 | + * on a white plate and resolves to near-black, which would be | |
| 831 | + * invisible here. | |
| 832 | + */ | |
| 833 | +.os-window--native { | |
| 834 | + --os-tabs-active-bg: var( --os-window-bg, #fff ); | |
| 835 | + --os-tabs-active-color: var( --os-ui-fg, #1d2327 ); | |
| 836 | + --os-tabs-active-color-muted: var( --os-ui-fg-muted, #50575e ); | |
| 837 | + --os-tabs-active-frost: none; | |
| 838 | + --os-tabs-active-crown: none; | |
| 839 | +} | |
| 840 | + | |
| 841 | +/* | |
| 842 | + * Windows with no submenu still get a strip — `createWindowElement()` | |
| 843 | + * appends it unconditionally so `addExternalTab()` has somewhere to | |
| 844 | + * put a tab later. Empty, it must take no room at all: with vertical | |
| 845 | + * padding on the track, an empty strip would otherwise paint a bare | |
| 846 | + * band of track colour under every submenu-less window's title bar. | |
| 847 | + * | |
| 848 | + * `:has()` rather than `:empty`, because the strip is never empty any | |
| 849 | + * more — it always carries the plate. What makes a strip vacant is | |
| 850 | + * having no TABS in it. | |
| 851 | + */ | |
| 852 | +.os-window__tabs:not( :has( .os-window__tab ) ) { | |
| 853 | + display: none; | |
| 854 | +} | |
| 855 | + | |
| 856 | +/* --------------------------------------------------------------- | |
| 857 | + * The plate — the active tab's surface. | |
| 858 | + * | |
| 859 | + * It is one element that TRAVELS between tabs rather than a fill | |
| 860 | + * that switches off on one tab and on at the next. That distinction | |
| 861 | + * is the whole reason it exists: a fill that switches has to cross- | |
| 862 | + * fade a dark tab into a light one, and every frame in between is a | |
| 863 | + * muddy grey that belongs to neither. Nothing crossfades here. The | |
| 864 | + * surface simply moves, and the labels change colour underneath it. | |
| 865 | + * | |
| 866 | + * `tabs.ts` publishes the target geometry as `--_tab-plate-x` and | |
| 867 | + * `--_tab-plate-w` on this element; everything else is CSS. | |
| 868 | + * --------------------------------------------------------------- */ | |
| 869 | +.os-window__tab-plate { | |
| 870 | + position: absolute; | |
| 871 | + bottom: 0; | |
| 872 | + left: 0; | |
| 873 | + height: var( --_tab-h ); | |
| 874 | + width: var( --_tab-plate-w, 0 ); | |
| 875 | + transform: translateX( var( --_tab-plate-x, 0 ) ); | |
| 876 | + pointer-events: none; | |
| 877 | + transition: | |
| 878 | + transform var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) ), | |
| 879 | + width var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) ), | |
| 880 | + opacity 0.15s ease; | |
| 881 | +} | |
| 882 | + | |
| 883 | +/* | |
| 884 | + * Until the first measurement lands, the plate has no business | |
| 885 | + * animating — it would slide in from the strip's left edge every | |
| 886 | + * time a window opens. | |
| 887 | + */ | |
| 888 | +.os-window__tab-plate:not( [ data-placed ] ) { | |
| 889 | + transition: none; | |
| 890 | +} | |
| 891 | + | |
| 892 | +/* | |
| 893 | + * The frosted face. | |
| 894 | + * | |
| 895 | + * The tint is TOP-WEIGHTED and reaches zero before the bottom of the | |
| 896 | + * plate, which is the load-bearing part: the joint below is the | |
| 897 | + * page's own colour, so anything still tinted down there draws a | |
| 898 | + * seam across the one edge this design exists to erase. Keep the | |
| 899 | + * gradient's last stop fully transparent and keep it above ~75%. | |
| 900 | + * | |
| 901 | + * The fallback is a flat `--os-tabs-active-bg`, so a stylesheet that | |
| 902 | + * loses the palette gets the plain white tab rather than nothing. | |
| 903 | + */ | |
| 904 | +.os-window__tab-plate-fill { | |
| 905 | + position: absolute; | |
| 906 | + /* | |
| 907 | + * Overshoots the track's floor by one radius, and is then clipped | |
| 908 | + * by the strip's own `overflow`. Ending the face exactly ON the | |
| 909 | + * floor puts a paint boundary on the one edge that has to be | |
| 910 | + * invisible; pushing it past and cutting it means there is no edge | |
| 911 | + * there to resolve at all. | |
| 912 | + */ | |
| 913 | + inset: 0 0 calc( var( --_tab-radius ) * -1 ) 0; | |
| 914 | + overflow: hidden; | |
| 915 | + border-radius: var( --_tab-radius ) var( --_tab-radius ) 0 0; | |
| 916 | + /* | |
| 917 | + * OPAQUE, and deliberately so — no `backdrop-filter`, no alpha. | |
| 918 | + * | |
| 919 | + * A translucent element with a backdrop filter is promoted to its | |
| 920 | + * own compositor layer, and the edge of that layer is a clip the | |
| 921 | + * compositor resolves against whatever is behind it. That leaves a | |
| 922 | + * faint grey hairline down the plate's sides and across its | |
| 923 | + * bottom. Everywhere else that would be a nuisance; here it draws | |
| 924 | + * a second, dimmer line a few pixels inside the real one and flatly | |
| 925 | + * contradicts what this design is claiming. The gradient below | |
| 926 | + * reproduces the frosted crown by eye with no alpha at all. | |
| 927 | + * | |
| 928 | + * `background-size` pins the gradient to the tab's true height so | |
| 929 | + * the overshoot underneath stays flat page-white. | |
| 930 | + */ | |
| 931 | + background-color: var( --os-tabs-active-bg, #fff ); | |
| 932 | + background-image: var( --os-tabs-active-frost, none ); | |
| 933 | + background-size: 100% var( --_tab-h ); | |
| 934 | + background-repeat: no-repeat; | |
| 935 | +} | |
| 936 | + | |
| 937 | +/* | |
| 938 | + * The crown — Holomesh over the top of the plate, masked away before | |
| 939 | + * the joint. Holomesh is nine stacked gradients, so it cannot be | |
| 940 | + * faded by adding a colour stop; it needs its own layer and a mask. | |
| 941 | + * | |
| 942 | + * It is on exactly one tab at a time. That is what keeps the mesh an | |
| 943 | + * identity moment rather than wallpaper, and it is the same rule the | |
| 944 | + * rest of the kit follows (see `src/ui/holo.ts`). | |
| 945 | + */ | |
| 946 | +.os-window__tab-plate-fill::before { | |
| 947 | + content: ''; | |
| 948 | + position: absolute; | |
| 949 | + inset: 0; | |
| 950 | + background-image: var( --os-tabs-active-crown, none ); | |
| 951 | + background-size: 210% 210%; | |
| 952 | + background-position: 20% 26%; | |
| 953 | + opacity: var( --os-tabs-active-crown-opacity, 0.5 ); | |
| 954 | + -webkit-mask-image: linear-gradient( | |
| 955 | + 180deg, | |
| 956 | + #000 0%, | |
| 957 | + rgba( 0, 0, 0, 0.55 ) 34%, | |
| 958 | + transparent 66% | |
| 959 | + ); | |
| 417 | 960 | mask-image: linear-gradient( |
| 961 | + 180deg, | |
| 962 | + #000 0%, | |
| 963 | + rgba( 0, 0, 0, 0.55 ) 34%, | |
| 964 | + transparent 66% | |
| 965 | + ); | |
| 966 | + animation: os-tab-crown-drift 16s ease-in-out infinite alternate; | |
| 967 | +} | |
| 968 | + | |
| 969 | +@keyframes os-tab-crown-drift { | |
| 970 | + from { background-position: 20% 26%; } | |
| 971 | + to { background-position: 72% 62%; } | |
| 972 | +} | |
| 973 | + | |
| 974 | +/* | |
| 975 | + * The joint: two concave quarter-circles carrying the plate's fill | |
| 976 | + * out to the floor of the track. Without them the plate is a rounded | |
| 977 | + * rectangle NEAR the page; with them it is attached to it. | |
| 978 | + * | |
| 979 | + * Its own element rather than a pseudo on the face, so the face can | |
| 980 | + * clip its crown (`overflow: hidden`) without clipping the curve | |
| 981 | + * that does the attaching. Pure `--os-tabs-active-bg` — see the note | |
| 982 | + * on the tint above. | |
| 983 | + */ | |
| 984 | +.os-window__tab-plate-joint { | |
| 985 | + position: absolute; | |
| 986 | + bottom: 0; | |
| 987 | + left: calc( var( --_tab-radius ) * -1 ); | |
| 988 | + right: calc( var( --_tab-radius ) * -1 ); | |
| 989 | + height: var( --_tab-radius ); | |
| 990 | + background: | |
| 991 | + radial-gradient( | |
| 992 | + circle at 0 0, | |
| 993 | + transparent var( --_tab-radius ), | |
| 994 | + var( --os-tabs-active-bg, #fff ) var( --_tab-radius ) | |
| 995 | + ) left bottom / var( --_tab-radius ) var( --_tab-radius ) no-repeat, | |
| 996 | + radial-gradient( | |
| 997 | + circle at 100% 0, | |
| 998 | + transparent var( --_tab-radius ), | |
| 999 | + var( --os-tabs-active-bg, #fff ) var( --_tab-radius ) | |
| 1000 | + ) right bottom / var( --_tab-radius ) var( --_tab-radius ) no-repeat; | |
| 1001 | +} | |
| 1002 | + | |
| 1003 | +/* --------------------------------------------------------------- | |
| 1004 | + * The rail — one continuous line around the whole silhouette. | |
| 1005 | + * | |
| 1006 | + * It runs the top edge of the page, curves up through the fillet, | |
| 1007 | + * traces the tab and comes back down. What it outlines is therefore | |
| 1008 | + * page-plus-tab as ONE shape, which is the same claim the joint makes | |
| 1009 | + * by omission, said out loud. | |
| 1010 | + * | |
| 1011 | + * Two rules govern every number below. | |
| 1012 | + * | |
| 1013 | + * 1. **The line lives on the DARK side of the boundary, everywhere.** | |
| 1014 | + * It hugs the white shape from outside and never paints on it. Get | |
| 1015 | + * this wrong on any one segment and the line steps sideways by its | |
| 1016 | + * own width at the tangent point where that segment meets the next | |
| 1017 | + * — which is exactly what a stroke that does not follow the shape | |
| 1018 | + * looks like. The convex tab corners are therefore annulus R→R+s | |
| 1019 | + * (outside the plate) while the concave fillets are R−s→R (inside | |
| 1020 | + * the fillet circle, whose white lies OUTSIDE it). Those look like | |
| 1021 | + * opposite conventions and are the same one. | |
| 1022 | + * | |
| 1023 | + * 2. **Every piece samples one mesh laid across the whole strip.** | |
| 1024 | + * `--_tab-strip-w` is why. Give the rail and the ring their own | |
| 1025 | + * backgrounds and you get two unrelated gradients meeting at a | |
| 1026 | + * visible join in the middle of the fillet. | |
| 1027 | + * | |
| 1028 | + * CSS cannot stroke a path, so the mesh is painted as a background and | |
| 1029 | + * a mask cuts it to the outline. Seven layers, one per segment, all | |
| 1030 | + * positioned off the box edges so the whole thing survives the plate | |
| 1031 | + * changing width mid-slide with no JS. | |
| 1032 | + * --------------------------------------------------------------- */ | |
| 1033 | +.os-window__tabs::before { | |
| 1034 | + content: ''; | |
| 1035 | + position: absolute; | |
| 1036 | + left: 0; | |
| 1037 | + right: 0; | |
| 1038 | + bottom: 0; | |
| 1039 | + height: var( --_tab-stroke ); | |
| 1040 | + background-image: var( --os-tabs-rail, none ); | |
| 1041 | + /* The rail's colour is derived from the accent; how much of it | |
| 1042 | + shows is a literal the palette answers. 1 is the earlier look. */ | |
| 1043 | + opacity: var( --os-tabs-rail-opacity, 1 ); | |
| 1044 | + background-size: var( --_tab-strip-w, 100% ) 100%; | |
| 1045 | + background-repeat: no-repeat; | |
| 1046 | + pointer-events: none; | |
| 1047 | + /* | |
| 1048 | + * Two segments, stopping where each fillet begins. Run the rail | |
| 1049 | + * straight through and it draws a chord across the concave curve. | |
| 1050 | + */ | |
| 1051 | + -webkit-mask-image: linear-gradient( #000 0 0 ), linear-gradient( #000 0 0 ); | |
| 1052 | + mask-image: linear-gradient( #000 0 0 ), linear-gradient( #000 0 0 ); | |
| 1053 | + /* | |
| 1054 | + * Each segment runs one `--_tab-seam` PAST where its fillet begins, | |
| 1055 | + * so the rail overlaps the arc rather than meeting it exactly — | |
| 1056 | + * the same anti-seam allowance the ring uses, for the same reason. | |
| 1057 | + */ | |
| 1058 | + -webkit-mask-size: | |
| 1059 | + calc( | |
| 1060 | + var( --_tab-plate-x, 0px ) - var( --_tab-radius ) + var( --_tab-seam ) | |
| 1061 | + ) 100%, | |
| 1062 | + calc( | |
| 1063 | + 100% - var( --_tab-plate-x, 0px ) - var( --_tab-plate-w, 0px ) - | |
| 1064 | + var( --_tab-radius ) + var( --_tab-seam ) | |
| 1065 | + ) 100%; | |
| 1066 | + mask-size: | |
| 1067 | + calc( | |
| 1068 | + var( --_tab-plate-x, 0px ) - var( --_tab-radius ) + var( --_tab-seam ) | |
| 1069 | + ) 100%, | |
| 1070 | + calc( | |
| 1071 | + 100% - var( --_tab-plate-x, 0px ) - var( --_tab-plate-w, 0px ) - | |
| 1072 | + var( --_tab-radius ) + var( --_tab-seam ) | |
| 1073 | + ) 100%; | |
| 1074 | + -webkit-mask-position: left top, right top; | |
| 1075 | + mask-position: left top, right top; | |
| 1076 | + -webkit-mask-repeat: no-repeat; | |
| 1077 | + mask-repeat: no-repeat; | |
| 1078 | + transition: | |
| 1079 | + -webkit-mask-size var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) ), | |
| 1080 | + mask-size var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) ); | |
| 1081 | +} | |
| 1082 | + | |
| 1083 | +/* With no active tab there is no gap to leave, so the rail is whole. */ | |
| 1084 | +.os-window__tabs[ data-tab-plate-empty ]::before { | |
| 1085 | + -webkit-mask-image: none; | |
| 1086 | + mask-image: none; | |
| 1087 | +} | |
| 1088 | + | |
| 1089 | +/* | |
| 1090 | + * The tab's half of the line. The box is the plate grown by one | |
| 1091 | + * stroke upward and by radius-plus-stroke on each side, so the top | |
| 1092 | + * edge, both corners and both fillet arcs all have room to sit | |
| 1093 | + * OUTSIDE the white. | |
| 1094 | + */ | |
| 1095 | +.os-window__tab-plate::after { | |
| 1096 | + --_ring-arc-out: radial-gradient( | |
| 1097 | + circle at 0 0, | |
| 1098 | + transparent calc( var( --_tab-radius ) - var( --_tab-stroke ) ), | |
| 1099 | + #000 calc( var( --_tab-radius ) - var( --_tab-stroke ) ), | |
| 1100 | + #000 var( --_tab-radius ), | |
| 1101 | + transparent var( --_tab-radius ) | |
| 1102 | + ); | |
| 1103 | + --_ring-arc-out-r: radial-gradient( | |
| 1104 | + circle at 100% 0, | |
| 1105 | + transparent calc( var( --_tab-radius ) - var( --_tab-stroke ) ), | |
| 1106 | + #000 calc( var( --_tab-radius ) - var( --_tab-stroke ) ), | |
| 1107 | + #000 var( --_tab-radius ), | |
| 1108 | + transparent var( --_tab-radius ) | |
| 1109 | + ); | |
| 1110 | + --_ring-corner-l: radial-gradient( | |
| 1111 | + circle at 100% 100%, | |
| 1112 | + transparent var( --_tab-radius ), | |
| 1113 | + #000 var( --_tab-radius ), | |
| 1114 | + #000 calc( var( --_tab-radius ) + var( --_tab-stroke ) ), | |
| 1115 | + transparent calc( var( --_tab-radius ) + var( --_tab-stroke ) ) | |
| 1116 | + ); | |
| 1117 | + --_ring-corner-r: radial-gradient( | |
| 1118 | + circle at 0 100%, | |
| 1119 | + transparent var( --_tab-radius ), | |
| 1120 | + #000 var( --_tab-radius ), | |
| 1121 | + #000 calc( var( --_tab-radius ) + var( --_tab-stroke ) ), | |
| 1122 | + transparent calc( var( --_tab-radius ) + var( --_tab-stroke ) ) | |
| 1123 | + ); | |
| 1124 | + --_ring-bar: linear-gradient( #000 0 0 ); | |
| 1125 | + | |
| 1126 | + content: ''; | |
| 1127 | + position: absolute; | |
| 1128 | + inset: | |
| 1129 | + calc( var( --_tab-stroke ) * -1 ) | |
| 1130 | + calc( ( var( --_tab-radius ) + var( --_tab-stroke ) ) * -1 ) | |
| 1131 | + 0; | |
| 1132 | + background-image: var( --os-tabs-rail, none ); | |
| 1133 | + /* The rail's colour is derived from the accent; how much of it | |
| 1134 | + shows is a literal the palette answers. 1 is the earlier look. */ | |
| 1135 | + opacity: var( --os-tabs-rail-opacity, 1 ); | |
| 1136 | + background-size: var( --_tab-strip-w, 100% ) 100%; | |
| 1137 | + background-position: | |
| 1138 | + calc( | |
| 1139 | + var( --_tab-radius ) + var( --_tab-stroke ) - | |
| 1140 | + var( --_tab-plate-x, 0px ) | |
| 1141 | + ) | |
| 1142 | + 0; | |
| 1143 | + background-repeat: no-repeat; | |
| 1144 | + pointer-events: none; | |
| 1145 | + | |
| 1146 | + -webkit-mask-image: | |
| 1147 | + var( --_ring-bar ), var( --_ring-corner-l ), var( --_ring-corner-r ), | |
| 1148 | + var( --_ring-bar ), var( --_ring-bar ), | |
| 1149 | + var( --_ring-arc-out ), var( --_ring-arc-out-r ); | |
| 1150 | + mask-image: | |
| 1151 | + var( --_ring-bar ), var( --_ring-corner-l ), var( --_ring-corner-r ), | |
| 1152 | + var( --_ring-bar ), var( --_ring-bar ), | |
| 1153 | + var( --_ring-arc-out ), var( --_ring-arc-out-r ); | |
| 1154 | + /* | |
| 1155 | + * The three straight runs are each grown by `--_tab-seam` at BOTH | |
| 1156 | + * ends, and shifted back by the same amount, so every one of them | |
| 1157 | + * overlaps the arc it hands over to instead of meeting it exactly. | |
| 1158 | + * | |
| 1159 | + * Two layers that abut on a shared boundary each contribute a | |
| 1160 | + * partial, antialiased alpha there, and the sum can fall short of | |
| 1161 | + * 1 — which prints as a faint hairline across the stroke at all | |
| 1162 | + * six tangent points. Overlapping cannot go wrong in the other | |
| 1163 | + * direction: mask alpha is clamped, so doubling it is still opaque | |
| 1164 | + * and the seam simply stops existing. | |
| 1165 | + * | |
| 1166 | + * The overlap stays on the dark side of the curve at every one of | |
| 1167 | + * those points, so it never bleeds onto the white. | |
| 1168 | + */ | |
| 1169 | + -webkit-mask-size: | |
| 1170 | + calc( 100% - 4 * var( --_tab-radius ) - 2 * var( --_tab-stroke ) + 2 * var( --_tab-seam ) ) var( --_tab-stroke ), | |
| 1171 | + calc( var( --_tab-radius ) + var( --_tab-stroke ) ) calc( var( --_tab-radius ) + var( --_tab-stroke ) ), | |
| 1172 | + calc( var( --_tab-radius ) + var( --_tab-stroke ) ) calc( var( --_tab-radius ) + var( --_tab-stroke ) ), | |
| 1173 | + var( --_tab-stroke ) calc( 100% - 2 * var( --_tab-radius ) - var( --_tab-stroke ) + 2 * var( --_tab-seam ) ), | |
| 1174 | + var( --_tab-stroke ) calc( 100% - 2 * var( --_tab-radius ) - var( --_tab-stroke ) + 2 * var( --_tab-seam ) ), | |
| 1175 | + var( --_tab-radius ) var( --_tab-radius ), | |
| 1176 | + var( --_tab-radius ) var( --_tab-radius ); | |
| 1177 | + mask-size: | |
| 1178 | + calc( 100% - 4 * var( --_tab-radius ) - 2 * var( --_tab-stroke ) + 2 * var( --_tab-seam ) ) var( --_tab-stroke ), | |
| 1179 | + calc( var( --_tab-radius ) + var( --_tab-stroke ) ) calc( var( --_tab-radius ) + var( --_tab-stroke ) ), | |
| 1180 | + calc( var( --_tab-radius ) + var( --_tab-stroke ) ) calc( var( --_tab-radius ) + var( --_tab-stroke ) ), | |
| 1181 | + var( --_tab-stroke ) calc( 100% - 2 * var( --_tab-radius ) - var( --_tab-stroke ) + 2 * var( --_tab-seam ) ), | |
| 1182 | + var( --_tab-stroke ) calc( 100% - 2 * var( --_tab-radius ) - var( --_tab-stroke ) + 2 * var( --_tab-seam ) ), | |
| 1183 | + var( --_tab-radius ) var( --_tab-radius ), | |
| 1184 | + var( --_tab-radius ) var( --_tab-radius ); | |
| 1185 | + /* | |
| 1186 | + * Four-value syntax throughout, and it is load-bearing. A | |
| 1187 | + * percentage in `mask-position` resolves against the container | |
| 1188 | + * MINUS the layer's own size, so `calc(100% - 8px)` does not mean | |
| 1189 | + * "8px from the right edge" — it means "right-aligned, then pushed | |
| 1190 | + * left by 8px PLUS the layer's width". Every right-hand segment | |
| 1191 | + * lands a radius too far left that way. `right <offset>` is | |
| 1192 | + * measured from the edge and does not care how wide the layer is. | |
| 1193 | + */ | |
| 1194 | + -webkit-mask-position: | |
| 1195 | + left calc( 2 * var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ) top 0, | |
| 1196 | + left var( --_tab-radius ) top 0, | |
| 1197 | + right var( --_tab-radius ) top 0, | |
| 1198 | + left var( --_tab-radius ) top calc( var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ), | |
| 1199 | + right var( --_tab-radius ) top calc( var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ), | |
| 1200 | + left var( --_tab-stroke ) bottom 0, | |
| 1201 | + right var( --_tab-stroke ) bottom 0; | |
| 1202 | + mask-position: | |
| 1203 | + left calc( 2 * var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ) top 0, | |
| 1204 | + left var( --_tab-radius ) top 0, | |
| 1205 | + right var( --_tab-radius ) top 0, | |
| 1206 | + left var( --_tab-radius ) top calc( var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ), | |
| 1207 | + right var( --_tab-radius ) top calc( var( --_tab-radius ) + var( --_tab-stroke ) - var( --_tab-seam ) ), | |
| 1208 | + left var( --_tab-stroke ) bottom 0, | |
| 1209 | + right var( --_tab-stroke ) bottom 0; | |
| 1210 | + -webkit-mask-repeat: no-repeat; | |
| 1211 | + mask-repeat: no-repeat; | |
| 1212 | + | |
| 1213 | + /* Travels with the plate, so the mesh stays locked to the strip | |
| 1214 | + * for the whole slide instead of snapping on the first frame. */ | |
| 1215 | + transition: background-position var( --os-tabs-slide, 340ms cubic-bezier( 0.22, 1, 0.28, 1 ) ); | |
| 1216 | +} | |
| 1217 | + | |
| 1218 | +/* | |
| 1219 | + * No active tab to sit under — `syncActiveTab` can land on a URL that | |
| 1220 | + * matches nothing, and an external sub-tab deactivates every submenu | |
| 1221 | + * tab. The plate keeps its last geometry and fades, so re-activating | |
| 1222 | + * a tab does not read as the plate flying in from nowhere. | |
| 1223 | + */ | |
| 1224 | +.os-window__tab-plate[ data-empty ] { | |
| 1225 | + opacity: 0; | |
| 1226 | +} | |
| 1227 | + | |
| 1228 | +@media ( prefers-reduced-motion: reduce ) { | |
| 1229 | + .os-window__tab-plate, | |
| 1230 | + .os-window__tab-plate::after, | |
| 1231 | + .os-window__tabs::before { | |
| 1232 | + transition-duration: 1ms; | |
| 1233 | + } | |
| 1234 | + .os-window__tab-plate-fill::before { | |
| 1235 | + animation: none; | |
| 1236 | + } | |
| 1237 | +} | |
| 1238 | + | |
| 1239 | +/* | |
| 1240 | + * Soft edge fades so overflowing tabs tell the user "scroll for more." | |
| 1241 | + * | |
| 1242 | + * Each fade is painted ONLY on an edge that is actually hiding a tab. | |
| 1243 | + * `observeTabOverflow()` in `src/window/tabs.ts` stamps `data-overflow` | |
| 1244 | + * with the physical edges currently covered — `left`, `right`, `both`, | |
| 1245 | + * or the attribute dropped when the strip fits — and re-measures on | |
| 1246 | + * scroll, on resize, and when tabs are added or removed. | |
| 1247 | + * | |
| 1248 | + * This used to be one unconditional mask on both ends. The reasoning | |
| 1249 | + * was that on a strip that fits, the faded ends land past the last tab | |
| 1250 | + * and so fade nothing. That held on the pre-brand light strip, where | |
| 1251 | + * the tab bar and the title bar above it were near enough the same | |
| 1252 | + * near-white that a mask over empty area was invisible. It stopped | |
| 1253 | + * holding on the station: the strip is Obsidian over a darker window | |
| 1254 | + * edge, so masking its ends to transparent punches two grey smudges | |
| 1255 | + * into every window's submenu, whether or not there is anything to | |
| 1256 | + * scroll to. A permanent affordance for a state that is usually false | |
| 1257 | + * is not an affordance — it is decoration that lies. | |
| 1258 | + */ | |
| 1259 | +.os-window__tabs[ data-overflow='left' ] { | |
| 1260 | + mask-image: linear-gradient( to right, transparent 0, #000 16px ); | |
| 1261 | + -webkit-mask-image: linear-gradient( to right, transparent 0, #000 16px ); | |
| 1262 | +} | |
| 1263 | + | |
| 1264 | +.os-window__tabs[ data-overflow='right' ] { | |
| 1265 | + mask-image: linear-gradient( | |
| 418 | 1266 | to right, |
| 1267 | + #000 calc(100% - 16px), | |
| 1268 | + transparent 100% | |
| 1269 | + ); | |
| 1270 | + -webkit-mask-image: linear-gradient( | |
| 1271 | + to right, | |
| 1272 | + #000 calc(100% - 16px), | |
| 1273 | + transparent 100% | |
| 1274 | + ); | |
| 1275 | +} | |
| 1276 | + | |
| 1277 | +.os-window__tabs[ data-overflow='both' ] { | |
| 1278 | + mask-image: linear-gradient( | |
| 1279 | + to right, | |
| 419 | 1280 | transparent 0, |
| 420 | 1281 | #000 16px, |
| 421 | 1282 | #000 calc(100% - 16px), |
| 422 | 1283 | transparent 100% |
| @@ -427,53 +1288,74 @@ | ||
| 427 | 1288 | #000 16px, |
| 428 | 1289 | #000 calc(100% - 16px), |
| 429 | 1290 | transparent 100% |
| 430 | 1291 | ); |
| 431 | - overscroll-behavior-x: contain; | |
| 432 | 1292 | } |
| 433 | 1293 | |
| 434 | -/* | |
| 435 | - * When JS assigns the --has-overflow marker (future enhancement), the | |
| 436 | - * mask is stronger. For now the CSS-only mask is subtle enough to be | |
| 437 | - * invisible on non-overflowing strips — mask cut-offs land outside the | |
| 438 | - * flex content. | |
| 439 | - */ | |
| 440 | - | |
| 441 | -.desktop-mode-window__tab { | |
| 1294 | +.os-window__tab { | |
| 1295 | + position: relative; | |
| 442 | 1296 | flex-shrink: 0; |
| 443 | 1297 | display: inline-flex; |
| 444 | 1298 | align-items: center; |
| 445 | - height: 32px; | |
| 446 | - padding: 0 12px; | |
| 1299 | + height: 30px; | |
| 1300 | + padding: 0 14px; | |
| 447 | 1301 | border: none; |
| 1302 | + border-radius: var( --_tab-radius ) var( --_tab-radius ) 0 0; | |
| 448 | 1303 | background: transparent; |
| 449 | - color: var(--desktop-mode-tabs-color, #50575e); | |
| 1304 | + color: var( --os-tabs-color, var( --os-ui-fg-muted, #50575e ) ); | |
| 450 | 1305 | font: inherit; |
| 451 | 1306 | font-size: 12px; |
| 452 | 1307 | line-height: 1; |
| 453 | 1308 | cursor: pointer; |
| 454 | - border-bottom: 2px solid transparent; | |
| 455 | - margin-bottom: -1px; | |
| 456 | 1309 | white-space: nowrap; |
| 457 | - transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease; | |
| 1310 | + transition: color 0.15s ease, background-color 0.15s ease; | |
| 458 | 1311 | } |
| 459 | 1312 | |
| 460 | -.desktop-mode-window__tab:hover { | |
| 1313 | +.os-window__tab:hover { | |
| 461 | 1314 | color: var(--wp-admin-theme-color, #2271b1); |
| 462 | - background: rgba(0, 0, 0, 0.03); | |
| 1315 | + background: var( --os-ui-hover, rgba(0, 0, 0, 0.03) ); | |
| 463 | 1316 | } |
| 464 | 1317 | |
| 465 | -.desktop-mode-window__tab:focus-visible { | |
| 1318 | +.os-window__tab:focus-visible { | |
| 466 | 1319 | outline: 2px solid var(--wp-admin-theme-color, #2271b1); |
| 467 | 1320 | outline-offset: -2px; |
| 468 | 1321 | } |
| 469 | 1322 | |
| 470 | -.desktop-mode-window__tab--active { | |
| 471 | - color: var(--wp-admin-theme-color, #2271b1); | |
| 472 | - border-bottom-color: var(--wp-admin-theme-color, #2271b1); | |
| 1323 | +/* | |
| 1324 | + * The active tab. It paints NO surface of its own — the plate does | |
| 1325 | + * that, and the plate is a sibling that slides. All this rule owns is | |
| 1326 | + * the label. | |
| 1327 | + * | |
| 1328 | + * No accent anywhere on it either. The shape already says "this one", | |
| 1329 | + * and the accent is spent: one Pulse divider in the dock, one focus | |
| 1330 | + * ring. A tab that is both a distinct surface AND coloured is saying | |
| 1331 | + * the same thing twice. | |
| 1332 | + */ | |
| 1333 | +.os-window__tab--active { | |
| 1334 | + color: var( --os-tabs-active-color, #1d2327 ); | |
| 473 | 1335 | font-weight: 600; |
| 1336 | + /* | |
| 1337 | + * Anything nested in an active tab (the external tab's detach and | |
| 1338 | + * close chips) is now sitting on a light fill inside dark chrome, | |
| 1339 | + * so the two tones it reads have to flip with the tab. Both are | |
| 1340 | + * re-pointed through names the palette owns rather than hardcoded | |
| 1341 | + * here, so a desktop theme retints the chips with the tab. | |
| 1342 | + */ | |
| 1343 | + --os-ui-fg-muted: var( --os-tabs-active-color-muted, rgba(29, 35, 39, 0.6) ); | |
| 1344 | + --os-ui-hover: color-mix( | |
| 1345 | + in srgb, | |
| 1346 | + var( --os-tabs-active-color, #1d2327 ) 8%, | |
| 1347 | + transparent | |
| 1348 | + ); | |
| 474 | 1349 | } |
| 475 | 1350 | |
| 1351 | +/* An active tab has the plate under it; a hover wash on top of that | |
| 1352 | + * would double-paint the surface. */ | |
| 1353 | +.os-window__tab--active:hover { | |
| 1354 | + background: transparent; | |
| 1355 | + color: var( --os-tabs-active-color, #1d2327 ); | |
| 1356 | +} | |
| 1357 | + | |
| 476 | 1358 | /* |
| 477 | 1359 | * External sub-tab. Same shape as a submenu tab, plus two inline |
| 478 | 1360 | * chips: detach (↗) and close (×). Chips are painted as spans |
| 479 | 1361 | * (rather than nested buttons — nested interactive elements are a |
| @@ -479,9 +1361,9 @@ | ||
| 479 | 1361 | * (rather than nested buttons — nested interactive elements are a |
| 480 | 1362 | * pain for a11y). The tab's click handler routes chip clicks via |
| 481 | 1363 | * the `data-tab-action` dataset attribute. |
| 482 | 1364 | */ |
| 483 | -.desktop-mode-window__tab--external { | |
| 1365 | +.os-window__tab--external { | |
| 484 | 1366 | /* Slightly wider gap between the label and the chip cluster, and |
| 485 | 1367 | * more breathing room at the trailing edge so the chips aren't |
| 486 | 1368 | * flush against the tab boundary. */ |
| 487 | 1369 | gap: 10px; |
| @@ -487,9 +1369,9 @@ | ||
| 487 | 1369 | gap: 10px; |
| 488 | 1370 | padding-inline-end: 4px; |
| 489 | 1371 | } |
| 490 | 1372 | |
| 491 | -.desktop-mode-window__tab-label { | |
| 1373 | +.os-window__tab-label { | |
| 492 | 1374 | max-width: 180px; |
| 493 | 1375 | overflow: hidden; |
| 494 | 1376 | text-overflow: ellipsis; |
| 495 | 1377 | white-space: nowrap; |
| @@ -494,44 +1376,163 @@ | ||
| 494 | 1376 | text-overflow: ellipsis; |
| 495 | 1377 | white-space: nowrap; |
| 496 | 1378 | } |
| 497 | 1379 | |
| 498 | -/* External-tab action chips moved to `<wpd-tab-chip>`. Spacing | |
| 1380 | +/* External-tab action chips moved to `<os-tab-chip>`. Spacing | |
| 499 | 1381 | * between chips handled by a single host-level rule since the |
| 500 | 1382 | * component doesn't know about its siblings. */ |
| 501 | -wpd-tab-chip + wpd-tab-chip { | |
| 1383 | +os-tab-chip + os-tab-chip { | |
| 502 | 1384 | margin-inline-start: 4px; |
| 503 | 1385 | } |
| 504 | 1386 | |
| 505 | 1387 | /* Window body: contains the iframe. */ |
| 506 | -.desktop-mode-window__body { | |
| 1388 | +.os-window__body { | |
| 507 | 1389 | flex: 1; |
| 508 | 1390 | position: relative; |
| 509 | 1391 | overflow: hidden; |
| 1392 | + /* | |
| 1393 | + * WINDOW_BODY texture slot. Visible behind native window content | |
| 1394 | + * and behind any iframe whose page has a transparent background; | |
| 1395 | + * an opaque wp-admin page inside an iframe paints over it, which | |
| 1396 | + * is why a theme wanting texture everywhere reaches for DESKTOP | |
| 1397 | + * and the chrome slots instead. | |
| 1398 | + */ | |
| 1399 | + background-image: var( --os-window-body-image, none ); | |
| 1400 | + background-repeat: var( --os-window-body-image-repeat, repeat ); | |
| 1401 | + background-size: var( --os-window-body-image-size, auto ); | |
| 1402 | + background-position: var( --os-window-body-image-position, center ); | |
| 1403 | + /* | |
| 1404 | + * Body typeface. `--os-ui-font` is the component kit's own token, so | |
| 1405 | + * one declaration here reaches every `<os-*>` element inside the | |
| 1406 | + * window: shadow DOM inherits `font-family` through the boundary. | |
| 1407 | + */ | |
| 1408 | + font-family: var( --os-ui-font, inherit ); | |
| 510 | 1409 | } |
| 511 | 1410 | |
| 512 | 1411 | /* Iframe fills the window body. */ |
| 513 | -.desktop-mode-window__iframe { | |
| 1412 | +.os-window__iframe { | |
| 514 | 1413 | width: 100%; |
| 515 | 1414 | height: 100%; |
| 516 | 1415 | border: none; |
| 517 | 1416 | display: block; |
| 518 | - background: var(--desktop-mode-window-bg); | |
| 1417 | + background: var(--os-window-bg); | |
| 519 | 1418 | opacity: 1; |
| 520 | 1419 | transition: opacity 0.25s ease; |
| 521 | 1420 | } |
| 522 | 1421 | |
| 523 | -/* Cross-window drag drop-overlay rules live in windows.css, NOT here. | |
| 524 | - * Putting them in this @import'd sub-sheet means browsers can serve a | |
| 525 | - * stale cached copy even when the parent windows.css has been cache- | |
| 526 | - * busted by mtime, leaving the drag stuck at the iframe boundary | |
| 527 | - * until the user manually clears their stylesheet cache. Keep them | |
| 528 | - * co-located with the @import declarations themselves. */ | |
| 1422 | +/* | |
| 1423 | + * Headings inside native window bodies. | |
| 1424 | + * | |
| 1425 | + * WordPress core's `wp-admin/css/common.css` styles headings with | |
| 1426 | + * BARE ELEMENT selectors — `h1 { color: #1d2327 }` and | |
| 1427 | + * `h2, h3 { color: #1d2327 }`. Native windows render in the parent | |
| 1428 | + * shell rather than in an iframe, so those rules reach straight into | |
| 1429 | + * our light-DOM window content: an `<h3>` in a `<os-card>` came out | |
| 1430 | + * near-black on a dark theme while every sibling paragraph correctly | |
| 1431 | + * followed the palette. | |
| 1432 | + * | |
| 1433 | + * The specificity here is doing real work. It has to sit BETWEEN two | |
| 1434 | + * other rules: | |
| 1435 | + * | |
| 1436 | + * core `h3` (0,0,1) must lose | |
| 1437 | + * THIS rule (0,0,1) wins on source order | |
| 1438 | + * `.os-my-wordpress__user-section h3` (0,1,1) must still win | |
| 1439 | + * | |
| 1440 | + * `:where()` contributes zero specificity, so the selector below | |
| 1441 | + * weighs the same as core's bare `h3` and beats it only because our | |
| 1442 | + * stylesheet prints later — while any of our own class-scoped heading | |
| 1443 | + * rules continue to override it. Raising this to a plain | |
| 1444 | + * `.os-window__body h3` would tie with those and break them | |
| 1445 | + * depending on file order. | |
| 1446 | + * | |
| 1447 | + * The `#1d2327` fallback is core's own value, so with no theme active | |
| 1448 | + * the computed colour is unchanged. | |
| 1449 | + */ | |
| 1450 | +:where( .os-window__body ) :is( h1, h2, h3, h4, h5, h6 ) { | |
| 1451 | + color: var( --os-ui-fg, #1d2327 ); | |
| 1452 | +} | |
| 529 | 1453 | |
| 530 | 1454 | /* |
| 1455 | + * Same story, same fix, for the other bare-element rules core ships: | |
| 1456 | + * | |
| 1457 | + * a { color: #2271b1 } | |
| 1458 | + * code { background: #f0f0f1 } | |
| 1459 | + * | |
| 1460 | + * `code` is the worse of the two — a light chip background with | |
| 1461 | + * palette-coloured text on it inverts to unreadable the moment a | |
| 1462 | + * theme goes dark. Both fallbacks are core's own values, so the | |
| 1463 | + * default is unchanged. | |
| 1464 | + * | |
| 1465 | + * Form controls (`input`, `textarea`, `select`) are deliberately NOT | |
| 1466 | + * included: core styles those through attribute selectors that carry | |
| 1467 | + * real specificity, native windows overwhelmingly use the `<os-*>` | |
| 1468 | + * components instead, and a half-applied override there would look | |
| 1469 | + * worse than leaving them alone. | |
| 1470 | + */ | |
| 1471 | +:where( .os-window__body ) a { | |
| 1472 | + color: var( --os-ui-accent, #2271b1 ); | |
| 1473 | +} | |
| 1474 | + | |
| 1475 | +:where( .os-window__body ) code { | |
| 1476 | + background: var( --os-ui-surface-sunken, #f0f0f1 ); | |
| 1477 | + color: var( --os-ui-fg, inherit ); | |
| 1478 | +} | |
| 1479 | + | |
| 1480 | +/* | |
| 1481 | + * Raw form controls in native window bodies. | |
| 1482 | + * | |
| 1483 | + * Core's `forms.css` styles these through ATTRIBUTE selectors — | |
| 1484 | + * `input[type="search"], select, textarea { background-color: #fff; | |
| 1485 | + * color: #1e1e1e; border: 1px solid #949494 }` — which weighs | |
| 1486 | + * (0,1,1). That beats a plain class selector, so a control our own | |
| 1487 | + * CSS had already tokenized (My WordPress's search box reads | |
| 1488 | + * `background: var( --os-ui-surface, #fff )` and has done all along) | |
| 1489 | + * still came out white: core simply outranked it. | |
| 1490 | + * | |
| 1491 | + * Hence `.os-window__body` + `:is( input[type], … )`, which | |
| 1492 | + * weighs (0,2,1) and wins. `<os-*>` form components are untouched — | |
| 1493 | + * they live in shadow DOM, where none of this reaches. | |
| 1494 | + * | |
| 1495 | + * Fallbacks are core's own values, so an unthemed shell is unchanged. | |
| 1496 | + */ | |
| 1497 | +.os-window__body | |
| 1498 | + :is( | |
| 1499 | + input[ type="text" ], | |
| 1500 | + input[ type="search" ], | |
| 1501 | + input[ type="email" ], | |
| 1502 | + input[ type="url" ], | |
| 1503 | + input[ type="tel" ], | |
| 1504 | + input[ type="number" ], | |
| 1505 | + input[ type="password" ], | |
| 1506 | + input[ type="date" ], | |
| 1507 | + input[ type="datetime-local" ], | |
| 1508 | + input[ type="month" ], | |
| 1509 | + input[ type="time" ], | |
| 1510 | + input[ type="week" ], | |
| 1511 | + select, | |
| 1512 | + textarea | |
| 1513 | + ) { | |
| 1514 | + background-color: var( --os-ui-surface, #fff ); | |
| 1515 | + color: var( --os-ui-fg, #1e1e1e ); | |
| 1516 | + border-color: var( --os-ui-border-strong, #949494 ); | |
| 1517 | +} | |
| 1518 | + | |
| 1519 | +.os-window__body :is( input, textarea )::placeholder { | |
| 1520 | + color: var( --os-ui-fg-muted, #646970 ); | |
| 1521 | +} | |
| 1522 | + | |
| 1523 | +/* Cross-window drag drop-overlay rules currently live in windows.css. | |
| 1524 | + * That was originally a cache workaround — this file was an `@import` | |
| 1525 | + * sub-sheet with no `?ver=` of its own, so edits here could be served | |
| 1526 | + * stale indefinitely. It is now a separately registered handle with | |
| 1527 | + * its own filemtime stamp, so the workaround is obsolete and those | |
| 1528 | + * rules could move back here. Left in place for now because moving | |
| 1529 | + * them is a behaviour-neutral churn better done on its own. */ | |
| 1530 | + | |
| 1531 | +/* | |
| 531 | 1532 | * Loading-state overlay — painted by `src/window/dom.ts` into |
| 532 | 1533 | * every window's body and removed once the body's content reports |
| 533 | - * ready. The `<wpd-spinner>` inside is sized responsively against | |
| 1534 | + * ready. The `<os-spinner>` inside is sized responsively against | |
| 534 | 1535 | * the window's width via `clamp(96px, 14vw, 192px)` so a tiny |
| 535 | 1536 | * popover gets a small spinner and a maximized window gets a big |
| 536 | 1537 | * one. Placed above the body content (iframe / native render |
| 537 | 1538 | * output) and kept aria-hidden so the spinner's own SR-label is |
| @@ -536,16 +1537,16 @@ | ||
| 536 | 1537 | * one. Placed above the body content (iframe / native render |
| 537 | 1538 | * output) and kept aria-hidden so the spinner's own SR-label is |
| 538 | 1539 | * the only loading announcement. |
| 539 | 1540 | * |
| 540 | - * `transition-delay` on the overlay's fade-in means a render | |
| 541 | - * that lands within ~120ms never paints the spinner — the | |
| 542 | - * overlay only becomes visible when there's actually a wait | |
| 543 | - * worth surfacing. This keeps the affordance from flashing on | |
| 544 | - * fast local-dev loads while still covering the slow production | |
| 545 | - * iframe boots that motivated it. | |
| 1541 | + * The `--visible` modifier turns the overlay on. JS adds it ~120ms | |
| 1542 | + * into the load (`LOADING_OVERLAY_SHOW_DELAY_MS`), so a fast render | |
| 1543 | + * never paints a spinner. The delay cannot be a `transition-delay` | |
| 1544 | + * here: the overlay is appended into a body that already carries | |
| 1545 | + * `--loading`, so its first computed style is the visible one and the | |
| 1546 | + * transition never runs. | |
| 546 | 1547 | */ |
| 547 | -.desktop-mode-window__loading { | |
| 1548 | +.os-window__loading { | |
| 548 | 1549 | position: absolute; |
| 549 | 1550 | inset: 0; |
| 550 | 1551 | display: flex; |
| 551 | 1552 | align-items: center; |
| @@ -550,40 +1551,155 @@ | ||
| 550 | 1551 | display: flex; |
| 551 | 1552 | align-items: center; |
| 552 | 1553 | justify-content: center; |
| 553 | 1554 | pointer-events: none; |
| 554 | - background: var(--desktop-mode-window-bg); | |
| 1555 | + background: var(--os-window-bg); | |
| 555 | 1556 | opacity: 0; |
| 556 | 1557 | /* Default (loaded → loading-removed) — fade out immediately. */ |
| 557 | 1558 | transition: opacity 0.25s ease; |
| 558 | - z-index: 1; | |
| 1559 | + /* | |
| 1560 | + * Above both reveal layers (z-index 2 and 3), so the spinner stays | |
| 1561 | + * readable for the whole load and the surface it is sitting on only | |
| 1562 | + * becomes visible once the spinner has faded out. | |
| 1563 | + */ | |
| 1564 | + z-index: 4; | |
| 559 | 1565 | } |
| 560 | 1566 | |
| 561 | -.desktop-mode-window__body--loading > .desktop-mode-window__iframe, | |
| 562 | -.desktop-mode-window__body--loading > :not(.desktop-mode-window__loading) { | |
| 1567 | +/* | |
| 1568 | + * Content hidden while loading. The reveal surface is excluded | |
| 1569 | + * alongside the spinner overlay: it is chrome, not content, and fading | |
| 1570 | + * it to transparent would show the very content it exists to cover. | |
| 1571 | + */ | |
| 1572 | +.os-window__body--loading > .os-window__iframe, | |
| 1573 | +.os-window__body--loading | |
| 1574 | + > :not(.os-window__loading):not(.os-window__reveal) { | |
| 563 | 1575 | opacity: 0; |
| 564 | 1576 | transition: opacity 0.25s ease; |
| 565 | 1577 | } |
| 566 | 1578 | |
| 567 | -.desktop-mode-window__body--loading > .desktop-mode-window__loading { | |
| 1579 | +.os-window__body--loading > .os-window__loading--visible { | |
| 568 | 1580 | opacity: 1; |
| 569 | - /* Entry transition with a small delay — loads that finish in | |
| 570 | - * under ~120ms never paint the spinner, so fast local-dev / | |
| 571 | - * hot-cache fires don't flash. Slow production iframe boots | |
| 572 | - * still see the spinner. */ | |
| 573 | - transition-delay: 0.12s; | |
| 574 | 1581 | } |
| 575 | 1582 | |
| 1583 | +/* | |
| 1584 | + * Content hand-off. `--loading` drops as soon as the content is ready, | |
| 1585 | + * but the overlay above it still needs 250ms to fade out. Without this | |
| 1586 | + * both layers are on screen at once, which reads as a flash. | |
| 1587 | + * | |
| 1588 | + * This modifier holds the content transparent for the length of that | |
| 1589 | + * fade, then fades it in. The delay in the shorthand does the holding. | |
| 1590 | + * | |
| 1591 | + * Only added when the spinner actually painted. A load that finishes | |
| 1592 | + * inside the show delay never reached `--visible`, so the shell drops | |
| 1593 | + * the overlay in the same tick instead. | |
| 1594 | + * | |
| 1595 | + * `--revealing` declares the same selector at the same specificity and | |
| 1596 | + * comes later in the file, so a window playing a reveal keeps its | |
| 1597 | + * content opaque under the reveal surface. | |
| 1598 | + */ | |
| 1599 | +.os-window__body--loading-out > .os-window__iframe, | |
| 1600 | +.os-window__body--loading-out | |
| 1601 | + > :not(.os-window__loading):not(.os-window__reveal) { | |
| 1602 | + opacity: 1; | |
| 1603 | + transition: opacity 0.25s ease 0.25s; | |
| 1604 | +} | |
| 1605 | + | |
| 576 | 1606 | @media ( prefers-reduced-motion: reduce ) { |
| 577 | - .desktop-mode-window__iframe, | |
| 578 | - .desktop-mode-window__loading, | |
| 579 | - .desktop-mode-window__body--loading > .desktop-mode-window__iframe, | |
| 580 | - .desktop-mode-window__body--loading > :not(.desktop-mode-window__loading) { | |
| 1607 | + .os-window__iframe, | |
| 1608 | + .os-window__loading, | |
| 1609 | + .os-window__body--loading > .os-window__iframe, | |
| 1610 | + .os-window__body--loading | |
| 1611 | + > :not(.os-window__loading):not(.os-window__reveal), | |
| 1612 | + .os-window__body--loading-out > .os-window__iframe, | |
| 1613 | + .os-window__body--loading-out | |
| 1614 | + > :not(.os-window__loading):not(.os-window__reveal) { | |
| 581 | 1615 | transition: none; |
| 582 | 1616 | } |
| 583 | 1617 | } |
| 584 | 1618 | |
| 585 | 1619 | /* |
| 1620 | + * Window reveal — the opaque layers a window's content is uncovered | |
| 1621 | + * from once it reports ready. Painted for every window (of either | |
| 1622 | + * kind) from construction, animated away by `src/reveals/surface.ts`, | |
| 1623 | + * then removed. | |
| 1624 | + * | |
| 1625 | + * They are SIBLINGS of the iframe, never wrappers. `clip-path` is | |
| 1626 | + * animated on these elements alone, so the content below keeps its own | |
| 1627 | + * compositing layer, its hit-testing, and its stacking context — a | |
| 1628 | + * reveal cannot swallow a click or re-rasterize the page it is | |
| 1629 | + * uncovering. Native window content gets the identical treatment for | |
| 1630 | + * the same reason. | |
| 1631 | + * | |
| 1632 | + * `--os-window-reveal-surface` is a theme token, white by | |
| 1633 | + * default — the surface has to be opaque or there is nothing to reveal | |
| 1634 | + * from. The EDGE token is `transparent` by default instead, since an | |
| 1635 | + * edge is an accent rather than the effect itself. Either layer that | |
| 1636 | + * computes to no paint is skipped rather than animated. A def may | |
| 1637 | + * override the surface for itself with an inline `background` (see | |
| 1638 | + * `WindowRevealDef.surfaceColor`), which is how `obturator` gets its | |
| 1639 | + * near-black shutter blades regardless of the token. | |
| 1640 | + * | |
| 1641 | + * No `transition` here on purpose — the shape is driven entirely by | |
| 1642 | + * the Web Animations API, which needs a matched `from` / `to` pair a | |
| 1643 | + * CSS transition cannot express. Reduced motion is handled JS-side by | |
| 1644 | + * removing the layers instead of animating them. | |
| 1645 | + */ | |
| 1646 | +.os-window__reveal { | |
| 1647 | + position: absolute; | |
| 1648 | + inset: 0; | |
| 1649 | + pointer-events: none; | |
| 1650 | + background: var( --os-window-reveal-surface, #fff ); | |
| 1651 | + z-index: 3; | |
| 1652 | +} | |
| 1653 | + | |
| 1654 | +/* | |
| 1655 | + * The reveal's leading edge. Same element shape, same keyframes, run | |
| 1656 | + * over a slightly longer duration so it trails the surface — the sliver | |
| 1657 | + * of it that is not yet covered by the surface IS the edge band. That | |
| 1658 | + * is why this needs no shape of its own, and why every reveal (built-in | |
| 1659 | + * or third-party) gets an edge that follows its geometry exactly. | |
| 1660 | + * | |
| 1661 | + * Behind the surface, still above the content. | |
| 1662 | + * | |
| 1663 | + * `--os-window-reveal-edge` is `transparent` by default, and | |
| 1664 | + * the shell drops the layer entirely while it computes that way — so | |
| 1665 | + * the default costs no element and no animation. Give the token a | |
| 1666 | + * colour (or a gradient) to turn the edge on across every reveal at | |
| 1667 | + * once. `--os-window-reveal-edge-thickness` tunes how wide | |
| 1668 | + * the band is; `edgeLag: 0` on a def opts a single reveal out. | |
| 1669 | + */ | |
| 1670 | +.os-window__reveal--edge { | |
| 1671 | + background: var( --os-window-reveal-edge, transparent ); | |
| 1672 | + z-index: 2; | |
| 1673 | +} | |
| 1674 | + | |
| 1675 | +/* | |
| 1676 | + * A reveal that renders its own DOM (`WindowRevealDef.render`) paints | |
| 1677 | + * itself — an `<svg>`, a canvas, whatever it built. The surface token | |
| 1678 | + * must NOT apply underneath it: an opaque rectangle behind the | |
| 1679 | + * renderer's output is what the effect would end up uncovering, so the | |
| 1680 | + * animation would play against a flat colour and the real content | |
| 1681 | + * would only appear when the layer is finally removed. | |
| 1682 | + */ | |
| 1683 | +.os-window__reveal--custom { | |
| 1684 | + background: none; | |
| 1685 | +} | |
| 1686 | + | |
| 1687 | +/* | |
| 1688 | + * While a reveal plays, pin the content to full opacity with no | |
| 1689 | + * transition. Dropping `--loading` normally starts a 250 ms content | |
| 1690 | + * fade-in; a reveal running over that fade would show a | |
| 1691 | + * half-transparent strip along its leading edge. Beats the base | |
| 1692 | + * `.os-window__iframe` rule on specificity, so no | |
| 1693 | + * `!important` is needed. | |
| 1694 | + */ | |
| 1695 | +.os-window__body--revealing | |
| 1696 | + > :not(.os-window__loading):not(.os-window__reveal) { | |
| 1697 | + opacity: 1; | |
| 1698 | + transition: none; | |
| 1699 | +} | |
| 1700 | + | |
| 1701 | +/* | |
| 586 | 1702 | * External sub-tab iframes stack in the same body as the primary |
| 587 | 1703 | * iframe. Only one is visible at a time (managed by `switchToTab` |
| 588 | 1704 | * via `style.display`). Absolute positioning so they don't push the |
| 589 | 1705 | * primary iframe around when added to the DOM; `display: none` on |
| @@ -588,23 +1704,57 @@ | ||
| 588 | 1704 | * via `style.display`). Absolute positioning so they don't push the |
| 589 | 1705 | * primary iframe around when added to the DOM; `display: none` on |
| 590 | 1706 | * inactive iframes is set inline by the JS. |
| 591 | 1707 | */ |
| 592 | -.desktop-mode-window__iframe--external { | |
| 1708 | +.os-window__iframe--external { | |
| 593 | 1709 | position: absolute; |
| 594 | 1710 | inset: 0; |
| 595 | 1711 | } |
| 596 | 1712 | |
| 597 | 1713 | /* |
| 1714 | + * Double-buffer twin for `Window.swapReload()` — the silent refresh | |
| 1715 | + * the editor-preview companion uses. The twin loads UNDERNEATH the | |
| 1716 | + * visible frame at full opacity: a normal, fully-rasterized paint | |
| 1717 | + * target, completely covered by the (opaque) old frame while it | |
| 1718 | + * loads. Deliberately not `opacity: 0`-on-top or | |
| 1719 | + * `visibility: hidden` — browsers defer rasterizing invisible | |
| 1720 | + * iframes, and revealing such a frame paints its blank background | |
| 1721 | + * before its raster lands (a white blink). | |
| 1722 | + * | |
| 1723 | + * Stacking: positioned elements paint above static ones regardless | |
| 1724 | + * of DOM order, so the buffer (absolute) would land on top of the | |
| 1725 | + * static primary frame — `--swap-front` elevates the OLD frame for | |
| 1726 | + * the duration of the swap instead. The swap itself is instant and | |
| 1727 | + * animation-free: removing the old frame exposes the ready-painted | |
| 1728 | + * twin in the same compositor frame. At no point is unpainted | |
| 1729 | + * content the only thing on screen. | |
| 1730 | + */ | |
| 1731 | +.os-window__iframe--buffer { | |
| 1732 | + position: absolute; | |
| 1733 | + inset: 0; | |
| 1734 | + pointer-events: none; | |
| 1735 | +} | |
| 1736 | + | |
| 1737 | +.os-window__iframe--swap-front { | |
| 1738 | + position: relative; | |
| 1739 | + z-index: 1; | |
| 1740 | +} | |
| 1741 | + | |
| 1742 | +/* | |
| 598 | 1743 | * Native window body — fills the window but lets its contents scroll |
| 599 | 1744 | * independently. Iframe bodies use `overflow: hidden` so the iframe |
| 600 | 1745 | * controls its own scroll; native bodies render real document content |
| 601 | 1746 | * that needs the parent to manage overflow. |
| 602 | 1747 | */ |
| 603 | -.desktop-mode-window__body--native { | |
| 1748 | +.os-window__body--native { | |
| 604 | 1749 | overflow: auto; |
| 605 | - color: #1d2327; | |
| 606 | - background: var(--desktop-mode-window-bg); | |
| 1750 | + color: var( --os-ui-fg, #1d2327 ); | |
| 1751 | + /* background-COLOR, not the shorthand. This is a modifier on the | |
| 1752 | + * SAME element as `.os-window__body`, at the same | |
| 1753 | + * specificity and later in source order — the shorthand would | |
| 1754 | + * reset the WINDOW_BODY texture declared there, which is exactly | |
| 1755 | + * the surface native windows exist to show. */ | |
| 1756 | + background-color: var(--os-window-bg); | |
| 607 | 1757 | } |
| 608 | 1758 | |
| 609 | 1759 | /* |
| 610 | 1760 | * Highlight rings. Toggled from JS by `Window.setHighlight()` — |
| @@ -636,12 +1786,12 @@ | ||
| 636 | 1786 | * plugin has registered a button for the window. When buttons ARE |
| 637 | 1787 | * present, they sit inline next to the title (left slot) or just |
| 638 | 1788 | * before the window controls (right slot). |
| 639 | 1789 | */ |
| 640 | -.desktop-mode-window__custom-buttons { | |
| 1790 | +.os-window__custom-buttons { | |
| 641 | 1791 | display: contents; |
| 642 | 1792 | } |
| 643 | -.desktop-mode-window__btn--custom { | |
| 1793 | +.os-window__btn--custom { | |
| 644 | 1794 | margin: 0 2px; |
| 645 | 1795 | } |
| 646 | 1796 | /* |
| 647 | 1797 | * Plugin-supplied icons render in the host's light DOM so the |
| @@ -663,22 +1813,64 @@ | ||
| 663 | 1813 | * width/height, we DO clamp to 18×18 — those are the cases where |
| 664 | 1814 | * a bare `<svg>` would otherwise size to its viewBox or default |
| 665 | 1815 | * to 300×150 (the spec default) and overflow the button. |
| 666 | 1816 | */ |
| 667 | -.desktop-mode-window__btn--custom svg:not( [ width ] ):not( [ height ] ) { | |
| 1817 | +.os-window__btn--custom svg:not( [ width ] ):not( [ height ] ) { | |
| 668 | 1818 | width: 18px; |
| 669 | 1819 | height: 18px; |
| 670 | 1820 | display: block; |
| 671 | 1821 | } |
| 672 | 1822 | |
| 1823 | +/* | |
| 1824 | + * Busy state for custom title-bar buttons — a gentle opacity pulse | |
| 1825 | + * while a round-trip is in flight (e.g. the editor-preview eye | |
| 1826 | + * waiting on the editor's autosave before opening the preview). | |
| 1827 | + * Driven by `aria-busy="true"` + this class, both set by the button's | |
| 1828 | + * render callback. | |
| 1829 | + */ | |
| 1830 | +.os-window__btn--busy { | |
| 1831 | + animation: os-btn-busy-pulse 1s ease-in-out infinite; | |
| 1832 | + pointer-events: none; | |
| 1833 | +} | |
| 1834 | + | |
| 1835 | +@keyframes os-btn-busy-pulse { | |
| 1836 | + 0%, | |
| 1837 | + 100% { | |
| 1838 | + opacity: 1; | |
| 1839 | + } | |
| 1840 | + | |
| 1841 | + 50% { | |
| 1842 | + opacity: 0.4; | |
| 1843 | + } | |
| 1844 | +} | |
| 1845 | + | |
| 1846 | +@media (prefers-reduced-motion: reduce) { | |
| 1847 | + .os-window__btn--busy { | |
| 1848 | + animation: none; | |
| 1849 | + opacity: 0.6; | |
| 1850 | + } | |
| 1851 | +} | |
| 1852 | + | |
| 1853 | +/* | |
| 1854 | + * Disabled state for custom title-bar buttons — visible but inert | |
| 1855 | + * (e.g. the editor-preview eye on an unsaved "Add New" screen, where | |
| 1856 | + * there is nothing to preview until the first save). The button keeps | |
| 1857 | + * pointer events so its explanatory tooltip/toast still works; | |
| 1858 | + * `aria-disabled` carries the semantics. | |
| 1859 | + */ | |
| 1860 | +.os-window__btn--disabled { | |
| 1861 | + opacity: 0.4; | |
| 1862 | + cursor: default; | |
| 1863 | +} | |
| 1864 | + | |
| 673 | 1865 | /* ---------------------------------------------------------------- |
| 674 | 1866 | * Custom-chrome marker — hides every framework-shipped titlebar |
| 675 | 1867 | * child while a plugin's chrome is mounted. |
| 676 | 1868 | * |
| 677 | - * The window gets the `desktop-mode-window--custom-chrome` class the | |
| 1869 | + * The window gets the `os-window--custom-chrome` class the | |
| 678 | 1870 | * moment `mountWindowChrome` succeeds (BEFORE the plugin's |
| 679 | 1871 | * `render()` runs). Default children carry the |
| 680 | - * `data-desktop-mode-default-chrome` attribute stamped at element- | |
| 1872 | + * `data-os-default-chrome` attribute stamped at element- | |
| 681 | 1873 | * creation time. The combination is a load-bearing guarantee: |
| 682 | 1874 | * even if the plugin's render() doesn't clear `titlebar.innerHTML`, |
| 683 | 1875 | * even if a plugin's destroy() leaks the standard chrome back into |
| 684 | 1876 | * place, even mid-fade during a window close — the default chrome |
| @@ -692,11 +1884,11 @@ | ||
| 692 | 1884 | * |
| 693 | 1885 | * @since 0.18.0 |
| 694 | 1886 | * ---------------------------------------------------------------- */ |
| 695 | 1887 | |
| 696 | -.desktop-mode-window--custom-chrome | |
| 697 | - > .desktop-mode-window__titlebar | |
| 698 | - > [ data-desktop-mode-default-chrome ] { | |
| 1888 | +.os-window--custom-chrome | |
| 1889 | + > .os-window__titlebar | |
| 1890 | + > [ data-os-default-chrome ] { | |
| 699 | 1891 | display: none !important; |
| 700 | 1892 | } |
| 701 | 1893 | |
| 702 | 1894 | /* --------------------------------------------------------------- |
| @@ -712,9 +1904,9 @@ | ||
| 712 | 1904 | * the animation cleanly. |
| 713 | 1905 | * |
| 714 | 1906 | * 2. While the window body is in the `--loading` state (set by |
| 715 | 1907 | * `markContentLoading()` and cleared on the iframe's |
| 716 | - * `desktop-mode-ready` postMessage), the button is dimmed and | |
| 1908 | + * `os-ready` postMessage), the button is dimmed and | |
| 717 | 1909 | * non-interactive so a second click can't desync the |
| 718 | 1910 | * chromeless bridge's loading handshake. |
| 719 | 1911 | * |
| 720 | 1912 | * `:has()` is used instead of mirroring the `--loading` modifier |
| @@ -722,10 +1914,10 @@ | ||
| 722 | 1914 | * decorates the body element. Browsers without :has() (very old) |
| 723 | 1915 | * fall back to a static dimmed button — feature, not bug. |
| 724 | 1916 | * --------------------------------------------------------------- */ |
| 725 | 1917 | |
| 726 | -.desktop-mode-window:has( .desktop-mode-window__body--loading ) | |
| 727 | - .desktop-mode-window__btn--reload { | |
| 1918 | +.os-window:has( .os-window__body--loading ) | |
| 1919 | + .os-window__btn--reload { | |
| 728 | 1920 | pointer-events: none; |
| 729 | 1921 | opacity: 0.55; |
| 730 | 1922 | } |
| 731 | 1923 | |
| @@ -738,13 +1930,13 @@ | ||
| 738 | 1930 | * of the gesture rather than progress through a long task. The `0.6s` |
| 739 | 1931 | * duration is short enough to never block the user but long enough |
| 740 | 1932 | * that the deceleration is legible. |
| 741 | 1933 | */ |
| 742 | -.desktop-mode-window__btn--reload.desktop-mode-window__btn--spinning { | |
| 743 | - animation: wpd-reload-spin-once 0.6s cubic-bezier( 0.05, 0.7, 0.1, 1 ); | |
| 1934 | +.os-window__btn--reload.os-window__btn--spinning { | |
| 1935 | + animation: os-reload-spin-once 0.6s cubic-bezier( 0.05, 0.7, 0.1, 1 ); | |
| 744 | 1936 | } |
| 745 | 1937 | |
| 746 | -@keyframes wpd-reload-spin-once { | |
| 1938 | +@keyframes os-reload-spin-once { | |
| 747 | 1939 | from { |
| 748 | 1940 | transform: rotate( 0deg ); |
| 749 | 1941 | } |
| 750 | 1942 | to { |
| @@ -752,8 +1944,8 @@ | ||
| 752 | 1944 | } |
| 753 | 1945 | } |
| 754 | 1946 | |
| 755 | 1947 | @media ( prefers-reduced-motion: reduce ) { |
| 756 | - .desktop-mode-window__btn--reload.desktop-mode-window__btn--spinning { | |
| 1948 | + .os-window__btn--reload.os-window__btn--spinning { | |
| 757 | 1949 | animation: none; |
| 758 | 1950 | } |
| 759 | 1951 | } |