PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.10
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.10
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / welcome-dialog.php

welcome-dialog.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.10, at includes/welcome-dialog.php

844 lines 26.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — First-run welcome dialog.
4 *
5 * Renders a one-time, self-contained modal inside the *classic*
6 * WordPress admin (never inside the desktop shell or a chromeless
7 * iframe) that introduces OpenStation and offers to switch it on.
8 * Dismissal is persisted via the existing seen-intros registry
9 * (`desktop_mode_seen_intros` user meta, slug `activation-welcome`),
10 * which means the "Reset what's-new dialogs" button in OpenStation
11 * Preferences → Features brings it back exactly like every other intro
12 * dialog.
13 *
14 * The dialog is intentionally self-contained — all HTML, CSS and JS are
15 * inlined into `admin_footer`. We deliberately do NOT use any of the
16 * `<os-*>` shell components here because they only ship inside the
17 * desktop bundle, which is precisely *not* loaded on the classic admin
18 * screens where this dialog is allowed to appear.
19 *
20 * @package OpenStation
21 */
22
23 defined( 'ABSPATH' ) || exit;
24
25 /** Slug stored in `desktop_mode_seen_intros` for this dialog. */
26 const OPENSTATION_WELCOME_INTRO_SLUG = 'activation-welcome';
27
28 /**
29 * Decides whether the welcome dialog should render on the current request.
30 *
31 * Six gates:
32 *
33 * 1. We're inside `/wp-admin` (`is_admin()`).
34 * 2. The user is logged in and can `read` (sanity gate — the dialog has
35 * no destructive surface, but anonymous output makes no sense).
36 * 3. The request is NOT chromeless — chromeless pages are iframes
37 * rendering inside the desktop shell; the parent shell already shows
38 * its own UX.
39 * 4. OpenStation is NOT already enabled for the user. This is a
40 * "switch to OpenStation" promo, so it has nothing to say once the
41 * user is in the shell. The desktop shell's *parent* page is admin
42 * context and is not chromeless, so without this gate the dialog
43 * re-renders there the moment the user clicks "Switch to
44 * OpenStation", which reads as a duplicate dialog because the
45 * fire-and-forget seen-intro POST races the redirect into the shell
46 * and often loses.
47 * 5. The user has not already dismissed this intro.
48 * 6. The `openstation_show_welcome_dialog` filter returns truthy, so
49 * sites can suppress the dialog entirely (e.g. managed-host onboarding
50 * flows that ship their own).
51 *
52 * @return bool
53 */
54 function openstation_should_show_welcome_dialog() {
55 if ( ! is_admin() || ! is_user_logged_in() ) {
56 return false;
57 }
58 if ( ! current_user_can( 'read' ) ) {
59 return false;
60 }
61 if ( function_exists( 'openstation_is_chromeless_request' ) && openstation_is_chromeless_request() ) {
62 return false;
63 }
64 if ( function_exists( 'openstation_is_enabled' ) && openstation_is_enabled() ) {
65 return false;
66 }
67 $user_id = get_current_user_id();
68 if ( openstation_has_seen_intro( $user_id, OPENSTATION_WELCOME_INTRO_SLUG ) ) {
69 return false;
70 }
71
72 /**
73 * Filters whether the first-run welcome dialog should render for
74 * the current user on the current request. All earlier gates
75 * (admin context, capability, chromeless, seen-state) have
76 * already passed by the time this filter fires.
77 *
78 * @param bool $show Whether to render the dialog. Default true.
79 * @param int $user_id Current user ID.
80 */
81 return (bool) apply_filters( 'openstation_show_welcome_dialog', true, $user_id );
82 }
83
84 /**
85 * Returns one of OpenStation's own icons as inline SVG markup.
86 *
87 * Reads the outlined copies in `assets/icons/` (the ones registered with
88 * Core's icon registry), which paint with `currentColor`, so the dialog's
89 * CSS decides their colour. Returns an empty string for a missing file,
90 * which leaves an empty icon slot rather than breaking the dialog.
91 *
92 * @param string $slug Icon slug, e.g. `windows`.
93 * @return string Sanitised SVG markup.
94 */
95 function openstation_welcome_dialog_icon( $slug ) {
96 $path = OPENSTATION_DIR . 'assets/icons/' . sanitize_key( $slug ) . '.svg';
97 if ( ! is_readable( $path ) ) {
98 return '';
99 }
100 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local plugin file, not a remote URL.
101 $svg = (string) file_get_contents( $path );
102
103 return wp_kses(
104 $svg,
105 array(
106 'svg' => array(
107 'xmlns' => true,
108 'viewbox' => true,
109 'width' => true,
110 'height' => true,
111 'aria-hidden' => true,
112 'focusable' => true,
113 ),
114 'path' => array(
115 'd' => true,
116 'fill' => true,
117 ),
118 )
119 );
120 }
121
122 /**
123 * Prints the welcome dialog markup, styles and dismiss script into
124 * `admin_footer`.
125 *
126 * Self-contained on purpose: everything is scoped under the
127 * `.os-welcome` namespace so it cannot collide with the host
128 * admin theme. The dismiss button POSTs to the seen-intros REST route,
129 * which is exactly the same endpoint the in-shell intros use.
130 *
131 * The look is deliberately quiet: a light card with a dark panel that
132 * shows two OpenStation windows, the holo mark, and Pulse kept to the
133 * feature icons. Every colour is a literal from the brand palette rather
134 * than a `--os-*` token, because `variables.css` is not loaded in classic
135 * admin. Spacing sits on an 8px grid.
136 */
137 function openstation_render_welcome_dialog() {
138 if ( ! openstation_should_show_welcome_dialog() ) {
139 return;
140 }
141
142 $rest_url = esc_url_raw( rest_url( 'desktop-mode/v1/intros/seen' ) );
143 $rest_nonce = wp_create_nonce( 'wp_rest' );
144 $ajax_url = esc_url_raw( admin_url( 'admin-ajax.php' ) );
145 $ajax_nonce = wp_create_nonce( 'save-openstation' );
146 $slug = OPENSTATION_WELCOME_INTRO_SLUG;
147 $font_url = OPENSTATION_URL . 'assets/fonts/Geist-Variable.woff2';
148 $mono_url = OPENSTATION_URL . 'assets/fonts/GeistMono-Variable.woff2';
149 $mark_url = OPENSTATION_URL . 'assets/images/openstation-mark-holo.svg';
150
151 // All user-facing strings are passed through translation; the dialog
152 // is keyboard-dismissible (Escape) and moves initial focus to the
153 // primary CTA.
154 $title = __( 'Welcome to OpenStation', 'desktop-mode' );
155 $body = __( 'Your admin, as a desktop. Keep several screens open at once and move between tasks without losing your place.', 'desktop-mode' );
156 $later = __( 'Not now', 'desktop-mode' );
157 $enable = __( 'Switch to OpenStation', 'desktop-mode' );
158 $enabling = __( 'Switching…', 'desktop-mode' );
159
160 $features = array(
161 array(
162 'icon' => 'windows',
163 'title' => __( 'Work in windows', 'desktop-mode' ),
164 'desc' => __( 'Posts, media and settings side by side.', 'desktop-mode' ),
165 ),
166 array(
167 'icon' => 'apps',
168 'title' => __( 'Apps for everyday tasks', 'desktop-mode' ),
169 'desc' => __( 'Fast lists with bulk actions and previews.', 'desktop-mode' ),
170 ),
171 array(
172 'icon' => 'dock',
173 'title' => __( 'A dock for your screens', 'desktop-mode' ),
174 'desc' => __( 'Pin what you use most, one click away.', 'desktop-mode' ),
175 ),
176 array(
177 'icon' => 'command',
178 /* translators: %s: the keyboard shortcut that opens search, e.g. ⌘K. */
179 'title' => __( 'Search with %s', 'desktop-mode' ),
180 'desc' => __( 'Jump to any screen or run a command.', 'desktop-mode' ),
181 'kbd' => true,
182 ),
183 );
184 ?>
185 <style id="os-welcome-style">
186 @font-face {
187 font-family: "OpenStation Geist";
188 src: url( "<?php echo esc_url( $font_url ); ?>" ) format( "woff2" );
189 font-weight: 100 900;
190 font-style: normal;
191 font-display: swap;
192 }
193 @font-face {
194 font-family: "OpenStation Geist Mono";
195 src: url( "<?php echo esc_url( $mono_url ); ?>" ) format( "woff2" );
196 font-weight: 100 900;
197 font-style: normal;
198 font-display: swap;
199 }
200 .os-welcome,
201 .os-welcome * {
202 box-sizing: border-box;
203 }
204 .os-welcome {
205 /* Brand palette, as literals: see the render function's docblock. */
206 --_void: #0c0b0f;
207 --_obsidian: #1a1721;
208 --_astro: #33303a;
209 --_silver: #4d4a52;
210 --_pewter: #66636b;
211 --_osmium: #99969c;
212 --_ash: #b3afb5;
213 --_cloud: #ccc8ce;
214 --_mist: #e6e2e6;
215 --_haze: #f4f1f4;
216 --_starlight: #fffbff;
217 --_pulse: #f252fc;
218
219 position: fixed;
220 inset: 0;
221 z-index: 100000;
222 display: flex;
223 padding: 24px;
224 overflow-y: auto;
225 background: rgba( 12, 11, 15, 0.72 );
226 backdrop-filter: blur( 16px );
227 -webkit-backdrop-filter: blur( 16px );
228 animation: os-welcome-fade 240ms ease-out;
229 font-family: "OpenStation Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
230 font-size: 16px;
231 line-height: 1.5;
232 color: var( --_obsidian );
233 -webkit-font-smoothing: antialiased;
234 -moz-osx-font-smoothing: grayscale;
235 }
236 @keyframes os-welcome-fade {
237 from { opacity: 0; }
238 to { opacity: 1; }
239 }
240 @keyframes os-welcome-pop {
241 from { opacity: 0; transform: translateY( 8px ); }
242 to { opacity: 1; transform: translateY( 0 ); }
243 }
244 .os-welcome__card {
245 /* margin:auto centres the card and still lets a short viewport scroll to its top. */
246 margin: auto;
247 width: 100%;
248 max-width: 820px;
249 min-height: 544px;
250 display: grid;
251 grid-template-columns: 5fr 6fr;
252 overflow: hidden;
253 background: var( --_starlight );
254 border-radius: 13px;
255 box-shadow:
256 0 0 0 1px rgba( 12, 11, 15, 0.08 ),
257 0 32px 64px -24px rgba( 12, 11, 15, 0.48 );
258 animation: os-welcome-pop 320ms cubic-bezier( 0.22, 1, 0.36, 1 ) both;
259 }
260
261 /* ---- Dark panel: two windows on the station ------------------- */
262
263 .os-welcome__art {
264 position: relative;
265 overflow: hidden;
266 isolation: isolate;
267 background:
268 radial-gradient( 1px 1px at 12% 18%, rgba( 255, 251, 255, 0.55 ) 50%, transparent 51% ),
269 radial-gradient( 1px 1px at 78% 12%, rgba( 255, 251, 255, 0.4 ) 50%, transparent 51% ),
270 radial-gradient( 1px 1px at 64% 44%, rgba( 255, 251, 255, 0.3 ) 50%, transparent 51% ),
271 radial-gradient( 1.5px 1.5px at 88% 62%, rgba( 255, 251, 255, 0.45 ) 50%, transparent 51% ),
272 radial-gradient( 1px 1px at 22% 72%, rgba( 255, 251, 255, 0.35 ) 50%, transparent 51% ),
273 radial-gradient( 1px 1px at 46% 88%, rgba( 255, 251, 255, 0.3 ) 50%, transparent 51% ),
274 radial-gradient( 1px 1px at 8% 48%, rgba( 255, 251, 255, 0.3 ) 50%, transparent 51% ),
275 radial-gradient( 1px 1px at 94% 90%, rgba( 255, 251, 255, 0.35 ) 50%, transparent 51% ),
276 radial-gradient( 1px 1px at 36% 8%, rgba( 255, 251, 255, 0.35 ) 50%, transparent 51% ),
277 radial-gradient( 60% 45% at 85% 100%, rgba( 236, 155, 255, 0.1 ), transparent 70% ),
278 radial-gradient( 50% 40% at 0% 30%, rgba( 159, 152, 255, 0.07 ), transparent 70% ),
279 var( --_void );
280 }
281 .os-welcome__art::after {
282 content: "";
283 position: absolute;
284 inset: 0;
285 pointer-events: none;
286 background:
287 linear-gradient( 180deg, rgba( 12, 11, 15, 0.85 ) 0%, rgba( 12, 11, 15, 0 ) 22% ),
288 linear-gradient( 0deg, rgba( 12, 11, 15, 0.6 ) 0%, rgba( 12, 11, 15, 0 ) 26% );
289 }
290 .os-welcome .os-welcome__mark {
291 position: absolute;
292 top: 32px;
293 inset-inline-start: 32px;
294 z-index: 2;
295 display: block;
296 width: 40px;
297 height: 40px;
298 max-width: none;
299 }
300 .os-welcome__pair {
301 position: absolute;
302 left: 50%;
303 top: 50%;
304 width: 300px;
305 height: 276px;
306 transform: translate( -50%, -44% );
307 direction: ltr;
308 }
309 .os-welcome__win {
310 position: absolute;
311 overflow: hidden;
312 border-radius: 9px;
313 /* The window greys sit a step above the palette's dark ramp so the art reads against Void. */
314 background: #201d28;
315 border: 1px solid rgba( 255, 251, 255, 0.12 );
316 box-shadow: 0 18px 36px -12px rgba( 0, 0, 0, 0.7 );
317 font-size: 9px;
318 line-height: 1;
319 }
320 .os-welcome__win--posts { left: 0; top: 0; width: 236px; height: 184px; }
321 .os-welcome__win--media { right: 0; bottom: 0; width: 236px; height: 160px; }
322 .os-welcome__bar {
323 display: flex;
324 align-items: center;
325 justify-content: space-between;
326 height: 24px;
327 padding: 0 8px;
328 font-weight: 500;
329 color: var( --_mist );
330 border-bottom: 1px solid rgba( 255, 251, 255, 0.08 );
331 }
332 .os-welcome__bar-title { display: flex; align-items: center; gap: 8px; }
333 .os-welcome__bar-title i { width: 7px; height: 7px; border: 1px solid var( --_osmium ); border-radius: 50%; }
334 .os-welcome__bar-ctl { display: flex; gap: 8px; }
335 .os-welcome__bar-ctl i { display: block; width: 6px; height: 6px; border: 1px solid #77747c; border-radius: 1.5px; }
336 .os-welcome__bar-ctl i:first-child { height: 0; border-width: 1px 0 0; margin-top: 3px; border-radius: 0; }
337 .os-welcome__rows { padding: 8px; }
338 .os-welcome__row {
339 display: grid;
340 grid-template-columns: 7px 1fr 34px;
341 align-items: center;
342 gap: 8px;
343 height: 24px;
344 border-bottom: 1px solid rgba( 255, 251, 255, 0.07 );
345 }
346 .os-welcome__row i { width: 7px; height: 7px; border: 1px solid #5c5963; border-radius: 2px; }
347 .os-welcome__row b { height: 5px; border-radius: 3px; background: #5c5963; }
348 .os-welcome__row em { height: 9px; border-radius: 999px; background: #3e3b46; }
349 .os-welcome__row--head b,
350 .os-welcome__row--head em { background: #3e3b46; }
351 .os-welcome__row--selected { background: rgba( 255, 251, 255, 0.05 ); }
352 .os-welcome__thumbs { padding: 8px; display: grid; grid-template-columns: repeat( 4, 1fr ); gap: 8px; }
353 .os-welcome__thumbs i {
354 display: block;
355 aspect-ratio: 1;
356 border-radius: 4px;
357 background: linear-gradient( 150deg, #34303d, #27242f );
358 border: 1px solid rgba( 255, 251, 255, 0.06 );
359 }
360 .os-welcome__thumbs i:nth-child( 3n + 1 ) { background: linear-gradient( 150deg, #3a3444, #2a2633 ); }
361 .os-welcome__thumbs i:nth-child( 5 ) {
362 background:
363 radial-gradient( circle at 70% 30%, rgba( 236, 155, 255, 0.22 ), transparent 60% ),
364 linear-gradient( 150deg, #36313f, #25222c );
365 }
366
367 /* ---- Content column ------------------------------------------- */
368
369 .os-welcome__main {
370 display: flex;
371 flex-direction: column;
372 min-width: 0;
373 text-align: start;
374 }
375 .os-welcome__content {
376 display: grid;
377 gap: 24px;
378 padding: 32px;
379 }
380 .os-welcome__head {
381 display: grid;
382 gap: 8px;
383 }
384 .os-welcome .os-welcome__title {
385 margin: 0;
386 padding: 0;
387 font-family: inherit;
388 font-size: 24px;
389 line-height: 1.3;
390 font-weight: 500;
391 letter-spacing: -0.01em;
392 color: var( --_obsidian );
393 text-wrap: balance;
394 }
395 .os-welcome .os-welcome__lede {
396 margin: 0;
397 font-size: 16px;
398 line-height: 1.5;
399 color: var( --_silver );
400 }
401 .os-welcome .os-welcome__features {
402 display: grid;
403 gap: 16px;
404 margin: 0;
405 padding: 0;
406 list-style: none;
407 }
408 .os-welcome .os-welcome__feature {
409 display: flex;
410 gap: 16px;
411 margin: 0;
412 }
413 .os-welcome__icon {
414 flex: none;
415 width: 20px;
416 height: 20px;
417 color: var( --_pulse );
418 }
419 .os-welcome__icon svg {
420 display: block;
421 width: 20px;
422 height: 20px;
423 }
424 .os-welcome__feature-title {
425 display: block;
426 font-size: 14px;
427 font-weight: 600;
428 line-height: 1.4;
429 color: var( --_obsidian );
430 }
431 .os-welcome .os-welcome__feature-desc {
432 margin: 0;
433 font-size: 14px;
434 line-height: 1.5;
435 color: var( --_pewter );
436 }
437 .os-welcome .os-welcome__kbd {
438 display: inline-block;
439 margin: 0;
440 padding: 0 8px;
441 font-family: "OpenStation Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
442 font-size: 12px;
443 font-weight: 500;
444 line-height: 16px;
445 color: var( --_astro );
446 background: var( --_haze );
447 border: 1px solid var( --_mist );
448 border-bottom-color: var( --_cloud );
449 border-radius: 5px;
450 white-space: nowrap;
451 }
452
453 /* Anchored to the bottom of the card, however long the copy runs. */
454 .os-welcome__actions {
455 margin-top: auto;
456 display: flex;
457 flex-wrap: wrap;
458 justify-content: flex-end;
459 gap: 8px;
460 padding: 0 32px 32px;
461 }
462 .os-welcome .os-welcome__btn {
463 display: inline-flex;
464 align-items: center;
465 justify-content: center;
466 min-height: 40px;
467 margin: 0;
468 padding: 0 16px;
469 font-family: inherit;
470 font-size: 14px;
471 font-weight: 500;
472 line-height: 1;
473 border-radius: 8px;
474 border: 1px solid var( --_obsidian );
475 box-shadow: none;
476 cursor: pointer;
477 transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease;
478 }
479 .os-welcome .os-welcome__btn:focus-visible {
480 outline: 2px solid var( --_obsidian );
481 outline-offset: 2px;
482 }
483 .os-welcome .os-welcome__btn--primary {
484 color: var( --_starlight );
485 background: var( --_obsidian );
486 }
487 .os-welcome .os-welcome__btn--primary:hover {
488 background: var( --_astro );
489 border-color: var( --_astro );
490 }
491 .os-welcome .os-welcome__btn--secondary {
492 color: var( --_obsidian );
493 background: transparent;
494 }
495 .os-welcome .os-welcome__btn--secondary:hover {
496 background: var( --_haze );
497 }
498 .os-welcome .os-welcome__btn[disabled] {
499 opacity: 0.64;
500 cursor: progress;
501 }
502 body.os-welcome-open {
503 overflow: hidden;
504 }
505
506 /* ---- Narrow screens ------------------------------------------- */
507
508 @media ( max-width: 760px ) {
509 .os-welcome__card {
510 grid-template-columns: 1fr;
511 min-height: 0;
512 }
513 .os-welcome__art {
514 height: 216px;
515 }
516 .os-welcome__pair {
517 transform: translate( -50%, -44% ) scale( 0.64 );
518 }
519 }
520 @media ( max-width: 480px ) {
521 .os-welcome {
522 padding: 16px;
523 }
524 .os-welcome .os-welcome__mark {
525 top: 24px;
526 inset-inline-start: 24px;
527 }
528 .os-welcome__content {
529 padding: 24px;
530 }
531 .os-welcome__actions {
532 padding: 0 24px 24px;
533 }
534 .os-welcome .os-welcome__btn {
535 flex: 1 1 auto;
536 }
537 }
538 @media ( prefers-reduced-motion: reduce ) {
539 .os-welcome,
540 .os-welcome__card {
541 animation: none !important;
542 }
543 .os-welcome .os-welcome__btn {
544 transition: none;
545 }
546 }
547 </style>
548 <div
549 class="os-welcome"
550 role="dialog"
551 aria-modal="true"
552 aria-labelledby="os-welcome-title"
553 aria-describedby="os-welcome-desc"
554 data-slug="<?php echo esc_attr( $slug ); ?>"
555 >
556 <div class="os-welcome__card">
557 <div class="os-welcome__art" aria-hidden="true">
558 <img class="os-welcome__mark" src="<?php echo esc_url( $mark_url ); ?>" alt="" width="40" height="40" />
559 <div class="os-welcome__pair">
560 <div class="os-welcome__win os-welcome__win--posts">
561 <div class="os-welcome__bar">
562 <span class="os-welcome__bar-title"><i></i><?php echo esc_html__( 'Posts', 'desktop-mode' ); ?></span>
563 <span class="os-welcome__bar-ctl"><i></i><i></i><i></i></span>
564 </div>
565 <div class="os-welcome__rows">
566 <div class="os-welcome__row os-welcome__row--head"><i></i><b style="width:40%"></b><em></em></div>
567 <div class="os-welcome__row"><i></i><b style="width:78%"></b><em></em></div>
568 <div class="os-welcome__row os-welcome__row--selected"><i></i><b style="width:62%"></b><em></em></div>
569 <div class="os-welcome__row"><i></i><b style="width:84%"></b><em></em></div>
570 <div class="os-welcome__row"><i></i><b style="width:55%"></b><em></em></div>
571 <div class="os-welcome__row"><i></i><b style="width:70%"></b><em></em></div>
572 </div>
573 </div>
574 <div class="os-welcome__win os-welcome__win--media">
575 <div class="os-welcome__bar">
576 <span class="os-welcome__bar-title"><i></i><?php echo esc_html__( 'Media', 'desktop-mode' ); ?></span>
577 <span class="os-welcome__bar-ctl"><i></i><i></i><i></i></span>
578 </div>
579 <div class="os-welcome__thumbs">
580 <i></i><i></i><i></i><i></i>
581 <i></i><i></i><i></i><i></i>
582 </div>
583 </div>
584 </div>
585 </div>
586 <div class="os-welcome__main">
587 <div class="os-welcome__content">
588 <div class="os-welcome__head">
589 <h2 id="os-welcome-title" class="os-welcome__title">
590 <?php echo esc_html( $title ); ?>
591 </h2>
592 <p id="os-welcome-desc" class="os-welcome__lede">
593 <?php echo esc_html( $body ); ?>
594 </p>
595 </div>
596 <ul class="os-welcome__features">
597 <?php foreach ( $features as $feature ) : ?>
598 <li class="os-welcome__feature">
599 <span class="os-welcome__icon" aria-hidden="true">
600 <?php echo openstation_welcome_dialog_icon( $feature['icon'] ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- sanitised with wp_kses() in the helper. ?>
601 </span>
602 <div>
603 <strong class="os-welcome__feature-title">
604 <?php
605 if ( ! empty( $feature['kbd'] ) ) {
606 echo wp_kses(
607 sprintf(
608 esc_html( $feature['title'] ),
609 '<kbd class="os-welcome__kbd" data-os-welcome-shortcut>⌘K</kbd>'
610 ),
611 array(
612 'kbd' => array(
613 'class' => true,
614 'data-*' => true,
615 ),
616 )
617 );
618 } else {
619 echo esc_html( $feature['title'] );
620 }
621 ?>
622 </strong>
623 <p class="os-welcome__feature-desc">
624 <?php echo esc_html( $feature['desc'] ); ?>
625 </p>
626 </div>
627 </li>
628 <?php endforeach; ?>
629 </ul>
630 </div>
631 <div class="os-welcome__actions">
632 <button
633 type="button"
634 class="os-welcome__btn os-welcome__btn--secondary"
635 data-os-welcome-cta
636 >
637 <?php echo esc_html( $later ); ?>
638 </button>
639 <button
640 type="button"
641 class="os-welcome__btn os-welcome__btn--primary"
642 data-os-welcome-enable
643 data-label-idle="<?php echo esc_attr( $enable ); ?>"
644 data-label-busy="<?php echo esc_attr( $enabling ); ?>"
645 >
646 <?php echo esc_html( $enable ); ?>
647 </button>
648 </div>
649 </div>
650 </div>
651 </div>
652 <script id="os-welcome-script">
653 ( function () {
654 var root = document.querySelector( '.os-welcome' );
655 if ( ! root ) {
656 return;
657 }
658 var cfg = {
659 url: <?php echo wp_json_encode( $rest_url ); ?>,
660 nonce: <?php echo wp_json_encode( $rest_nonce ); ?>,
661 slug: <?php echo wp_json_encode( $slug ); ?>,
662 ajaxUrl: <?php echo wp_json_encode( $ajax_url ); ?>,
663 ajaxNonce: <?php echo wp_json_encode( $ajax_nonce ); ?>,
664 };
665
666 document.body.classList.add( 'os-welcome-open' );
667
668 // The shell answers Cmd+K and Ctrl+K alike; show the one this keyboard has.
669 var shortcut = root.querySelector( '[data-os-welcome-shortcut]' );
670 if ( shortcut && ! /Mac|iPhone|iPad|iPod/.test( navigator.platform || navigator.userAgent ) ) {
671 shortcut.textContent = 'Ctrl K';
672 }
673
674 // Focus the primary CTA so keyboard users land somewhere meaningful.
675 var primary = root.querySelector( '[data-os-welcome-enable]' )
676 || root.querySelector( '[data-os-welcome-cta]' );
677 if ( primary ) {
678 try { primary.focus( { preventScroll: true } ); } catch ( e ) {}
679 }
680
681 function close() {
682 if ( ! root || ! root.parentNode ) {
683 return;
684 }
685 root.parentNode.removeChild( root );
686 document.body.classList.remove( 'os-welcome-open' );
687 document.removeEventListener( 'keydown', onKey );
688 }
689
690 // Rebuilds an absolute URL onto the origin the admin page was actually
691 // loaded from. `rest_url()` / `admin_url()` are pinned to `site_url()`,
692 // but the admin may be viewed through a different origin — a reverse
693 // proxy, a Flexible-SSL edge, a mapped multisite domain, or simply an
694 // HTTPS dev proxy in front of an HTTP site. POSTing the *absolute*
695 // site_url URL from such a page is cross-origin (and mixed-content when
696 // the page is HTTPS and site_url is HTTP); the browser blocks it, the
697 // dismissal never reaches the server, and the dialog re-renders on every
698 // page load. Reissuing the request to `window.location.origin` keeps it
699 // same-origin — where the logged-in cookie (domain-scoped, not
700 // port-scoped) and the `wp_rest` nonce (session-bound, origin-agnostic)
701 // are both valid.
702 function sameOrigin( url ) {
703 try {
704 var parsed = new URL( url, window.location.href );
705 return window.location.origin + parsed.pathname + parsed.search;
706 } catch ( e ) {
707 return url;
708 }
709 }
710
711 function persist() {
712 // Fire-and-forget. The seen-intros endpoint always returns the
713 // post-mutation list, but we don't need it here; if the request
714 // fails (offline, REST disabled) the dialog will simply show
715 // again next page load — exactly the behavior a user would
716 // expect from a "save my dismissal" call that didn't reach the
717 // server.
718 var url = sameOrigin( cfg.url );
719 var payload = JSON.stringify( { slug: cfg.slug } );
720
721 // Prefer `navigator.sendBeacon`: it is queued by the browser and
722 // survives the navigation that "Switch to OpenStation" triggers
723 // without the keepalive caveats, and it is inherently
724 // same-origin-credentialed. The `wp_rest` nonce rides along as
725 // `_wpnonce` (REST cookie auth reads it from `$_REQUEST`), and the
726 // Blob's `application/json` type lets the REST server parse the
727 // `slug` body param.
728 try {
729 if ( navigator.sendBeacon ) {
730 var beaconUrl = url +
731 ( url.indexOf( '?' ) === -1 ? '?' : '&' ) +
732 '_wpnonce=' + encodeURIComponent( cfg.nonce );
733 var blob = new Blob( [ payload ], { type: 'application/json' } );
734 if ( navigator.sendBeacon( beaconUrl, blob ) ) {
735 return;
736 }
737 }
738 } catch ( e ) {}
739
740 // Fallback: `keepalive: true` keeps the POST alive across the
741 // "Switch to OpenStation" redirect on browsers without sendBeacon.
742 try {
743 var headers = { 'Content-Type': 'application/json' };
744 if ( cfg.nonce ) {
745 headers[ 'X-WP-Nonce' ] = cfg.nonce;
746 }
747 fetch( url, {
748 method: 'POST',
749 credentials: 'same-origin',
750 keepalive: true,
751 headers: headers,
752 body: payload,
753 } ).catch( function () {} );
754 } catch ( e ) {}
755 }
756
757 function dismiss() {
758 persist();
759 close();
760 }
761
762 var enabling = false;
763
764 function enableNow( btn ) {
765 if ( enabling ) {
766 return;
767 }
768 enabling = true;
769 var idle = btn.getAttribute( 'data-label-idle' ) || btn.textContent;
770 var busy = btn.getAttribute( 'data-label-busy' ) || idle;
771 btn.disabled = true;
772 btn.textContent = busy;
773
774 // Persist the dismissal in parallel — even if the AJAX save fails
775 // the user explicitly asked to turn the mode on, so they don't
776 // need to see the welcome again.
777 persist();
778
779 var form = new FormData();
780 form.append( 'action', 'save-openstation' );
781 form.append( 'nonce', cfg.ajaxNonce );
782 form.append( 'enabled', '1' );
783
784 fetch( sameOrigin( cfg.ajaxUrl ), {
785 method: 'POST',
786 credentials: 'same-origin',
787 body: form,
788 } ).then( function ( r ) {
789 return r.json().catch( function () { return null; } );
790 } ).then( function ( data ) {
791 var redirect = data && data.success && data.data && data.data.redirect
792 ? data.data.redirect
793 : null;
794 if ( redirect ) {
795 window.location.href = redirect;
796 return;
797 }
798 // Fallback — reload so the shell takes over (the AJAX endpoint
799 // already wrote the user meta, so this request will boot
800 // straight into OpenStation via the portal flow).
801 window.location.reload();
802 } ).catch( function () {
803 enabling = false;
804 btn.disabled = false;
805 btn.textContent = idle;
806 } );
807 }
808
809 root.addEventListener( 'click', function ( event ) {
810 var target = event.target;
811 if ( ! ( target instanceof Element ) ) {
812 return;
813 }
814 var enableBtn = target.closest( '[data-os-welcome-enable]' );
815 if ( enableBtn ) {
816 event.preventDefault();
817 enableNow( enableBtn );
818 return;
819 }
820 if ( target.closest( '[data-os-welcome-cta]' ) ) {
821 event.preventDefault();
822 dismiss();
823 return;
824 }
825 // Backdrop click — outside the card.
826 if ( target === root ) {
827 event.preventDefault();
828 dismiss();
829 }
830 } );
831
832 function onKey( event ) {
833 if ( event.key === 'Escape' || event.key === 'Esc' ) {
834 event.preventDefault();
835 dismiss();
836 }
837 }
838 document.addEventListener( 'keydown', onKey );
839 } )();
840 </script>
841 <?php
842 }
843 add_action( 'admin_footer', 'openstation_render_welcome_dialog' );
844