'boolean', 'sanitize_callback' => static fn( $value ): string => ( '1' === (string) $value || true === $value ) ? '1' : '', 'default' => '', ] ); add_settings_section( self::OBSERVABILITY_SECTION, __( 'UI Observability', 'propertyrightsweb' ), [ self::class, 'render_observability_section_intro' ], self::OPTION_OBSERVABILITY ); add_settings_field( self::OPTION_OBSERVABILITY, __( 'Client-side diagnostics', 'propertyrightsweb' ), [ self::class, 'render_observability_field' ], self::OPTION_OBSERVABILITY, self::OBSERVABILITY_SECTION, [ 'label_for' => self::OPTION_OBSERVABILITY ] ); } /** Section blurb above the opt-in checkbox. */ public static function render_observability_section_intro(): void { echo '

'; esc_html_e( 'Optional, off by default. When enabled, the wp-admin browser reports JavaScript errors and basic performance/diagnostic events to help debug the admin UI. Identifying values are masked client-side before sending; nothing is reported until you turn this on.', 'propertyrightsweb' ); echo '

'; } /** Checkbox that flips OPTION_OBSERVABILITY on/off. Default unchecked (OFF). */ public static function render_observability_field(): void { $enabled = '1' === (string) get_option( self::OPTION_OBSERVABILITY, '' ); ?>

'dashboard', self::SLUG_CONTENT => 'content', self::SLUG_OFFERS => 'offers', self::SLUG_ACTIVITY => 'activity', self::SLUG_BUYERS => 'buyers', self::SLUG_WALLET => 'wallet', self::SLUG_SETTINGS => 'settings', self::SLUG_HELP => 'help', ]; public function enqueue( string $hook ): void { if ( ! $this->is_plugin_screen( $hook ) ) { return; } wp_enqueue_style( Prweb_Brand::asset( 'tokens' ), PRWEB_URL . 'assets/css/tokens.css', [], PRWEB_VERSION ); wp_enqueue_style( Prweb_Brand::asset( 'admin' ), PRWEB_URL . 'assets/css/admin.css', [ Prweb_Brand::asset( 'tokens' ) ], PRWEB_VERSION ); // UI observability is OPT-IN and OFF by default (ORL-37 blocker 3). // Only enqueue the SDK bundle — and only feed shared.js a config block // carrying an `endpoint` — when the publisher has turned it on. When // off, the bundle never loads and shared.js's init() guard // (`prweb.observability && prweb.observability.endpoint`) is never met, // so no client-side data is reported. $observability_on = $this->observability_opt_in(); $shared_deps = [ 'jquery', 'wp-i18n' ]; if ( $observability_on ) { // UI observability vendor bundle (vanilla SDK). Loaded as a dependency // of the shared bundle so the SDK is on `window.prwebObservability` // before shared.js runs init(). The SDK lives on its own top-level // global because wp_localize_script emits `var prweb = {...}` between // this bundle and shared.js — nesting under window.prweb would clobber // the SDK. Per-page config rides the `prweb.observability` sub-key. wp_enqueue_script( Prweb_Brand::asset( 'admin-observability' ), PRWEB_URL . 'assets/admin/vendor/ui-observability.iife.js', [], PRWEB_VERSION, true ); $shared_deps[] = Prweb_Brand::asset( 'admin-observability' ); } wp_enqueue_script( Prweb_Brand::asset( 'admin-shared' ), PRWEB_URL . 'assets/admin/shared.js', $shared_deps, PRWEB_VERSION, true ); wp_set_script_translations( Prweb_Brand::asset( 'admin-shared' ), Prweb_Brand::TEXT_DOMAIN, PRWEB_PATH . 'languages' ); // Both arguments carry the brand prefix (ORL-229): the handle via // asset(), the global variable name via js_global(). The second one is // why WP.org's prefix guideline names wp_localize_script() alongside // wp_register_script() — it declares `var prweb = {...}` at global // scope. The bundles under assets/ cannot read JS_GLOBAL, so they // repeat the literal and scripts/check-wp-asset-handles.mjs fails if // the two ever disagree. wp_localize_script( Prweb_Brand::asset( 'admin-shared' ), Prweb_Brand::js_global(), [ 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( self::NONCE ), 'urls' => [ 'dashboard' => admin_url( 'admin.php?page=' . self::SLUG_DASHBOARD ), 'content' => admin_url( 'admin.php?page=' . self::SLUG_CONTENT ), 'contentDetailBase' => admin_url( 'admin.php?page=' . self::SLUG_CONTENT . '&post=' ), 'offers' => admin_url( 'admin.php?page=' . self::SLUG_OFFERS ), 'activity' => admin_url( 'admin.php?page=' . self::SLUG_ACTIVITY ), 'buyers' => admin_url( 'admin.php?page=' . self::SLUG_BUYERS ), 'wallet' => admin_url( 'admin.php?page=' . self::SLUG_WALLET ), 'settings' => admin_url( 'admin.php?page=' . self::SLUG_SETTINGS ), 'help' => admin_url( 'admin.php?page=' . self::SLUG_HELP ), 'rslXml' => get_rest_url( null, 'pds/v1/rsl' ), 'robotsTxt' => home_url( '/robots.txt' ), ], // Only carries an `endpoint` (the trigger shared.js's init() guards // on) when the publisher has opted in. Off → empty array → no init. 'observability' => $observability_on ? [ 'endpoint' => PRWEB_OBSERVABILITY_PUBLIC_BASE, 'projectId' => 'property-rights-web-admin', 'release' => PRWEB_VERSION, ] : [], ] ); // First-run takeover loads the onboarding bundle instead of the // per-screen script while the wizard is mid-flow. $screen = Prweb_Ajax_Onboarding::wizard_in_progress() ? 'onboarding' : $this->resolve_screen( $hook ); if ( $screen !== null && file_exists( PRWEB_PATH . 'assets/admin/' . $screen . '.js' ) ) { $handle = Prweb_Brand::asset( 'admin-' . $screen ); $deps = [ Prweb_Brand::asset( 'admin-shared' ), 'wp-i18n' ]; // The onboarding bundle's offer-defaults editor and pure // schema/brand/transition helpers live in their own files so // onboarding.js stays under the 500-line cap. Enqueue them // first so window.prwebOnboardingDefaults and // window.prweb.onboardingHelpers are defined before // onboarding.js runs. if ( $screen === 'onboarding' ) { wp_enqueue_script( Prweb_Brand::asset( 'admin-onboarding-helpers' ), PRWEB_URL . 'assets/admin/onboarding-helpers.js', [ 'wp-i18n' ], PRWEB_VERSION, true ); wp_enqueue_script( Prweb_Brand::asset( 'admin-onboarding-defaults' ), PRWEB_URL . 'assets/admin/onboarding-defaults.js', [ Prweb_Brand::asset( 'admin-shared' ), 'wp-i18n' ], PRWEB_VERSION, true ); wp_set_script_translations( Prweb_Brand::asset( 'admin-onboarding-defaults' ), Prweb_Brand::TEXT_DOMAIN, PRWEB_PATH . 'languages' ); $deps[] = Prweb_Brand::asset( 'admin-onboarding-helpers' ); $deps[] = Prweb_Brand::asset( 'admin-onboarding-defaults' ); } // The wallet bundle's auto-payout policy module lives in its own // file so wallet.js stays under the 500-line cap. Enqueue it // first so window.prweb.walletPayouts is defined before // wallet.js runs initWallet(). if ( $screen === 'wallet' ) { wp_enqueue_script( Prweb_Brand::asset( 'admin-wallet-payouts' ), PRWEB_URL . 'assets/admin/wallet-payouts.js', [ Prweb_Brand::asset( 'admin-shared' ), 'wp-i18n' ], PRWEB_VERSION, true ); wp_set_script_translations( Prweb_Brand::asset( 'admin-wallet-payouts' ), Prweb_Brand::TEXT_DOMAIN, PRWEB_PATH . 'languages' ); $deps[] = Prweb_Brand::asset( 'admin-wallet-payouts' ); } wp_enqueue_script( $handle, PRWEB_URL . 'assets/admin/' . $screen . '.js', $deps, PRWEB_VERSION, true ); wp_set_script_translations( $handle, Prweb_Brand::TEXT_DOMAIN, PRWEB_PATH . 'languages' ); if ( $screen === 'onboarding' ) { wp_localize_script( $handle, Prweb_Brand::js_global( 'Wizard' ), [ 'schema' => Prweb_Wizard_Stages::for_localize(), 'brand' => [ 'name' => Prweb_Brand::NAME, 'displayName' => Prweb_Brand::DISPLAY_NAME, ], 'urls' => [ 'rslXml' => get_rest_url( null, 'pds/v1/rsl' ), 'robotsTxt' => home_url( '/robots.txt' ), 'walletPage' => admin_url( 'admin.php?page=' . self::SLUG_WALLET ), 'offersPage' => admin_url( 'admin.php?page=' . self::SLUG_OFFERS ), 'contentPage' => admin_url( 'admin.php?page=' . self::SLUG_CONTENT ), 'settingsPage' => admin_url( 'admin.php?page=' . self::SLUG_SETTINGS ), ], ] ); } } } private function resolve_screen( string $hook ): ?string { if ( 'toplevel_page_' . self::SLUG_DASHBOARD === $hook ) { return 'dashboard'; } foreach ( self::SCREEN_MAP as $slug => $name ) { if ( str_ends_with( $hook, '_page_' . $slug ) ) { // R1.5 — branch the Content screen onto its detail bundle // when the URL carries `?post=`. The PHP renderer in // Prweb_Admin_Menu::render_content() does the same branch, // so PHP + JS agree on which view is loading. if ( $name === 'content' && isset( $_GET['post'] ) && (int) $_GET['post'] > 0 ) { return 'content-detail'; } return $name; } } return null; } /** * Type scale for the desktop admin sidebar (ORL-170). * * The admin menu column is a fixed 160px, leaving 116px to `.wp-menu-name` * (core's `padding: 8px 8px 8px 36px`), and core breaks that element by * character, so any label wider than the budget wraps mid-word and orphans its * trailing character. The plugin's label is a single unbroken PascalCase token * whose width is entirely a property of the face the browser resolves from * wp-admin's stack — which is why the wrap appeared on some installs and not * others. Measured in Chromium from the real font files, `PropertyRightsWeb` * against the 116px budget: * * face 14px 13px 12px * DejaVu Sans 129.8 120.5 111.3 ← generic sans-serif on Linux * Verdana 130.5 121.0 111.8 ← only via a custom browser default * Noto Sans 122.0 113.3 104.6 * Nimbus Sans 120.0 111.4 102.9 * Liberation Sans 119.8 111.3 102.7 * Ubuntu 118.3 109.5 101.5 ← CONFIRMED culprit (ORL-170) * FreeSans 116.9 108.6 100.2 * Segoe UI Variable 114.8 107.0 98.0 * Segoe UI 114.0 105.8 97.8 ← Windows * Tahoma 114.0 105.8 97.8 * Calibri 104.0 96.0 89.3 * * The reported wrap was `Ubuntu`, confirmed via CDP `getPlatformFontsForNode` * against this stack on a Linux/WSL browser: it is entry #6 of nine, the first * one installed there (`-apple-system`, `BlinkMacSystemFont`, `Segoe UI`, * `Roboto` and `Oxygen-Sans` all miss), so matching stops before the generic * `sans-serif` tail is ever consulted. 118.3px against a 116px budget — over by * 2.3px, which is why it surfaced as a single orphaned trailing letter. * * The scale is 13px. That clears every reachable face measured: Ubuntu at * 109.9px, and Liberation Sans — what Chromium's `sans-serif` resolves to via * its Arial preference — at 111.3px. Two measured faces still overrun 13px: * DejaVu Sans (120.5px) and Verdana (121px). Rather than drop the whole column * to 12px for two faces most publishers never resolve, those two get scaled * individually through `@font-face` + `local()` + `size-adjust: 92.3%` — 92.3% * of 13px is 12.0px, and `local()` resolves only where the face is actually * installed, so each alias is inert everywhere else. * * The aliases sit at the END of core's stack, immediately before the generic * `sans-serif` tail, and core's named entries are reproduced verbatim ahead of * them. So a system that has any named entry resolves exactly as it does today * (verified: this box has both Ubuntu and DejaVu Sans installed and still * renders Ubuntu at a full 13px); the aliases engage only on the fall-through * that would otherwise reach a wide generic. Measured: DejaVu Sans paints at * 112px on one line through the alias, versus 120.5px on two lines without it. * * `size-adjust` scales glyphs, not the computed `font-size`, which is why this * beats a plain `font-size: 12px` — the computed size stays 13px, so the line * box stays 18.2px and rows stay 34.2px even for a scaled face. Faces that could not be measured here * — SF Pro, Roboto, Cantarell, Oxygen-Sans, Helvetica Neue — are all narrower * than DejaVu Sans, the binding constraint. * * There is no per-item selector, no brand-slug interpolation, and nothing about * this plugin's menu item that WordPress's markup or CSS treats specially. It is * a core label like any other, and a core label of the same length sits at the * same width. The tradeoff is named honestly: this restyles core's menu items * and other plugins' alongside ours, one point below stock WordPress. It * changes size only — never family, weight, colour, or geometry — so nothing * another plugin draws in that column can break on it. The typeface in * particular is untouched: core sets no `font-family` inside `#adminmenu` * (labels inherit the system-UI stack from `body` in `wp-admin/css/common.css`) * and neither does this. * * `line-height: 1.4` holds the rows exactly where core puts them. Core's * unitless `1.3` on `a.menu-top` computes to 18.2px at 14px and would fall to * 16.9px here, shrinking every item and its highlight block by 1.3px; 13 × 1.4 * restates the same 18.2px, keeping anchors at 34.2px. Submenu links are left * alone — core already has them at 13px with the same 18.2px line box, so they * now match the top-level labels exactly. Verified against a * `wp-admin/css/admin-menu.css` harness. * * Gated to `min-width: 783px` — the desktop sidebar, the only place the 160px * column exists. Below that core deliberately sets `.auto-fold #adminmenu li a` * to 16px for touch comfort, and because this rule targets the label element * directly it would override that inherited size, so the responsive menu is * left to core. Measured there for the record: between 783px and 601px core * parks the labels off-screen (`left: -999px`) and shows icons plus flyouts, so * there is nothing to fit; at 600px and below it restores them at 16px in a * 136px box, where Ubuntu paints 135.2px on one line with 0.8px to spare and a * wide fallback face wraps. That is core's own behaviour for any label this * long at that size — the alias cannot rescue it either (16px × 92.3% is still * ~137px for DejaVu Sans), and forcing it smaller would undo the touch sizing * core put there on purpose. * * Deliberately NOT a width override. Widening `#adminmenu` would relayout the * sidebar for WordPress core and every other plugin's menu items, which WP.org * reviewers reject and which reads as a bug on any install with a long menu. * * Enqueued on every admin screen (not gated by `is_plugin_screen()`) because * the sidebar renders on all of them. Inline on a dependency-free handle: two * declarations do not warrant an HTTP request. */ public function enqueue_menu_label_style(): void { $handle = Prweb_Brand::asset( 'admin-menu-label' ); wp_register_style( $handle, false, [], PRWEB_VERSION ); wp_enqueue_style( $handle ); // Core's own stack, verbatim and in core's order, with two scaled aliases // spliced in ahead of the generic tail. Every named entry still wins first, // so the resolved face is unchanged wherever one of them exists. // The two alias family names are custom @font-face identifiers, which // are install-global the same way a handle is — this style is enqueued // on every admin screen, not just the plugin's own — so they carry the // asset prefix too. $dejavu = Prweb_Brand::asset( 'menu-dejavu' ); $verdana = Prweb_Brand::asset( 'menu-verdana' ); $stack = '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,' . 'Ubuntu,Cantarell,"Helvetica Neue",' . $dejavu . ',' . $verdana . ',sans-serif'; wp_add_inline_style( $handle, // 92.3% of 13px is 12.0px — the size at which these two clear the // column. `local()` only resolves if the face is actually installed, so // each alias is inert everywhere else. '@font-face{font-family:' . $dejavu . ';' . 'src:local("DejaVu Sans");size-adjust:92.3%}' . '@font-face{font-family:' . $verdana . ';' . 'src:local("Verdana");size-adjust:92.3%}' . '@media screen and (min-width:783px){' . '#adminmenu .wp-menu-name{' . 'font-family:' . $stack . ';' . 'font-size:13px;' . 'line-height:1.4' . '}' . '}' ); } private function is_plugin_screen( string $hook ): bool { if ( 'toplevel_page_' . self::SLUG_DASHBOARD === $hook ) { return true; } $slugs = array_merge( array_keys( self::SCREEN_MAP ), [ self::SLUG_OAUTH_CB ] ); foreach ( $slugs as $slug ) { if ( str_ends_with( $hook, '_page_' . $slug ) ) { return true; } } return false; } } // Register the opt-in UI-Observability setting (default OFF) on admin_init so // the toggle round-trips through the Settings API. Self-registered here to keep // the opt-in gate self-contained in the class that consumes it. add_action( 'admin_init', [ Prweb_Admin_Assets::class, 'register_settings' ] );