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
desktop-mode / assets / css / desktop.css

desktop.css in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.13, at assets/css/desktop.css

2,262 lines 70.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * OpenStation — Shell Layout.
3 *
4 * When OpenStation is active, the classic admin chrome still renders
5 * server-side (for hook compatibility) but is hidden with CSS. The
6 * plugin-injected shell floats above it, positioned just below the
7 * fixed admin bar, covering the rest of the viewport.
8 *
9 * @since 0.1.0
10 */
11
12 /*
13 * Solid backdrop behind the shell. Reads the backstop token rather
14 * than a literal so it moves with the desk: this is the colour a
15 * dropped frame shows, and it has to be the same one the shell paints
16 * or the seam is visible at exactly the wrong moment.
17 */
18 body.os-active {
19 background: var( --os-backstop, #1d2327 );
20 overflow: hidden;
21 }
22
23 /*
24 * Text selection.
25 *
26 * The shell had no ::selection rule at all, so every selection in it
27 * was the browser default — a colour chosen for a white page, landing
28 * on a near-black one. In Chrome that is a dark translucent blue over
29 * Obsidian, which is roughly 1.3:1 against the surface and leaves the
30 * selected text at a contrast the UA never checked, because the UA
31 * assumed a light background. Dragging across a paragraph produced no
32 * visible change at all on some displays.
33 *
34 * Both halves are set on purpose. `background` alone is the common
35 * half-fix: the UA then keeps its own selected-text colour, which is
36 * frequently forced to black and lands on this violet at ~2:1.
37 *
38 * Scoped to the shell document. `variables.css` is a dependency of
39 * `chromeless.css` and so loads inside every iframe window, but this
40 * file does not — a wp-admin page in a window keeps the selection
41 * colour it has outside one, which is the same promise the palette
42 * makes about everything else.
43 *
44 * `::-moz-selection` is a separate rule rather than a second selector
45 * on this one: an unrecognised pseudo-element invalidates the entire
46 * selector list, so pairing them would leave Chrome unstyled too.
47 */
48 body.os-active ::selection {
49 background: var( --os-ui-selection-bg, rgba( 159, 152, 255, 0.6 ) );
50 color: var( --os-ui-selection-fg, #fffbff );
51 }
52
53 body.os-active ::-moz-selection {
54 background: var( --os-ui-selection-bg, rgba( 159, 152, 255, 0.6 ) );
55 color: var( --os-ui-selection-fg, #fffbff );
56 }
57
58 /*
59 * Cursor policy — default arrow everywhere in OpenStation.
60 *
61 * Per user preference (since 0.20.0): no hand/pointer/grab/grabbing/
62 * copy/no-drop cursors on icons, tiles, dock items, dock peek,
63 * window chrome, or any other clickable surface. The default arrow
64 * is the only "neutral" cursor we render.
65 *
66 * Exceptions:
67 * - Window + widget resize handles keep their direction-specific
68 * cursors (ns-resize, ew-resize, nesw-resize, nwse-resize) —
69 * they're a functional cue, not a hover affordance, and removing
70 * them would make corner-grabs guesswork.
71 * - Text inputs / textareas / contenteditable surfaces keep the
72 * browser-default I-beam.
73 * - The AI assistant's inline "AI Settings" recovery link keeps a
74 * pointer so it reads as a genuine link (rule lives in
75 * ai-assistant.css).
76 *
77 * `!important` is needed because the specific `cursor: pointer` and
78 * drag-state declarations elsewhere in the codebase have the same or
79 * higher specificity. The resize-handle exceptions use `!important`
80 * for the same reason.
81 *
82 * Shadow-DOM web components (`os-*`) define their own cursors
83 * inside their shadow trees; those internal rules win regardless of
84 * what we set on the host. Anything visible on the desktop shell
85 * (light DOM) is covered by this policy.
86 */
87 body.os-active,
88 body.os-active * {
89 cursor: default !important;
90 }
91
92 body.os-active input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]):not([type="file"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="image"]),
93 body.os-active textarea,
94 body.os-active [contenteditable=""],
95 body.os-active [contenteditable="true"] {
96 cursor: text !important;
97 }
98
99 body.os-active .os-window__resize-handle--se,
100 body.os-active .os-window__resize-handle--nw,
101 body.os-active .os-widgets__resize--se,
102 body.os-active .os-widgets__resize--nw {
103 cursor: nwse-resize !important;
104 }
105
106 body.os-active .os-window__resize-handle--sw,
107 body.os-active .os-window__resize-handle--ne,
108 body.os-active .os-widgets__resize--sw,
109 body.os-active .os-widgets__resize--ne {
110 cursor: nesw-resize !important;
111 }
112
113 body.os-active .os-widgets__resize--n,
114 body.os-active .os-widgets__resize--s {
115 cursor: ns-resize !important;
116 }
117
118 body.os-active .os-widgets__resize--e,
119 body.os-active .os-widgets__resize--w {
120 cursor: ew-resize !important;
121 }
122
123 /*
124 * Hide the classic admin chrome while the shell is active.
125 *
126 * The markup still exists in the DOM so server-side action hooks that
127 * target these regions (admin_notices, in_admin_footer, etc.) continue
128 * to fire — we just take them out of the visual flow.
129 */
130 body.os-active #adminmenuwrap,
131 body.os-active #adminmenuback,
132 body.os-active #wpbody,
133 body.os-active #wpfooter,
134 body.os-active .wp-responsive-toggle,
135 body.os-active #collapse-menu {
136 display: none !important;
137 }
138
139 /*
140 * The admin bar is the ONLY escape hatch in OpenStation — it hosts the
141 * "Switch to Classic Admin" toggle. Any plugin CSS, Gutenberg state,
142 * user preference, or stray `display:none` that hides it would strand
143 * the user inside the shell with no way out. Hard-pin it visible.
144 *
145 * The exception: while a window is in immersive fullscreen, we *want*
146 * the bar hidden (macOS convention). That rule lives below and wins on
147 * specificity because it also targets #wpadminbar under a body class.
148 */
149 body.os-active #wpadminbar {
150 display: block !important;
151 visibility: visible !important;
152 opacity: 1 !important;
153 position: fixed !important;
154 inset-block-start: 0 !important;
155 inset-inline-start: 0 !important;
156 inset-inline-end: 0 !important;
157 }
158
159 /*
160 * No top-level item may be taller than the bar.
161 *
162 * Core lays the two item groups out as floats, and a float that
163 * overflows the 32px bar overflows invisibly. A host may lay a group
164 * out as a flex row instead — WordPress.com's Debug Bar does, to
165 * order its own item first — and then every item stretches to the
166 * tallest one and the group's background paints the difference under
167 * the windows' title bars. Our own items were that tallest one once
168 * (an `inline-flex` item on a 32px line box grew to 37px; fixed at
169 * the source in `includes/admin-bar.php`), but any item from any
170 * plugin can be, so the cap lives here: the bar's own height token,
171 * which is also the 46px mobile bar. Submenus are absolutely
172 * positioned and unaffected.
173 */
174 body.os-active #wpadminbar .ab-top-menu > li {
175 max-height: var(--wp-admin--admin-bar--height, 32px);
176 }
177
178 /*
179 * Admin-bar presentation modes — OS Settings → Appearance → Admin bar,
180 * persisted as `adminBarMode` and emitted as a
181 * `os-admin-bar-<mode>` body class by PHP on first paint and
182 * re-written by the shell's apply pass on every change.
183 *
184 * `static` is the absence of rules: the pin above stands and the shell
185 * starts below the bar. The two modes below both take the bar out of
186 * the shell's way, so the shell reclaims the full viewport in each.
187 *
188 * Every selector here carries two body classes, so it outranks the
189 * one-class pin rule above on specificity; the one property the pin
190 * marks `!important` (`inset-block-start`) is re-declared `!important`
191 * where a mode moves the bar, and wins the same way.
192 */
193
194 /*
195 * Dynamic — the bar parks off the top edge leaving a peek strip, and
196 * slides back in when the pointer reaches the top of the viewport or
197 * something inside it takes keyboard focus. The classic Windows
198 * auto-hide taskbar.
199 *
200 * It parks by moving its `inset-block-start`, NOT by a `transform`,
201 * and that is load-bearing. A transformed element becomes the
202 * containing block for every `position: fixed` descendant, and the
203 * bar has descendants like that which it does not own: the
204 * WordPress.com notifications panel is `position: fixed; top: 32px;
205 * bottom: 0` inside `#wp-admin-bar-notes`. Measured against a
206 * transformed 32px bar instead of the viewport, that panel computes to
207 * zero height — the button "did nothing" in this mode and worked in
208 * `static`. `translate`, `filter`, `perspective`, `contain` and
209 * `will-change: transform` all create the same containing block;
210 * `tests/vitest/admin-bar-dynamic.test.ts` keeps every one of them off
211 * this rule. Moving `inset-block-start` hit-tests the same way — the
212 * part above the viewport is simply outside it — so what catches the
213 * pointer is still only the peek strip plus the reveal zone below.
214 *
215 * The pin above sets `inset-block-start: 0 !important`; these carry
216 * `!important` too and win on specificity. The bar's height is Core's
217 * own token, so the parked offset follows the 46px mobile bar as well.
218 */
219 body.os-active.os-admin-bar-dynamic #wpadminbar {
220 /* One number for the slide; the reveal zone below waits on it. */
221 --os-admin-bar-slide: 180ms;
222 inset-block-start: calc(
223 var(--os-admin-bar-peek, 4px) - var(--wp-admin--admin-bar--height, 32px)
224 ) !important;
225 transition: inset-block-start var(--os-admin-bar-slide, 180ms) ease;
226 }
227
228 body.os-active.os-admin-bar-dynamic #wpadminbar:hover,
229 body.os-active.os-admin-bar-dynamic #wpadminbar:focus-within,
230 body.os-active.os-admin-bar-dynamic #wpadminbar:active {
231 inset-block-start: 0 !important;
232 }
233
234 /*
235 * Reveal zone — an invisible extension of the bar's hit area, hanging
236 * below the parked peek strip.
237 *
238 * Hovering a pseudo-element counts as hovering its originating
239 * element, so this widens what `:hover` above responds to WITHOUT
240 * widening the visible seam. The two wants are genuinely different:
241 * the seam should be a hairline, the target should be forgiving, and
242 * `--os-admin-bar-peek` alone can't be both.
243 *
244 * Core ships no bare `#wpadminbar::before/::after` rule, so this
245 * claims the slot rather than fighting one.
246 */
247 body.os-active.os-admin-bar-dynamic #wpadminbar::after {
248 content: "";
249 position: absolute;
250 inset-inline: 0;
251 inset-block-start: 100%;
252 height: var(--os-admin-bar-reveal-zone, 16px);
253 /*
254 * The zone changes size only once the bar has come to rest — a
255 * step, not a slide, held back for the length of the bar's own
256 * slide. That delay is what keeps the reveal stable. Collapsed the
257 * instant hover began, the zone was gone while the bar was still
258 * on its way down: a pointer resting in the band the zone had
259 * covered was outside the bar's box for the rest of the slide, so
260 * hover dropped, the bar turned back, the zone returned under the
261 * pointer, hover resumed — the bar flickered up and down for as
262 * long as the pointer stayed near the edge. Held for the slide,
263 * the zone still hangs off the moving bar and the two together
264 * always cover the pointer that tripped it; by the time the zone
265 * goes, the bar's own box is over that pointer. The same delay on
266 * the way back keeps the zone from reappearing under a pointer
267 * that just left the bar while the bar is still retracting.
268 */
269 transition: height 0s linear var(--os-admin-bar-slide, 180ms);
270 }
271
272 /*
273 * ...but only while the bar is parked. Left standing once the bar is
274 * out, the zone would hang over the top of the desktop and swallow
275 * clicks on whatever window is up there.
276 *
277 * Collapsing it is also what keeps the reveal stable: the pointer
278 * that tripped the zone is, post-reveal, inside the bar's real box,
279 * so `:hover` holds without the zone's help — and it collapses only
280 * once the bar is there (the transition above).
281 */
282 body.os-active.os-admin-bar-dynamic #wpadminbar:hover::after,
283 body.os-active.os-admin-bar-dynamic #wpadminbar:focus-within::after,
284 body.os-active.os-admin-bar-dynamic #wpadminbar:active::after {
285 height: 0;
286 }
287
288 /*
289 * Coarse pointers have no hover state and a much fatter contact patch,
290 * so a 4px seam is neither discoverable nor tappable. Widen both the
291 * seam and the zone — `:active` above is what actually reveals the bar
292 * there. Kept under the 32px bar height, same ceiling as the default.
293 */
294 @media (hover: none) {
295 body.os-active.os-admin-bar-dynamic #wpadminbar {
296 --os-admin-bar-peek: 10px;
297 --os-admin-bar-reveal-zone: 20px;
298 }
299 }
300
301 @media (prefers-reduced-motion: reduce) {
302 body.os-active.os-admin-bar-dynamic #wpadminbar,
303 body.os-active.os-admin-bar-dynamic #wpadminbar::after {
304 /* The bar jumps, so the zone has nothing to wait for. */
305 transition: none;
306 }
307 }
308
309 /*
310 * Hidden — the bar is gone. This removes the "Switch to Classic Admin"
311 * toggle, which is why it is safe: the dock's core rail always carries
312 * an "Exit OpenStation" tile (src/exit-os.ts) hitting the
313 * same endpoint. Do not add a mode that removes both.
314 */
315 body.os-active.os-admin-bar-hidden #wpadminbar {
316 display: none !important;
317 }
318
319 /*
320 * Both non-static modes: the shell owns the whole viewport. Two body
321 * classes beat the `.os-shell` base rule AND its <783px
322 * media-query variant on specificity, so source order doesn't matter.
323 */
324 body.os-active.os-admin-bar-dynamic .os-shell,
325 body.os-active.os-admin-bar-hidden .os-shell {
326 inset-block-start: 0;
327 }
328
329 /*
330 * Core reserves the bar's height as `padding-top` on <html>. In both
331 * non-static modes the bar is out of flow (or absent) and the shell
332 * covers the viewport anyway, so the reserve is dead space — release
333 * it so any classic content behind the shell isn't pushed down by a
334 * bar that isn't sitting there. Same idiom as chromeless.css.
335 */
336 html.wp-toolbar:has(body.os-admin-bar-dynamic),
337 html.wp-toolbar:has(body.os-admin-bar-hidden) {
338 padding-top: 0 !important;
339 }
340
341 /*
342 * A desktop theme that draws the toolbar.
343 *
344 * The toolbar's colours normally belong to the user's admin colour
345 * scheme, whose own stylesheet paints it. A theme takes them over only
346 * when it declares an `--os-toolbar-*` token, which is what puts
347 * `os-toolbar-themed` on the body (PHP on first paint, the theme apply
348 * pass on a live switch). Without that class none of this matches, so
349 * a scheme's toolbar stays the scheme's under every other theme.
350 *
351 * The fallbacks are Core's own values for the default scheme.
352 */
353 body.os-active.os-toolbar-themed #wpadminbar {
354 background: var(--os-toolbar-bg, #1d2327);
355 box-shadow: var(--os-toolbar-shadow, none);
356 -webkit-font-smoothing: var(--os-font-smoothing, subpixel-antialiased);
357 }
358
359 body.os-active.os-toolbar-themed #wpadminbar :not(.ab-icon):not([class*="dashicons"]) {
360 font-family: var(--os-toolbar-font, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif);
361 font-size: var(--os-toolbar-font-size, 13px);
362 }
363
364 /*
365 * Core sets the bar's line heights in ems against its 13px type. A
366 * face at another size keeps Core's pixel lines (the bar's height for a
367 * top-level item, 26px for a menu row), or its labels ride high or low
368 * against the icons. A face whose metrics sit differently from the
369 * system font's nudges its labels with `--os-toolbar-line-height`.
370 */
371 body.os-active.os-toolbar-themed #wpadminbar .ab-top-menu > li > .ab-item,
372 body.os-active.os-toolbar-themed #wpadminbar .ab-top-menu > li > .ab-item :not(.ab-icon):not(img) {
373 line-height: var(--os-toolbar-line-height, var(--wp-admin--admin-bar--height, 32px));
374 }
375
376 body.os-active.os-toolbar-themed #wpadminbar .quicklinks .menupop ul li .ab-item {
377 line-height: 26px;
378 }
379
380 body.os-active.os-toolbar-themed #wpadminbar .ab-empty-item,
381 body.os-active.os-toolbar-themed #wpadminbar a.ab-item,
382 body.os-active.os-toolbar-themed #wpadminbar > #wp-toolbar span.ab-label,
383 body.os-active.os-toolbar-themed #wpadminbar > #wp-toolbar span.noticon {
384 color: var(--os-toolbar-fg, #f0f0f1);
385 }
386
387 body.os-active.os-toolbar-themed #wpadminbar .ab-icon::before,
388 body.os-active.os-toolbar-themed #wpadminbar .ab-item::before,
389 body.os-active.os-toolbar-themed #wpadminbar #adminbarsearch::before {
390 color: var(--os-toolbar-icon, rgba(240, 246, 252, 0.6));
391 }
392
393 body.os-active.os-toolbar-themed #wpadminbar:not(.mobile) .ab-top-menu > li > .ab-item:focus,
394 body.os-active.os-toolbar-themed #wpadminbar:not(.mobile) .ab-top-menu > li:hover > .ab-item,
395 body.os-active.os-toolbar-themed #wpadminbar .ab-top-menu > li.hover > .ab-item {
396 background: var(--os-toolbar-hover-bg, #2c3338);
397 color: var(--os-toolbar-hover-fg, #72aee6);
398 }
399
400 body.os-active.os-toolbar-themed #wpadminbar:not(.mobile) > #wp-toolbar li:hover span.ab-label,
401 body.os-active.os-toolbar-themed #wpadminbar > #wp-toolbar li.hover span.ab-label,
402 body.os-active.os-toolbar-themed #wpadminbar li:hover .ab-icon::before,
403 body.os-active.os-toolbar-themed #wpadminbar li:hover .ab-item::before,
404 body.os-active.os-toolbar-themed #wpadminbar li.hover .ab-icon::before,
405 body.os-active.os-toolbar-themed #wpadminbar li.hover .ab-item::before {
406 color: var(--os-toolbar-hover-fg, #72aee6);
407 }
408
409 body.os-active.os-toolbar-themed #wpadminbar .menupop .ab-sub-wrapper {
410 background: var(--os-toolbar-menu-bg, #2c3338);
411 box-shadow: var(--os-toolbar-menu-shadow, 0 3px 5px rgba(0, 0, 0, 0.2));
412 }
413
414 /* The WordPress menu's lower group sits on a darker panel of its own. */
415 body.os-active.os-toolbar-themed #wpadminbar .quicklinks .menupop ul.ab-sub-secondary,
416 body.os-active.os-toolbar-themed #wpadminbar .quicklinks .menupop ul.ab-sub-secondary .ab-submenu {
417 background: var(--os-toolbar-menu-bg, #3c434a);
418 }
419
420 body.os-active.os-toolbar-themed #wpadminbar .ab-submenu .ab-item,
421 body.os-active.os-toolbar-themed #wpadminbar .quicklinks .menupop ul li a,
422 body.os-active.os-toolbar-themed #wpadminbar .quicklinks .menupop ul li a strong {
423 color: var(--os-toolbar-menu-fg, rgba(240, 246, 252, 0.7));
424 }
425
426 /*
427 * The account menu's name lines. Core colours them by ID, which
428 * out-ranks the rows above, so they are restated here, hover included.
429 */
430 body.os-active.os-toolbar-themed #wpadminbar #wp-admin-bar-user-info .display-name {
431 color: var(--os-toolbar-menu-fg, #fff);
432 }
433
434 body.os-active.os-toolbar-themed #wpadminbar #wp-admin-bar-user-info .username {
435 color: var(--os-toolbar-menu-fg-muted, #a7aaad);
436 }
437
438 body.os-active.os-toolbar-themed #wpadminbar #wp-admin-bar-user-info a:hover span,
439 body.os-active.os-toolbar-themed #wpadminbar #wp-admin-bar-user-info a:focus span {
440 color: var(--os-toolbar-menu-hover-fg, #72aee6);
441 }
442
443 body.os-active.os-toolbar-themed #wpadminbar .quicklinks .menupop ul li a:hover,
444 body.os-active.os-toolbar-themed #wpadminbar .quicklinks .menupop ul li a:focus,
445 body.os-active.os-toolbar-themed #wpadminbar .quicklinks .menupop.hover ul li a:hover,
446 body.os-active.os-toolbar-themed #wpadminbar .quicklinks .menupop.hover ul li a:focus {
447 background: var(--os-toolbar-menu-hover-bg, transparent);
448 color: var(--os-toolbar-menu-hover-fg, #72aee6);
449 }
450
451 /*
452 * The time, for a theme that draws the toolbar as a menu bar
453 * (`src/desktop-themes/toolbar-clock.ts`). It is not a control, so it
454 * does not light up under the pointer.
455 */
456 body.os-active.os-toolbar-themed #wpadminbar #wp-admin-bar-os-clock {
457 display: var(--os-toolbar-clock-display, none);
458 }
459
460 body.os-active.os-toolbar-themed #wpadminbar #wp-admin-bar-os-clock > .ab-item {
461 background: none;
462 color: var(--os-toolbar-fg, #f0f0f1);
463 }
464
465 /*
466 * Align the admin-bar W logo with the dock below it.
467 *
468 * Without this rule the admin bar's leftmost item (the WordPress W)
469 * renders at its Core-dictated ~44 px width, while the dock below it
470 * can be 48, 56, or 72 px wide depending on the user's OS-Settings
471 * preference. The misalignment reads as "two disconnected left
472 * regions" — visually scruffy on an otherwise tight shell.
473 *
474 * We size the logo's <li> to match the dock's width and center the
475 * W inside, so the two columns share an axis. `--os-dock-width`
476 * is now set on :root (see settings.ts → apply()) so it reaches
477 * siblings of #os-shell like the admin bar.
478 */
479 body.os-active #wpadminbar #wp-admin-bar-menu-toggle {
480 display: none;
481 }
482
483 body.os-active #wpadminbar #wp-admin-bar-wp-logo {
484 width: var( --os-dock-width, 56px );
485 }
486
487 body.os-active #wpadminbar #wp-admin-bar-wp-logo > .ab-item {
488 display: flex !important;
489 align-items: center;
490 justify-content: center;
491 padding-inline: 0 !important;
492 }
493
494 /*
495 * The W glyph is painted via a ::before pseudo on `.ab-icon`. Center
496 * it within the flex cell so varying dock widths don't leave it
497 * visually offset.
498 */
499 body.os-active #wpadminbar #wp-admin-bar-wp-logo > .ab-item > .ab-icon {
500 margin: 0;
501 }
502
503 /*
504 * Core nudges the W down 2px to centre it from the top of its cell.
505 * The flex cell above already centres it, so the nudge would sit it low.
506 */
507 body.os-active #wpadminbar #wp-admin-bar-wp-logo > .ab-item > .ab-icon::before {
508 top: 0;
509 }
510
511 /*
512 * Classic wpcontent carries a `margin-left: 160px` for the sidebar. With
513 * the sidebar hidden we let wpcontent fall back to flush — the shell is
514 * fixed-positioned anyway so this doesn't change the shell's layout, it
515 * just prevents a phantom sidebar gutter in any stray visible content.
516 */
517 body.os-active #wpcontent {
518 margin-inline-start: 0;
519 padding-inline-start: 0;
520 }
521
522 /*
523 * Desktop shell — fills the viewport below the admin bar.
524 * Uses position: fixed so it's unaffected by any containing-block
525 * quirks in #wpwrap/#wpcontent.
526 *
527 * The inset is the bar's MEASURED bottom edge, `--os-admin-bar-height`
528 * (written on <html> by `src/admin-bar-height.ts`), with Core's own
529 * token behind it for the first paint. Core's token is Core's promise
530 * about Core's bar; a host can make the bar taller, give it a border
531 * or push it down under a strip of its own — WordPress.com's staff
532 * debug chrome does — and the shell then started under the bar by
533 * exactly the difference. The measured token is absent whenever the
534 * bar has no box at the top edge (hidden, mobile, solo, fullscreen,
535 * parked in the dynamic mode), so every mode that rebinds Core's
536 * token below still resolves through it.
537 */
538 .os-shell {
539 position: fixed;
540 inset-block-start: var(
541 --os-admin-bar-height,
542 var(--wp-admin--admin-bar--height, 32px)
543 );
544 inset-inline-start: 0;
545 inset-inline-end: 0;
546 inset-block-end: 0;
547 display: flex;
548 flex-direction: column;
549 overflow: hidden;
550 z-index: var(--os-z-base);
551 /*
552 * Opaque floor. The wallpaper paints over this and is what anyone
553 * actually sees — but the wallpaper is one layer among several,
554 * and behind this shell is the classic admin page, which is WHITE.
555 * Without a background here, any frame in which the wallpaper
556 * doesn't paint flashes that white through the whole viewport.
557 * See `--os-backstop`.
558 */
559 background: var(--os-backstop, #1d2327);
560 /*
561 * A bitmap face drawn on whole pixels wants plain greyscale
562 * smoothing; the fallback is what the shell inherits from Core's
563 * `#wpwrap` anyway.
564 */
565 -webkit-font-smoothing: var(--os-font-smoothing, subpixel-antialiased);
566 /*
567 * Shell typeface. Every piece of chrome that doesn't set its own
568 * face inherits from here — dock labels, desktop icon labels,
569 * widgets, the overview. Windows override with `--os-ui-font` on
570 * their body, so a theme can run a display face on the desk and a
571 * text face inside windows. `inherit` is the no-theme value, i.e.
572 * identical to not declaring the property at all.
573 */
574 font-family: var(--os-font, inherit);
575 }
576
577 /*
578 * Wallpaper layer — first child of the shell, sits behind the dock
579 * and desktop area so a translucent dock shows through to the
580 * background (macOS pattern). CSS wallpapers paint via the
581 * `--os-bg` custom property; canvas wallpapers mount their
582 * own DOM (typically a <canvas>) into this element via JS.
583 *
584 * z-index: 0 keeps it below the shell body flex row which has no
585 * explicit z-index of its own but establishes its own local
586 * stacking context via `position: relative`, so the dock + windows
587 * always render above the wallpaper regardless of DOM order.
588 */
589 .os-wallpaper {
590 position: absolute;
591 inset: 0;
592 z-index: 0;
593 background: var(--os-bg);
594 overflow: hidden;
595 pointer-events: none;
596 }
597
598 /*
599 * Mio layer — the desk companion.
600 *
601 * A sibling of the wallpaper inside the shell, but at the far end of
602 * the stack: Mio paints ABOVE windows (it perches on them)
603 * and below the dock (it must never cover navigation). Created on
604 * demand by `src/mio/controller.ts`. A shell whose user has never
605 * switched Mio on has no extra element at all; switching it off
606 * hides this one rather than removing it, because the instance is
607 * parked (WebGL context intact) rather than destroyed — see the
608 * `parked` field there.
609 *
610 * The layer and its <canvas> are permanently inert. Making a
611 * full-shell canvas interactive — even conditionally, from a
612 * per-frame hit test — would swallow clicks meant for the window
613 * underneath. Only the small round `__handle` inside it takes
614 * pointer events, and it rides on the blob.
615 */
616 .os-mio {
617 position: absolute;
618 inset: 0;
619 z-index: var(--os-z-mio);
620 overflow: hidden;
621 pointer-events: none;
622 }
623
624 .os-mio__handle {
625 position: absolute;
626 /*
627 * PHYSICAL `top` / `left`, deliberately — the one place in this
628 * sheet that isn't logical. The handle is positioned every frame
629 * by a `translate3d()` carrying the body's canvas coordinates,
630 * and `transform` is always physical. Anchoring with
631 * `inset-inline-start` would flip the origin to the right edge
632 * under RTL while the translation kept pushing rightwards, and
633 * the handle would walk off screen.
634 */
635 top: 0;
636 left: 0;
637 border-radius: 50%;
638 pointer-events: auto;
639 cursor: grab;
640 /* Promote to its own layer: the handle is re-positioned via
641 * `transform` on every animation frame. */
642 will-change: transform;
643 touch-action: none;
644 }
645
646 .os-mio__handle.is-dragging {
647 cursor: grabbing;
648 }
649
650 /*
651 * "Make it yours" panel — a live-preview dialog, not a modal.
652 *
653 * `<os-modal>` is built for the usual case: dim the page, blur it
654 * slightly, put the decision in front of everything. Every one of
655 * those defaults is wrong here, because the thing being edited is
656 * ON the page and the whole point is watching it change.
657 *
658 * - No scrim and no `backdrop-filter`, or the companion the sliders
659 * are driving is a blurred grey smudge behind them.
660 * - `pointer-events: none` on the scrim so the desk stays live: Mio
661 * can still be picked up and thrown while the panel is open, and a
662 * click on the wallpaper doesn't dismiss the panel mid-adjustment.
663 * `::part(dialog)` puts them back for the box itself.
664 * - Parked against the inline end rather than centred, so it isn't
665 * sitting on top of the subject.
666 *
667 * Mio never treats the panel as an obstacle: the collision set comes
668 * from `getWallpaperSurfaces()`, which seeds windows, the shell floor,
669 * docks and widget cards — a dialog on `document.body` is none of
670 * those, so there is nothing to bump into.
671 */
672 .os-mio-panel {
673 background: transparent;
674 background-image: none;
675 backdrop-filter: none;
676 pointer-events: none;
677 justify-content: flex-end;
678 padding-inline-end: clamp(16px, 4vw, 64px);
679 }
680
681 /*
682 * Both halves are required, and the second one is easy to miss.
683 *
684 * `::part(dialog)` reaches the box in the modal's SHADOW tree — its
685 * header, title and close button inherit from there. But the controls
686 * are slotted LIGHT-DOM children of `<os-modal>`, and slotted content
687 * inherits from its light-DOM parent (the host) rather than from the
688 * shadow ancestor it is projected into. So the host's
689 * `pointer-events: none` reaches them regardless of what the part
690 * says, and without this second rule every slider and button in the
691 * panel is inert while looking perfectly normal.
692 */
693 .os-mio-panel::part(dialog),
694 .os-mio-panel > * {
695 pointer-events: auto;
696 }
697
698 /*
699 * Overview (Exposé) hides the desk furniture so the window grid
700 * reads cleanly — Mio goes with the widgets and the notes.
701 * `:has()` rather than a descendant selector because Mio
702 * layer is a sibling of `.os-shell__body`, not a child of
703 * the desktop area.
704 */
705 .os-mio {
706 transition: opacity 160ms ease;
707 }
708
709 .os-shell:has( .os-area--overview ) .os-mio {
710 opacity: 0;
711 }
712
713 /*
714 * Reduced motion is handled in the simulation, not here: the runtime
715 * zeroes the idle float and the hue drift so Mio holds still
716 * until the user actually interacts with it. Hiding it would be
717 * wrong — the user switched it on deliberately.
718 */
719
720 /*
721 * Desktop-theme wallpaper texture (DESKTOP slot).
722 *
723 * On a `::before` rather than this element's own background, because
724 * `--os-bg` is set by the wallpaper picker and can be a
725 * gradient — i.e. a background-IMAGE. Overriding `background-image`
726 * here would silently erase every gradient wallpaper the moment a
727 * theme was active. The pseudo-element layers instead of replacing.
728 *
729 * Resulting precedence, bottom to top:
730 * CSS wallpaper (`--os-bg`)
731 * → theme DESKTOP texture (this rule)
732 * → canvas wallpapers, which mount as CHILDREN of the layer and
733 * therefore paint above both.
734 *
735 * A theme that wants the user's wallpaper to remain visible ships a
736 * texture with transparency; an opaque one takes the desk over.
737 * With no theme active the image is `none` and this paints nothing.
738 */
739 .os-wallpaper::before {
740 content: "";
741 position: absolute;
742 inset: 0;
743 pointer-events: none;
744 background-image: var(--os-desktop-image, none);
745 background-repeat: var(--os-desktop-image-repeat, repeat);
746 background-size: var(--os-desktop-image-size, auto);
747 background-position: var(--os-desktop-image-position, center);
748 }
749
750 /*
751 * Canvas children fill the layer and ignore pointer events by
752 * default — a wallpaper is presentational chrome, not an interactive
753 * element. Plugins that genuinely need a clickable wallpaper can
754 * override `pointer-events` on their own mounted node.
755 */
756 .os-wallpaper > canvas,
757 .os-wallpaper > * {
758 width: 100%;
759 height: 100%;
760 display: block;
761 }
762
763 /* Below 783px WP collapses the admin bar to 46px. */
764 @media screen and (max-width: 782px) {
765 .os-shell {
766 inset-block-start: var(
767 --os-admin-bar-height,
768 var(--wp-admin--admin-bar--height, 46px)
769 );
770 }
771 }
772
773 /*
774 * Immersive fullscreen: when any window is in fullscreen state, hide the
775 * admin bar and let the shell cover the full viewport. This keeps the
776 * fullscreen window visually above all chrome without fighting the shell's
777 * stacking context (a fullscreen window lives inside the shell, so its
778 * z-index is always bounded by the shell's — moving it out of the shell
779 * to raise it would break focus, drag, and session snapshotting).
780 *
781 * The window's title bar remains visible because the window itself is
782 * position: fixed at top: 0, so the focus/exit-fullscreen button is
783 * still reachable.
784 */
785 body.os-has-fullscreen-window #wpadminbar {
786 display: none !important;
787 }
788
789 body.os-has-fullscreen-window .os-shell {
790 inset-block-start: 0;
791 }
792
793 /* Shell body: contains dock + desktop area side by side. */
794 .os-shell__body {
795 flex: 1;
796 display: flex;
797 overflow: hidden;
798 position: relative;
799 }
800
801 /*
802 * Desktop area — where windows float. Background is now transparent
803 * because the wallpaper renders in the shell-level layer behind
804 * both this area and the dock. Stacking context via position: relative
805 * ensures children (windows) sit above the wallpaper.
806 *
807 * The padding reserves the band the floating bottom dock pill covers.
808 * It reads the work-area insets `src/work-area/index.ts` writes on
809 * `#os-shell` from the pill's LIVE geometry, so it is 0 with a side
810 * dock (which is a flex sibling and already narrows the area) and
811 * tracks the dock-size preference with the pill at the bottom. The
812 * `80px` literal is the pre-measurement floor: 40px tile + 16px pill
813 * padding + 12px gap below + 8px breathing room, what the rule
814 * hardcoded before the work area existed, in force until the shell
815 * has measured once.
816 */
817 .os-area {
818 flex: 1;
819 position: relative;
820 overflow: hidden;
821 background: transparent;
822 padding-top: var( --os-work-area-inset-top, 0px );
823 padding-right: var( --os-work-area-inset-right, 0px );
824 padding-bottom: var( --os-work-area-inset-bottom, 80px );
825 padding-left: var( --os-work-area-inset-left, 0px );
826 transition: opacity 180ms ease;
827 }
828
829 /*
830 * Boot reveal gate.
831 *
832 * On F5 the shell paints in stages: the dispatcher's
833 * `repaintIcons()` lands the server-rendered wallpaper icons
834 * synchronously from `openStationConfig.desktopIcons`, then the
835 * files-layer mounts and momentarily renders an empty bucket while
836 * REST `listPlacements(0)` is in flight, then folders/posts/links
837 * slot in on the next paint. The user reads that as "plugins show
838 * first, then everything blinks and the rest arrives."
839 *
840 * The `--booting` modifier is applied BY PHP (see
841 * `includes/render/shell.php`) on the initial HTML so the area
842 * starts invisible BEFORE any JS runs — otherwise the dispatcher
843 * gets to paint the wallpaper icons into a visible area first and
844 * the user sees them flash before our gate kicks in.
845 *
846 * `desktop.ts` removes the class inside a `requestAnimationFrame`
847 * after the root files-layer's `hydrated` promise resolves (with a
848 * 2 s JS safety timeout). The opacity transition declared on
849 * `.os-area` smooths the reveal.
850 *
851 * The fallback animation is a resilience layer for the case where
852 * the shell JS never runs (network error mid-load, ad-blocker
853 * tearing down a bundle, …). The animation holds the area
854 * invisible for 2.7 s then flips it to opacity 1; the
855 * `animation-fill-mode: forwards` keeps the revealed state held
856 * after the animation ends. In the normal case JS removes the
857 * class long before 2.7 s and the animation is discarded.
858 *
859 * `pointer-events: none` while booting prevents a stray click on
860 * the still-hidden surface from triggering Show-Desktop or opening
861 * a tile that's about to reposition once REST returns.
862 *
863 * @since 0.18.x
864 */
865 .os-area--booting {
866 opacity: 0;
867 pointer-events: none;
868 animation: os-area-boot-fallback 3s forwards;
869 }
870
871 @keyframes os-area-boot-fallback {
872 0%,
873 90% {
874 opacity: 0;
875 pointer-events: none;
876 }
877 100% {
878 opacity: 1;
879 pointer-events: auto;
880 }
881 }
882
883 /* ---------------------------------------------------------------
884 * Desktop icons — wallpaper shortcut tiles registered via the
885 * `wp_register_desktop_icon()` PHP API. Rendered inside the
886 * desktop area as the first child after the wallpaper, so they
887 * paint above the wallpaper but beneath any window.
888 *
889 * Grid layout is fixed-width columns; tiles wrap onto multiple
890 * rows when the desktop is wide enough to host more than one
891 * column's worth. `pointer-events: none` on the container keeps
892 * the background clickable (for "minimize all" on wallpaper
893 * click) while individual tiles re-enable pointer events so they
894 * remain clickable.
895 * --------------------------------------------------------------- */
896
897 .os-icons {
898 position: absolute;
899 /*
900 * 16px of gutter inside the WORK AREA, not inside the desktop
901 * area: an absolutely positioned box ignores its parent's
902 * padding, so without the insets the grid's last row sat under
903 * the bottom dock pill. Same tokens `.os-area` reserves with.
904 *
905 * Physical `left` / `right`, not the logical pair: the insets are
906 * measured from rects, so `-left` is the visual left whichever
907 * way the locale reads. The gutter is 16px on both sides, so
908 * nothing is lost by going physical here.
909 */
910 top: calc( var( --os-work-area-inset-top, 0px ) + 16px );
911 bottom: calc( var( --os-work-area-inset-bottom, 80px ) + 16px );
912 left: calc( var( --os-work-area-inset-left, 0px ) + 16px );
913 right: calc( var( --os-work-area-inset-right, 0px ) + 16px );
914 display: grid;
915 /*
916 * Column-major flow: icons fill the first column top-to-bottom,
917 * then start the next column. Matches the macOS / Finder
918 * convention. `grid-auto-flow: column` plus `auto-fill` rows
919 * (sized to the container height) yields exactly that layout.
920 *
921 * All four insets are pinned so the container has a definite
922 * height — `repeat(auto-fill, 96px)` needs that to compute the
923 * row count. 96px is one icon button (48px image + 6px gap +
924 * up-to-two-line label + 8px vertical padding); a slightly
925 * generous figure leaves room for descenders without clipping.
926 */
927 grid-auto-flow: column;
928 grid-template-rows: repeat(auto-fill, 96px);
929 grid-auto-columns: 88px;
930 align-content: start;
931 justify-content: start;
932 gap: 12px;
933 z-index: 1;
934 pointer-events: none;
935 /*
936 * Spill protection: if a user ever pins more icons than fit
937 * vertically, the grid scrolls horizontally so the unreachable
938 * tiles can be reached without clipping the visible ones.
939 * Individual tiles re-enable pointer events so they stay
940 * clickable inside the scroll surface.
941 */
942 overflow: auto;
943 }
944
945 .os-icon {
946 pointer-events: auto;
947 display: flex;
948 flex-direction: column;
949 align-items: center;
950 justify-content: flex-start;
951 gap: 6px;
952 width: 88px;
953 padding: 8px 4px;
954 border: 0;
955 background-color: transparent;
956 /*
957 * ICON_TILE texture slot — a plate behind each wallpaper icon.
958 * Painted under the icon image and its label, so a theme can give
959 * desktop shortcuts a physical tile the way a phone home screen
960 * does. Unset = `none` = the transparent tile we always had.
961 */
962 background-image: var(--os-tile-image, none);
963 background-repeat: var(--os-tile-image-repeat, no-repeat);
964 background-size: var(--os-tile-image-size, auto);
965 background-position: var(--os-tile-image-position, center);
966 /* Also the glyph: silhouette art is masked with `currentColor`. */
967 color: var(--os-desk-fg, var(--os-fg, #fff));
968 cursor: pointer;
969 border-radius: 8px;
970 transition: background-color 0.15s ease;
971 /* Positioning context for `.os-icon__badge`. */
972 position: relative;
973 }
974
975 /*
976 * Icon badge — symmetric to `.os-dock__badge` but anchored
977 * to the wallpaper-icon image. Same gradient, same ring, slightly
978 * larger so it reads at the longer wallpaper viewing distance.
979 * Painted by `wp.os.icons.setBadge( id, count )` — see
980 * `src/desktop-icons.ts` for the imperative API and
981 * `src/recycle-bin/badge.ts` for the canonical multi-rail
982 * consumer. The class name is part of the stable contract; the
983 * positioning, color, and shadow are not — themes or future
984 * shell builds may restyle freely.
985 */
986 .os-icon__badge {
987 position: absolute;
988 top: 4px;
989 inset-inline-end: 14px;
990 /* Same derivation as the dock badge, sized off the wallpaper icon
991 * rather than the dock icon. The default resolves to 18px / 11px —
992 * exactly the old values. */
993 --os-icon-badge-computed-size: var(
994 --os-icon-badge-size,
995 calc( var( --os-icon-image-size, 48px ) * 0.375 )
996 );
997 min-width: var( --os-icon-badge-computed-size );
998 height: var( --os-icon-badge-computed-size );
999 padding: 0 var(
1000 --os-icon-badge-padding,
1001 calc( var( --os-icon-badge-computed-size ) * 0.28 )
1002 );
1003 box-sizing: border-box;
1004 border-radius: 999px;
1005 background: var(
1006 --os-icon-badge-bg,
1007 linear-gradient( 180deg, #ff5a5a 0%, var( --os-ui-danger, #d63638 ) 100% )
1008 );
1009 color: var( --os-icon-badge-fg, var( --os-ui-fg-on-accent, #fff ) );
1010 display: inline-flex;
1011 align-items: center;
1012 justify-content: center;
1013 font-size: var(
1014 --os-icon-badge-font-size,
1015 calc( var( --os-icon-badge-computed-size ) * 0.611 )
1016 );
1017 font-weight: var( --os-icon-badge-font-weight, 700 );
1018 line-height: 1;
1019 font-variant-numeric: tabular-nums;
1020 box-shadow:
1021 0 0 0 1.5px rgba( 0, 0, 0, 0.4 ),
1022 0 1px 3px rgba( 0, 0, 0, 0.35 );
1023 pointer-events: none;
1024 }
1025
1026 .os-icon:hover,
1027 .os-icon:focus-visible {
1028 /* background-COLOR, not the shorthand: the shorthand would reset
1029 * the ICON_TILE texture declared above, so a themed tile would
1030 * vanish on hover. */
1031 background-color: var(--os-icon-hover-bg, rgba(255, 255, 255, 0.12));
1032 outline: none;
1033 }
1034
1035 .os-icon:focus-visible {
1036 box-shadow: 0 0 0 2px var(--wp-admin-theme-color, #2271b1);
1037 }
1038
1039 .os-icon__image {
1040 display: inline-flex;
1041 align-items: center;
1042 justify-content: center;
1043 /* Tokenized so a theme can scale wallpaper icons — and so the
1044 * badge above has something to derive its own size from. */
1045 width: var( --os-icon-image-size, 48px );
1046 height: var( --os-icon-image-size, 48px );
1047 font-size: calc( var( --os-icon-image-size, 48px ) * 0.667 );
1048 line-height: 1;
1049 }
1050
1051 .os-icon__image img {
1052 max-width: 48px;
1053 max-height: 48px;
1054 object-fit: contain;
1055 }
1056
1057 .os-icon__image {
1058 filter: var(--os-icon-glyph-shadow, none);
1059 }
1060
1061 .os-icon__label {
1062 font-size: var(--os-icon-label-font-size, 12px);
1063 text-align: center;
1064 background: var(--os-icon-label-bg, transparent);
1065 border-radius: var(--os-icon-label-radius, 6px);
1066 /* Inline only: a two-line caption already fills the 96px grid row. */
1067 padding: var(--os-icon-label-padding, 0 6px);
1068 text-shadow: var(--os-icon-label-shadow, 0 1px 2px rgba(0, 0, 0, 0.45));
1069 line-height: 1.2;
1070 word-break: break-word;
1071 }
1072
1073 /* ---------------------------------------------------------------
1074 * Widgets column — right-edge glass strip that paints above the
1075 * wallpaper but beneath windows. Hosts stacked widget cards and a
1076 * trailing "Add widget" tile. Interactive only on its own cards +
1077 * the add tile; every other pixel is pointer-transparent so window
1078 * drag / resize / click-through behaviour is unaffected.
1079 * --------------------------------------------------------------- */
1080
1081 .os-widgets {
1082 position: absolute;
1083 /*
1084 * Inside the work area vertically — see `.os-icons` for why the
1085 * insets are here. The inline edge is logical: the column hugs
1086 * the trailing side, while the side insets are physical; and a
1087 * side rail never claims one anyway (it is a flex sibling, the
1088 * area is already narrower).
1089 *
1090 * The cards sit 16px in from the area's top and trailing edges
1091 * and 16px above the work area's foot, but the box reaches
1092 * further than that and pads back in. The column scrolls, and a
1093 * scroll container clips everything inside it to its padding
1094 * box, the cards' drop shadow included: with the box hugging the
1095 * cards, the shadow ended in a hard rectangle the size of the
1096 * column, easy to see on a light wallpaper. The padding is the
1097 * room the shadow needs (it reaches 28px out and 36px down, more
1098 * on hover). The trailing side only gets the 16px to the area's
1099 * edge, since the area clips there anyway. The column is
1100 * pointer-transparent, so the extra room catches nothing; the
1101 * add-widget pill measures the content box, not this one.
1102 */
1103 box-sizing: content-box;
1104 top: var( --os-work-area-inset-top, 0px );
1105 bottom: calc( var( --os-work-area-inset-bottom, 80px ) - 24px );
1106 inset-inline-end: 0;
1107 width: 320px;
1108 --_os-widgets-room-start: 40px;
1109 --_os-widgets-room-end: 16px;
1110 padding-block: 16px 40px;
1111 padding-inline: var( --_os-widgets-room-start ) var( --_os-widgets-room-end );
1112 display: flex;
1113 flex-direction: column;
1114 gap: 12px;
1115 z-index: 1;
1116 pointer-events: none;
1117 overflow-y: auto;
1118 /* Hide the scrollbar chrome unless the user actually overflows —
1119 * most setups never will. */
1120 scrollbar-width: thin;
1121 /* Fade alongside the overview backdrop. The class flip that
1122 * drives this is on the parent area, set at t=0 of enter and
1123 * removed at t=0 of exit, so widgets fade in parallel with the
1124 * dock collapse / return — never sequential. */
1125 opacity: 1;
1126 transition:
1127 opacity 0.22s ease,
1128 transform 0.28s cubic-bezier( 0.2, 0, 0.2, 1 );
1129 }
1130
1131 .os-area--overview .os-widgets {
1132 opacity: 0;
1133 /* Slight slide-off-right so the fade reads as "the column is
1134 * stepping back out of the way" rather than flat-dimming. */
1135 transform: translateX( 16px );
1136 pointer-events: none;
1137 }
1138
1139 .os-widgets__list {
1140 display: flex;
1141 flex-direction: column;
1142 gap: 12px;
1143 padding: 4px;
1144 }
1145
1146 /*
1147 * Individual widget card — glass backdrop matching the dock /
1148 * overview top bar. Card root is pointer-events: auto so clicks on
1149 * content + the remove X land; the column itself stays
1150 * pointer-transparent so a drag that grazes the column's margin
1151 * falls through to the window beneath.
1152 */
1153 .os-widgets__card {
1154 position: relative;
1155 padding: 0;
1156 /* Column layout so the body can flex-shrink and scroll when the
1157 * frame writes a fixed inline height (floating / resized cards).
1158 * Without this the body keeps its natural height and content
1159 * spills past the card's rounded bottom edge. */
1160 display: flex;
1161 flex-direction: column;
1162 pointer-events: auto;
1163 /*
1164 * The glass is also `--os-ui-color-surface`, the one name in the
1165 * widget contract that describes the card itself. Declared in
1166 * `variables.css` so a widget reading it and the card painting it
1167 * cannot drift apart.
1168 */
1169 background-color: var( --os-ui-color-surface, rgba( 20, 20, 22, 0.55 ) );
1170 /*
1171 * WIDGET texture slot — layered over the frosted card colour, so a
1172 * translucent texture still gets the blur underneath.
1173 */
1174 background-image: var( --os-widget-image, none );
1175 background-repeat: var( --os-widget-image-repeat, repeat );
1176 background-size: var( --os-widget-image-size, auto );
1177 background-position: var( --os-widget-image-position, center );
1178 backdrop-filter: var( --os-widget-card-backdrop, blur( 18px ) saturate( 140% ) );
1179 -webkit-backdrop-filter: var( --os-widget-card-backdrop, blur( 18px ) saturate( 140% ) );
1180 border: var( --os-widget-card-border, 1px solid rgba( 255, 255, 255, 0.08 ) );
1181 border-radius: var( --os-widget-card-radius, 14px );
1182 /* The widget contract's text colour, which is what a widget's own
1183 * stylesheet is told to read. */
1184 color: var( --os-ui-color-text, var( --os-ui-fg-on-accent, #fff ) );
1185 box-shadow: var(
1186 --os-widget-card-shadow,
1187 0 8px 28px rgba( 0, 0, 0, 0.35 ),
1188 inset 0 0 0 1px rgba( 255, 255, 255, 0.04 )
1189 );
1190 transition:
1191 box-shadow 0.18s ease,
1192 opacity 0.18s ease;
1193 }
1194
1195 /* Hover feedback is shadow-only — no transform. Cards must feel
1196 * anchored; a hover nudge reads as the widget drifting under the
1197 * pointer (and fought the user's chosen position on floating cards). */
1198 .os-widgets__card:hover {
1199 box-shadow: var(
1200 --os-widget-card-shadow-hover,
1201 0 12px 36px rgba( 0, 0, 0, 0.45 ),
1202 inset 0 0 0 1px rgba( 255, 255, 255, 0.08 )
1203 );
1204 }
1205
1206 .os-widgets__card-body {
1207 padding: 16px;
1208 min-height: 48px;
1209 flex: 1 1 auto;
1210 overflow-x: hidden;
1211 overflow-y: auto;
1212 scrollbar-width: thin;
1213 }
1214
1215 .os-widgets__card-close {
1216 position: absolute;
1217 top: 6px;
1218 inset-inline-end: 6px;
1219 /* 24px is the WCAG 2.2 SC 2.5.8 floor. The glyph inside is unchanged;
1220 only the target grows. */
1221 width: 24px;
1222 height: 24px;
1223 display: flex;
1224 align-items: center;
1225 justify-content: center;
1226 padding: 0;
1227 border: 0;
1228 background: var( --os-ui-scrim, rgba( 0, 0, 0, 0.55 ) );
1229 color: rgba( 255, 255, 255, 0.75 );
1230 border-radius: 50%;
1231 cursor: pointer;
1232 opacity: 0;
1233 transition:
1234 opacity 0.15s ease,
1235 background-color 0.15s ease,
1236 color 0.15s ease;
1237 }
1238
1239 .os-widgets__card:hover .os-widgets__card-close,
1240 .os-widgets__card-close:focus-visible {
1241 opacity: 1;
1242 }
1243
1244 /*
1245 * The corner close button rests at `opacity: 0` and only the card's
1246 * hover reveals it, so on touch it is invisible and the widget cannot
1247 * be dismissed at all. Rest it at the same 0.7 the chrome header's
1248 * close button uses there, rather than a full 1, so a card that is
1249 * only being read does not wear a hard × in its corner.
1250 */
1251 @media ( hover: none ) {
1252 .os-widgets__card-close {
1253 opacity: 0.7;
1254 }
1255 }
1256
1257 .os-widgets__card-close:hover {
1258 background: var( --os-ui-danger, #d63638 );
1259 color: var( --os-ui-fg-on-accent, #fff );
1260 }
1261
1262 /*
1263 * Movable widgets — a thin chrome header at the top of the card holds
1264 * the drag grip, the widget's own label, and the × button. The whole
1265 * header is the drag handle (except for interactive children — those
1266 * remain clickable as normal). Non-movable widgets keep today's
1267 * corner-× layout unchanged.
1268 */
1269 .os-widgets__chrome {
1270 display: flex;
1271 flex-shrink: 0;
1272 align-items: center;
1273 gap: 8px;
1274 padding: 6px 8px;
1275 border-bottom: 1px solid rgba( 255, 255, 255, 0.06 );
1276 cursor: grab;
1277 user-select: none;
1278 -webkit-user-select: none;
1279 touch-action: none;
1280 }
1281
1282 .os-widgets__card--dragging .os-widgets__chrome {
1283 cursor: grabbing;
1284 }
1285
1286 .os-widgets__grip {
1287 flex-shrink: 0;
1288 width: 10px;
1289 height: 16px;
1290 background-image: radial-gradient(
1291 circle,
1292 rgba( 255, 255, 255, 0.55 ) 1.2px,
1293 transparent 1.5px
1294 );
1295 background-size: 5px 5px;
1296 background-position: 0 1px;
1297 background-repeat: space;
1298 opacity: 0.45;
1299 transition: opacity 0.12s ease;
1300 }
1301
1302 .os-widgets__card:hover .os-widgets__grip {
1303 opacity: 0.85;
1304 }
1305
1306 .os-widgets__title {
1307 flex: 1;
1308 font-size: 12px;
1309 font-weight: 500;
1310 letter-spacing: 0.01em;
1311 color: rgba( 255, 255, 255, 0.85 );
1312 overflow: hidden;
1313 text-overflow: ellipsis;
1314 white-space: nowrap;
1315 }
1316
1317 /* When the close button sits inside the chrome it's inline, not
1318 corner-absolute — reset position + always-visible opacity. */
1319 .os-widgets__chrome .os-widgets__card-close {
1320 position: static;
1321 opacity: 0.7;
1322 width: 24px;
1323 height: 24px;
1324 }
1325
1326 .os-widgets__chrome .os-widgets__card-close:hover,
1327 .os-widgets__chrome .os-widgets__card-close:focus-visible {
1328 opacity: 1;
1329 }
1330
1331 /*
1332 * Re-dock button — sits in the chrome next to the close button and
1333 * returns a floating widget to the right column. Visibility is
1334 * gated on the `--floating` modifier class so docked widgets never
1335 * expose it (tapping it on a docked card would be a confusing
1336 * no-op). The DOM node stays mounted either way so state flips
1337 * don't rebuild chrome.
1338 *
1339 * Styling mirrors the inline close button: 24×24 round tile
1340 * (the WCAG 2.2 SC 2.5.8 target-size floor),
1341 * dimmed by default, full-opacity on hover / focus. Distinct hover
1342 * color (theme accent, not red) signals "put back" vs. "remove."
1343 */
1344 .os-widgets__card-redock {
1345 display: none;
1346 align-items: center;
1347 justify-content: center;
1348 padding: 0;
1349 border: 0;
1350 background: var( --os-ui-scrim, rgba( 0, 0, 0, 0.55 ) );
1351 color: rgba( 255, 255, 255, 0.75 );
1352 border-radius: 50%;
1353 width: 24px;
1354 height: 24px;
1355 cursor: pointer;
1356 opacity: 0.7;
1357 transition:
1358 opacity 0.15s ease,
1359 background-color 0.15s ease,
1360 color 0.15s ease;
1361 }
1362
1363 .os-widgets__card--floating .os-widgets__card-redock {
1364 display: flex;
1365 }
1366
1367 .os-widgets__card-redock:hover,
1368 .os-widgets__card-redock:focus-visible {
1369 opacity: 1;
1370 background: var( --wp-admin-theme-color, #2271b1 );
1371 color: var( --os-ui-accent-ink, var( --os-ui-fg-on-accent, #fff ) );
1372 }
1373
1374 /*
1375 * Floating widgets — absolute-positioned, inline top/left/width/height
1376 * written by the frame.
1377 */
1378 .os-widgets__card--floating {
1379 position: absolute;
1380 margin: 0;
1381 }
1382
1383 /*
1384 * A liberated card is reparented to the desktop area, so the column's
1385 * overview fade above no longer reaches it. Direct children only: a
1386 * card inside a window body belongs to that window's thumbnail.
1387 */
1388 .os-area--overview > .os-widgets__card--floating {
1389 opacity: 0;
1390 pointer-events: none;
1391 }
1392
1393 .os-widgets__card--dragging,
1394 .os-widgets__card--resizing {
1395 transition: none;
1396 z-index: 2;
1397 }
1398
1399 /*
1400 * Resize handles — 8 in all, with the 4 edges as hair-line strips and
1401 * the 4 corners as small squares. Invisible by default; cursor change
1402 * telegraphs them on hover. The frame attaches pointer listeners to
1403 * every handle; a non-movable widget ignores everything except the
1404 * `s` (bottom-edge, height-only) handle at the pointer layer, so we
1405 * visually hide the mismatched handles via the modifier class.
1406 */
1407 .os-widgets__resize {
1408 position: absolute;
1409 z-index: 1;
1410 }
1411
1412 .os-widgets__resize--n {
1413 top: -3px;
1414 left: 8px;
1415 right: 8px;
1416 height: 6px;
1417 cursor: ns-resize;
1418 }
1419
1420 .os-widgets__resize--s {
1421 bottom: -3px;
1422 left: 8px;
1423 right: 8px;
1424 height: 6px;
1425 cursor: ns-resize;
1426 }
1427
1428 .os-widgets__resize--e {
1429 top: 8px;
1430 bottom: 8px;
1431 right: -3px;
1432 width: 6px;
1433 cursor: ew-resize;
1434 }
1435
1436 .os-widgets__resize--w {
1437 top: 8px;
1438 bottom: 8px;
1439 left: -3px;
1440 width: 6px;
1441 cursor: ew-resize;
1442 }
1443
1444 .os-widgets__resize--ne {
1445 top: -4px;
1446 right: -4px;
1447 width: 12px;
1448 height: 12px;
1449 cursor: nesw-resize;
1450 }
1451
1452 .os-widgets__resize--nw {
1453 top: -4px;
1454 left: -4px;
1455 width: 12px;
1456 height: 12px;
1457 cursor: nwse-resize;
1458 }
1459
1460 .os-widgets__resize--se {
1461 bottom: -4px;
1462 right: -4px;
1463 width: 12px;
1464 height: 12px;
1465 cursor: nwse-resize;
1466 }
1467
1468 .os-widgets__resize--sw {
1469 bottom: -4px;
1470 left: -4px;
1471 width: 12px;
1472 height: 12px;
1473 cursor: nesw-resize;
1474 }
1475
1476 /*
1477 * Only floating cards expose the full 8-direction handle set. A
1478 * column-docked card — non-movable OR movable-but-not-yet-liberated —
1479 * resizes on the height axis alone (the frame ignores every other
1480 * direction while docked), so hide all handles except the bottom
1481 * edge and the cursor never lies about what's interactive.
1482 */
1483 .os-widgets__card--resizable:not( .os-widgets__card--floating ) .os-widgets__resize:not( .os-widgets__resize--s ) {
1484 display: none;
1485 }
1486
1487 /*
1488 * Add-widget tile — a compact pill that trails the widget stack, not
1489 * a full-width drop zone. Hidden until the pointer comes near the
1490 * column (the layer toggles `--hovered`), so the desktop stays clean
1491 * for people who never touch widgets.
1492 *
1493 * Absolute rather than in flow because "the bottom of the stack" is
1494 * not something the column's layout knows: a widget dragged out of
1495 * the column becomes a floating card parented to the desktop, so it
1496 * leaves the flex flow while still sitting visually in the column.
1497 * The layer measures those too and writes `top` — see
1498 * `positionAddTile()`.
1499 */
1500 .os-widgets__add {
1501 position: absolute;
1502 top: 0;
1503 /* Centred over the cards, not over the column's box: the column
1504 * pads out unevenly to give the card shadows room (see
1505 * `.os-widgets`), so the box's own middle is off to one side. */
1506 inset-inline: var( --_os-widgets-room-start, 0px ) var( --_os-widgets-room-end, 0px );
1507 width: fit-content;
1508 margin-inline: auto;
1509 padding: 8px 22px;
1510 display: flex;
1511 align-items: center;
1512 justify-content: center;
1513 gap: 7px;
1514 pointer-events: none;
1515 /* `.os-widgets__card`'s glass, to the number: the pill trails a
1516 stack of those cards. Its dashed edge is what makes it a slot. */
1517 background: var( --os-widgets-add-bg, transparent );
1518 backdrop-filter: blur( 18px ) saturate( 140% );
1519 -webkit-backdrop-filter: blur( 18px ) saturate( 140% );
1520 box-shadow: 0 8px 28px var( --os-widgets-add-shadow, rgba( 0, 0, 0, 0.35 ) );
1521 border: 1px dashed var( --os-widgets-add-border, rgba( 255, 255, 255, 0.22 ) );
1522 border-radius: 10px;
1523 color: var( --os-ui-color-text, var( --os-fg, #fff ) );
1524 font: inherit;
1525 font-weight: 500;
1526 cursor: pointer;
1527 opacity: 0;
1528 transition:
1529 opacity 0.15s ease,
1530 border-color 0.15s ease,
1531 background-color 0.15s ease,
1532 color 0.15s ease;
1533 }
1534
1535 /*
1536 * Reveal conditions. An empty column is no exception — approaching
1537 * the right side is the gesture, whether or not there's a card there
1538 * to aim at. `:focus-visible` keeps it reachable by keyboard, where
1539 * there is no pointer to bring near.
1540 */
1541 .os-widgets--hovered .os-widgets__add,
1542 .os-widgets--picking .os-widgets__add,
1543 .os-widgets__add:focus-visible {
1544 opacity: 1;
1545 pointer-events: auto;
1546 }
1547
1548 /*
1549 * Touch has no hover to approach the column with. The wallpaper's
1550 * right-click menu carries an "Add widget" entry, but a long-press
1551 * to reach it is a lot to ask of someone who just wants a clock, so
1552 * on those devices the pill stays put.
1553 */
1554 @media ( hover: none ) {
1555 .os-widgets__add {
1556 opacity: 1;
1557 pointer-events: auto;
1558 }
1559 }
1560
1561 .os-widgets__add:hover {
1562 border-color: rgba( 255, 255, 255, 0.4 );
1563 background: var( --os-widgets-add-bg-hover, rgba( 255, 255, 255, 0.06 ) );
1564 color: var( --os-fg, #fff );
1565 }
1566
1567 .os-widgets__add:focus-visible {
1568 outline: 2px solid var( --wp-admin-theme-color, #2271b1 );
1569 outline-offset: 2px;
1570 }
1571
1572 .os-widgets__add-plus {
1573 font-size: 15px;
1574 line-height: 1;
1575 font-weight: 300;
1576 }
1577
1578 .os-widgets__add-label {
1579 font-size: 13px;
1580 letter-spacing: 0.02em;
1581 }
1582
1583 /* Built-in clock widget */
1584 .os-widget-clock {
1585 text-align: center;
1586 /* The time + date are passive glyphs, not content users would
1587 * ever reasonably select — turning it off avoids the accidental-
1588 * highlight mess that happens when a drag starts on the clock. */
1589 user-select: none;
1590 -webkit-user-select: none;
1591 }
1592
1593 .os-widget-clock__time {
1594 font-size: 34px;
1595 font-weight: 600;
1596 line-height: 1.1;
1597 font-variant-numeric: tabular-nums;
1598 letter-spacing: 0.01em;
1599 }
1600
1601 .os-widget-clock__date {
1602 margin-top: 4px;
1603 font-size: 13px;
1604 color: var( --os-ui-color-text-subtle, rgba( 255, 255, 255, 0.75 ) );
1605 letter-spacing: 0.02em;
1606 }
1607
1608 /* ---------------------------------------------------------------
1609 * Widget picker popover — opens from the add tile, lists every
1610 * registered widget. Positioned fixed so it can extend outside
1611 * the desktop-area clip.
1612 * --------------------------------------------------------------- */
1613
1614 .os-widget-picker {
1615 position: fixed;
1616 /* Border-box so the picker script can cap max-height to the
1617 * room it measured, frame included. 342 = the 320px list plus
1618 * the padding and border it always carried. */
1619 box-sizing: border-box;
1620 width: 342px;
1621 max-height: 60vh;
1622 overflow-y: auto;
1623 padding: 10px;
1624 border-radius: 14px;
1625 background: rgba( 20, 20, 22, 0.88 );
1626 backdrop-filter: blur( 22px ) saturate( 160% );
1627 -webkit-backdrop-filter: blur( 22px ) saturate( 160% );
1628 border: 1px solid rgba( 255, 255, 255, 0.1 );
1629 box-shadow: 0 18px 54px rgba( 0, 0, 0, 0.55 );
1630 color: var( --os-ui-fg-on-accent, #fff );
1631 z-index: 9999;
1632 animation: os-widget-picker-in 0.14s ease-out;
1633 }
1634
1635 @keyframes os-widget-picker-in {
1636 from {
1637 opacity: 0;
1638 transform: translateY( 4px );
1639 }
1640 }
1641
1642 .os-widget-picker__title {
1643 padding: 4px 8px 8px;
1644 font-size: 11px;
1645 font-weight: 600;
1646 letter-spacing: 0.08em;
1647 text-transform: uppercase;
1648 color: rgba( 255, 255, 255, 0.55 );
1649 }
1650
1651 .os-widget-picker__list {
1652 display: flex;
1653 flex-direction: column;
1654 gap: 4px;
1655 }
1656
1657 .os-widget-picker__entry {
1658 display: flex;
1659 align-items: center;
1660 gap: 12px;
1661 padding: 10px;
1662 background: transparent;
1663 border: 0;
1664 border-radius: 10px;
1665 color: var( --os-ui-fg-on-accent, #fff );
1666 cursor: pointer;
1667 text-align: start;
1668 font: inherit;
1669 transition: background-color 0.12s ease;
1670 }
1671
1672 .os-widget-picker__entry:hover:not( :disabled ) {
1673 background: rgba( 255, 255, 255, 0.08 );
1674 }
1675
1676 .os-widget-picker__entry:focus-visible {
1677 outline: 2px solid var( --wp-admin-theme-color, #2271b1 );
1678 outline-offset: 1px;
1679 }
1680
1681 .os-widget-picker__entry--added {
1682 opacity: 0.55;
1683 cursor: default;
1684 }
1685
1686 .os-widget-picker__entry-icon {
1687 flex-shrink: 0;
1688 font-size: 20px;
1689 width: 20px;
1690 height: 20px;
1691 line-height: 1;
1692 }
1693
1694 .os-widget-picker__entry-text {
1695 display: flex;
1696 flex-direction: column;
1697 gap: 2px;
1698 flex: 1;
1699 min-width: 0;
1700 }
1701
1702 .os-widget-picker__entry-label {
1703 font-size: 13px;
1704 font-weight: 500;
1705 white-space: nowrap;
1706 overflow: hidden;
1707 text-overflow: ellipsis;
1708 }
1709
1710 .os-widget-picker__entry-description {
1711 font-size: 11px;
1712 color: rgba( 255, 255, 255, 0.6 );
1713 line-height: 1.35;
1714 white-space: normal;
1715 }
1716
1717 .os-widget-picker__entry-status {
1718 flex-shrink: 0;
1719 font-size: 11px;
1720 color: rgba( 255, 255, 255, 0.5 );
1721 text-transform: uppercase;
1722 letter-spacing: 0.08em;
1723 }
1724
1725 .os-widget-picker__empty {
1726 padding: 12px 8px;
1727 font-size: 12px;
1728 color: rgba( 255, 255, 255, 0.6 );
1729 line-height: 1.4;
1730 }
1731
1732 /**
1733 * Chromeless mode: pages loaded inside iframes.
1734 * Remove all shell chrome and let content fill the frame.
1735 */
1736 .os-chromeless {
1737 margin: 0;
1738 padding: 0;
1739 background: var(--os-window-bg);
1740 }
1741
1742 /*
1743 * Chromeless #wpbody-content, #adminmenuwrap, #wpfooter overrides are in
1744 * chromeless.css to keep all legacy page tweaks in one place.
1745 */
1746
1747 /*
1748 * Hide WordPress core's built-in command palette (`@wordpress/commands`).
1749 * OpenStation force-enqueues `wp_enqueue_command_palette_assets()` so
1750 * the `core/commands` data store is populated for our shell harvester
1751 * (see `src/commands/shell-harvester.ts`); the side effect is that WP
1752 * also mounts its own `<CommandPalette>` to the document body. We
1753 * already block its Cmd+K handler in `src/palette-registry.ts`, but if
1754 * a third-party script opens it programmatically (`wp.data.dispatch(
1755 * 'core/commands' ).open()`), the dialog would float over the desktop
1756 * with admin-menu navigation callbacks whose `document.location = url`
1757 * would unload the shell. Keep it permanently hidden.
1758 */
1759 .commands-command-menu,
1760 .commands-command-menu__overlay {
1761 display: none !important;
1762 }
1763
1764 /*
1765 * ---- Links slotted into <os-notice> ---------------------------
1766 *
1767 * `<os-notice>` styles its slotted links from inside its shadow root,
1768 * through `::slotted( a )` and the `--os-ui-notice-link` tokens. That
1769 * rule cannot win here, and no amount of specificity on it would help.
1770 *
1771 * A slotted element lives in the DOCUMENT tree; the shadow root only
1772 * borrows it for rendering. When declarations from two trees collide,
1773 * CSS Scoping resolves normal (non-`!important`) ones in favour of the
1774 * OUTER tree regardless of specificity — so `wp-admin/css/common.css`,
1775 * which sets `a { color: #2271b1 }` and `a:hover { color: #135e96 }` on
1776 * every anchor in the admin, beats `::slotted( a )` every time. The
1777 * shell renders inside wp-admin, so it always loses: the link painted
1778 * WordPress Blue at rest and a darker blue on hover, the second of
1779 * which is close to illegible on a notice's dark wash.
1780 *
1781 * Hence a document-tree rule. It reads the same tokens the component
1782 * documents, so a theme still restyles the link by setting them; this
1783 * only moves the declaration into a tree that can win. `a:hover` is
1784 * (0,1,1), so the hover selector has to carry its own pseudo-class to
1785 * outrank it — matching `os-notice a` alone would lose the hover state
1786 * while winning the base one, which is exactly the inert-looking link
1787 * the component's own styles already went out of their way to avoid.
1788 */
1789 os-notice a {
1790 color: var(--os-ui-notice-link, #ec9bff);
1791 }
1792
1793 os-notice a:hover,
1794 os-notice a:focus,
1795 os-notice a:active {
1796 color: var(--os-ui-notice-link-hover, #fffbff);
1797 }
1798
1799 /* --------------------------------------------------------------------
1800 * The workspace hop — cross-document view transitions between shells.
1801 *
1802 * Loaded only on the SHELL screen (this sheet never reaches chromeless
1803 * iframes), so only shell→shell navigations transition: hopping between
1804 * a site's desktop and the network admin's crossfades the two desktops
1805 * instead of hard-cutting, which is what makes the per-admin sessions
1806 * read as workspaces. Classic admin pages never opt in, so entering or
1807 * leaving the shell stays a plain navigation, and reloads are excluded
1808 * by the spec. Browsers without cross-document view transitions ignore
1809 * all of this and navigate plainly. See docs/multisite.md.
1810 */
1811 @view-transition {
1812 navigation: auto;
1813 }
1814
1815 /* Reduced motion swaps instantly — the hop still happens, the
1816 * animation does not. */
1817 @media ( prefers-reduced-motion: reduce ) {
1818 @view-transition {
1819 navigation: none;
1820 }
1821 }
1822
1823 /* A gentle zoom-through: the desktop being left recedes as the one
1824 * arriving settles in. Subtle on purpose — this runs on a full page
1825 * swap, and anything louder reads as a reload effect. */
1826 ::view-transition-old(root) {
1827 animation: os-workspace-hop-out 220ms cubic-bezier( 0.4, 0, 1, 1 ) both;
1828 }
1829
1830 ::view-transition-new(root) {
1831 animation: os-workspace-hop-in 280ms cubic-bezier( 0, 0, 0.2, 1 ) both;
1832 }
1833
1834 @keyframes os-workspace-hop-out {
1835 to {
1836 opacity: 0;
1837 transform: scale( 0.985 );
1838 }
1839 }
1840
1841 @keyframes os-workspace-hop-in {
1842 from {
1843 opacity: 0;
1844 transform: scale( 1.015 );
1845 }
1846 }
1847
1848 /* The instance hop's two halves, on either side of that page swap.
1849 * The switcher slides this desk out towards the site it picked
1850 * (`os-shell--hop-out-next` / `-prev`, for the beat before it
1851 * navigates), and a shell asked to boot into overview arrives with its
1852 * desk hidden (`os-shell--arriving`, stamped server-side) until overview
1853 * is up, then slides it in from the same side (`-next` / `-prev`, the
1854 * hint the switcher left in sessionStorage; no hint, a plain fade). The
1855 * wallpaper never moves, which is what makes it read as the tiles
1856 * changing rather than the page. `src/multisite/instance-transition.ts`
1857 * drives the classes. */
1858 .os-shell--hop-out-next #os-area,
1859 .os-shell--hop-out-prev #os-area {
1860 opacity: 0;
1861 transition:
1862 opacity 220ms cubic-bezier( 0.4, 0, 1, 1 ),
1863 transform 220ms cubic-bezier( 0.4, 0, 1, 1 );
1864 }
1865
1866 .os-shell--hop-out-next #os-area {
1867 transform: translateX( -48px );
1868 }
1869
1870 .os-shell--hop-out-prev #os-area {
1871 transform: translateX( 48px );
1872 }
1873
1874 .os-shell--hop-out-next .os-dock,
1875 .os-shell--hop-out-prev .os-dock,
1876 .os-shell--arriving .os-dock {
1877 opacity: 0;
1878 transition: opacity 220ms ease;
1879 }
1880
1881 .os-shell--arriving #os-area {
1882 opacity: 0;
1883 transition: none;
1884 }
1885
1886 .os-shell--arriving-next #os-area {
1887 transform: translateX( 48px );
1888 }
1889
1890 .os-shell--arriving-prev #os-area {
1891 transform: translateX( -48px );
1892 }
1893
1894 /* The reveal: the arriving classes come off and this goes on in the
1895 * same frame, so the desk transitions from hidden to its resting
1896 * place. */
1897 .os-shell--revealing #os-area {
1898 transition:
1899 opacity 360ms cubic-bezier( 0, 0, 0.2, 1 ),
1900 transform 360ms cubic-bezier( 0, 0, 0.2, 1 );
1901 }
1902
1903 .os-shell--revealing .os-dock {
1904 transition: opacity 360ms ease;
1905 }
1906
1907 /* A desk must never stay hidden: if the boot that reveals it never
1908 * runs, the area and the dock come back on their own a few seconds
1909 * in. */
1910 @keyframes os-instance-arrive-fallback {
1911 to {
1912 opacity: 1;
1913 transform: none;
1914 }
1915 }
1916
1917 .os-shell--arriving #os-area,
1918 .os-shell--arriving .os-dock {
1919 animation: os-instance-arrive-fallback 1ms linear 6s forwards;
1920 }
1921
1922 @media ( prefers-reduced-motion: reduce ) {
1923 .os-shell--hop-out-next #os-area,
1924 .os-shell--hop-out-prev #os-area,
1925 .os-shell--arriving-next #os-area,
1926 .os-shell--arriving-prev #os-area {
1927 transform: none;
1928 }
1929
1930 .os-shell--hop-out-next #os-area,
1931 .os-shell--hop-out-prev #os-area,
1932 .os-shell--hop-out-next .os-dock,
1933 .os-shell--hop-out-prev .os-dock,
1934 .os-shell--revealing #os-area,
1935 .os-shell--revealing .os-dock {
1936 transition: none;
1937 }
1938 }
1939
1940 /* A residency frame follows the window body without scrolling with its contents. */
1941 .os-mio-residence {
1942 position: absolute;
1943 z-index: 50;
1944 pointer-events: none;
1945 overflow: hidden;
1946 container-type: inline-size;
1947 }
1948
1949 .os-mio-residence[hidden],
1950 .os-mio-chat-launcher[hidden] {
1951 display: none;
1952 }
1953
1954 .os-mio-chat-launcher {
1955 position: absolute;
1956 inset-inline-end: 20px;
1957 inset-block-end: 18px;
1958 pointer-events: auto;
1959 }
1960
1961 .os-mio-chat {
1962 position: absolute;
1963 inset-inline-end: 16px;
1964 inset-block-end: 16px;
1965 inline-size: min(380px, calc(100% - 32px));
1966 max-block-size: calc(100% - 32px);
1967 box-sizing: border-box;
1968 display: flex;
1969 flex-direction: column;
1970 gap: 12px;
1971 padding: 18px;
1972 pointer-events: auto;
1973 color: var(--os-mio-chat-fg, #1d2327);
1974 background: var(--os-mio-chat-bg, #fff);
1975 border: 1px solid var(--os-mio-chat-border, #c3c4c7);
1976 border-radius: var(--os-mio-chat-radius, 16px);
1977 box-shadow: 0 8px 40px var(--os-mio-chat-glow, #00000020);
1978 animation: os-mio-chat-enter 180ms ease-out;
1979 }
1980
1981 .os-mio-chat header {
1982 display: flex;
1983 align-items: center;
1984 gap: 12px;
1985 }
1986
1987 .os-mio-chat header strong {
1988 letter-spacing: .14em;
1989 }
1990
1991 .os-mio-chat header span {
1992 flex: 1;
1993 font-size: 11px;
1994 color: var(--os-mio-chat-muted, #646970);
1995 }
1996
1997 .os-mio-chat__log {
1998 position: relative;
1999 min-block-size: 60px;
2000 max-block-size: 300px;
2001 overflow: auto;
2002 overscroll-behavior: contain;
2003 }
2004
2005 .os-mio-chat__message {
2006 margin-block: 0 10px;
2007 padding: 10px 12px;
2008 line-height: 1.6;
2009 white-space: pre-wrap;
2010 overflow-wrap: anywhere;
2011 }
2012
2013 .os-mio-chat__message--user {
2014 border-radius: 16px 16px 4px 16px;
2015 background: var(--os-mio-chat-user-bg, #f0f0f1);
2016 }
2017
2018 .os-mio-chat__composer {
2019 display: flex;
2020 align-items: center;
2021 gap: 8px;
2022 }
2023
2024 .os-mio-chat__composer os-textarea {
2025 flex: 1;
2026 min-inline-size: 0;
2027 }
2028
2029 .os-mio-chat__status,
2030 .os-mio-chat small {
2031 margin: 0;
2032 font-size: 11px;
2033 line-height: 1.5;
2034 color: var(--os-mio-chat-muted, #646970);
2035 }
2036
2037 @keyframes os-mio-chat-enter {
2038 from { opacity: 0; transform: translateY(6px) scale(.98); }
2039 to { opacity: 1; transform: translateY(0) scale(1); }
2040 }
2041
2042 @media (prefers-reduced-motion: reduce) {
2043 .os-mio-chat { animation: none; }
2044 }
2045
2046 .os-mio-residence > .os-mio { z-index: 0; }
2047 .os-mio-chat, .os-mio-chat-launcher { z-index: 1; }
2048
2049 .os-mio-chat [hidden] {
2050 display: none !important;
2051 }
2052
2053 .os-mio-chat__message--assistant {
2054 white-space: normal;
2055 }
2056
2057 .os-mio-chat__message p {
2058 margin-block: 0 8px;
2059 }
2060
2061 .os-mio-chat__message ul,
2062 .os-mio-chat__message ol {
2063 margin-block: 8px;
2064 padding-inline-start: 20px;
2065 }
2066
2067 .os-mio-chat__message a {
2068 color: inherit;
2069 text-decoration: underline;
2070 }
2071
2072 /* Shared MIO chrome: the same portrait with a softly revealed off-state slash. */
2073 .os-mio-window-toggle svg {
2074 width: 20px;
2075 height: 20px;
2076 overflow: visible;
2077 }
2078
2079 .os-mio-window-toggle__slash {
2080 fill: none;
2081 stroke: currentColor;
2082 stroke-width: 2.4;
2083 stroke-linecap: round;
2084 opacity: 0;
2085 stroke-dasharray: 26;
2086 stroke-dashoffset: 26;
2087 transition: opacity 240ms ease, stroke-dashoffset 240ms ease;
2088 }
2089
2090 .os-mio-window-toggle[data-mio-disabled="true"] .os-mio-window-toggle__slash {
2091 opacity: 1;
2092 stroke-dashoffset: 0;
2093 }
2094
2095 .os-mio-chat__activity {
2096 display: flex;
2097 align-items: center;
2098 gap: 8px;
2099 min-height: 18px;
2100 }
2101
2102 .os-mio-chat__thinking {
2103 display: inline-flex;
2104 align-items: center;
2105 gap: 3px;
2106 width: 24px;
2107 flex-shrink: 0;
2108 opacity: 0;
2109 color: var(--os-mio-chat-muted, #646970);
2110 transition: opacity 240ms ease;
2111 }
2112
2113 .os-mio-chat[data-thinking="true"] .os-mio-chat__thinking {
2114 opacity: 1;
2115 }
2116
2117 .os-mio-chat__thinking i {
2118 width: 4px;
2119 height: 4px;
2120 border-radius: 50%;
2121 background: currentColor;
2122 animation: os-mio-thinking 1.6s ease-in-out infinite;
2123 animation-play-state: paused;
2124 }
2125
2126 .os-mio-chat__thinking i:nth-child(2) { animation-delay: 160ms; }
2127 .os-mio-chat__thinking i:nth-child(3) { animation-delay: 320ms; }
2128 .os-mio-chat[data-thinking="true"] .os-mio-chat__thinking i { animation-play-state: running; }
2129 .os-mio-window-toggle[data-mio-thinking="true"] circle { animation: os-mio-thinking 2.4s ease-in-out infinite; }
2130
2131 @keyframes os-mio-thinking {
2132 0%, 100% { opacity: 0.45; }
2133 50% { opacity: 1; }
2134 }
2135
2136 @media (prefers-reduced-motion: reduce) {
2137 .os-mio-window-toggle__slash,
2138 .os-mio-chat__thinking { transition: none; }
2139 .os-mio-chat__thinking i,
2140 .os-mio-window-toggle[data-mio-thinking="true"] circle { animation: none; }
2141 }
2142
2143 .os-mio-window-toggle {
2144 max-inline-size: 30px;
2145 opacity: 1;
2146 visibility: visible;
2147 transition: opacity 180ms ease, max-inline-size 240ms ease, visibility 0s;
2148 }
2149
2150 .os-mio-window-toggle[data-mio-available="false"] {
2151 max-inline-size: 0;
2152 opacity: 0;
2153 visibility: hidden;
2154 overflow: hidden;
2155 pointer-events: none;
2156 transition: opacity 180ms ease, max-inline-size 240ms ease, visibility 0s 180ms;
2157 }
2158
2159 @media (prefers-reduced-motion: reduce) {
2160 .os-mio-window-toggle,
2161 .os-mio-window-toggle[data-mio-available="false"] { transition: none; }
2162 }
2163
2164 /* The launcher stays available; the companion only speaks when invited. */
2165 .os-mio > canvas,
2166 .os-mio > .os-mio__handle {
2167 transition: opacity 180ms ease, visibility 180ms;
2168 }
2169
2170 .os-mio[data-mio-visible="false"] > canvas,
2171 .os-mio[data-mio-visible="false"] > .os-mio__handle {
2172 opacity: 0;
2173 visibility: hidden;
2174 pointer-events: none;
2175 }
2176
2177 .os-mio-callout {
2178 position: absolute;
2179 z-index: 2;
2180 display: flex;
2181 align-items: center;
2182 gap: 10px;
2183 max-inline-size: calc(100% - 16px);
2184 box-sizing: border-box;
2185 padding-block: 6px;
2186 padding-inline: 15px 6px;
2187 border: 1px solid var(--os-mio-callout-border, #d6b8cb);
2188 border-radius: 22px;
2189 background: var(--os-mio-callout-bg, #ffe0cf);
2190 color: var(--os-mio-callout-fg, #382b38);
2191 box-shadow: 0 4px 16px var(--os-mio-chat-glow, #00000020);
2192 font-size: 12px;
2193 line-height: 1.5;
2194 font-weight: 500;
2195 pointer-events: auto;
2196 animation: os-mio-chat-enter 180ms ease-out;
2197 }
2198
2199 .os-mio-callout os-button::part(button) {
2200 inline-size: 26px;
2201 block-size: 26px;
2202 font-size: 17px;
2203 line-height: 1;
2204 }
2205
2206 /* A small rounded tail joins the companion without a second outlined shape. */
2207 .os-mio-callout::after {
2208 content: "";
2209 position: absolute;
2210 inset-block-end: -4px;
2211 inset-inline-end: 18px;
2212 inline-size: 8px;
2213 block-size: 8px;
2214 background: inherit;
2215 border-inline-end: inherit;
2216 border-block-end: inherit;
2217 border-end-end-radius: 3px;
2218 transform: rotate(45deg);
2219 }
2220
2221 .os-mio-callout[hidden] { display: none; }
2222
2223 @media (prefers-reduced-motion: reduce) {
2224 .os-mio > canvas,
2225 .os-mio > .os-mio__handle { transition: none; }
2226 .os-mio-callout { animation: none; }
2227 }
2228
2229 /* Response controls belong to their message; all layout follows inline flow. */
2230 .os-mio-chat__actions {
2231 display: flex;
2232 flex-wrap: wrap;
2233 gap: 8px;
2234 margin-block-start: 12px;
2235 }
2236
2237 .os-mio-chat__action {
2238 display: flex;
2239 flex-direction: column;
2240 align-items: flex-start;
2241 gap: 4px;
2242 min-inline-size: 0;
2243 max-inline-size: 100%;
2244 }
2245
2246 .os-mio-chat__action os-button::part(button) {
2247 white-space: normal;
2248 overflow-wrap: anywhere;
2249 }
2250
2251 .os-mio-chat__action-status {
2252 font-size: 12px;
2253 color: var(--os-mio-chat-muted, #646970);
2254 overflow-wrap: anywhere;
2255 }
2256
2257 .os-mio-chat__composer os-textarea::part(textarea) {
2258 white-space: pre-wrap;
2259 overflow-wrap: anywhere;
2260 overflow-x: hidden;
2261 }
2262