PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.10
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.10
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
← All changes | assets/css/desktop.css +1246 -193 0.8.71.1.10 View file →
@@ -1,8 +1,8 @@
1 1 /**
2 - * Desktop Mode — Shell Layout.
2 + * OpenStation — Shell Layout.
3 3 *
4 - * When desktop mode is active, the classic admin chrome still renders
4 + * When OpenStation is active, the classic admin chrome still renders
5 5 * server-side (for hook compatibility) but is hidden with CSS. The
6 6 * plugin-injected shell floats above it, positioned just below the
7 7 * fixed admin bar, covering the rest of the viewport.
8 8 *
@@ -8,17 +8,57 @@
8 8 *
9 9 * @since 0.1.0
10 10 */
11 11
12 -/* Solid backdrop behind the shell. */
13 -body.desktop-mode-active {
14 - background: #1d2327;
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 );
15 20 overflow: hidden;
16 21 }
17 22
18 23 /*
19 - * Cursor policy — default arrow everywhere in desktop mode.
24 + * Text selection.
20 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 + *
21 61 * Per user preference (since 0.20.0): no hand/pointer/grab/grabbing/
22 62 * copy/no-drop cursors on icons, tiles, dock items, dock peek,
23 63 * window chrome, or any other clickable surface. The default arrow
24 64 * is the only "neutral" cursor we render.
@@ -29,8 +69,11 @@
29 69 * they're a functional cue, not a hover affordance, and removing
30 70 * them would make corner-grabs guesswork.
31 71 * - Text inputs / textareas / contenteditable surfaces keep the
32 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).
33 76 *
34 77 * `!important` is needed because the specific `cursor: pointer` and
35 78 * drag-state declarations elsewhere in the codebase have the same or
36 79 * higher specificity. The resize-handle exceptions use `!important`
@@ -35,46 +78,46 @@
35 78 * drag-state declarations elsewhere in the codebase have the same or
36 79 * higher specificity. The resize-handle exceptions use `!important`
37 80 * for the same reason.
38 81 *
39 - * Shadow-DOM web components (`wpd-*`) define their own cursors
82 + * Shadow-DOM web components (`os-*`) define their own cursors
40 83 * inside their shadow trees; those internal rules win regardless of
41 84 * what we set on the host. Anything visible on the desktop shell
42 85 * (light DOM) is covered by this policy.
43 86 */
44 -body.desktop-mode-active,
45 -body.desktop-mode-active * {
87 +body.os-active,
88 +body.os-active * {
46 89 cursor: default !important;
47 90 }
48 91
49 -body.desktop-mode-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"]),
50 -body.desktop-mode-active textarea,
51 -body.desktop-mode-active [contenteditable=""],
52 -body.desktop-mode-active [contenteditable="true"] {
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"] {
53 96 cursor: text !important;
54 97 }
55 98
56 -body.desktop-mode-active .desktop-mode-window__resize-handle--se,
57 -body.desktop-mode-active .desktop-mode-window__resize-handle--nw,
58 -body.desktop-mode-active .desktop-mode-widgets__resize--se,
59 -body.desktop-mode-active .desktop-mode-widgets__resize--nw {
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 {
60 103 cursor: nwse-resize !important;
61 104 }
62 105
63 -body.desktop-mode-active .desktop-mode-window__resize-handle--sw,
64 -body.desktop-mode-active .desktop-mode-window__resize-handle--ne,
65 -body.desktop-mode-active .desktop-mode-widgets__resize--sw,
66 -body.desktop-mode-active .desktop-mode-widgets__resize--ne {
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 {
67 110 cursor: nesw-resize !important;
68 111 }
69 112
70 -body.desktop-mode-active .desktop-mode-widgets__resize--n,
71 -body.desktop-mode-active .desktop-mode-widgets__resize--s {
113 +body.os-active .os-widgets__resize--n,
114 +body.os-active .os-widgets__resize--s {
72 115 cursor: ns-resize !important;
73 116 }
74 117
75 -body.desktop-mode-active .desktop-mode-widgets__resize--e,
76 -body.desktop-mode-active .desktop-mode-widgets__resize--w {
118 +body.os-active .os-widgets__resize--e,
119 +body.os-active .os-widgets__resize--w {
77 120 cursor: ew-resize !important;
78 121 }
79 122
80 123 /*
@@ -83,19 +126,19 @@
83 126 * The markup still exists in the DOM so server-side action hooks that
84 127 * target these regions (admin_notices, in_admin_footer, etc.) continue
85 128 * to fire — we just take them out of the visual flow.
86 129 */
87 -body.desktop-mode-active #adminmenuwrap,
88 -body.desktop-mode-active #adminmenuback,
89 -body.desktop-mode-active #wpbody,
90 -body.desktop-mode-active #wpfooter,
91 -body.desktop-mode-active .wp-responsive-toggle,
92 -body.desktop-mode-active #collapse-menu {
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 {
93 136 display: none !important;
94 137 }
95 138
96 139 /*
97 - * The admin bar is the ONLY escape hatch in desktop mode — it hosts the
140 + * The admin bar is the ONLY escape hatch in OpenStation — it hosts the
98 141 * "Switch to Classic Admin" toggle. Any plugin CSS, Gutenberg state,
99 142 * user preference, or stray `display:none` that hides it would strand
100 143 * the user inside the shell with no way out. Hard-pin it visible.
101 144 *
@@ -102,9 +145,9 @@
102 145 * The exception: while a window is in immersive fullscreen, we *want*
103 146 * the bar hidden (macOS convention). That rule lives below and wins on
104 147 * specificity because it also targets #wpadminbar under a body class.
105 148 */
106 -body.desktop-mode-active #wpadminbar {
149 +body.os-active #wpadminbar {
107 150 display: block !important;
108 151 visibility: visible !important;
109 152 opacity: 1 !important;
110 153 position: fixed !important;
@@ -113,8 +156,198 @@
113 156 inset-inline-end: 0 !important;
114 157 }
115 158
116 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 +/*
117 350 * Align the admin-bar W logo with the dock below it.
118 351 *
119 352 * Without this rule the admin bar's leftmost item (the WordPress W)
120 353 * renders at its Core-dictated ~44 px width, while the dock below it
@@ -122,17 +355,17 @@
122 355 * preference. The misalignment reads as "two disconnected left
123 356 * regions" — visually scruffy on an otherwise tight shell.
124 357 *
125 358 * We size the logo's <li> to match the dock's width and center the
126 - * W inside, so the two columns share an axis. `--desktop-mode-dock-width`
359 + * W inside, so the two columns share an axis. `--os-dock-width`
127 360 * is now set on :root (see settings.ts → apply()) so it reaches
128 - * siblings of #desktop-mode-shell like the admin bar.
361 + * siblings of #os-shell like the admin bar.
129 362 */
130 -body.desktop-mode-active #wpadminbar #wp-admin-bar-wp-logo {
131 - width: var( --desktop-mode-dock-width, 56px );
363 +body.os-active #wpadminbar #wp-admin-bar-wp-logo {
364 + width: var( --os-dock-width, 56px );
132 365 }
133 366
134 -body.desktop-mode-active #wpadminbar #wp-admin-bar-wp-logo > .ab-item {
367 +body.os-active #wpadminbar #wp-admin-bar-wp-logo > .ab-item {
135 368 display: flex !important;
136 369 align-items: center;
137 370 justify-content: center;
138 371 padding-inline: 0 !important;
@@ -142,9 +375,9 @@
142 375 * The W glyph is painted via a ::before pseudo on `.ab-icon`. Center
143 376 * it within the flex cell so varying dock widths don't leave it
144 377 * visually offset.
145 378 */
146 -body.desktop-mode-active #wpadminbar #wp-admin-bar-wp-logo > .ab-item > .ab-icon {
379 +body.os-active #wpadminbar #wp-admin-bar-wp-logo > .ab-item > .ab-icon {
147 380 margin: 0;
148 381 }
149 382
150 383 /*
@@ -152,9 +385,9 @@
152 385 * the sidebar hidden we let wpcontent fall back to flush — the shell is
153 386 * fixed-positioned anyway so this doesn't change the shell's layout, it
154 387 * just prevents a phantom sidebar gutter in any stray visible content.
155 388 */
156 -body.desktop-mode-active #wpcontent {
389 +body.os-active #wpcontent {
157 390 margin-inline-start: 0;
158 391 padding-inline-start: 0;
159 392 }
160 393
@@ -161,12 +394,26 @@
161 394 /*
162 395 * Desktop shell — fills the viewport below the admin bar.
163 396 * Uses position: fixed so it's unaffected by any containing-block
164 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.
165 409 */
166 -.desktop-mode-shell {
410 +.os-shell {
167 411 position: fixed;
168 - inset-block-start: var(--wp-admin--admin-bar--height, 32px);
412 + inset-block-start: var(
413 + --os-admin-bar-height,
414 + var(--wp-admin--admin-bar--height, 32px)
415 + );
169 416 inset-inline-start: 0;
170 417 inset-inline-end: 0;
171 418 inset-block-end: 0;
172 419 display: flex;
@@ -171,9 +418,27 @@
171 418 inset-block-end: 0;
172 419 display: flex;
173 420 flex-direction: column;
174 421 overflow: hidden;
175 - z-index: var(--desktop-mode-z-base);
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);
176 441 }
177 442
178 443 /*
179 444 * Wallpaper layer — first child of the shell, sits behind the dock
@@ -178,9 +443,9 @@
178 443 /*
179 444 * Wallpaper layer — first child of the shell, sits behind the dock
180 445 * and desktop area so a translucent dock shows through to the
181 446 * background (macOS pattern). CSS wallpapers paint via the
182 - * `--desktop-mode-bg` custom property; canvas wallpapers mount their
447 + * `--os-bg` custom property; canvas wallpapers mount their
183 448 * own DOM (typically a <canvas>) into this element via JS.
184 449 *
185 450 * z-index: 0 keeps it below the shell body flex row which has no
186 451 * explicit z-index of its own but establishes its own local
@@ -186,25 +451,177 @@
186 451 * explicit z-index of its own but establishes its own local
187 452 * stacking context via `position: relative`, so the dock + windows
188 453 * always render above the wallpaper regardless of DOM order.
189 454 */
190 -.desktop-mode-wallpaper {
455 +.os-wallpaper {
191 456 position: absolute;
192 457 inset: 0;
193 458 z-index: 0;
194 - background: var(--desktop-mode-bg);
459 + background: var(--os-bg);
195 460 overflow: hidden;
196 461 pointer-events: none;
197 462 }
198 463
199 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 +/*
200 617 * Canvas children fill the layer and ignore pointer events by
201 618 * default — a wallpaper is presentational chrome, not an interactive
202 619 * element. Plugins that genuinely need a clickable wallpaper can
203 620 * override `pointer-events` on their own mounted node.
204 621 */
205 -.desktop-mode-wallpaper > canvas,
206 -.desktop-mode-wallpaper > * {
622 +.os-wallpaper > canvas,
623 +.os-wallpaper > * {
207 624 width: 100%;
208 625 height: 100%;
209 626 display: block;
210 627 }
@@ -210,10 +627,13 @@
210 627 }
211 628
212 629 /* Below 783px WP collapses the admin bar to 46px. */
213 630 @media screen and (max-width: 782px) {
214 - .desktop-mode-shell {
215 - inset-block-start: var(--wp-admin--admin-bar--height, 46px);
631 + .os-shell {
632 + inset-block-start: var(
633 + --os-admin-bar-height,
634 + var(--wp-admin--admin-bar--height, 46px)
635 + );
216 636 }
217 637 }
218 638
219 639 /*
@@ -227,18 +647,18 @@
227 647 * The window's title bar remains visible because the window itself is
228 648 * position: fixed at top: 0, so the focus/exit-fullscreen button is
229 649 * still reachable.
230 650 */
231 -body.desktop-mode-has-fullscreen-window #wpadminbar {
651 +body.os-has-fullscreen-window #wpadminbar {
232 652 display: none !important;
233 653 }
234 654
235 -body.desktop-mode-has-fullscreen-window .desktop-mode-shell {
655 +body.os-has-fullscreen-window .os-shell {
236 656 inset-block-start: 0;
237 657 }
238 658
239 659 /* Shell body: contains dock + desktop area side by side. */
240 -.desktop-mode-shell__body {
660 +.os-shell__body {
241 661 flex: 1;
242 662 display: flex;
243 663 overflow: hidden;
244 664 position: relative;
@@ -249,18 +669,27 @@
249 669 * because the wallpaper renders in the shell-level layer behind
250 670 * both this area and the dock. Stacking context via position: relative
251 671 * ensures children (windows) sit above the wallpaper.
252 672 *
253 - * `padding-bottom` reserves room for the floating bottom dock pill
254 - * that every layout (Classic, Unified, Spatial) puts at the bottom
255 - * — 40px icons + 16px padding + 12px gap below + 8px breathing room.
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.
256 682 */
257 -.desktop-mode-area {
683 +.os-area {
258 684 flex: 1;
259 685 position: relative;
260 686 overflow: hidden;
261 687 background: transparent;
262 - padding-bottom: 80px;
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 );
263 692 transition: opacity 180ms ease;
264 693 }
265 694
266 695 /*
@@ -267,9 +696,9 @@
267 696 * Boot reveal gate.
268 697 *
269 698 * On F5 the shell paints in stages: the dispatcher's
270 699 * `repaintIcons()` lands the server-rendered wallpaper icons
271 - * synchronously from `desktopModeConfig.desktopIcons`, then the
700 + * synchronously from `openStationConfig.desktopIcons`, then the
272 701 * files-layer mounts and momentarily renders an empty bucket while
273 702 * REST `listPlacements(0)` is in flight, then folders/posts/links
274 703 * slot in on the next paint. The user reads that as "plugins show
275 704 * first, then everything blinks and the rest arrives."
@@ -282,9 +711,9 @@
282 711 *
283 712 * `desktop.ts` removes the class inside a `requestAnimationFrame`
284 713 * after the root files-layer's `hydrated` promise resolves (with a
285 714 * 2 s JS safety timeout). The opacity transition declared on
286 - * `.desktop-mode-area` smooths the reveal.
715 + * `.os-area` smooths the reveal.
287 716 *
288 717 * The fallback animation is a resilience layer for the case where
289 718 * the shell JS never runs (network error mid-load, ad-blocker
290 719 * tearing down a bundle, …). The animation holds the area
@@ -298,15 +727,15 @@
298 727 * a tile that's about to reposition once REST returns.
299 728 *
300 729 * @since 0.18.x
301 730 */
302 -.desktop-mode-area--booting {
731 +.os-area--booting {
303 732 opacity: 0;
304 733 pointer-events: none;
305 - animation: desktop-mode-area-boot-fallback 3s forwards;
734 + animation: os-area-boot-fallback 3s forwards;
306 735 }
307 736
308 -@keyframes desktop-mode-area-boot-fallback {
737 +@keyframes os-area-boot-fallback {
309 738 0%,
310 739 90% {
311 740 opacity: 0;
312 741 pointer-events: none;
@@ -330,14 +759,25 @@
330 759 * click) while individual tiles re-enable pointer events so they
331 760 * remain clickable.
332 761 * --------------------------------------------------------------- */
333 762
334 -.desktop-mode-icons {
763 +.os-icons {
335 764 position: absolute;
336 - inset-block-start: 16px;
337 - inset-block-end: 16px;
338 - inset-inline-start: 16px;
339 - inset-inline-end: 16px;
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 );
340 780 display: grid;
341 781 /*
342 782 * Column-major flow: icons fill the first column top-to-bottom,
343 783 * then start the next column. Matches the macOS / Finder
@@ -367,9 +807,9 @@
367 807 */
368 808 overflow: auto;
369 809 }
370 810
371 -.desktop-mode-icon {
811 +.os-icon {
372 812 pointer-events: auto;
373 813 display: flex;
374 814 flex-direction: column;
375 815 align-items: center;
@@ -377,22 +817,32 @@
377 817 gap: 6px;
378 818 width: 88px;
379 819 padding: 8px 4px;
380 820 border: 0;
381 - background: transparent;
382 - color: var(--desktop-mode-fg, #fff);
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);
383 833 cursor: pointer;
384 834 border-radius: 8px;
385 835 transition: background-color 0.15s ease;
386 - /* Positioning context for `.desktop-mode-icon__badge`. */
836 + /* Positioning context for `.os-icon__badge`. */
387 837 position: relative;
388 838 }
389 839
390 840 /*
391 - * Icon badge — symmetric to `.desktop-mode-dock__badge` but anchored
841 + * Icon badge — symmetric to `.os-dock__badge` but anchored
392 842 * to the wallpaper-icon image. Same gradient, same ring, slightly
393 843 * larger so it reads at the longer wallpaper viewing distance.
394 - * Painted by `wp.desktop.icons.setBadge( id, count )` — see
844 + * Painted by `wp.os.icons.setBadge( id, count )` — see
395 845 * `src/desktop-icons.ts` for the imperative API and
396 846 * `src/recycle-bin/badge.ts` for the canonical multi-rail
397 847 * consumer. The class name is part of the stable contract; the
398 848 * positioning, color, and shadow are not — themes or future
@@ -397,24 +847,40 @@
397 847 * consumer. The class name is part of the stable contract; the
398 848 * positioning, color, and shadow are not — themes or future
399 849 * shell builds may restyle freely.
400 850 */
401 -.desktop-mode-icon__badge {
851 +.os-icon__badge {
402 852 position: absolute;
403 853 top: 4px;
404 854 inset-inline-end: 14px;
405 - min-width: 18px;
406 - height: 18px;
407 - padding: 0 5px;
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 + );
408 868 box-sizing: border-box;
409 869 border-radius: 999px;
410 - background: linear-gradient( 180deg, #ff5a5a 0%, #d63638 100% );
411 - color: #fff;
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 ) );
412 875 display: inline-flex;
413 876 align-items: center;
414 877 justify-content: center;
415 - font-size: 11px;
416 - font-weight: 700;
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 );
417 883 line-height: 1;
418 884 font-variant-numeric: tabular-nums;
419 885 box-shadow:
420 886 0 0 0 1.5px rgba( 0, 0, 0, 0.4 ),
@@ -421,35 +887,40 @@
421 887 0 1px 3px rgba( 0, 0, 0, 0.35 );
422 888 pointer-events: none;
423 889 }
424 890
425 -.desktop-mode-icon:hover,
426 -.desktop-mode-icon:focus-visible {
427 - background: rgba(255, 255, 255, 0.12);
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);
428 897 outline: none;
429 898 }
430 899
431 -.desktop-mode-icon:focus-visible {
900 +.os-icon:focus-visible {
432 901 box-shadow: 0 0 0 2px var(--wp-admin-theme-color, #2271b1);
433 902 }
434 903
435 -.desktop-mode-icon__image {
904 +.os-icon__image {
436 905 display: inline-flex;
437 906 align-items: center;
438 907 justify-content: center;
439 - width: 48px;
440 - height: 48px;
441 - font-size: 32px;
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 );
442 913 line-height: 1;
443 914 }
444 915
445 -.desktop-mode-icon__image img {
916 +.os-icon__image img {
446 917 max-width: 48px;
447 918 max-height: 48px;
448 919 object-fit: contain;
449 920 }
450 921
451 -.desktop-mode-icon__label {
922 +.os-icon__label {
452 923 font-size: 12px;
453 924 text-align: center;
454 925 text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
455 926 line-height: 1.2;
@@ -463,12 +934,20 @@
463 934 * the add tile; every other pixel is pointer-transparent so window
464 935 * drag / resize / click-through behaviour is unaffected.
465 936 * --------------------------------------------------------------- */
466 937
467 -.desktop-mode-widgets {
938 +.os-widgets {
939 + border-radius: 14px;
468 940 position: absolute;
469 - top: 16px;
470 - bottom: 16px;
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 );
471 950 inset-inline-end: 16px;
472 951 width: 320px;
473 952 display: flex;
474 953 flex-direction: column;
@@ -488,9 +967,9 @@
488 967 opacity 0.22s ease,
489 968 transform 0.28s cubic-bezier( 0.2, 0, 0.2, 1 );
490 969 }
491 970
492 -.desktop-mode-area--overview .desktop-mode-widgets {
971 +.os-area--overview .os-widgets {
493 972 opacity: 0;
494 973 /* Slight slide-off-right so the fade reads as "the column is
495 974 * stepping back out of the way" rather than flat-dimming. */
496 975 transform: translateX( 16px );
@@ -496,12 +975,13 @@
496 975 transform: translateX( 16px );
497 976 pointer-events: none;
498 977 }
499 978
500 -.desktop-mode-widgets__list {
979 +.os-widgets__list {
501 980 display: flex;
502 981 flex-direction: column;
503 982 gap: 12px;
983 + padding: 4px;
504 984 }
505 985
506 986 /*
507 987 * Individual widget card — glass backdrop matching the dock /
@@ -509,51 +989,78 @@
509 989 * content + the remove X land; the column itself stays
510 990 * pointer-transparent so a drag that grazes the column's margin
511 991 * falls through to the window beneath.
512 992 */
513 -.desktop-mode-widgets__card {
993 +.os-widgets__card {
514 994 position: relative;
515 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;
516 1002 pointer-events: auto;
517 - background: rgba( 20, 20, 22, 0.55 );
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 );
518 1018 backdrop-filter: blur( 18px ) saturate( 140% );
519 1019 -webkit-backdrop-filter: blur( 18px ) saturate( 140% );
520 1020 border: 1px solid rgba( 255, 255, 255, 0.08 );
521 1021 border-radius: 14px;
522 - color: #fff;
1022 + color: var( --os-ui-fg-on-accent, #fff );
523 1023 box-shadow:
524 1024 0 8px 28px rgba( 0, 0, 0, 0.35 ),
525 1025 inset 0 0 0 1px rgba( 255, 255, 255, 0.04 );
526 1026 transition:
527 - transform 0.18s ease,
528 1027 box-shadow 0.18s ease,
529 1028 opacity 0.18s ease;
530 1029 }
531 1030
532 -.desktop-mode-widgets__card:hover {
533 - transform: translateX( -2px );
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 {
534 1035 box-shadow:
535 1036 0 12px 36px rgba( 0, 0, 0, 0.45 ),
536 1037 inset 0 0 0 1px rgba( 255, 255, 255, 0.08 );
537 1038 }
538 1039
539 -.desktop-mode-widgets__card-body {
1040 +.os-widgets__card-body {
540 1041 padding: 16px;
541 1042 min-height: 48px;
1043 + flex: 1 1 auto;
1044 + overflow-x: hidden;
1045 + overflow-y: auto;
1046 + scrollbar-width: thin;
542 1047 }
543 1048
544 -.desktop-mode-widgets__card-close {
1049 +.os-widgets__card-close {
545 1050 position: absolute;
546 1051 top: 6px;
547 1052 inset-inline-end: 6px;
548 - width: 22px;
549 - height: 22px;
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;
550 1057 display: flex;
551 1058 align-items: center;
552 1059 justify-content: center;
553 1060 padding: 0;
554 1061 border: 0;
555 - background: rgba( 0, 0, 0, 0.55 );
1062 + background: var( --os-ui-scrim, rgba( 0, 0, 0, 0.55 ) );
556 1063 color: rgba( 255, 255, 255, 0.75 );
557 1064 border-radius: 50%;
558 1065 cursor: pointer;
559 1066 opacity: 0;
@@ -562,16 +1069,16 @@
562 1069 background-color 0.15s ease,
563 1070 color 0.15s ease;
564 1071 }
565 1072
566 -.desktop-mode-widgets__card:hover .desktop-mode-widgets__card-close,
567 -.desktop-mode-widgets__card-close:focus-visible {
1073 +.os-widgets__card:hover .os-widgets__card-close,
1074 +.os-widgets__card-close:focus-visible {
568 1075 opacity: 1;
569 1076 }
570 1077
571 -.desktop-mode-widgets__card-close:hover {
572 - background: #d63638;
573 - color: #fff;
1078 +.os-widgets__card-close:hover {
1079 + background: var( --os-ui-danger, #d63638 );
1080 + color: var( --os-ui-fg-on-accent, #fff );
574 1081 }
575 1082
576 1083 /*
577 1084 * Movable widgets — a thin chrome header at the top of the card holds
@@ -579,10 +1086,11 @@
579 1086 * header is the drag handle (except for interactive children — those
580 1087 * remain clickable as normal). Non-movable widgets keep today's
581 1088 * corner-× layout unchanged.
582 1089 */
583 -.desktop-mode-widgets__chrome {
1090 +.os-widgets__chrome {
584 1091 display: flex;
1092 + flex-shrink: 0;
585 1093 align-items: center;
586 1094 gap: 8px;
587 1095 padding: 6px 8px;
588 1096 border-bottom: 1px solid rgba( 255, 255, 255, 0.06 );
@@ -591,13 +1099,13 @@
591 1099 -webkit-user-select: none;
592 1100 touch-action: none;
593 1101 }
594 1102
595 -.desktop-mode-widgets__card--dragging .desktop-mode-widgets__chrome {
1103 +.os-widgets__card--dragging .os-widgets__chrome {
596 1104 cursor: grabbing;
597 1105 }
598 1106
599 -.desktop-mode-widgets__grip {
1107 +.os-widgets__grip {
600 1108 flex-shrink: 0;
601 1109 width: 10px;
602 1110 height: 16px;
603 1111 background-image: radial-gradient(
@@ -611,13 +1119,13 @@
611 1119 opacity: 0.45;
612 1120 transition: opacity 0.12s ease;
613 1121 }
614 1122
615 -.desktop-mode-widgets__card:hover .desktop-mode-widgets__grip {
1123 +.os-widgets__card:hover .os-widgets__grip {
616 1124 opacity: 0.85;
617 1125 }
618 1126
619 -.desktop-mode-widgets__title {
1127 +.os-widgets__title {
620 1128 flex: 1;
621 1129 font-size: 12px;
622 1130 font-weight: 500;
623 1131 letter-spacing: 0.01em;
@@ -628,17 +1136,17 @@
628 1136 }
629 1137
630 1138 /* When the close button sits inside the chrome it's inline, not
631 1139 corner-absolute — reset position + always-visible opacity. */
632 -.desktop-mode-widgets__chrome .desktop-mode-widgets__card-close {
1140 +.os-widgets__chrome .os-widgets__card-close {
633 1141 position: static;
634 1142 opacity: 0.7;
635 - width: 20px;
636 - height: 20px;
1143 + width: 24px;
1144 + height: 24px;
637 1145 }
638 1146
639 -.desktop-mode-widgets__chrome .desktop-mode-widgets__card-close:hover,
640 -.desktop-mode-widgets__chrome .desktop-mode-widgets__card-close:focus-visible {
1147 +.os-widgets__chrome .os-widgets__card-close:hover,
1148 +.os-widgets__chrome .os-widgets__card-close:focus-visible {
641 1149 opacity: 1;
642 1150 }
643 1151
644 1152 /*
@@ -648,23 +1156,24 @@
648 1156 * expose it (tapping it on a docked card would be a confusing
649 1157 * no-op). The DOM node stays mounted either way so state flips
650 1158 * don't rebuild chrome.
651 1159 *
652 - * Styling mirrors the inline close button: 20×20 round tile,
1160 + * Styling mirrors the inline close button: 24×24 round tile
1161 + * (the WCAG 2.2 SC 2.5.8 target-size floor),
653 1162 * dimmed by default, full-opacity on hover / focus. Distinct hover
654 1163 * color (theme accent, not red) signals "put back" vs. "remove."
655 1164 */
656 -.desktop-mode-widgets__card-redock {
1165 +.os-widgets__card-redock {
657 1166 display: none;
658 1167 align-items: center;
659 1168 justify-content: center;
660 1169 padding: 0;
661 1170 border: 0;
662 - background: rgba( 0, 0, 0, 0.55 );
1171 + background: var( --os-ui-scrim, rgba( 0, 0, 0, 0.55 ) );
663 1172 color: rgba( 255, 255, 255, 0.75 );
664 1173 border-radius: 50%;
665 - width: 20px;
666 - height: 20px;
1174 + width: 24px;
1175 + height: 24px;
667 1176 cursor: pointer;
668 1177 opacity: 0.7;
669 1178 transition:
670 1179 opacity 0.15s ease,
@@ -671,35 +1180,40 @@
671 1180 background-color 0.15s ease,
672 1181 color 0.15s ease;
673 1182 }
674 1183
675 -.desktop-mode-widgets__card--floating .desktop-mode-widgets__card-redock {
1184 +.os-widgets__card--floating .os-widgets__card-redock {
676 1185 display: flex;
677 1186 }
678 1187
679 -.desktop-mode-widgets__card-redock:hover,
680 -.desktop-mode-widgets__card-redock:focus-visible {
1188 +.os-widgets__card-redock:hover,
1189 +.os-widgets__card-redock:focus-visible {
681 1190 opacity: 1;
682 1191 background: var( --wp-admin-theme-color, #2271b1 );
683 - color: #fff;
1192 + color: var( --os-ui-accent-ink, var( --os-ui-fg-on-accent, #fff ) );
684 1193 }
685 1194
686 1195 /*
687 1196 * Floating widgets — absolute-positioned, inline top/left/width/height
688 - * written by the frame. The hover translate-X effect would fight the
689 - * user's chosen position, so disable it.
1197 + * written by the frame.
690 1198 */
691 -.desktop-mode-widgets__card--floating {
1199 +.os-widgets__card--floating {
692 1200 position: absolute;
693 1201 margin: 0;
694 1202 }
695 1203
696 -.desktop-mode-widgets__card--floating:hover {
697 - transform: none;
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;
698 1212 }
699 1213
700 -.desktop-mode-widgets__card--dragging,
701 -.desktop-mode-widgets__card--resizing {
1214 +.os-widgets__card--dragging,
1215 +.os-widgets__card--resizing {
702 1216 transition: none;
703 1217 z-index: 2;
704 1218 }
705 1219
@@ -710,14 +1224,14 @@
710 1224 * every handle; a non-movable widget ignores everything except the
711 1225 * `s` (bottom-edge, height-only) handle at the pointer layer, so we
712 1226 * visually hide the mismatched handles via the modifier class.
713 1227 */
714 -.desktop-mode-widgets__resize {
1228 +.os-widgets__resize {
715 1229 position: absolute;
716 1230 z-index: 1;
717 1231 }
718 1232
719 -.desktop-mode-widgets__resize--n {
1233 +.os-widgets__resize--n {
720 1234 top: -3px;
721 1235 left: 8px;
722 1236 right: 8px;
723 1237 height: 6px;
@@ -723,9 +1237,9 @@
723 1237 height: 6px;
724 1238 cursor: ns-resize;
725 1239 }
726 1240
727 -.desktop-mode-widgets__resize--s {
1241 +.os-widgets__resize--s {
728 1242 bottom: -3px;
729 1243 left: 8px;
730 1244 right: 8px;
731 1245 height: 6px;
@@ -731,9 +1245,9 @@
731 1245 height: 6px;
732 1246 cursor: ns-resize;
733 1247 }
734 1248
735 -.desktop-mode-widgets__resize--e {
1249 +.os-widgets__resize--e {
736 1250 top: 8px;
737 1251 bottom: 8px;
738 1252 right: -3px;
739 1253 width: 6px;
@@ -739,9 +1253,9 @@
739 1253 width: 6px;
740 1254 cursor: ew-resize;
741 1255 }
742 1256
743 -.desktop-mode-widgets__resize--w {
1257 +.os-widgets__resize--w {
744 1258 top: 8px;
745 1259 bottom: 8px;
746 1260 left: -3px;
747 1261 width: 6px;
@@ -747,9 +1261,9 @@
747 1261 width: 6px;
748 1262 cursor: ew-resize;
749 1263 }
750 1264
751 -.desktop-mode-widgets__resize--ne {
1265 +.os-widgets__resize--ne {
752 1266 top: -4px;
753 1267 right: -4px;
754 1268 width: 12px;
755 1269 height: 12px;
@@ -755,9 +1269,9 @@
755 1269 height: 12px;
756 1270 cursor: nesw-resize;
757 1271 }
758 1272
759 -.desktop-mode-widgets__resize--nw {
1273 +.os-widgets__resize--nw {
760 1274 top: -4px;
761 1275 left: -4px;
762 1276 width: 12px;
763 1277 height: 12px;
@@ -763,9 +1277,9 @@
763 1277 height: 12px;
764 1278 cursor: nwse-resize;
765 1279 }
766 1280
767 -.desktop-mode-widgets__resize--se {
1281 +.os-widgets__resize--se {
768 1282 bottom: -4px;
769 1283 right: -4px;
770 1284 width: 12px;
771 1285 height: 12px;
@@ -771,9 +1285,9 @@
771 1285 height: 12px;
772 1286 cursor: nwse-resize;
773 1287 }
774 1288
775 -.desktop-mode-widgets__resize--sw {
1289 +.os-widgets__resize--sw {
776 1290 bottom: -4px;
777 1291 left: -4px;
778 1292 width: 12px;
779 1293 height: 12px;
@@ -780,67 +1294,107 @@
780 1294 cursor: nesw-resize;
781 1295 }
782 1296
783 1297 /*
784 - * Non-movable resizable widgets only expose the bottom-edge handle —
785 - * width stays locked to the column width. Hide the other seven so the
786 - * cursor doesn't lie about what's interactive.
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.
787 1303 */
788 -.desktop-mode-widgets__card--resizable:not( .desktop-mode-widgets__card--movable ) .desktop-mode-widgets__resize:not( .desktop-mode-widgets__resize--s ) {
1304 +.os-widgets__card--resizable:not( .os-widgets__card--floating ) .os-widgets__resize:not( .os-widgets__resize--s ) {
789 1305 display: none;
790 1306 }
791 1307
792 1308 /*
793 - * Add-widget tile — matches the "+" tile in the overview top bar.
794 - * Always visible at the bottom of the column; promoted to center
795 - * when the column is otherwise empty so first-run users see a
796 - * friendly target rather than an empty strip.
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()`.
797 1320 */
798 -.desktop-mode-widgets__add {
799 - margin-top: auto;
800 - padding: 14px 12px;
1321 +.os-widgets__add {
1322 + position: absolute;
1323 + top: 0;
1324 + left: 50%;
1325 + transform: translateX( -50% );
1326 + padding: 8px 22px;
801 1327 display: flex;
802 1328 align-items: center;
803 1329 justify-content: center;
804 - gap: 8px;
805 - pointer-events: auto;
1330 + gap: 7px;
1331 + pointer-events: none;
806 1332 background: transparent;
807 - border: 2px dashed rgba( 255, 255, 255, 0.22 );
808 - border-radius: 14px;
1333 + border: 1px dashed rgba( 255, 255, 255, 0.22 );
1334 + border-radius: 10px;
809 1335 color: rgba( 255, 255, 255, 0.72 );
810 1336 font: inherit;
811 1337 font-weight: 500;
812 1338 cursor: pointer;
1339 + opacity: 0;
813 1340 transition:
1341 + opacity 0.15s ease,
814 1342 border-color 0.15s ease,
815 1343 background-color 0.15s ease,
816 1344 color 0.15s ease;
817 1345 }
818 1346
819 -.desktop-mode-widgets__add:hover {
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 {
820 1374 border-color: rgba( 255, 255, 255, 0.4 );
821 1375 background: rgba( 255, 255, 255, 0.06 );
822 - color: #fff;
1376 + color: var( --os-ui-fg-on-accent, #fff );
823 1377 }
824 1378
825 -.desktop-mode-widgets__add:focus-visible {
1379 +.os-widgets__add:focus-visible {
826 1380 outline: 2px solid var( --wp-admin-theme-color, #2271b1 );
827 1381 outline-offset: 2px;
828 1382 }
829 1383
830 -.desktop-mode-widgets__add-plus {
831 - font-size: 20px;
1384 +.os-widgets__add-plus {
1385 + font-size: 15px;
832 1386 line-height: 1;
833 - font-weight: 200;
1387 + font-weight: 300;
834 1388 }
835 1389
836 -.desktop-mode-widgets__add-label {
1390 +.os-widgets__add-label {
837 1391 font-size: 13px;
838 1392 letter-spacing: 0.02em;
839 1393 }
840 1394
841 1395 /* Built-in clock widget */
842 -.desktop-mode-widget-clock {
1396 +.os-widget-clock {
843 1397 text-align: center;
844 1398 /* The time + date are passive glyphs, not content users would
845 1399 * ever reasonably select — turning it off avoids the accidental-
846 1400 * highlight mess that happens when a drag starts on the clock. */
@@ -847,9 +1401,9 @@
847 1401 user-select: none;
848 1402 -webkit-user-select: none;
849 1403 }
850 1404
851 -.desktop-mode-widget-clock__time {
1405 +.os-widget-clock__time {
852 1406 font-size: 34px;
853 1407 font-weight: 600;
854 1408 line-height: 1.1;
855 1409 font-variant-numeric: tabular-nums;
@@ -855,9 +1409,9 @@
855 1409 font-variant-numeric: tabular-nums;
856 1410 letter-spacing: 0.01em;
857 1411 }
858 1412
859 -.desktop-mode-widget-clock__date {
1413 +.os-widget-clock__date {
860 1414 margin-top: 4px;
861 1415 font-size: 13px;
862 1416 color: rgba( 255, 255, 255, 0.75 );
863 1417 letter-spacing: 0.02em;
@@ -868,9 +1422,9 @@
868 1422 * registered widget. Positioned fixed so it can extend outside
869 1423 * the desktop-area clip.
870 1424 * --------------------------------------------------------------- */
871 1425
872 -.desktop-mode-widget-picker {
1426 +.os-widget-picker {
873 1427 position: fixed;
874 1428 width: 320px;
875 1429 max-height: 60vh;
876 1430 overflow-y: auto;
@@ -880,14 +1434,14 @@
880 1434 backdrop-filter: blur( 22px ) saturate( 160% );
881 1435 -webkit-backdrop-filter: blur( 22px ) saturate( 160% );
882 1436 border: 1px solid rgba( 255, 255, 255, 0.1 );
883 1437 box-shadow: 0 18px 54px rgba( 0, 0, 0, 0.55 );
884 - color: #fff;
1438 + color: var( --os-ui-fg-on-accent, #fff );
885 1439 z-index: 9999;
886 - animation: desktop-mode-widget-picker-in 0.14s ease-out;
1440 + animation: os-widget-picker-in 0.14s ease-out;
887 1441 }
888 1442
889 -@keyframes desktop-mode-widget-picker-in {
1443 +@keyframes os-widget-picker-in {
890 1444 from {
891 1445 opacity: 0;
892 1446 transform: translateY( 4px );
893 1447 }
@@ -892,9 +1446,9 @@
892 1446 transform: translateY( 4px );
893 1447 }
894 1448 }
895 1449
896 -.desktop-mode-widget-picker__title {
1450 +.os-widget-picker__title {
897 1451 padding: 4px 8px 8px;
898 1452 font-size: 11px;
899 1453 font-weight: 600;
900 1454 letter-spacing: 0.08em;
@@ -901,15 +1455,15 @@
901 1455 text-transform: uppercase;
902 1456 color: rgba( 255, 255, 255, 0.55 );
903 1457 }
904 1458
905 -.desktop-mode-widget-picker__list {
1459 +.os-widget-picker__list {
906 1460 display: flex;
907 1461 flex-direction: column;
908 1462 gap: 4px;
909 1463 }
910 1464
911 -.desktop-mode-widget-picker__entry {
1465 +.os-widget-picker__entry {
912 1466 display: flex;
913 1467 align-items: center;
914 1468 gap: 12px;
915 1469 padding: 10px;
@@ -915,9 +1469,9 @@
915 1469 padding: 10px;
916 1470 background: transparent;
917 1471 border: 0;
918 1472 border-radius: 10px;
919 - color: #fff;
1473 + color: var( --os-ui-fg-on-accent, #fff );
920 1474 cursor: pointer;
921 1475 text-align: start;
922 1476 font: inherit;
923 1477 transition: background-color 0.12s ease;
@@ -922,23 +1476,23 @@
922 1476 font: inherit;
923 1477 transition: background-color 0.12s ease;
924 1478 }
925 1479
926 -.desktop-mode-widget-picker__entry:hover:not( :disabled ) {
1480 +.os-widget-picker__entry:hover:not( :disabled ) {
927 1481 background: rgba( 255, 255, 255, 0.08 );
928 1482 }
929 1483
930 -.desktop-mode-widget-picker__entry:focus-visible {
1484 +.os-widget-picker__entry:focus-visible {
931 1485 outline: 2px solid var( --wp-admin-theme-color, #2271b1 );
932 1486 outline-offset: 1px;
933 1487 }
934 1488
935 -.desktop-mode-widget-picker__entry--added {
1489 +.os-widget-picker__entry--added {
936 1490 opacity: 0.55;
937 1491 cursor: default;
938 1492 }
939 1493
940 -.desktop-mode-widget-picker__entry-icon {
1494 +.os-widget-picker__entry-icon {
941 1495 flex-shrink: 0;
942 1496 font-size: 20px;
943 1497 width: 20px;
944 1498 height: 20px;
@@ -944,9 +1498,9 @@
944 1498 height: 20px;
945 1499 line-height: 1;
946 1500 }
947 1501
948 -.desktop-mode-widget-picker__entry-text {
1502 +.os-widget-picker__entry-text {
949 1503 display: flex;
950 1504 flex-direction: column;
951 1505 gap: 2px;
952 1506 flex: 1;
@@ -952,9 +1506,9 @@
952 1506 flex: 1;
953 1507 min-width: 0;
954 1508 }
955 1509
956 -.desktop-mode-widget-picker__entry-label {
1510 +.os-widget-picker__entry-label {
957 1511 font-size: 13px;
958 1512 font-weight: 500;
959 1513 white-space: nowrap;
960 1514 overflow: hidden;
@@ -960,9 +1514,9 @@
960 1514 overflow: hidden;
961 1515 text-overflow: ellipsis;
962 1516 }
963 1517
964 -.desktop-mode-widget-picker__entry-description {
1518 +.os-widget-picker__entry-description {
965 1519 font-size: 11px;
966 1520 color: rgba( 255, 255, 255, 0.6 );
967 1521 line-height: 1.35;
968 1522 white-space: normal;
@@ -967,9 +1521,9 @@
967 1521 line-height: 1.35;
968 1522 white-space: normal;
969 1523 }
970 1524
971 -.desktop-mode-widget-picker__entry-status {
1525 +.os-widget-picker__entry-status {
972 1526 flex-shrink: 0;
973 1527 font-size: 11px;
974 1528 color: rgba( 255, 255, 255, 0.5 );
975 1529 text-transform: uppercase;
@@ -975,9 +1529,9 @@
975 1529 text-transform: uppercase;
976 1530 letter-spacing: 0.08em;
977 1531 }
978 1532
979 -.desktop-mode-widget-picker__empty {
1533 +.os-widget-picker__empty {
980 1534 padding: 12px 8px;
981 1535 font-size: 12px;
982 1536 color: rgba( 255, 255, 255, 0.6 );
983 1537 line-height: 1.4;
@@ -986,12 +1540,12 @@
986 1540 /**
987 1541 * Chromeless mode: pages loaded inside iframes.
988 1542 * Remove all shell chrome and let content fill the frame.
989 1543 */
990 -.desktop-mode-chromeless {
1544 +.os-chromeless {
991 1545 margin: 0;
992 1546 padding: 0;
993 - background: var(--desktop-mode-window-bg);
1547 + background: var(--os-window-bg);
994 1548 }
995 1549
996 1550 /*
997 1551 * Chromeless #wpbody-content, #adminmenuwrap, #wpfooter overrides are in
@@ -999,9 +1553,9 @@
999 1553 */
1000 1554
1001 1555 /*
1002 1556 * Hide WordPress core's built-in command palette (`@wordpress/commands`).
1003 - * Desktop mode force-enqueues `wp_enqueue_command_palette_assets()` so
1557 + * OpenStation force-enqueues `wp_enqueue_command_palette_assets()` so
1004 1558 * the `core/commands` data store is populated for our shell harvester
1005 1559 * (see `src/commands/shell-harvester.ts`); the side effect is that WP
1006 1560 * also mounts its own `<CommandPalette>` to the document body. We
1007 1561 * already block its Cmd+K handler in `src/palette-registry.ts`, but if
@@ -1012,5 +1566,504 @@
1012 1566 */
1013 1567 .commands-command-menu,
1014 1568 .commands-command-menu__overlay {
1015 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;
1016 2069 }