'
. '#adminmenu #toplevel_page_wpwand .wp-menu-image img{width:20px;height:20px;padding-top:7px;}'
. '#wpadminbar #wp-admin-bar-wpwand-trigger .ab-item img{width:18px;height:18px;vertical-align:middle;margin-right:4px;}'
. '';
}
public function add_menu(): void
{
$brand = \WPWand\Data\Brand::resolve();
$label = $brand['name'];
$icon = $brand['icon'] ?: 'dashicons-edit';
add_menu_page($label, $label, 'edit_posts', self::PAGE_SLUG, [$this, 'render'], $icon);
// Rename the auto-created first submenu (which defaults to the brand name) to "Settings".
add_submenu_page(
self::PAGE_SLUG,
__('Settings', 'ai-content-generation'),
__('Settings', 'ai-content-generation'),
'edit_posts',
self::PAGE_SLUG,
[$this, 'render']
);
}
/**
* The container carries a static, JS-free loading skeleton so the page paints the REAL settings
* layout the moment WordPress prints it — before settings.js has even downloaded. It reuses the
* app's actual markup/classes (wpwand-app header, wpws-card/tabs/panel/rows), which are styled by
* style-settings.css (enqueued in
, independent of JS), blurred under a spinner overlay.
* React's createRoot() clears these children on first mount, so the skeleton is replaced by the
* live UI with no teardown code. Skeleton-only CSS is scoped under #wpwand-settings-root.
*/
public function render(): void
{
echo '
';
}
private function skeleton(): string
{
$brand = \WPWand\Data\Brand::resolve();
$name = $brand['name'] !== '' ? $brand['name'] : 'WP Wand';
// Mirrors the General tab: the provider key rows in the order the app renders them. Keep
// these in step with KEY_FIELDS in assets/src/apps/settings/App.js — this is what the user
// stares at while the bundle loads, and a skeleton that reshuffles when the real thing
// arrives reads as a broken page. It was missing Gemini entirely and had the pre-2.0.0
// order, so the page visibly rearranged itself on every load.
$labels = [
__('Gemini API key', 'ai-content-generation'),
__('OpenRouter API key', 'ai-content-generation'),
__('OpenAI API key', 'ai-content-generation'),
__('Claude API key', 'ai-content-generation'),
__('DeepSeek API key', 'ai-content-generation'),
];
// Same sentence the app prints under these rows. Two different wordings meant the page
// rewrote itself the moment the bundle mounted.
$desc = esc_html__('Paste a key to switch this on.', 'ai-content-generation');
$rows = '';
foreach ($labels as $label) {
$rows .= '
'
. '
'
. '' . $desc . '
'
. '
'
. '
';
}
// Only the form panel blurs behind the spinner; the brand header + tab rail + Pro button
// stay crisp so the page reads as "loaded, fetching your settings" — not a blurred mess.
return ''
. '
';
}
public function enqueue(string $hook): void
{
// Only load on our page. Submenu hooks look like "{parent}_page_{slug}".
if (substr($hook, -strlen(self::PAGE_SLUG)) !== self::PAGE_SLUG) {
return;
}
$asset_file = WPWAND_NEW_DIR . 'build/settings.asset.php';
if (!is_readable($asset_file)) {
return; // build not present (dev forgot `npm run build`)
}
$asset = require $asset_file;
// The White Label tab picks the logo/icon from the media library (window.wp.media).
wp_enqueue_media();
wp_enqueue_script(
self::HANDLE,
WPWAND_NEW_URL . 'build/settings.js',
$asset['dependencies'],
$asset['version'],
true
);
// No webfont request here on purpose: hitting fonts.googleapis.com from wp-admin is a
// third-party round trip nobody asked for and a wordpress.org review flag. Every rule in the
// stylesheet reads --wpwand-font, which names Inter first and then the platform UI stack, so
// a machine without Inter renders one consistent typeface rather than two.
// @wordpress/scripts emits the entry's stylesheet as style-{entry}.css.
// The model picker is a ComboboxControl. Without core's own component styles its suggestions
// list computes max-height:none, so all 426 models render at once and the page grows to
// ~12,400px. The script already declares wp-components; only the stylesheet was missing.
wp_enqueue_style('wp-components');
if (is_readable(WPWAND_NEW_DIR . 'build/style-settings.css')) {
wp_enqueue_style(
self::HANDLE,
WPWAND_NEW_URL . 'build/style-settings.css',
[],
(string) filemtime(WPWAND_NEW_DIR . 'build/style-settings.css')
);
}
ScriptConfig::merge(self::HANDLE, ScriptConfig::base());
if (function_exists('wp_set_script_translations')) {
wp_set_script_translations(self::HANDLE, 'ai-content-generation');
}
}
}