PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.13
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.13
1.1.13 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 All 37 releases
desktop-mode / assets / css / dock.css

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

1,489 lines 51.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * OpenStation — Dock.
3 *
4 * A rail that hosts admin menu tiles plus shell-level system tiles.
5 * Placement (left / right / bottom) is reflected on each dock element
6 * itself as `data-os-dock-placement`; this stylesheet keys
7 * off that attribute for layout, tooltip anchor, and indicator
8 * position. Two instances can coexist (Classic layout: a left side
9 * bar with core menus + a bottom dock with plugin menus); each
10 * carries its own placement attribute, so the two rails are styled
11 * independently without leaking selector state to one another.
12 *
13 * @since 6.9.0
14 */
15
16 /* ------------------------------------------------------------------
17 * Shared dock chrome — applies to every placement.
18 * ------------------------------------------------------------------ */
19
20 .os-dock {
21 display: flex;
22 align-items: center;
23 z-index: var( --os-z-dock );
24 background-color: var( --os-dock-bg );
25 /*
26 * Desktop-theme dock texture (DOCK slot). Layered OVER the dock
27 * background colour, so a theme can ship a semi-transparent
28 * texture and still get the translucent wash underneath. `none`
29 * when unset — no theme, no cost.
30 */
31 background-image: var( --os-dock-bg-image, none );
32 background-repeat: var( --os-dock-bg-image-repeat, repeat );
33 background-size: var( --os-dock-bg-image-size, auto );
34 background-position: var( --os-dock-bg-image-position, center );
35 backdrop-filter: blur( 20px );
36 -webkit-backdrop-filter: blur( 20px );
37 flex-shrink: 0;
38 /*
39 * overflow: visible so the per-tile "Open another" chip can float
40 * past the dock's outer edge. Real-world bottom-dock menus fit
41 * the viewport without a scrollbar; vertical placements route
42 * their overflow through the inner `__scroll` wrapper, leaving
43 * the dock itself non-scrolling.
44 */
45 overflow: visible;
46 }
47
48 /* A single dock tile — icon button (+ optional instance rail below). */
49 .os-dock__item {
50 position: relative;
51 display: flex;
52 flex-direction: column;
53 align-items: center;
54 width: var( --os-dock-item-size, 40px );
55 flex-shrink: 0;
56 }
57
58 /* Primary icon button — opens or focuses the page. */
59 .os-dock__item-primary {
60 position: relative;
61 display: flex;
62 align-items: center;
63 justify-content: center;
64 width: var( --os-dock-item-size, 40px );
65 height: var( --os-dock-item-size, 40px );
66 border: none;
67 border-radius: var( --os-dock-item-radius, 10px );
68 cursor: pointer;
69 padding: 0;
70 background-color: transparent;
71 /*
72 * DOCK_ITEM texture slot — the face of a single dock tile, so a
73 * theme can give every launcher a physical key/plate instead of
74 * relying on the dock strip's texture showing through. Painted
75 * under the icon; the hover wash below is a background-COLOR so
76 * it composes with the texture rather than erasing it.
77 */
78 background-image: var( --os-dock-item-image, none );
79 background-repeat: var( --os-dock-item-image-repeat, no-repeat );
80 background-size: var( --os-dock-item-image-size, auto );
81 background-position: var( --os-dock-item-image-position, center );
82 /*
83 * Dock glyph colour. The literal is what this rule always said;
84 * the token is the name a theme aims at when its dock strip is
85 * not dark. See the "Dock glyphs" block in variables.css.
86 */
87 color: var( --os-dock-icon-color, rgba( 255, 255, 255, 0.7 ) );
88 transition: background-color 0.15s ease, transform 0.15s ease, color 0.15s ease;
89 }
90
91 /*
92 * The tile whose window is in front sits on a lit plate. Declared
93 * before the hover rule at the same specificity so the pointer still
94 * wins while it is over the tile. Falls back to nothing, which is
95 * what every placement drew before the token existed.
96 */
97 .os-dock__item--focused .os-dock__item-primary {
98 background-color: var( --os-dock-item-bg-focused, transparent );
99 color: var( --os-dock-icon-color-hover, var( --os-ui-fg-on-accent, #fff ) );
100 }
101
102 /*
103 * DOCK_ITEM_FOCUSED texture slot — the face of the tile whose window is
104 * in front, and of any tile held down: a key that stays pressed while
105 * its window is up. Falls back to the resting face.
106 */
107 .os-dock__item--focused .os-dock__item-primary,
108 .os-dock__item-primary:active {
109 background-image: var(
110 --os-dock-item-image-focused,
111 var( --os-dock-item-image, none )
112 );
113 }
114
115 .os-dock__item-primary:hover {
116 background-color: var(
117 --os-dock-item-bg-hover,
118 rgba( 255, 255, 255, 0.15 )
119 );
120 color: var(
121 --os-dock-icon-color-hover,
122 var( --os-ui-fg-on-accent, #fff )
123 );
124 transform: var( --os-dock-item-hover-transform, scale( 1.1 ) );
125 }
126
127 .os-dock__item-primary:active {
128 transform: var( --os-dock-item-press-transform, scale( 0.95 ) );
129 }
130
131 .os-dock__item-primary:focus-visible {
132 outline: 2px solid var(
133 --os-dock-item-outline,
134 rgba( 255, 255, 255, 0.7 )
135 );
136 outline-offset: 2px;
137 }
138
139 /* Stacked card hint on hover — a second card peeks from behind the
140 icon when the tile has multiple open windows. Only visible on hover so
141 it never conflicts with focused tiles.
142 *
143 * Painted on the TILE, not on the button it hints at. The hover that
144 * arms this also scales the button, a transform makes a stacking
145 * context, and a `z-index: -1` child of one cannot escape it: the card
146 * landed above the button's own hover plate instead of behind the
147 * tile, reading as a second plate laid over the first. The tile is
148 * transformed by nothing, and a positioned child (the button) paints
149 * over a parent's `::before` at the same level, so the card sits where
150 * its name says without asking for a z-index at all. */
151 .os-dock__item--stacked:hover::before,
152 .os-dock__item--stacked[ data-peek-active ]::before {
153 content: "";
154 position: absolute;
155 top: -4px;
156 inset-inline-start: -4px;
157 width: 40px;
158 height: 40px;
159 border-radius: 10px;
160 background: rgba( 255, 255, 255, 0.06 );
161 border: 1px solid rgba( 255, 255, 255, 0.10 );
162 pointer-events: none;
163 }
164
165 /* Dashicon inside dock item. */
166 .os-dock__item .dashicons {
167 font-size: var( --os-dock-icon-size, 20px );
168 width: var( --os-dock-icon-size, 20px );
169 height: var( --os-dock-icon-size, 20px );
170 line-height: 1;
171 }
172
173 /* SVG icon (for custom post types with data:image/svg+xml icons).
174 *
175 * The FALLBACK path now: `_makeSvgIcon()` paints these as a mask
176 * filled with `currentColor` (see `__item-mask` below) and only lands
177 * here when the mask refuses the URL — one carrying quotes, spaces or
178 * parens. The filter is what that fallback still needs. */
179 .os-dock__item-svg {
180 width: var( --os-dock-icon-size, 20px );
181 height: var( --os-dock-icon-size, 20px );
182 display: block;
183 fill: currentColor;
184 opacity: 0.7;
185 /* Some plugin SVGs ship hardcoded `fill="..."` attributes inside
186 the markup, which override `fill: currentColor` and make the
187 icon keep its brand color. Force the monochrome white palette
188 to match dashicons. Flattens to WHITE specifically, which is why
189 the mask path is preferred: a filter has no colour to name, so a
190 theme cannot reach it. */
191 filter: brightness(0) invert(1);
192 transition: opacity 0.15s ease;
193 }
194
195 .os-dock__item-primary:hover .os-dock__item-svg {
196 opacity: 1;
197 }
198
199 /*
200 * MASKED icon — the glyph is painted as a CSS mask filled with a
201 * colour, so only the source artwork's alpha is used.
202 *
203 * Two callers, one shape:
204 *
205 * - A desktop theme's tinted iconset, filled with the colour the
206 * theme named for that slot.
207 * - Every other plugin / CPT image icon, filled with
208 * `currentColor` — which is `--os-dock-icon-color`, the
209 * same token the dashicons follow.
210 *
211 * Deliberately NOT `__item-svg`: that class force-whitens its content
212 * with `filter: brightness(0) invert(1)`. Both mechanisms discard the
213 * artwork's colours and keep its alpha, but only one of them has a
214 * colour a theme can name.
215 *
216 * Full opacity, unlike `__item-svg`'s 0.7: the fill carries its own
217 * alpha. Unthemed that is `rgba( 255, 255, 255, 0.7 )` at rest and
218 * `#fff` on hover — exactly what the filter plus the opacity pair
219 * used to compute — and a theme that names a colour has already
220 * decided how prominent its glyphs should be, so dimming it again
221 * would silently mix it with the dock.
222 *
223 * @since 0.9.8
224 */
225 .os-dock__item-mask {
226 width: var( --os-dock-icon-size, 20px );
227 height: var( --os-dock-icon-size, 20px );
228 display: block;
229 flex-shrink: 0;
230 }
231
232 /*
233 * Letter-badge icon — rendered for items registered without a
234 * dashicon / SVG / image URL. Miniature "app placeholder": rounded
235 * square tinted with a title-derived hue, first letter centered.
236 */
237 .os-dock__item-letter {
238 display: flex;
239 align-items: center;
240 justify-content: center;
241 width: 28px;
242 height: 28px;
243 border-radius: 8px;
244 color: var( --os-ui-fg-on-accent, #fff );
245 font-size: 15px;
246 font-weight: 700;
247 font-family: var(
248 --os-font,
249 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif
250 );
251 line-height: 1;
252 letter-spacing: 0;
253 text-shadow: 0 1px 1px rgba( 0, 0, 0, 0.22 );
254 box-shadow:
255 inset 0 1px 0 rgba( 255, 255, 255, 0.2 ),
256 inset 0 -1px 0 rgba( 0, 0, 0, 0.15 );
257 }
258
259 .os-dock__item img.os-dock__item-img {
260 width: var( --os-dock-icon-size, 20px );
261 height: var( --os-dock-icon-size, 20px );
262 display: block;
263 opacity: 0.7;
264 transition: opacity 0.15s ease;
265 }
266
267 .os-dock__item-primary:hover img.os-dock__item-img {
268 opacity: 1;
269 }
270
271 /* "Open another" floating chip — shown on multi-capable tiles that
272 * already have ≥1 open instance. Positioned inside each orientation
273 * block so the chip hugs the outward edge of the tile. */
274 .os-dock__item-new {
275 position: absolute;
276 display: flex;
277 align-items: center;
278 justify-content: center;
279 width: 20px;
280 height: 20px;
281 padding: 0;
282 border: 2px solid var( --os-dock-bg, rgba( 0, 0, 0, 0.4 ) );
283 border-radius: 50%;
284 background: var( --wp-admin-theme-color, #2271b1 );
285 color: var( --os-ui-accent-ink, var( --os-ui-fg-on-accent, #fff ) );
286 cursor: pointer;
287 transition: background-color 0.15s ease, transform 0.15s ease;
288 z-index: 1;
289 }
290
291 .os-dock__item-new[hidden] {
292 display: none;
293 }
294
295 .os-dock__item-new:hover {
296 background: var( --os-ui-surface, #fff );
297 color: var( --wp-admin-theme-color, #2271b1 );
298 }
299
300 .os-dock__item-new:focus-visible {
301 outline: 2px solid rgba( 255, 255, 255, 0.85 );
302 outline-offset: 2px;
303 }
304
305 /* Notification badge — iOS/macOS-style pill straddling the tile
306 * corner. Ring matches the dock bg so it reads as "punched out."
307 * Badge positioning is placement-aware via the blocks below. */
308 .os-dock__badge {
309 /*
310 * Badge geometry DERIVES from the dock's icon size instead of
311 * being pinned to 16px.
312 *
313 * The dock is user-sizable (18 / 20 / 26px icons via OS Settings →
314 * dock size). A fixed badge tracked none of that: at "Large" it
315 * read as a speck stuck to an oversized tile. The ratios below are
316 * chosen so the DEFAULT dock (20px icons) resolves to exactly the
317 * old numbers — 16px box, 10px text, ~5px padding — so nothing
318 * moves for anyone who hasn't changed the setting.
319 *
320 * `--os-dock-badge-size` overrides the whole derivation
321 * for themes that want a specific badge; the font size and padding
322 * then follow it, so one token is usually enough.
323 */
324 --os-dock-badge-computed-size: var(
325 --os-dock-badge-size,
326 calc( var( --os-dock-icon-size, 20px ) * 0.8 )
327 );
328 position: absolute;
329 min-width: var( --os-dock-badge-computed-size );
330 height: var( --os-dock-badge-computed-size );
331 padding: 0 var(
332 --os-dock-badge-padding,
333 calc( var( --os-dock-badge-computed-size ) * 0.3 )
334 );
335 box-sizing: border-box;
336 border-radius: 999px;
337 background: var(
338 --os-dock-badge-bg,
339 linear-gradient( 180deg, #ff5a5a 0%, var( --os-ui-danger, #d63638 ) 100% )
340 );
341 color: var( --os-dock-badge-fg, var( --os-ui-fg-on-accent, #fff ) );
342 display: inline-flex;
343 align-items: center;
344 justify-content: center;
345 /*
346 * 0.625 of the badge box — 10px on the default 16px badge, the
347 * value this rule carried before the geometry was derived. It is
348 * the right weight for a menu tile's alert count: these badges sit
349 * on the tile CORNER where the pill itself carries the signal, so
350 * the digit only has to confirm it.
351 *
352 * The Recycle Bin's badge is a different problem and has its own
353 * ratio below — it sits INSIDE the bin glyph, where the pill reads
354 * as part of the artwork and the number has to carry itself.
355 */
356 font-size: var(
357 --os-dock-badge-font-size,
358 calc( var( --os-dock-badge-computed-size ) * 0.625 )
359 );
360 font-weight: var( --os-dock-badge-font-weight, 700 );
361 line-height: 1;
362 letter-spacing: 0.01em;
363 text-align: center;
364 font-variant-numeric: tabular-nums;
365 box-shadow:
366 0 0 0 1.5px var( --os-dock-bg, rgba( 0, 0, 0, 0.4 ) ),
367 0 1px 3px rgba( 0, 0, 0, 0.35 );
368 pointer-events: none;
369 transition: transform 0.15s ease;
370 top: var( --os-dock-badge-offset, -3px );
371 inset-inline-end: var( --os-dock-badge-offset, -3px );
372 }
373
374 .os-dock__item:hover .os-dock__badge {
375 transform: scale( 1.05 );
376 top: -4px;
377 }
378
379 /*
380 * Recycle Bin count — unlike unread/update badges, the trash count
381 * is ambient state. Keep it inside the bin glyph as a quiet neutral
382 * marker instead of a red alert on the tile corner.
383 */
384 .os-dock__item[ data-system-id="desktop-mode-recycle-bin" ] .os-dock__badge {
385 /*
386 * Sized off the dock icon like every other badge, so it tracks the
387 * dock-size setting instead of sitting at a fixed 13px on an icon
388 * that may be 18, 20 or 26px.
389 *
390 * Deliberately quieter than the corner alert badge above — this is
391 * ambient state, not a notification — but it still has to be a
392 * READABLE number. It was 13px with an 8px digit, which is below
393 * the point where a count reads as a count.
394 *
395 * Override chain: bin-specific token → the generic dock-badge
396 * token → the derivation, so a theme that says "dock badges are
397 * 20px" reaches the bin without naming it.
398 */
399 --os-dock-recycle-badge-computed-size: var(
400 --os-dock-recycle-badge-size,
401 var(
402 --os-dock-badge-size,
403 calc( var( --os-dock-icon-size, 20px ) * 0.75 )
404 )
405 );
406 top: calc( var( --os-dock-icon-size, 20px ) * 1 );
407 inset-inline-end: calc( var( --os-dock-icon-size, 20px ) * 0.35 );
408 min-width: var( --os-dock-recycle-badge-computed-size );
409 height: var( --os-dock-recycle-badge-computed-size );
410 padding: 0 calc( var( --os-dock-recycle-badge-computed-size ) * 0.25 );
411 border-radius: 999px;
412 background: var(
413 --os-dock-recycle-badge-bg,
414 rgba( 29, 35, 39, 0.72 )
415 );
416 color: var(
417 --os-dock-recycle-badge-fg,
418 rgba( 255, 255, 255, 0.92 )
419 );
420 font-size: var(
421 --os-dock-recycle-badge-font-size,
422 var(
423 --os-dock-badge-font-size,
424 calc( var( --os-dock-recycle-badge-computed-size ) * 0.8 )
425 )
426 );
427 font-weight: 700;
428 letter-spacing: 0;
429 box-shadow:
430 inset 0 0 0 1px rgba( 255, 255, 255, 0.26 ),
431 0 1px 2px rgba( 0, 0, 0, 0.24 );
432 }
433
434 .os-dock__item[ data-system-id="desktop-mode-recycle-bin" ]:hover .os-dock__badge {
435 /* Re-assert the derived offset, not a literal: the base rule above
436 * is overridden by the generic `__item:hover .__badge` rule, and
437 * hardcoding 20px here would strand the badge the moment the dock
438 * icon size changed. */
439 top: calc( var( --os-dock-icon-size, 20px ) * 1 );
440 transform: scale( 1.03 );
441 }
442
443 /* System tiles — same footprint as menu tiles, no badges, no rails.
444 *
445 * Reads the SAME glyph token as a menu tile, with its own literal as
446 * the fallback: unthemed, system tiles keep sitting a notch brighter
447 * than the rest (0.8 vs 0.7); themed, one colour covers every glyph
448 * in the dock rather than leaving these four stranded white. */
449 .os-dock__item--system .os-dock__item-primary {
450 color: var( --os-dock-icon-color, rgba( 255, 255, 255, 0.8 ) );
451 }
452
453 /* -----------------------------------------------------------------------------
454 * Attention animations — driven by `Dock.setAttention()` /
455 * `Window.requestAttention()`. The class lands on the tile root,
456 * the animation runs on the primary icon button so the badge stays
457 * stable. Three modes (pulse / shake / bounce) and three intensities
458 * (subtle / normal / strong). All animations gated on
459 * `prefers-reduced-motion: no-preference`; the reduced-motion fallback
460 * shows a static accent ring for the same duration so the affordance
461 * still works.
462 * ------------------------------------------------------------------------- */
463
464 @keyframes os-dock-attention-pulse {
465 0%, 100% {
466 transform: scale( 1 );
467 box-shadow: 0 0 0 0 rgba( 255, 90, 90, 0.55 );
468 }
469 50% {
470 transform: scale( 1.06 );
471 box-shadow: 0 0 0 8px rgba( 255, 90, 90, 0 );
472 }
473 }
474
475 @keyframes os-dock-attention-shake {
476 0%, 100% {
477 transform: translateX( 0 );
478 }
479 20%, 60% {
480 transform: translateX( -3px );
481 }
482 40%, 80% {
483 transform: translateX( 3px );
484 }
485 }
486
487 @keyframes os-dock-attention-bounce {
488 0%, 100% {
489 transform: translateY( 0 );
490 }
491 30% {
492 transform: translateY( -6px );
493 }
494 60% {
495 transform: translateY( -2px );
496 }
497 }
498
499 .os-dock__item--attention-pulse .os-dock__item-primary {
500 animation: os-dock-attention-pulse 1.4s ease-in-out infinite;
501 }
502 .os-dock__item--attention-shake .os-dock__item-primary {
503 animation: os-dock-attention-shake 0.8s ease-in-out infinite;
504 }
505 .os-dock__item--attention-bounce .os-dock__item-primary {
506 animation: os-dock-attention-bounce 1.2s ease-in-out infinite;
507 }
508
509 /* Intensity modulates animation duration — strong = faster, more
510 urgent; subtle = slower, calmer. */
511 .os-dock__item--intensity-strong.os-dock__item--attention-pulse .os-dock__item-primary {
512 animation-duration: 1s;
513 }
514 .os-dock__item--intensity-strong.os-dock__item--attention-shake .os-dock__item-primary {
515 animation-duration: 0.6s;
516 }
517 .os-dock__item--intensity-strong.os-dock__item--attention-bounce .os-dock__item-primary {
518 animation-duration: 0.9s;
519 }
520 .os-dock__item--intensity-subtle.os-dock__item--attention-pulse .os-dock__item-primary {
521 animation-duration: 1.8s;
522 }
523 .os-dock__item--intensity-subtle.os-dock__item--attention-shake .os-dock__item-primary {
524 animation-duration: 1s;
525 }
526 .os-dock__item--intensity-subtle.os-dock__item--attention-bounce .os-dock__item-primary {
527 animation-duration: 1.6s;
528 }
529
530 /* Reduced-motion: kill the animation, replace with a static accent ring
531 for the same visual prominence without the movement. */
532 @media ( prefers-reduced-motion: reduce ) {
533 .os-dock__item--attention-pulse .os-dock__item-primary,
534 .os-dock__item--attention-shake .os-dock__item-primary,
535 .os-dock__item--attention-bounce .os-dock__item-primary {
536 animation: none;
537 outline: 2px solid var( --wp-admin-theme-color, #2271b1 );
538 outline-offset: 2px;
539 border-radius: 8px;
540 }
541 }
542
543 /* Shared tooltip — placement-specific anchor modifiers applied by JS. */
544 .os-dock__tooltip {
545 position: fixed;
546 padding: var( --os-tooltip-padding, 6px 12px );
547 background: var(
548 --os-tooltip-bg,
549 var( --os-ui-scrim, rgba( 0, 0, 0, 0.85 ) )
550 );
551 color: var(
552 --os-tooltip-fg,
553 var( --os-ui-fg-on-accent, #fff )
554 );
555 font-size: var( --os-tooltip-font-size, 12px );
556 font-weight: 400;
557 line-height: 1.4;
558 white-space: nowrap;
559 border: var( --os-tooltip-border, 0 );
560 border-radius: var( --os-tooltip-radius, 6px );
561 box-shadow: var( --os-tooltip-shadow, none );
562 pointer-events: none;
563 z-index: calc( var( --os-z-dock ) + 1 );
564 opacity: 0;
565 transition: opacity 0.15s ease, transform 0.15s ease;
566 }
567
568 .os-dock__tooltip--visible {
569 opacity: 1;
570 }
571
572 /* ------------------------------------------------------------------
573 * Vertical placement (left + right) — dock runs the full shell body
574 * height along one edge, with items stacked vertically.
575 * ------------------------------------------------------------------ */
576
577 .os-dock[ data-os-dock-placement="left" ],
578 .os-dock[ data-os-dock-placement="right" ] {
579 width: var( --os-dock-width );
580 flex-direction: column;
581 padding: 16px 0 12px;
582 gap: 6px;
583 /*
584 * Vertical docks split into two inner wrappers:
585 * - `__scroll` — flex: 1, scrollable, hosts menu tiles + the
586 * inline core/plugin separator. When the menu
587 * exceeds the dock's height, this is what
588 * scrolls; the outer dock stays a fixed pillar.
589 * - `__pinned` — sized to content, hosts system tiles
590 * (Recycle Bin, OS Settings, …) + their hairline
591 * separator. Always visible at the bottom edge
592 * regardless of scroll position.
593 *
594 * `min-height: 0` on the dock is defensive: even though `__scroll`
595 * absorbs the overflow, the dock is a flex item inside the shell
596 * body (a flex row), and the default `min-height: auto` would let
597 * a tall `__pinned` push the dock past the parent's height.
598 */
599 min-height: 0;
600 }
601
602 /*
603 * Scrollable menu-tile area inside vertical docks. `flex: 1 1 0` plus
604 * `min-height: 0` lets it shrink below its content's natural height so
605 * `overflow-y: auto` actually engages. `overflow-x: clip` keeps the
606 * active-dot / focused-pill indicators visible (they sit at x≈2px
607 * inside the wrapper's content box) while preventing `overflow-y: auto`
608 * from coercing horizontal scroll. Scrollbar is hidden visually —
609 * Firefox via `scrollbar-width: none`, WebKit via the pseudo-element
610 * rule below — so the rail keeps its clean macOS look. Mouse wheel,
611 * touchpad, touch, and keyboard focus-into-view all still work.
612 */
613 .os-dock[ data-os-dock-placement="left" ] .os-dock__scroll,
614 .os-dock[ data-os-dock-placement="right" ] .os-dock__scroll {
615 display: flex;
616 flex-direction: column;
617 align-items: center;
618 gap: 6px;
619 flex: 1 1 0;
620 min-height: 0;
621 width: 100%;
622 overflow-y: auto;
623 overflow-x: clip;
624 scrollbar-width: none;
625 }
626
627 .os-dock[ data-os-dock-placement="left" ] .os-dock__scroll::-webkit-scrollbar,
628 .os-dock[ data-os-dock-placement="right" ] .os-dock__scroll::-webkit-scrollbar {
629 display: none;
630 }
631
632 /*
633 * Pinned area below `__scroll` — system tiles stay reachable here
634 * regardless of how the menu list scrolls. `flex-shrink: 0` so it's
635 * never compressed; sized to its content's natural height.
636 */
637 .os-dock[ data-os-dock-placement="left" ] .os-dock__pinned,
638 .os-dock[ data-os-dock-placement="right" ] .os-dock__pinned {
639 display: flex;
640 flex-direction: column;
641 align-items: center;
642 gap: 6px;
643 flex-shrink: 0;
644 width: 100%;
645 }
646
647 /* Left: dock is the first flex item in __body (row layout). */
648 .os-dock[ data-os-dock-placement="left" ] {
649 border-inline-end: 1px solid var( --os-dock-border );
650 order: -1;
651 }
652
653 /* Right: push the dock to the end of __body and flip the border edge. */
654 .os-dock[ data-os-dock-placement="right" ] {
655 border-inline-start: 1px solid var( --os-dock-border );
656 order: 1;
657 }
658
659 /* Active-indicator dot — left dock: inside-edge vertical pill. */
660 .os-dock[ data-os-dock-placement="left" ]
661 .os-dock__item--active::before {
662 content: "";
663 position: absolute;
664 inset-inline-start: -6px;
665 top: 50%;
666 transform: translateY( -50% );
667 width: 4px;
668 height: 4px;
669 border-radius: 50%;
670 background: var( --os-dock-active-indicator, var( --os-dock-item-outline, #fff ) );
671 }
672
673 .os-dock[ data-os-dock-placement="left" ]
674 .os-dock__item--focused::before {
675 width: 4px;
676 height: 16px;
677 border-radius: 2px;
678 }
679
680 /*
681 * "All instances minimized" — the tile still has windows, they're
682 * just hidden. Swap the solid dot for a hollow ring so the user sees
683 * "I have something here, currently tucked away" instead of "I have
684 * a focused / visible window here." `--all-minimized` is layered on
685 * top of `--active`; the rule below wins on selector specificity.
686 *
687 * Sized slightly larger than the solid dot so the ring is legible at
688 * dock size — a 4×4 outline reads as a dirty pixel.
689 */
690 .os-dock[ data-os-dock-placement="left" ]
691 .os-dock__item--all-minimized::before {
692 width: 6px;
693 height: 6px;
694 border-radius: 50%;
695 background: transparent;
696 border: 1px solid var( --os-dock-active-indicator, var( --os-dock-item-outline, rgba( 255, 255, 255, 0.85 ) ) );
697 }
698
699 /* Right dock: indicator on the opposite edge (inside-facing). */
700 .os-dock[ data-os-dock-placement="right" ]
701 .os-dock__item--active::before {
702 content: "";
703 position: absolute;
704 inset-inline-end: -6px;
705 top: 50%;
706 transform: translateY( -50% );
707 width: 4px;
708 height: 4px;
709 border-radius: 50%;
710 background: var( --os-dock-active-indicator, var( --os-dock-item-outline, #fff ) );
711 }
712
713 .os-dock[ data-os-dock-placement="right" ]
714 .os-dock__item--focused::before {
715 width: 4px;
716 height: 16px;
717 border-radius: 2px;
718 }
719
720 .os-dock[ data-os-dock-placement="right" ]
721 .os-dock__item--all-minimized::before {
722 width: 6px;
723 height: 6px;
724 border-radius: 50%;
725 background: transparent;
726 border: 1px solid var( --os-dock-active-indicator, var( --os-dock-item-outline, rgba( 255, 255, 255, 0.85 ) ) );
727 }
728
729 /* "Open another" chip — left: right of tile; right: left of tile. */
730 .os-dock[ data-os-dock-placement="left" ]
731 .os-dock__item-new {
732 top: 50%;
733 inset-inline-end: -13px;
734 transform: translateY( -50% );
735 }
736
737 .os-dock[ data-os-dock-placement="left" ]
738 .os-dock__item-new:hover {
739 transform: translateY( -50% ) scale( 1.1 );
740 }
741
742 .os-dock[ data-os-dock-placement="right" ]
743 .os-dock__item-new {
744 top: 50%;
745 inset-inline-start: -13px;
746 transform: translateY( -50% );
747 }
748
749 .os-dock[ data-os-dock-placement="right" ]
750 .os-dock__item-new:hover {
751 transform: translateY( -50% ) scale( 1.1 );
752 }
753
754 /* Separator — the line between two groups of tiles. With the
755 * `__scroll` / `__pinned` split the system separator lives at the top
756 * of `__pinned`; flex-flow handles the "system tiles at the bottom"
757 * placement, no margin-top: auto needed. The `--group` variant is used
758 * inline between core and plugin menu tiles in `__scroll` and sits
759 * exactly where it's inserted.
760 *
761 * ONE treatment for every division in the rail. The selector carries
762 * only the base class on purpose: `--group` elements carry it too, so
763 * both boundaries resolve here and cannot drift apart. Two weights in
764 * one short rail read as two unrelated ideas rather than one system,
765 * which is why the earlier loud-plus-hairline pairing is gone.
766 *
767 * A gradient, not a flat fill: the line is brightest where the eye
768 * lands and gone by the time it reaches the rail's padding, so it
769 * never terminates in a visible stub against the glass. The soft glow
770 * around it is what keeps a 2px line from disappearing into the dock
771 * tint; nothing is drawn on the line itself. */
772 .os-dock[ data-os-dock-placement="left" ]
773 .os-dock__separator,
774 .os-dock[ data-os-dock-placement="right" ]
775 .os-dock__separator {
776 position: relative;
777 width: 60%;
778 height: 2px;
779 margin: 12px auto;
780 border-radius: 2px;
781 background: linear-gradient(
782 to right,
783 transparent 0%,
784 var( --os-dock-divider, rgba( 217, 46, 227, 0.7 ) ) 28%,
785 var( --os-dock-divider, rgba( 217, 46, 227, 0.7 ) ) 72%,
786 transparent 100%
787 );
788 box-shadow: 0 0 10px
789 color-mix(
790 in srgb,
791 var( --os-dock-divider, rgba( 217, 46, 227, 0.7 ) ) 45%,
792 transparent
793 );
794 flex-shrink: 0;
795 }
796
797 /*
798 * Hide the system separator when nothing precedes it — on a clean
799 * install (or a low-cap user with zero menu items) the separator
800 * would render as a stray hairline with nothing to divide.
801 *
802 * Two cases:
803 * 1. Bottom dock — separator is a direct child of the dock; hide
804 * when it's the first such child.
805 * 2. Vertical docks — separator lives inside `__pinned`; hide when
806 * `__scroll` (its sibling above) is empty of menu tiles.
807 */
808 .os-dock > .os-dock__separator:first-child {
809 display: none;
810 }
811
812 .os-dock__scroll:not(:has( .os-dock__item ))
813 + .os-dock__pinned
814 .os-dock__separator {
815 display: none;
816 }
817
818
819 /*
820 * Drop an empty `__scroll` from the flex flow entirely. When only
821 * system tiles are present (e.g. just the Recycle Bin), the empty
822 * wrapper would otherwise still occupy the rail: its `flex-grow`
823 * stretches it — shoving the lone `__pinned` tile to the far edge on
824 * vertical docks — and the inter-wrapper `gap` leaves a phantom
825 * offset that nudges the tile off-center on the bottom dock. Removing
826 * it lets the dock center the lone system tile. As soon as a menu
827 * tile is added the `:has()` no longer matches and normal flow
828 * resumes.
829 *
830 * The `[data-os-dock-placement]` qualifier is load-bearing:
831 * the per-placement rules that set `display: flex` on `__scroll` are
832 * (0,3,0); this selector must out-specify them, so it carries the
833 * placement attribute to reach (0,4,0).
834 */
835 .os-dock[ data-os-dock-placement ]
836 .os-dock__scroll:not(:has( .os-dock__item )) {
837 display: none;
838 }
839
840 /* Tooltip — anchor modifiers are applied directly to the tooltip
841 * element (NOT via a descendant selector on `.os-dock`)
842 * because the tooltip lives in `document.body`. JS writes the
843 * absolute `top` / `left` coords; CSS handles the slide-in
844 * animation direction per modifier.
845 *
846 * - `--after` : left-placed dock (tooltip to the RIGHT of tile)
847 * - `--before` : right-placed dock (tooltip to the LEFT of tile)
848 * - `--above` : bottom-placed dock (tooltip ABOVE the tile)
849 */
850
851 /* Left dock — tooltip slides in from the left by 4px. JS sets
852 * `left` to `tile.right + 8px`; the transform animates the entry. */
853 .os-dock__tooltip--after {
854 transform: translateX( -4px );
855 }
856
857 .os-dock__tooltip--after.os-dock__tooltip--visible {
858 transform: translateX( 0 );
859 }
860
861 .os-dock__tooltip--before {
862 /* Right-placed dock — tooltip sits to the left of the tile. JS
863 * writes the left coord to the tile's left edge; this translate
864 * pulls the tooltip further left by its own width. */
865 transform: translate( calc( -100% - 8px ), 0 );
866 }
867
868 .os-dock__tooltip--before.os-dock__tooltip--visible {
869 transform: translate( calc( -100% - 8px ), 0 );
870 opacity: 1;
871 }
872
873 /* ------------------------------------------------------------------
874 * Bottom placement — floating macOS-style pill centered above the
875 * viewport floor. Absolutely positioned so it floats as pure chrome
876 * rather than stealing vertical space from the shell body.
877 * ------------------------------------------------------------------ */
878
879 /*
880 * The pill's geometry is tokenized so a theme can draw a different
881 * object along the same edge: a full-width bar flush with the floor
882 * is `width: 100%`, `bottom: 0`, radius 0. The work area measures the
883 * rail's real box, so windows keep clear of whatever shape it takes.
884 */
885 .os-dock[ data-os-dock-placement="bottom" ] {
886 position: absolute;
887 inset-inline-start: 0;
888 inset-inline-end: 0;
889 bottom: var( --os-dock-floating-offset, 12px );
890 margin: 0 auto;
891 width: var( --os-dock-floating-width, fit-content );
892 max-width: var( --os-dock-floating-max-width, calc( 100% - 32px ) );
893 flex-direction: row;
894 justify-content: center;
895 /* Top padding is 4px instead of 8px so that the inner `__scroll`
896 * wrapper can claim its own 4px padding-top for badge clearance
897 * (the tile's badge sits at top: -3px). Net vertical chrome stays
898 * symmetric: 4px pill padding + 4px wrapper padding above the
899 * tile, 8px pill padding below — tile is 8px from both edges. */
900 padding: var( --os-dock-floating-padding, 4px 12px 8px );
901 gap: var( --os-dock-floating-gap, 6px );
902 border-radius: var( --os-dock-floating-radius, 18px );
903
904 /* Warmer, more saturated glass tint than vertical placements so
905 * the floating pill reads as a distinct piece of chrome rather
906 * than a section of the sidebar.
907 *
908 * background-COLOR, not the shorthand: the base `.os-dock`
909 * rule declares the DOCK texture slot's background-image, and a
910 * shorthand here would reset it — a themed dock texture would
911 * simply never appear in the bottom placement.
912 *
913 * The tint is its own token rather than `--os-dock-bg`
914 * precisely because the two are meant to differ; a theme that
915 * wants them identical points this at the same value. */
916 background-color: var(
917 --os-dock-floating-bg,
918 rgba( 22, 22, 26, 0.42 )
919 );
920 backdrop-filter: var( --os-dock-floating-backdrop, blur( 28px ) saturate( 170% ) );
921 -webkit-backdrop-filter: var( --os-dock-floating-backdrop, blur( 28px ) saturate( 170% ) );
922
923 /*
924 * Hairline + inset highlight. These used to be hardcoded, which
925 * meant a theme could restyle every dock placement EXCEPT this
926 * one and had no way to remove the outline it saw floating over
927 * an empty desktop.
928 *
929 * The chain matters: the floating-specific token wins, then the
930 * general `--os-dock-border` (so a theme that tokenized
931 * its side docks gets the pill for free, which is what anyone
932 * setting that token expects), then the literal. Set either to
933 * `transparent` for a borderless pill.
934 */
935 border: 1px solid var(
936 --os-dock-floating-border,
937 var( --os-dock-border, rgba( 255, 255, 255, 0.12 ) )
938 );
939 border-block-start: 1px solid var(
940 --os-dock-floating-border-top,
941 var(
942 --os-dock-floating-border,
943 var( --os-dock-border, rgba( 255, 255, 255, 0.18 ) )
944 )
945 );
946 box-shadow:
947 inset 0 1px 0 var(
948 --os-dock-floating-highlight,
949 rgba( 255, 255, 255, 0.08 )
950 ),
951 0 10px 32px var(
952 --os-dock-floating-shadow,
953 rgba( 0, 0, 0, 0.45 )
954 );
955 }
956
957 /* Active-indicator dot — below the tile, macOS-style. */
958 .os-dock[ data-os-dock-placement="bottom" ]
959 .os-dock__item--active::before {
960 content: "";
961 position: absolute;
962 inset-inline-start: 50%;
963 top: auto;
964 bottom: -3px;
965 transform: translateX( -50% );
966 width: 4px;
967 height: 4px;
968 border-radius: 50%;
969 background: var( --os-dock-active-indicator, var( --os-dock-item-outline, #fff ) );
970 }
971
972 .os-dock[ data-os-dock-placement="bottom" ]
973 .os-dock__item--focused::before {
974 width: 16px;
975 height: 4px;
976 border-radius: 2px;
977 }
978
979 /*
980 * "All instances minimized" on the bottom dock — solid dot becomes
981 * a hollow ring. Matches the vertical-dock treatment so the cue is
982 * consistent across placements.
983 */
984 .os-dock[ data-os-dock-placement="bottom" ]
985 .os-dock__item--all-minimized::before {
986 width: 6px;
987 height: 6px;
988 border-radius: 50%;
989 background: transparent;
990 border: 1px solid var( --os-dock-active-indicator, var( --os-dock-item-outline, rgba( 255, 255, 255, 0.85 ) ) );
991 }
992
993 /*
994 * "Show Desktop" — every live window on the active desktop minimized.
995 *
996 * The dock does not change when this happens. It used to: the bottom
997 * pill and the vertical rails each picked up a 1px inset ring, on the
998 * reasoning that an empty wallpaper otherwise looks identical to a
999 * desktop that never had anything on it.
1000 *
1001 * The reasoning was sound and the ring was still the wrong answer. It
1002 * outlined the whole dock — the one surface on screen that is always
1003 * present and never the subject — to say something about windows that
1004 * are not on the dock at all. Every tile that has minimized windows
1005 * already says so, precisely and locally, by swapping its solid
1006 * indicator dot for a hollow ring. That is the cue: it points at the
1007 * tiles the windows are under, in the place the user will click to get
1008 * them back.
1009 *
1010 * `body.os-show-desktop-active` is still set (see `src/dock.ts`) and
1011 * still public, so a theme or plugin that wants a global cue can paint
1012 * one. Core does not.
1013 */
1014
1015 /* "Open another" chip — floats above the tile's top-right corner so
1016 * it doesn't collide with neighbouring tiles in the horizontal row. */
1017 .os-dock[ data-os-dock-placement="bottom" ]
1018 .os-dock__item-new {
1019 top: -6px;
1020 inset-inline-end: -6px;
1021 transform: none;
1022 }
1023
1024 .os-dock[ data-os-dock-placement="bottom" ]
1025 .os-dock__item-new:hover {
1026 transform: scale( 1.1 );
1027 }
1028
1029 /*
1030 * Bottom-dock inner wrappers — `__scroll` carries the menu tiles and
1031 * absorbs horizontal overflow; `__pinned` carries the system tiles and
1032 * stays anchored to the trailing edge.
1033 *
1034 * **The padding around `__scroll` is what keeps badges visible, and it
1035 * has to be on both axes.** `overflow-x: auto` below cannot coexist
1036 * with `overflow-y: visible`: per spec the visible axis computes to
1037 * `auto`, so this wrapper is a scroll container in BOTH directions
1038 * however it is authored, and anything a child hangs outside its
1039 * padding box is clipped. A badge sits at `top: -3px; right: -3px` on
1040 * its tile, and the LAST tile's edge is the wrapper's edge once the
1041 * content is wide enough to scroll — which is why this showed up as
1042 * "the badge on the last plugin looks cut off" rather than as a
1043 * general problem. The inline padding is symmetric so the tile cluster
1044 * stays centred in the pill. `min-width: 0` lets the wrapper shrink below its
1045 * content's natural width, so the dock pill's `max-width` is what
1046 * decides when scroll kicks in instead of the content forcing the
1047 * pill wider. `justify-content: safe center` keeps tiles balanced inside
1048 * the pill when content fits; when it overflows, the `safe` keyword
1049 * ensures alignment falls back to `start` so `scrollLeft = 0` reveals the
1050 * first item and all items remain scrollable without clipping. Bare
1051 * `center` put the leading tiles at a negative offset, and a scroll
1052 * container clamps `scrollLeft` at 0, so Dashboard and its neighbours
1053 * were clipped with no way to scroll back to them. **The plain `start`
1054 * above it is the fallback, not a leftover** — a browser that cannot
1055 * parse the overflow-alignment keyword drops the `safe center`
1056 * declaration and keeps that one, which left-aligns the tiles rather
1057 * than hiding them. Deleting it restores the bug on those browsers.
1058 */
1059 .os-dock[ data-os-dock-placement="bottom" ] .os-dock__scroll {
1060 display: flex;
1061 flex-direction: row;
1062 align-items: center;
1063 justify-content: start;
1064 justify-content: var( --os-dock-floating-justify, safe center );
1065 gap: var( --os-dock-floating-gap, 6px );
1066 flex: 1 1 auto;
1067 min-width: 0;
1068 padding: var( --os-dock-floating-track-padding, 4px 4px 6px );
1069 overflow-x: auto;
1070 /*
1071 * Authored `visible`, computed `auto` — see the note above. Kept as
1072 * the honest statement of intent: nothing here wants a vertical
1073 * scrollbar, and `scrollbar-width: none` plus the padding is what
1074 * makes that true in practice.
1075 */
1076 overflow-y: visible;
1077 scrollbar-width: none;
1078 }
1079
1080 .os-dock[ data-os-dock-placement="bottom" ] .os-dock__scroll::-webkit-scrollbar {
1081 display: none;
1082 }
1083
1084 .os-dock[ data-os-dock-placement="bottom" ] .os-dock__pinned {
1085 display: flex;
1086 flex-direction: row;
1087 align-items: center;
1088 gap: var( --os-dock-floating-gap, 6px );
1089 flex-shrink: 0;
1090 /* Match the scroll wrapper's badge clearance so system tiles sit
1091 * on the same vertical baseline as the menu tiles. */
1092 padding: var( --os-dock-floating-pinned-padding, 4px 0 6px );
1093 /* A well the system tiles sit in, when a theme draws one. */
1094 background: var( --os-dock-pinned-bg, transparent );
1095 box-shadow: var( --os-dock-pinned-shadow, none );
1096 }
1097
1098 /* Separator — the line between two groups of tiles. The system one
1099 * lives at the leading edge of `__pinned` (flex flow handles the
1100 * "pinned at the trailing edge" placement, no margin-inline-start:
1101 * auto needed); the `--group` one sits inline inside `__scroll` where
1102 * it was inserted. Both resolve through this single rule — see the
1103 * note on the vertical placements above for why there is only one. */
1104 .os-dock[ data-os-dock-placement="bottom" ]
1105 .os-dock__separator {
1106 position: relative;
1107 width: var( --os-dock-separator-width, 2px );
1108 /* Fixed height — `60%` collapses against the floating pill's
1109 intrinsic height, leaving the line effectively invisible. */
1110 height: var( --os-dock-separator-height, 34px );
1111 /* Symmetric horizontal margin so the divider sits centered in the
1112 * inter-tile gap: the 6px flex gap on each side (`__scroll`→`__pinned`
1113 * on the left, `__pinned`'s own gap on the right) plus an equal
1114 * margin per side balances out. An asymmetric margin here nudges the
1115 * divider — and the whole menu cluster with it — off the pill's
1116 * center. Wider than the tile gap on purpose: the clusters need to
1117 * read as groups before the line between them means anything. */
1118 margin: auto var( --os-dock-separator-margin, 14px );
1119 border-radius: 2px;
1120 /*
1121 * The glowing seam by default. A theme drawing an etched groove
1122 * instead answers `--os-dock-separator-bg` with a flat colour and
1123 * `--os-dock-separator-shadow` with the highlight beside it.
1124 */
1125 background: var(
1126 --os-dock-separator-bg,
1127 linear-gradient(
1128 to bottom,
1129 transparent 0%,
1130 var( --os-dock-divider, rgba( 217, 46, 227, 0.7 ) ) 28%,
1131 var( --os-dock-divider, rgba( 217, 46, 227, 0.7 ) ) 72%,
1132 transparent 100%
1133 )
1134 );
1135 box-shadow: var(
1136 --os-dock-separator-shadow,
1137 0 0 10px
1138 color-mix(
1139 in srgb,
1140 var( --os-dock-divider, rgba( 217, 46, 227, 0.7 ) ) 45%,
1141 transparent
1142 )
1143 );
1144 flex-shrink: 0;
1145 }
1146
1147 /* Tooltip — above the hovered tile. JS writes the horizontal center
1148 * + a top near the tile's top edge; CSS translates the tooltip up by
1149 * its own height so it clears the tile. */
1150 .os-dock__tooltip--above {
1151 inset-inline-start: auto;
1152 transform: translate( -50%, calc( -100% - 4px ) );
1153 }
1154
1155 .os-dock__tooltip--above.os-dock__tooltip--visible {
1156 transform: translate( -50%, -100% );
1157 }
1158
1159
1160
1161 /* Drag-to-reorder (since 0.25.0).
1162 *
1163 * The dragged tile follows the cursor via inline `transform:
1164 * translate()` written from JS. It stays IN flow so flex doesn't
1165 * collapse the row — the visual lift is a z-index bump and
1166 * `pointer-events: none` so `elementFromPoint` finds siblings
1167 * underneath the cursor. Sibling tiles animate via FLIP — the JS
1168 * writes inline `transform` + `transition` per leg, so no CSS
1169 * transition is declared here (it would race with the per-leg
1170 * inline transition). */
1171 .os-dock__item--dragging {
1172 pointer-events: none;
1173 z-index: 50;
1174 opacity: 0.92;
1175 cursor: grabbing;
1176 will-change: transform;
1177 /* Subtle shadow so the dragged tile reads as "lifted" against
1178 * the dock surface it floats above. */
1179 filter: drop-shadow( 0 6px 12px rgba( 0, 0, 0, 0.35 ) );
1180 }
1181
1182 .os-dock__item:not( .os-dock__item--system ) {
1183 cursor: grab;
1184 }
1185
1186 .os-dock__item:not( .os-dock__item--system ):active {
1187 cursor: grabbing;
1188 }
1189
1190 /* ------------------------------------------------------------------
1191 * The way out — `Exit OpenStation`
1192 *
1193 * Every other system tile opens something you can close again. This
1194 * one leaves the desktop, and with the admin bar hidden by default it
1195 * is the only route back to classic admin. Drawn like its neighbours
1196 * it read as one more launcher, which is the same "some tiles do a
1197 * different kind of thing" confusion the single dock set out to fix,
1198 * just moved to the other end of the rail.
1199 *
1200 * Three signals, none of them colour. Danger red would overstate it
1201 * (nothing is destroyed, the session is saved and the desktop is one
1202 * click away) and Pulse is already spent on the seam:
1203 *
1204 * 1. **Last, always.** `order: 1` rather than registration order —
1205 * native-window tiles from plugins sync in after boot and would
1206 * otherwise land behind it.
1207 * 2. **Its own gap**, wider than the rail's 6px, so it reads as
1208 * sitting outside the set rather than at the end of it. A second
1209 * divider would have said this too, and would have cost the rail
1210 * the one-structural-line rule it just earned.
1211 * 3. **A different silhouette** — a ring rather than a plate. Shape
1212 * survives every theme, every dock texture, greyscale and a
1213 * colour-blind reading of the rail; a tint survives none of them
1214 * reliably.
1215 *
1216 * And it leans toward the edge it leads to on hover instead of lifting
1217 * toward the pointer, because every other tile's lift means "I will
1218 * come to you" and this one means the opposite.
1219 *
1220 * Keyed on the tile id, the same idiom the Recycle Bin badge uses
1221 * above. `os-exit` is frozen in `src/exit-openstation.ts`
1222 * (`EXIT_OPENSTATION_TILE_ID`) and `dock-exit-tile.test.ts` pins the
1223 * attribute this selector depends on.
1224 * ------------------------------------------------------------------ */
1225
1226 .os-dock__item[ data-system-id="os-exit" ] {
1227 order: 1;
1228 }
1229
1230 .os-dock__item[ data-system-id="os-exit" ] .os-dock__item-primary {
1231 border-radius: var( --os-dock-exit-radius, 50% );
1232 background-color: transparent;
1233 box-shadow: inset 0 0 0 1px var(
1234 --os-dock-exit-ring,
1235 var( --os-dock-border, rgba( 255, 255, 255, 0.18 ) )
1236 );
1237 color: var( --os-dock-exit-icon, rgba( 255, 255, 255, 0.55 ) );
1238 }
1239
1240 /*
1241 * The gap runs along the rail's own axis, so it follows the placement:
1242 * inline for the horizontal pill, block for the vertical pillars.
1243 */
1244 .os-dock[ data-os-dock-placement="bottom" ]
1245 .os-dock__item[ data-system-id="os-exit" ] {
1246 margin-inline-start: var( --os-dock-exit-gap, 10px );
1247 }
1248
1249 .os-dock[ data-os-dock-placement="left" ]
1250 .os-dock__item[ data-system-id="os-exit" ],
1251 .os-dock[ data-os-dock-placement="right" ]
1252 .os-dock__item[ data-system-id="os-exit" ] {
1253 margin-block-start: 8px;
1254 }
1255
1256 /*
1257 * Hover: the ring closes up and the tile moves TOWARD its edge. The
1258 * generic tile rule scales up by 1.1, so these have to out-specify it
1259 * rather than sit alongside it.
1260 */
1261 .os-dock__item[ data-system-id="os-exit" ] .os-dock__item-primary:hover,
1262 .os-dock__item[ data-system-id="os-exit" ]
1263 .os-dock__item-primary:focus-visible {
1264 background-color: transparent;
1265 box-shadow: inset 0 0 0 1px var(
1266 --os-dock-exit-ring-hover,
1267 var( --os-dock-item-outline, rgba( 255, 255, 255, 0.7 ) )
1268 );
1269 color: var( --os-dock-icon-color-hover, var( --os-ui-fg-on-accent, #fff ) );
1270 }
1271
1272 .os-dock[ data-os-dock-placement="bottom" ]
1273 .os-dock__item[ data-system-id="os-exit" ]
1274 .os-dock__item-primary:hover {
1275 transform: var( --os-dock-exit-hover-transform, translateY( 2px ) );
1276 }
1277
1278 .os-dock[ data-os-dock-placement="left" ]
1279 .os-dock__item[ data-system-id="os-exit" ]
1280 .os-dock__item-primary:hover {
1281 transform: translateX( -2px );
1282 }
1283
1284 .os-dock[ data-os-dock-placement="right" ]
1285 .os-dock__item[ data-system-id="os-exit" ]
1286 .os-dock__item-primary:hover {
1287 transform: translateX( 2px );
1288 }
1289
1290 /* The press keeps the shared cue: every tile in the rail dips on
1291 * :active, and this one should not feel unresponsive by comparison. */
1292 .os-dock__item[ data-system-id="os-exit" ] .os-dock__item-primary:active {
1293 transform: scale( 0.95 );
1294 }
1295
1296 @media ( prefers-reduced-motion: reduce ) {
1297 .os-dock[ data-os-dock-placement="bottom" ]
1298 .os-dock__item[ data-system-id="os-exit" ]
1299 .os-dock__item-primary:hover,
1300 .os-dock[ data-os-dock-placement="left" ]
1301 .os-dock__item[ data-system-id="os-exit" ]
1302 .os-dock__item-primary:hover,
1303 .os-dock[ data-os-dock-placement="right" ]
1304 .os-dock__item[ data-system-id="os-exit" ]
1305 .os-dock__item-primary:hover {
1306 transform: none;
1307 }
1308 }
1309
1310 /* ------------------------------------------------------------------
1311 * Dynamic dock behavior — OpenStation Preferences → Appearance →
1312 * Desktop layout, persisted as `dockBehavior` (the dock) and
1313 * `sideDockBehavior` (the Split sidebar) and worn by each rail as
1314 * `data-os-dock-behavior` — an attribute PER RAIL rather than a body
1315 * class, because Split's two rails answer independently. PHP stamps
1316 * the dock on first paint; the apply pass and `src/dock-behavior.ts`
1317 * re-stamp on every change and rebuild. `static` is the absence of
1318 * rules.
1319 *
1320 * A dynamic rail that is not `os-dock--revealed` is PARKED: it
1321 * collapses into a thin indicator line hugging its edge — the iOS
1322 * home indicator, one line that says "there is a dock here" — and
1323 * expands back into the full rail when the pointer comes for it.
1324 * `src/dock-behavior.ts` owns the flip (a full-width edge zone, the
1325 * rail's own box, its flyouts, keyboard focus) and runs it through
1326 * the View Transitions API, so the line morphs into the pill and
1327 * back; the rules below only describe the two resting states and
1328 * tune the morph. There is deliberately no `:hover` here — a state
1329 * CSS flipped on its own would jump instead of morphing.
1330 *
1331 * The line is the rail itself, not a separate element: same node,
1332 * same `view-transition-name`, which is what lets the browser animate
1333 * one box into the other. Its children are hidden while parked so
1334 * the line is a line. A side rail leaves the flex row for the
1335 * duration, so the area grows to the full width and the rail rides
1336 * over it when summoned; the work area reserves nothing for a
1337 * dynamic rail (`src/work-area/index.ts`).
1338 * ------------------------------------------------------------------ */
1339
1340 /*
1341 * Side rails overlay the area in both states — the flex row must not
1342 * shrink for a rail that is only sometimes there.
1343 */
1344 .os-dock[ data-os-dock-behavior="dynamic" ][ data-os-dock-placement="left" ],
1345 .os-dock[ data-os-dock-behavior="dynamic" ][ data-os-dock-placement="right" ] {
1346 position: absolute;
1347 inset-block: 0;
1348 }
1349
1350 .os-dock[ data-os-dock-behavior="dynamic" ][ data-os-dock-placement="left" ] {
1351 inset-inline-start: 0;
1352 }
1353
1354 .os-dock[ data-os-dock-behavior="dynamic" ][ data-os-dock-placement="right" ] {
1355 inset-inline-end: 0;
1356 }
1357
1358 /*
1359 * Parked — the indicator line. Everything that makes the pill a
1360 * pill (padding, border, blur, texture, tile gap) comes off, and the
1361 * box is the line's size. `overflow: hidden` clips whatever a tile
1362 * paints past its box during the morph.
1363 */
1364 .os-dock[ data-os-dock-behavior="dynamic" ]:not( .os-dock--revealed ) {
1365 min-width: 0;
1366 min-height: 0;
1367 max-width: none;
1368 padding: 0;
1369 gap: 0;
1370 border: 0;
1371 border-radius: 999px;
1372 overflow: hidden;
1373 background-color: var(
1374 --os-dock-indicator-bg,
1375 var( --os-dock-item-outline, rgba( 255, 255, 255, 0.55 ) )
1376 );
1377 background-image: none;
1378 backdrop-filter: none;
1379 -webkit-backdrop-filter: none;
1380 box-shadow: none;
1381 cursor: pointer;
1382 }
1383
1384 .os-dock[ data-os-dock-behavior="dynamic" ]:not( .os-dock--revealed ) > * {
1385 display: none;
1386 }
1387
1388 .os-dock[ data-os-dock-behavior="dynamic" ][ data-os-dock-placement="bottom" ]:not( .os-dock--revealed ) {
1389 width: var( --os-dock-indicator-length, 180px );
1390 height: var( --os-dock-indicator-thickness, 5px );
1391 bottom: 8px;
1392 }
1393
1394 .os-dock[ data-os-dock-behavior="dynamic" ][ data-os-dock-placement="left" ]:not( .os-dock--revealed ),
1395 .os-dock[ data-os-dock-behavior="dynamic" ][ data-os-dock-placement="right" ]:not( .os-dock--revealed ) {
1396 width: var( --os-dock-indicator-thickness, 5px );
1397 height: var( --os-dock-indicator-length, 180px );
1398 inset-block: calc( 50% - var( --os-dock-indicator-length, 180px ) / 2 );
1399 }
1400
1401 .os-dock[ data-os-dock-behavior="dynamic" ][ data-os-dock-placement="left" ]:not( .os-dock--revealed ) {
1402 inset-inline-start: 8px;
1403 }
1404
1405 .os-dock[ data-os-dock-behavior="dynamic" ][ data-os-dock-placement="right" ]:not( .os-dock--revealed ) {
1406 inset-inline-end: 8px;
1407 }
1408
1409 /*
1410 * The morph. With the API, `src/dock-behavior.ts` names the rail for
1411 * the duration of one flip and the browser animates the old box into
1412 * the new one; the rail's contents cross-fade underneath. The root is
1413 * opted out of the snapshot so the rest of the desktop keeps
1414 * running while the line becomes a pill — a frozen page for 260ms
1415 * on every reveal would be the animation costing more than it
1416 * gives. Without the API, the properties that can transition do.
1417 */
1418 @supports ( view-transition-name: none ) {
1419 html.os-dock-vt {
1420 view-transition-name: none;
1421 }
1422
1423 html.os-dock-vt::view-transition-old( root ),
1424 html.os-dock-vt::view-transition-new( root ) {
1425 animation: none;
1426 }
1427
1428 /*
1429 * The transition layer must not catch the pointer. By default it
1430 * does — the whole page reads as inert until the morph settles —
1431 * and with the root opted out of the snapshot that is worse than
1432 * pointless: the live desktop is right there under a layer that
1433 * swallows every mouseup and every pointerenter for 260ms. The
1434 * behavior module already holds a park back while a button is
1435 * down; this is the other half, so a hover that lands on a tile
1436 * while the rail is still morphing out starts its hover-intent
1437 * timer instead of waiting for the next pointer movement.
1438 */
1439 html.os-dock-vt::view-transition {
1440 pointer-events: none;
1441 }
1442
1443 ::view-transition-group( os-dock-os-dock ),
1444 ::view-transition-group( os-dock-os-side-dock ) {
1445 animation-duration: 260ms;
1446 animation-timing-function: cubic-bezier( 0.22, 1, 0.36, 1 );
1447 }
1448
1449 ::view-transition-old( os-dock-os-dock ),
1450 ::view-transition-old( os-dock-os-side-dock ) {
1451 animation: os-dock-vt-out 120ms ease-out both;
1452 }
1453
1454 ::view-transition-new( os-dock-os-dock ),
1455 ::view-transition-new( os-dock-os-side-dock ) {
1456 animation: os-dock-vt-in 180ms ease-out 80ms both;
1457 }
1458
1459 @keyframes os-dock-vt-out {
1460 to {
1461 opacity: 0;
1462 }
1463 }
1464
1465 @keyframes os-dock-vt-in {
1466 from {
1467 opacity: 0;
1468 }
1469 }
1470 }
1471
1472 @supports not ( view-transition-name: none ) {
1473 .os-dock[ data-os-dock-behavior="dynamic" ] {
1474 transition:
1475 width 240ms ease,
1476 height 240ms ease,
1477 opacity 240ms ease,
1478 border-radius 240ms ease,
1479 bottom 240ms ease,
1480 inset-block 240ms ease;
1481 }
1482 }
1483
1484 @media ( prefers-reduced-motion: reduce ) {
1485 .os-dock[ data-os-dock-behavior="dynamic" ] {
1486 transition: none;
1487 }
1488 }
1489