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