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

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

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