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

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