PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.0.1
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.0.1
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 / registries / native-windows.php

native-windows.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.0.1, at includes/registries/native-windows.php

878 lines 29.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Native windows registry.
4 *
5 * The largest of the five components.php registries — owns:
6 *
7 * - `openstation_register_window()` — plugin-author API
8 * - `openstation_native_window_registry()` — internal store
9 * - `openstation_native_window_allowed_html()` — wp_kses
10 * allowlist for `<template>` payloads
11 * - `openstation_build_native_window_template_html()` —
12 * wraps the registered template callback in tabs markup
13 * when the window has multiple registered tabs
14 * - `openstation_enqueue_native_window_scripts()` — enqueue
15 * hook that ships every registered window's script handle
16 * - `openstation_render_native_window_templates()` — renders
17 * the `<template>` elements the shell clones
18 *
19 * Extracted from `components.php` during the architecture-0.8.1
20 * PHP slicing (phase 6). The window-tabs registry that builds on
21 * top of this lives in `includes/registries/window-tabs.php`.
22 *
23 * @package OpenStation
24 */
25
26 defined( 'ABSPATH' ) || exit;
27
28 /**
29 * Register a PHP-owned native desktop window with one call.
30 *
31 * Under the hood this:
32 *
33 * 1. Captures the $args and stores them on a module-level
34 * registry so the relevant admin_footer + enqueue hooks fire
35 * only for the current user's openstation shell.
36 * 2. On `admin_footer` (shell-side only), emits
37 * `<template id="os-native-window-<id>">` wrapping the
38 * output of the `template` callback. Each registered window
39 * gets its own template element.
40 * 3. On `admin_enqueue_scripts` (shell-side), enqueues the
41 * caller's `script` handle if one was provided. The script
42 * registers a render callback at
43 * `window.openStationNativeWindows[<id>]`. On every window open
44 * the shell clones the registered template into the body and
45 * then invokes the callback — render is enhancement: query
46 * the body for mount points your template declared, light
47 * them up. Without a `script` the cloned template IS the
48 * window; declarative-only plugins need zero JS.
49 * 4. Passes a localized config blob to the script
50 * (`openStationNativeWindow_<id>`) carrying the window's
51 * `id`, `title`, `icon`, dimensions, and `placement`. The
52 * script then calls `wp.os.registerSystemTile()` +
53 * `wp.os.registerWindow()` to wire up the dock tile
54 * and the open-on-click behaviour.
55 *
56 * Plugins write the template callback + the render callback on
57 * the JS side; everything else is shell plumbing. Capability gate
58 * honours WP admin conventions: any `capabilities` entries must
59 * ALL match for the window to register.
60 *
61 * Note on scope: the shell doesn't auto-open windows server-side
62 * — `registerWindow` declares availability, not presence. Users
63 * click the registered tile (or your plugin calls
64 * `wp.os.windowManager.open()` programmatically) to surface
65 * the window.
66 *
67 * @param string $id Doubles as window id + dock-tile id. Must
68 * be a kebab-case-ish slug.
69 * @param array $args {
70 * Window registration options.
71 *
72 * @type string $title Window + tooltip title. Required.
73 * @type string $icon Dashicons class or URL. Required.
74 * @type callable $template Echoes the window body markup.
75 * Wrapped on `admin_footer` in a
76 * `<template id="os-native-window-
77 * <id>">`; cloned into the window
78 * body on every open. The render
79 * callback runs against the cloned
80 * body, so mount points declared in
81 * the template are guaranteed to be
82 * present.
83 * @type string $script Registered script handle that
84 * owns the JS render callback.
85 * Optional — omit for a purely
86 * declarative window whose body is
87 * exactly the cloned template.
88 * @type int $width Initial width (px). Default 520.
89 * @type int $height Initial height (px). Default 400.
90 * @type int $min_width Minimum width (px). Default 280.
91 * @type int $min_height Minimum height (px). Default 220.
92 * @type string $placement 'dock' | 'none'. Default 'dock'.
93 * 'none' skips the tile (plugin
94 * opens the window programmatically).
95 * @type string[] $capabilities User capabilities that gate the
96 * registration. ANY miss returns
97 * `WP_Error openstation_capability_denied`.
98 * @type bool|string $autofocus Passed verbatim to
99 * `NativeWindowDef.autofocus`.
100 * @type string $main_tab_label Label for the "main" tab that
101 * displays the window's own
102 * `template` output. Only rendered
103 * when at least one additional
104 * tab is registered via
105 * {@see openstation_register_window_tab()}.
106 * Defaults to the window's `title`.
107 * @type int $main_tab_padding Padding (in px) applied to the
108 * auto-generated tab-wrap around
109 * the window body. Only applies
110 * when additional tabs are
111 * registered. Default 16. Pass 0
112 * for edge-to-edge content.
113 * Filterable at runtime via
114 * `openstation_native_window_tab_wrap_padding`.
115 * @type array $config Arbitrary serializable data to ship
116 * to the bundle alongside the script
117 * tag. Read in JS via
118 * `wp.os.getWindowConfig( $id )`
119 * (or directly at
120 * `window.openStationWindowConfig[ $id ]`).
121 * Recommended over `wp_localize_script`
122 * for native-window scripts because
123 * the lazy-load path bypasses
124 * `wp_print_scripts` — passing config
125 * through this arg guarantees delivery
126 * on both eager AND lazy paths
127 * (mid-session activation). Use this
128 * for REST URLs, nonces, capability
129 * flags, anything session-bound. Empty
130 * array (default) ships nothing.
131 * }
132 * @return true|WP_Error `true` on success; `WP_Error` when any
133 * required arg is missing/invalid or a
134 * declared capability is unmet.
135 */
136 function openstation_register_window( $id, $args = array() ) {
137 $id = sanitize_key( (string) $id );
138 if ( '' === $id ) {
139 return openstation_registration_error(
140 'openstation_missing_id',
141 __( 'Native window id is required and must be a valid slug.', 'desktop-mode' )
142 );
143 }
144
145 $defaults = array(
146 'title' => '',
147 'icon' => 'dashicons-admin-generic',
148 'template' => null,
149 'script' => '',
150 // Optional WP style handle (registered with `wp_register_style()`).
151 // Resolved at payload-build time so the shell can lazy-inject a
152 // `<link rel="stylesheet">` when a peer plugin is activated
153 // mid-session — without this, the parent shell page already
154 // finished `wp_print_styles` and the plugin's CSS is missing
155 // until F5.
156 'style' => '',
157 'width' => 520,
158 'height' => 400,
159 'min_width' => 280,
160 'min_height' => 220,
161 'placement' => 'dock',
162 'capabilities' => array(),
163 'autofocus' => false,
164 'main_tab_label' => '',
165 'main_tab_padding' => '',
166 'config' => array(),
167 );
168 $args = wp_parse_args( $args, $defaults );
169
170 // Capability gate — ALL listed caps must match. Fail closed.
171 foreach ( (array) $args['capabilities'] as $cap ) {
172 if ( ! current_user_can( (string) $cap ) ) {
173 return openstation_registration_error(
174 'openstation_capability_denied',
175 sprintf(
176 /* translators: %s: capability slug. */
177 __( 'Current user lacks the %s capability required to register this native window.', 'desktop-mode' ),
178 (string) $cap
179 ),
180 array(
181 'capability' => (string) $cap,
182 'id' => $id,
183 )
184 );
185 }
186 }
187
188 // Required fields.
189 if ( '' === (string) $args['title'] ) {
190 return openstation_registration_error(
191 'openstation_missing_title',
192 __( 'Native window registration requires a non-empty `title`.', 'desktop-mode' ),
193 array( 'id' => $id )
194 );
195 }
196 if ( ! is_callable( $args['template'] ) ) {
197 return openstation_registration_error(
198 'openstation_invalid_template',
199 __( 'Native window registration requires a callable `template` that echoes the template body.', 'desktop-mode' ),
200 array( 'id' => $id )
201 );
202 }
203
204 $placement = in_array( $args['placement'], array( 'dock', 'none' ), true )
205 ? $args['placement']
206 : 'dock';
207
208 $entry = array(
209 'id' => $id,
210 'title' => (string) $args['title'],
211 'icon' => (string) $args['icon'],
212 'template' => $args['template'],
213 'script' => (string) $args['script'],
214 'style' => (string) $args['style'],
215 'width' => (int) $args['width'],
216 'height' => (int) $args['height'],
217 'min_width' => (int) $args['min_width'],
218 'min_height' => (int) $args['min_height'],
219 'placement' => $placement,
220 'autofocus' => $args['autofocus'],
221 'main_tab_label' => (string) $args['main_tab_label'],
222 // Stored as-is (string or int). `openstation_build_native_window_template_html`
223 // coerces to int and falls back to 16 when absent.
224 'main_tab_padding' => $args['main_tab_padding'],
225 // Bundle-bound config delivered through the same path as
226 // `wp_localize_script` `extra['data']` — see the `config` doc
227 // in this function's `$args` block and `openstation_resolve_script_payload()`
228 // for how it lands on the wire.
229 'config' => is_array( $args['config'] ) ? $args['config'] : array(),
230 );
231 openstation_native_window_registry( $id, $entry );
232
233 /**
234 * Fires after a native desktop window is successfully registered.
235 *
236 * Lets plugins react to registrations made by other plugins —
237 * e.g. a widget that auto-opens when a given window registers,
238 * or analytics tracking of which windows the current install
239 * exposes. Does NOT fire when `openstation_register_window()`
240 * returns a `WP_Error`.
241 *
242 * @param string $id The window id.
243 * @param array $entry The stored registry entry (id, title,
244 * icon, template callback, script handle,
245 * size defaults, placement, autofocus).
246 */
247 do_action( 'openstation_native_window_registered', $id, $entry );
248
249 return true;
250 }
251
252 /**
253 * Internal module-level registry for native windows registered
254 * via {@see openstation_register_window()}. Passing a second
255 * argument stores the entry; passing only the id returns the
256 * stored value (or null). Kept small and side-effect-free so
257 * tests can introspect.
258 *
259 * @internal
260 *
261 * @param string $id Window id.
262 * @param array|null $entry Entry to store, or null to just read.
263 * @return array|null Either the stored entry or the full registry
264 * (when id is empty).
265 */
266 function openstation_native_window_registry( $id = '', $entry = null ) {
267 static $store = array();
268
269 if ( '' === (string) $id ) {
270 return $store;
271 }
272 if ( null !== $entry ) {
273 $store[ $id ] = $entry;
274 }
275 return isset( $store[ $id ] ) ? $store[ $id ] : null;
276 }
277
278
279 /**
280 * Returns the `wp_kses`-shaped allowlist used to escape native-window
281 * `<template>` payloads (and the recycle-bin template) before they're
282 * emitted into the page.
283 *
284 * Templates are inert until JS clones them out of the `<template>`
285 * tag — but Plugin Check still requires escape-on-output. The list
286 * extends `wp_kses_allowed_html( 'post' )` with form controls,
287 * `<os-*>` web components, and dashicon spans, plus permissive
288 * `data-*`, `aria-*`, and component-specific attributes. Plugins
289 * registering their own native windows can extend the list via the
290 * `openstation_native_window_allowed_html` filter below.
291 *
292 * @return array<string,array<string,bool>>
293 */
294 function openstation_native_window_allowed_html() {
295 $base = wp_kses_allowed_html( 'post' );
296
297 $global_attrs = array(
298 'id' => true,
299 'class' => true,
300 'style' => true,
301 'title' => true,
302 'role' => true,
303 'tabindex' => true,
304 'hidden' => true,
305 'slot' => true,
306 'part' => true,
307 'lang' => true,
308 'dir' => true,
309 'draggable' => true,
310 'contenteditable' => true,
311 'data-*' => true,
312 'aria-*' => true,
313 // `full-width` is a layout-level flag honoured by
314 // `<os-form>` (and any future os-* container that opts in
315 // to row-spanning slotted children). Lives in the global
316 // allowlist so a plain `<div full-width>` wrapper isn't
317 // stripped by kses on its way through the template.
318 'full-width' => true,
319 );
320
321 $form_attrs = array_merge(
322 $global_attrs,
323 array(
324 'name' => true,
325 'value' => true,
326 'placeholder' => true,
327 'required' => true,
328 'disabled' => true,
329 'readonly' => true,
330 'checked' => true,
331 'selected' => true,
332 'min' => true,
333 'max' => true,
334 'step' => true,
335 'minlength' => true,
336 'maxlength' => true,
337 'pattern' => true,
338 'autocomplete' => true,
339 'autofocus' => true,
340 'multiple' => true,
341 'rows' => true,
342 'cols' => true,
343 'wrap' => true,
344 'size' => true,
345 'for' => true,
346 'form' => true,
347 'type' => true,
348 'accept' => true,
349 'list' => true,
350 'src' => true,
351 'href' => true,
352 'target' => true,
353 'rel' => true,
354 'open' => true,
355 'variant' => true,
356 )
357 );
358
359 $wpd_attrs = array_merge(
360 $form_attrs,
361 array(
362 'gap' => true,
363 'padding' => true,
364 'align' => true,
365 'justify' => true,
366 'direction' => true,
367 'wrap' => true,
368 'inset' => true,
369 'icon' => true,
370 'tone' => true,
371 'size' => true,
372 'shape' => true,
373 'badge' => true,
374 'selectable' => true,
375 'sticky-header' => true,
376 'sticky-columns' => true,
377 'hover' => true,
378 'striped' => true,
379 'bordered' => true,
380 'compact' => true,
381 'loading' => true,
382 'loading-rows' => true,
383 'columns' => true,
384 'rows' => true,
385 'sortable' => true,
386 'expandable' => true,
387 'preset' => true,
388 'label' => true,
389 'description' => true,
390 'orientation' => true,
391 'level' => true,
392 'collapsed' => true,
393 // `<os-form>` props + the `full-width` row span flag
394 // honoured by the form's slotted-child layout rule.
395 'submit-label' => true,
396 'reset-label' => true,
397 'busy' => true,
398 'error' => true,
399 'min-column' => true,
400 'show-reset' => true,
401 'reveal' => true,
402 'full-width' => true,
403 )
404 );
405
406 // Built-in HTML elements the templates rely on.
407 $extra = array(
408 'form' => $form_attrs,
409 'fieldset' => $form_attrs,
410 'legend' => $global_attrs,
411 'label' => $form_attrs,
412 'input' => $form_attrs,
413 'select' => $form_attrs,
414 'option' => $form_attrs,
415 'optgroup' => $form_attrs,
416 'textarea' => $form_attrs,
417 'button' => $form_attrs,
418 'output' => $form_attrs,
419 'datalist' => $global_attrs,
420 'progress' => $form_attrs,
421 'meter' => $form_attrs,
422 'details' => $global_attrs,
423 'summary' => $global_attrs,
424 'dialog' => $global_attrs,
425 'header' => $global_attrs,
426 'footer' => $global_attrs,
427 'main' => $global_attrs,
428 'nav' => $global_attrs,
429 'section' => $global_attrs,
430 'article' => $global_attrs,
431 'aside' => $global_attrs,
432 'figure' => $global_attrs,
433 'figcaption' => $global_attrs,
434 'time' => array_merge( $global_attrs, array( 'datetime' => true ) ),
435 'mark' => $global_attrs,
436 'small' => $global_attrs,
437 'svg' => array_merge(
438 $global_attrs,
439 array(
440 'viewbox' => true,
441 'width' => true,
442 'height' => true,
443 'fill' => true,
444 'stroke' => true,
445 'xmlns' => true,
446 )
447 ),
448 'path' => array(
449 'd' => true,
450 'fill' => true,
451 'stroke' => true,
452 'stroke-width' => true,
453 'stroke-linecap' => true,
454 'stroke-linejoin' => true,
455 'class' => true,
456 ),
457 'g' => array(
458 'class' => true,
459 'transform' => true,
460 'fill' => true,
461 ),
462 'circle' => array(
463 'cx' => true,
464 'cy' => true,
465 'r' => true,
466 'fill' => true,
467 'stroke' => true,
468 'class' => true,
469 ),
470 'rect' => array(
471 'x' => true,
472 'y' => true,
473 'width' => true,
474 'height' => true,
475 'rx' => true,
476 'ry' => true,
477 'fill' => true,
478 'stroke' => true,
479 'class' => true,
480 ),
481 'line' => array(
482 'x1' => true,
483 'y1' => true,
484 'x2' => true,
485 'y2' => true,
486 'stroke' => true,
487 'stroke-width' => true,
488 'class' => true,
489 ),
490 'polyline' => array(
491 'points' => true,
492 'fill' => true,
493 'stroke' => true,
494 'class' => true,
495 ),
496 'polygon' => array(
497 'points' => true,
498 'fill' => true,
499 'stroke' => true,
500 'class' => true,
501 ),
502 'use' => array(
503 'href' => true,
504 'class' => true,
505 ),
506 );
507
508 // `<os-*>` web components — every shipped tag plus a permissive
509 // open door for new ones added by plugin templates.
510 $wpd_tags = array(
511 'os-stack',
512 'os-cluster',
513 'os-grid',
514 'os-spacer',
515 'os-divider',
516 'os-tabs',
517 'os-tab',
518 'os-tabpanel',
519 'os-segmented',
520 'os-segment',
521 'os-button',
522 'os-icon-button',
523 'os-button-group',
524 'os-text-field',
525 'os-textarea',
526 'os-search-field',
527 'os-select',
528 'os-option',
529 'os-checkbox',
530 'os-checkbox-label',
531 'os-radio',
532 'os-radio-group',
533 'os-form',
534 'os-switch',
535 'os-slider',
536 'os-table',
537 'os-table-column',
538 'os-table-row',
539 'os-table-cell',
540 'os-card',
541 'os-list',
542 'os-list-item',
543 'os-badge',
544 'os-pill',
545 'os-tag',
546 'os-chip',
547 'os-spinner',
548 'os-skeleton',
549 'os-empty-state',
550 'os-tooltip',
551 'os-popover',
552 'os-menu',
553 'os-menu-item',
554 'os-modal',
555 'os-drawer',
556 'os-toast',
557 'os-icon',
558 'os-avatar',
559 'os-heading',
560 'os-text',
561 'os-link',
562 'os-banner',
563 'os-alert',
564 'os-callout',
565 'os-form-row',
566 'os-form-section',
567 'os-help-text',
568 'os-toolbar',
569 'os-toolbar-group',
570 );
571 foreach ( $wpd_tags as $tag ) {
572 $extra[ $tag ] = $wpd_attrs;
573 }
574
575 $allowed = array_merge( $base, $extra );
576
577 // Promote the framework's global attrs (`slot`, `part`,
578 // `full-width`, `data-*`, `aria-*`, …) to EVERY allowed tag —
579 // otherwise plain wrappers like `<div slot="header">` lose
580 // their `slot` attribute on the way through kses and get
581 // projected into the default slot instead of the named one.
582 // Caught by inspection when the Add User form's header
583 // rendered as a fields-grid cell instead of a banner above
584 // the fields. `array_merge( + )` with a kses-true value
585 // (boolean `true`) is harmless for tags whose entries are
586 // just `true` rather than an attrs map — array_merge skips
587 // non-array values.
588 foreach ( $allowed as $tag => $attrs ) {
589 if ( is_array( $attrs ) ) {
590 $allowed[ $tag ] = array_merge( $attrs, $global_attrs );
591 }
592 }
593
594 /**
595 * Filters the kses allowlist used when escaping native-window
596 * `<template>` payloads.
597 *
598 * Plugins registering their own native windows can extend the
599 * list with custom tags or attributes if their templates need
600 * markup not covered here.
601 *
602 * @param array $allowed wp_kses-shaped allowlist.
603 */
604 return (array) apply_filters( 'openstation_native_window_allowed_html', $allowed );
605 }
606
607 /**
608 * Run `wp_kses` on a native-window template body with the framework
609 * allowlist, **auto-extending the allowlist with every `<os-*>` tag
610 * the template actually uses.**
611 *
612 * The pain this fixes: each shipped `<os-*>` component had to be
613 * manually added to the `$wpd_tags` list above, and the failure mode
614 * of forgetting it was silent — kses would strip the tag, the
615 * template would render as a sea of unparented children, and you'd
616 * spend an afternoon working out why "the form has no buttons."
617 *
618 * Plugin authors registering a new component now only need to
619 * `defineComponent('os-foo', OsFoo)` on the JS side and use
620 * `<os-foo>` in their template — this helper finds the tag at
621 * render time, tags it onto the allowlist with the standard
622 * permissive attrs, and runs kses with the extended list.
623 *
624 * Every callsite in the framework that previously did the
625 * `wp_kses( $html, openstation_native_window_allowed_html() )`
626 * dance can call this instead and get tag-discovery for free.
627 *
628 * @param string $html Template HTML to sanitize.
629 * @return string Sanitized HTML.
630 */
631 function openstation_kses_native_window_template( $html ) {
632 $allowed = openstation_native_window_allowed_html();
633
634 if ( preg_match_all( '/<(os-[a-z][a-z0-9-]*)\b/i', (string) $html, $matches ) ) {
635 $unique = array_unique( array_map( 'strtolower', $matches[1] ) );
636 $wpd_attrs = isset( $allowed['os-button'] )
637 ? $allowed['os-button']
638 : array();
639 foreach ( $unique as $tag ) {
640 if ( ! isset( $allowed[ $tag ] ) ) {
641 $allowed[ $tag ] = $wpd_attrs;
642 }
643 }
644 }
645
646 return wp_kses( (string) $html, $allowed );
647 }
648
649 /**
650 * Render a native window's template HTML to a string, wrapping
651 * with tabs when the window has at least one additional tab
652 * registered. Shared by `openstation_render_native_window_templates()`
653 * (which emits the live `<template>` element) and
654 * `openstation_build_native_windows_payload()` (which captures the same
655 * string for the shell config so mid-session activation can inject
656 * the template without a reload).
657 *
658 * Single-tab windows (no additional tabs registered) render the
659 * same flat body they always did — backwards-compatible with
660 * every existing caller.
661 *
662 * @param array $entry Window registry entry.
663 * @return string Template body HTML (no outer `<template>` tag).
664 */
665 function openstation_build_native_window_template_html( $entry ) {
666 if ( ! is_array( $entry ) || ! is_callable( $entry['template'] ) ) {
667 return '';
668 }
669
670 $tabs = openstation_get_native_window_tabs( $entry['id'] );
671 $has_extras = count( $tabs ) > 1;
672
673 // Fast path — single-pane window, no wrapping.
674 if ( ! $has_extras ) {
675 ob_start();
676 call_user_func( $entry['template'] );
677 return (string) ob_get_clean();
678 }
679
680 // Multi-tab window — wrap in <os-stack> + <os-tabs> + one
681 // <os-tabpanel> per tab. The default active tab is the main
682 // one (the window's own template). Plugin authors still get to
683 // declare their own tab-change side effects via the
684 // `os-tab-change` event bubbled by <os-tabs>.
685 //
686 // The wrap's padding is plugin-controllable two ways:
687 // 1. `main_tab_padding` arg on `openstation_register_window` —
688 // a per-window override. `0` opts into edge-to-edge
689 // content.
690 // 2. `openstation_native_window_tab_wrap_padding` filter for
691 // late-bound overrides (e.g. a theme that wants every
692 // tabbed window to adopt a narrower inset).
693 // Default stays 16px so existing plugins don't shift.
694 $default_padding = isset( $entry['main_tab_padding'] )
695 && '' !== (string) $entry['main_tab_padding']
696 ? (int) $entry['main_tab_padding']
697 : 16;
698 /**
699 * Filters the padding (in px) applied to the auto-generated
700 * tab wrap around a native window's template body. The shell
701 * emits the wrap as `<os-stack padding="N">`; the CSS-as-
702 * attribute pipeline at the client translates that to
703 * `style.padding`.
704 *
705 * Return `0` for edge-to-edge content. Negative values are
706 * clamped to 0.
707 *
708 * @param int $padding Default padding in px.
709 * @param string $window_id The native window id.
710 */
711 $padding = (int) apply_filters(
712 'openstation_native_window_tab_wrap_padding',
713 $default_padding,
714 (string) $entry['id']
715 );
716 if ( $padding < 0 ) {
717 $padding = 0;
718 }
719
720 $buffer = sprintf(
721 '<os-stack gap="12" padding="%d">',
722 $padding
723 );
724 $buffer .= '<os-tabs value="' . esc_attr( OPENSTATION_NATIVE_WINDOW_MAIN_TAB ) . '">';
725 foreach ( $tabs as $tab ) {
726 $buffer .= sprintf(
727 '<os-tab value="%s">%s</os-tab>',
728 esc_attr( $tab['value'] ),
729 esc_html( $tab['label'] )
730 );
731 }
732 $buffer .= '</os-tabs>';
733
734 // Stamp `hidden` on every non-active panel directly in the
735 // emitted HTML. The client-side `<os-tabs>` syncs panel
736 // visibility on `value` changes, but its initial sync runs
737 // inside a microtask — and panel siblings may not have upgraded
738 // in time on first paint. Setting the attribute server-side
739 // makes first paint correct regardless of upgrade order; the JS
740 // keeps owning subsequent transitions.
741 foreach ( $tabs as $tab ) {
742 if ( ! is_callable( $tab['template'] ) ) {
743 continue;
744 }
745 $is_active = OPENSTATION_NATIVE_WINDOW_MAIN_TAB === $tab['value'];
746 $buffer .= sprintf(
747 '<os-tabpanel for="%s"%s>',
748 esc_attr( $tab['value'] ),
749 $is_active ? '' : ' hidden'
750 );
751 ob_start();
752 call_user_func( $tab['template'] );
753 $buffer .= (string) ob_get_clean();
754 $buffer .= '</os-tabpanel>';
755 }
756
757 $buffer .= '</os-stack>';
758 return $buffer;
759 }
760
761 /**
762 * Enqueue every registered native window's script when the shell
763 * is active. Runs on `admin_enqueue_scripts` alongside the main
764 * shell enqueue so ordering (shell → plugin scripts) is
765 * deterministic.
766 */
767 function openstation_enqueue_native_window_scripts() {
768 if ( ! openstation_is_enabled() || openstation_is_chromeless_request() || openstation_is_classic_request() ) {
769 return;
770 }
771 $registry = openstation_native_window_registry();
772 if ( ! is_array( $registry ) ) {
773 return;
774 }
775 foreach ( $registry as $entry ) {
776 // Enqueue per-tab scripts — each tab registration can carry
777 // its own script handle so a tab's JS module stays scoped to
778 // that tab. Main tab uses the window's own `script`; it's
779 // enqueued below alongside the localize call.
780 $tabs = openstation_get_native_window_tabs( $entry['id'] );
781 foreach ( $tabs as $tab ) {
782 if ( $tab['is_main'] || empty( $tab['script'] ) ) {
783 continue;
784 }
785 wp_enqueue_script( $tab['script'] );
786 }
787
788 if ( empty( $entry['script'] ) ) {
789 continue;
790 }
791 wp_enqueue_script( $entry['script'] );
792 // Localize the config the JS side reads to register itself.
793 wp_localize_script(
794 $entry['script'],
795 'openStationNativeWindow_' . str_replace( '-', '_', $entry['id'] ),
796 array(
797 'id' => $entry['id'],
798 'title' => $entry['title'],
799 'icon' => $entry['icon'],
800 'width' => $entry['width'],
801 'height' => $entry['height'],
802 'minWidth' => $entry['min_width'],
803 'minHeight' => $entry['min_height'],
804 'placement' => $entry['placement'],
805 'autofocus' => $entry['autofocus'],
806 'templateId' => 'os-native-window-' . $entry['id'],
807 'tabs' => array_map(
808 static function ( $tab ) {
809 return array(
810 'value' => $tab['value'],
811 'label' => $tab['label'],
812 'isMain' => $tab['is_main'],
813 );
814 },
815 $tabs
816 ),
817 )
818 );
819
820 // Bundle-bound `config`. Ships through
821 // `wp_add_inline_script` `'before'` so it lands on the eager
822 // path the same way `wp_localize_script` does, AND through
823 // the lazy-load payload (see `openstation_resolve_script_payload`)
824 // so the same data is available even when the script is
825 // dynamically injected mid-session. The bundle reads it via
826 // `wp.os.getWindowConfig( id )` or directly at
827 // `window.openStationWindowConfig[ id ]`.
828 if ( ! empty( $entry['config'] ) && is_array( $entry['config'] ) ) {
829 wp_add_inline_script(
830 $entry['script'],
831 sprintf(
832 'window.openStationWindowConfig=window.openStationWindowConfig||{};window.openStationWindowConfig[%s]=%s;',
833 wp_json_encode( $entry['id'] ),
834 wp_json_encode( $entry['config'] )
835 ),
836 'before'
837 );
838 }
839 }
840 }
841 add_action( 'admin_enqueue_scripts', 'openstation_enqueue_native_window_scripts', 20 );
842
843 /**
844 * Emit a `<template>` tag for every registered native window on
845 * `admin_footer` when the shell is active. The JS side resolves
846 * these via `document.getElementById( `os-native-window-${id}` )`
847 * and clones them into each opened window's body.
848 */
849 function openstation_render_native_window_templates() {
850 if ( ! openstation_is_enabled() || openstation_is_chromeless_request() || openstation_is_classic_request() ) {
851 return;
852 }
853 $registry = openstation_native_window_registry();
854 if ( ! is_array( $registry ) ) {
855 return;
856 }
857 foreach ( $registry as $entry ) {
858 if ( ! is_callable( $entry['template'] ) ) {
859 continue;
860 }
861 $html = openstation_build_native_window_template_html( $entry );
862 if ( '' === $html ) {
863 continue;
864 }
865 printf(
866 '<template id="os-native-window-%s">',
867 esc_attr( $entry['id'] )
868 );
869 // `openstation_kses_native_window_template()` auto-extends
870 // the allowlist with any `<os-*>` tag the template carries
871 // — so plugin authors never have to remember to register
872 // their custom component tags in the kses list.
873 echo openstation_kses_native_window_template( $html ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper kses-escapes.
874 echo '</template>';
875 }
876 }
877 add_action( 'admin_footer', 'openstation_render_native_window_templates', 20 );
878