PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / trunk
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin vtrunk
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 0.8.7 0.8.6 All 33 releases
desktop-mode / assets / css / desktop.css

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

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