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 / includes / welcome-dialog.php

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

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