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