PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.11
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.11
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 0.8.9 0.8.8 All 35 releases
desktop-mode / assets / css / desktop.css

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

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