| @@ -99,8 +99,27 @@ | ||
| 99 | 99 | * paints. Declaring it here is what |
| 100 | 100 | * keeps it off the boot critical |
| 101 | 101 | * path: it travels with the window |
| 102 | 102 | * it extends. Default empty. |
| 103 | + * @type string[] $styles Companion style handles injected on | |
| 104 | + * the window's first open, after the | |
| 105 | + * window's own `$style`, in the order | |
| 106 | + * given — so at equal specificity a | |
| 107 | + * companion's overrides win, the same | |
| 108 | + * source-order contract an enqueue | |
| 109 | + * dependency gives. The styles-side | |
| 110 | + * mirror of `$scripts`: a stylesheet | |
| 111 | + * that only paints surfaces inside | |
| 112 | + * this window is dead weight on every | |
| 113 | + * document that never shows it — | |
| 114 | + * declared here it costs nothing at | |
| 115 | + * boot and never reaches chromeless | |
| 116 | + * iframes at all. Unlike `$style` | |
| 117 | + * (injected when the window registers, | |
| 118 | + * so mid-session activations paint), | |
| 119 | + * companions wait for the first open; | |
| 120 | + * the deferral is the point. Default | |
| 121 | + * empty. | |
| 103 | 122 | * @type bool $preload_script Load `$script` (and `$scripts`) at |
| 104 | 123 | * shell boot instead of on first |
| 105 | 124 | * open. Default false — a window's |
| 106 | 125 | * bundle is dead weight until the |
| @@ -125,8 +144,27 @@ | ||
| 125 | 144 | * @type int $min_height Minimum height (px). Default 220. |
| 126 | 145 | * @type string $placement 'dock' | 'none'. Default 'dock'. |
| 127 | 146 | * 'none' skips the tile (plugin |
| 128 | 147 | * opens the window programmatically). |
| 148 | + * A PROPOSED default only: the user's | |
| 149 | + * OpenStation Preferences → Navigation | |
| 150 | + * pick wins, and so does a right-click | |
| 151 | + * "Keep in dock". | |
| 152 | + * @type string $admin 'site' | 'network' | 'any'. Default | |
| 153 | + * 'site': offered on every site's | |
| 154 | + * shell and never on the network | |
| 155 | + * admin's, which is right for a window | |
| 156 | + * that reads the current site's REST | |
| 157 | + * API. 'network' is the network | |
| 158 | + * admin's shell only; 'any' is both. | |
| 159 | + * @type string $nav_kind 'app' | 'control'. Default 'app'. | |
| 160 | + * What the window IS, which decides | |
| 161 | + * where its launcher defaults to (apps | |
| 162 | + * to the desktop, controls to the | |
| 163 | + * dock) and which dock zone it sits | |
| 164 | + * in. Plugins want 'app'; 'control' | |
| 165 | + * is for OpenStation's own | |
| 166 | + * affordances. | |
| 129 | 167 | * @type int $dock_order Sort key among system tiles, |
| 130 | 168 | * ascending; ties keep registration |
| 131 | 169 | * order. Default 0, which places the |
| 132 | 170 | * tile ahead of the shell's own |
| @@ -202,8 +240,9 @@ | ||
| 202 | 240 | 'icon' => 'dashicons-admin-generic', |
| 203 | 241 | 'template' => null, |
| 204 | 242 | 'script' => '', |
| 205 | 243 | 'scripts' => array(), |
| 244 | + 'styles' => array(), | |
| 206 | 245 | 'preload_script' => false, |
| 207 | 246 | // Optional WP style handle (registered with `wp_register_style()`). |
| 208 | 247 | // Resolved at payload-build time so the shell can lazy-inject a |
| 209 | 248 | // `<link rel="stylesheet">` when a peer plugin is activated |
| @@ -215,8 +254,10 @@ | ||
| 215 | 254 | 'height' => 400, |
| 216 | 255 | 'min_width' => 280, |
| 217 | 256 | 'min_height' => 220, |
| 218 | 257 | 'placement' => 'dock', |
| 258 | + 'admin' => 'site', | |
| 259 | + 'nav_kind' => 'app', | |
| 219 | 260 | 'dock_order' => 0, |
| 220 | 261 | 'placeable' => false, |
| 221 | 262 | 'capabilities' => array(), |
| 222 | 263 | 'autofocus' => false, |
| @@ -221,11 +262,17 @@ | ||
| 221 | 262 | 'capabilities' => array(), |
| 222 | 263 | 'autofocus' => false, |
| 223 | 264 | 'main_tab_label' => '', |
| 224 | 265 | 'main_tab_padding' => '', |
| 266 | + // Admin pages this window answers for, `array( id, page )` per | |
| 267 | + // entry. See `App::menu()` and `openstation_apps_menu_pages()`. | |
| 268 | + 'menu_pages' => array(), | |
| 225 | 269 | 'config' => array(), |
| 226 | 270 | ); |
| 227 | 271 | $args = wp_parse_args( $args, $defaults ); |
| 272 | + if ( ! in_array( $args['admin'], array( 'site', 'network', 'any' ), true ) ) { | |
| 273 | + $args['admin'] = 'site'; | |
| 274 | + } | |
| 228 | 275 | |
| 229 | 276 | // Capability gate — ALL listed caps must match. Fail closed. |
| 230 | 277 | foreach ( (array) $args['capabilities'] as $cap ) { |
| 231 | 278 | if ( ! current_user_can( (string) $cap ) ) { |
| @@ -263,8 +310,17 @@ | ||
| 263 | 310 | $placement = in_array( $args['placement'], array( 'dock', 'none' ), true ) |
| 264 | 311 | ? $args['placement'] |
| 265 | 312 | : 'dock'; |
| 266 | 313 | |
| 314 | + // What the window IS, which is what decides where its launcher | |
| 315 | + // goes by default and which dock zone it sits in. `'app'` for an | |
| 316 | + // installed app (the default, and what every plugin wants); | |
| 317 | + // `'control'` for an OpenStation affordance — the Trash is the | |
| 318 | + // only shipped one. | |
| 319 | + $nav_kind = in_array( $args['nav_kind'], array( 'app', 'control' ), true ) | |
| 320 | + ? $args['nav_kind'] | |
| 321 | + : 'app'; | |
| 322 | + | |
| 267 | 323 | $entry = array( |
| 268 | 324 | 'id' => $id, |
| 269 | 325 | 'title' => (string) $args['title'], |
| 270 | 326 | 'icon' => (string) $args['icon'], |
| @@ -281,8 +337,19 @@ | ||
| 281 | 337 | } |
| 282 | 338 | ) |
| 283 | 339 | ) |
| 284 | 340 | ), |
| 341 | + // Companion style handles, same dedupe/strip as `scripts`. | |
| 342 | + 'styles' => array_values( | |
| 343 | + array_unique( | |
| 344 | + array_filter( | |
| 345 | + array_map( 'strval', (array) $args['styles'] ), | |
| 346 | + static function ( $handle ) { | |
| 347 | + return '' !== $handle; | |
| 348 | + } | |
| 349 | + ) | |
| 350 | + ) | |
| 351 | + ), | |
| 285 | 352 | 'preload_script' => (bool) $args['preload_script'], |
| 286 | 353 | 'style' => (string) $args['style'], |
| 287 | 354 | 'width' => (int) $args['width'], |
| 288 | 355 | 'height' => (int) $args['height'], |
| @@ -288,8 +355,11 @@ | ||
| 288 | 355 | 'height' => (int) $args['height'], |
| 289 | 356 | 'min_width' => (int) $args['min_width'], |
| 290 | 357 | 'min_height' => (int) $args['min_height'], |
| 291 | 358 | 'placement' => $placement, |
| 359 | + 'nav_kind' => $nav_kind, | |
| 360 | + // Which admin's shell offers it; see the `admin` arg. | |
| 361 | + 'admin' => $args['admin'], | |
| 292 | 362 | // Sort key among system tiles, ascending. `0` (the default) |
| 293 | 363 | // puts a plugin's tile ahead of the shell's own trailing |
| 294 | 364 | // cluster — Mio 10, Overview 20, System 30 — which is where a |
| 295 | 365 | // launcher belongs. Trash uses 40 to sit at the very end. |
| @@ -303,8 +373,12 @@ | ||
| 303 | 373 | // Bundle-bound config delivered through the same path as |
| 304 | 374 | // `wp_localize_script` `extra['data']` — see the `config` doc |
| 305 | 375 | // in this function's `$args` block and `openstation_resolve_script_payload()` |
| 306 | 376 | // for how it lands on the wire. |
| 377 | + // Admin pages this window answers for, `array( id, page )` per | |
| 378 | + // entry — dropped here once, which is why the shell saw an | |
| 379 | + // empty list and claimed none of them. | |
| 380 | + 'menu_pages' => is_array( $args['menu_pages'] ) ? array_values( $args['menu_pages'] ) : array(), | |
| 307 | 381 | 'config' => is_array( $args['config'] ) ? $args['config'] : array(), |
| 308 | 382 | ); |
| 309 | 383 | openstation_native_window_registry( $id, $entry ); |
| 310 | 384 | |
| @@ -362,9 +436,9 @@ | ||
| 362 | 436 | * Templates are inert until JS clones them out of the `<template>` |
| 363 | 437 | * tag — but Plugin Check still requires escape-on-output. The list |
| 364 | 438 | * extends `wp_kses_allowed_html( 'post' )` with form controls, |
| 365 | 439 | * `<os-*>` web components, and dashicon spans, plus permissive |
| 366 | - * `data-*`, `aria-*`, and component-specific attributes. Plugins | |
| 440 | + * `data-*`, common ARIA, and component-specific attributes. Plugins | |
| 367 | 441 | * registering their own native windows can extend the list via the |
| 368 | 442 | * `openstation_native_window_allowed_html` filter below. |
| 369 | 443 | * |
| 370 | 444 | * @return array<string,array<string,bool>> |
| @@ -386,9 +460,15 @@ | ||
| 386 | 460 | 'dir' => true, |
| 387 | 461 | 'draggable' => true, |
| 388 | 462 | 'contenteditable' => true, |
| 389 | 463 | 'data-*' => true, |
| 390 | - 'aria-*' => true, | |
| 464 | + // `wp_kses` only treats the `data-*` wildcard specially. ARIA | |
| 465 | + // attributes must be admitted by their exact names or they are | |
| 466 | + // silently stripped from native-window templates. | |
| 467 | + 'aria-label' => true, | |
| 468 | + 'aria-labelledby' => true, | |
| 469 | + 'aria-current' => true, | |
| 470 | + 'aria-hidden' => true, | |
| 391 | 471 | // `full-width` is a layout-level flag honoured by |
| 392 | 472 | // `<os-form>` (and any future os-* container that opts in |
| 393 | 473 | // to row-spanning slotted children). Lives in the global |
| 394 | 474 | // allowlist so a plain `<div full-width>` wrapper isn't |
| @@ -457,8 +537,9 @@ | ||
| 457 | 537 | 'bordered' => true, |
| 458 | 538 | 'compact' => true, |
| 459 | 539 | 'loading' => true, |
| 460 | 540 | 'loading-rows' => true, |
| 541 | + 'empty' => true, | |
| 461 | 542 | 'columns' => true, |
| 462 | 543 | 'rows' => true, |
| 463 | 544 | 'sortable' => true, |
| 464 | 545 | 'expandable' => true, |
| @@ -463,8 +544,9 @@ | ||
| 463 | 544 | 'sortable' => true, |
| 464 | 545 | 'expandable' => true, |
| 465 | 546 | 'preset' => true, |
| 466 | 547 | 'label' => true, |
| 548 | + 'heading' => true, | |
| 467 | 549 | 'description' => true, |
| 468 | 550 | 'orientation' => true, |
| 469 | 551 | 'level' => true, |
| 470 | 552 | 'collapsed' => true, |
| @@ -652,9 +734,9 @@ | ||
| 652 | 734 | |
| 653 | 735 | $allowed = array_merge( $base, $extra ); |
| 654 | 736 | |
| 655 | 737 | // Promote the framework's global attrs (`slot`, `part`, |
| 656 | - // `full-width`, `data-*`, `aria-*`, …) to EVERY allowed tag — | |
| 738 | + // `full-width`, `data-*`, common ARIA, …) to EVERY allowed tag — | |
| 657 | 739 | // otherwise plain wrappers like `<div slot="header">` lose |
| 658 | 740 | // their `slot` attribute on the way through kses and get |
| 659 | 741 | // projected into the default slot instead of the named one. |
| 660 | 742 | // Caught by inspection when the Add User form's header |
| @@ -834,8 +916,53 @@ | ||
| 834 | 916 | return $buffer; |
| 835 | 917 | } |
| 836 | 918 | |
| 837 | 919 | /** |
| 920 | + * Run a native window's registered `config` through the | |
| 921 | + * `openstation_native_window_config` filter, normalized to an array. | |
| 922 | + * | |
| 923 | + * Called at BOTH serialization points — the eager inline-script | |
| 924 | + * attach in `openstation_enqueue_native_window_scripts()` and the | |
| 925 | + * lazy `scriptL10n` synthesis in | |
| 926 | + * `openstation_build_native_windows_payload()` — so the filter sees | |
| 927 | + * every copy of the blob that can reach a browser. | |
| 928 | + * | |
| 929 | + * @param array $entry Registry entry (needs `id`; `config` optional). | |
| 930 | + * @return array Filtered config. Empty array when nothing to ship. | |
| 931 | + */ | |
| 932 | +function openstation_filter_native_window_config( $entry ) { | |
| 933 | + $config = isset( $entry['config'] ) && is_array( $entry['config'] ) | |
| 934 | + ? $entry['config'] | |
| 935 | + : array(); | |
| 936 | + | |
| 937 | + /** | |
| 938 | + * Filter a native window's config blob at emit time. | |
| 939 | + * | |
| 940 | + * The registry snapshots `config` when `openstation_register_window()` | |
| 941 | + * runs — usually `init`. This filter runs when the blob is | |
| 942 | + * serialized for the browser (enqueue time on the eager path, | |
| 943 | + * payload-build time on the lazy path), so values that depend on | |
| 944 | + * hooks registered later in the bootstrap can be refreshed without | |
| 945 | + * moving the whole registration. The WP Explorer uses it to | |
| 946 | + * re-collect `previewActions` so plugins may add | |
| 947 | + * `openstation_my_wordpress_preview_actions` callbacks any time | |
| 948 | + * during a normal bootstrap, not just before `init` 99. | |
| 949 | + * | |
| 950 | + * Runs per request, after the current user is determined — | |
| 951 | + * capability-gated values are safe to compute here. | |
| 952 | + * | |
| 953 | + * **Status: Experimental** | |
| 954 | + * | |
| 955 | + * @param array $config Config blob as registered (empty array | |
| 956 | + * when the window registered none). | |
| 957 | + * @param string $window_id Native window id. | |
| 958 | + */ | |
| 959 | + $config = apply_filters( 'openstation_native_window_config', $config, (string) $entry['id'] ); | |
| 960 | + | |
| 961 | + return is_array( $config ) ? $config : array(); | |
| 962 | +} | |
| 963 | + | |
| 964 | +/** | |
| 838 | 965 | * Attach every registered native window's script data, and enqueue |
| 839 | 966 | * the handful of bundles that asked to load at boot. |
| 840 | 967 | * |
| 841 | 968 | * **A native window's bundle is not enqueued here.** It loads the |
| @@ -857,9 +984,9 @@ | ||
| 857 | 984 | * builds that payload at 10, and data attached after it would ship a |
| 858 | 985 | * bundle with no config. |
| 859 | 986 | */ |
| 860 | 987 | function openstation_enqueue_native_window_scripts() { |
| 861 | - if ( ! openstation_is_enabled() || openstation_is_chromeless_request() || openstation_is_classic_request() ) { | |
| 988 | + if ( ! openstation_is_shell_request() ) { | |
| 862 | 989 | return; |
| 863 | 990 | } |
| 864 | 991 | $registry = openstation_native_window_registry(); |
| 865 | 992 | if ( ! is_array( $registry ) ) { |
| @@ -887,8 +1014,16 @@ | ||
| 887 | 1014 | wp_enqueue_script( $entry['script'] ); |
| 888 | 1015 | foreach ( (array) $entry['scripts'] as $companion ) { |
| 889 | 1016 | wp_enqueue_script( $companion ); |
| 890 | 1017 | } |
| 1018 | + // Preload means "everything at boot" — companion styles | |
| 1019 | + // ride along so the window paints styled on a preloaded | |
| 1020 | + // first open, same as its scripts are already parsed. | |
| 1021 | + if ( ! empty( $entry['styles'] ) ) { | |
| 1022 | + foreach ( (array) $entry['styles'] as $companion_style ) { | |
| 1023 | + wp_enqueue_style( $companion_style ); | |
| 1024 | + } | |
| 1025 | + } | |
| 891 | 1026 | } |
| 892 | 1027 | // Localize the config the JS side reads to register itself. |
| 893 | 1028 | wp_localize_script( |
| 894 | 1029 | $entry['script'], |
| @@ -926,15 +1061,16 @@ | ||
| 926 | 1061 | // would mean a shell page shipped the identical assignment |
| 927 | 1062 | // twice: once as `before`, once as `l10n`. The bundle reads it |
| 928 | 1063 | // via `wp.os.getWindowConfig( id )` or directly at |
| 929 | 1064 | // `window.openStationWindowConfig[ id ]`. |
| 930 | - if ( $preload && ! empty( $entry['config'] ) && is_array( $entry['config'] ) ) { | |
| 1065 | + $config = openstation_filter_native_window_config( $entry ); | |
| 1066 | + if ( $preload && ! empty( $config ) ) { | |
| 931 | 1067 | wp_add_inline_script( |
| 932 | 1068 | $entry['script'], |
| 933 | 1069 | sprintf( |
| 934 | 1070 | 'window.openStationWindowConfig=window.openStationWindowConfig||{};window.openStationWindowConfig[%s]=%s;', |
| 935 | 1071 | wp_json_encode( $entry['id'] ), |
| 936 | - wp_json_encode( $entry['config'] ) | |
| 1072 | + wp_json_encode( $config ) | |
| 937 | 1073 | ), |
| 938 | 1074 | 'before' |
| 939 | 1075 | ); |
| 940 | 1076 | } |
| @@ -948,9 +1084,9 @@ | ||
| 948 | 1084 | * these via `document.getElementById( `os-native-window-${id}` )` |
| 949 | 1085 | * and clones them into each opened window's body. |
| 950 | 1086 | */ |
| 951 | 1087 | function openstation_render_native_window_templates() { |
| 952 | - if ( ! openstation_is_enabled() || openstation_is_chromeless_request() || openstation_is_classic_request() ) { | |
| 1088 | + if ( ! openstation_is_shell_request() ) { | |
| 953 | 1089 | return; |
| 954 | 1090 | } |
| 955 | 1091 | $registry = openstation_native_window_registry(); |
| 956 | 1092 | if ( ! is_array( $registry ) ) { |
| @@ -976,4 +1112,21 @@ | ||
| 976 | 1112 | echo '</template>'; |
| 977 | 1113 | } |
| 978 | 1114 | } |
| 979 | 1115 | add_action( 'admin_footer', 'openstation_render_native_window_templates', 20 ); |
| 1116 | + | |
| 1117 | +/** | |
| 1118 | + * Whether a registered window is offered on the admin this request is | |
| 1119 | + * in: the network admin's shell offers `network` and `any` windows, | |
| 1120 | + * every site's shell offers `site` and `any`. See the `admin` arg of | |
| 1121 | + * {@see openstation_register_window()}. | |
| 1122 | + * | |
| 1123 | + * @param array<string,mixed> $entry Registry entry. | |
| 1124 | + * @return bool | |
| 1125 | + */ | |
| 1126 | +function openstation_native_window_offered_here( $entry ) { | |
| 1127 | + $admin = isset( $entry['admin'] ) ? (string) $entry['admin'] : 'site'; | |
| 1128 | + if ( 'any' === $admin ) { | |
| 1129 | + return true; | |
| 1130 | + } | |
| 1131 | + return is_network_admin() ? 'network' === $admin : 'site' === $admin; | |
| 1132 | +} | |