' . esc_html__('The designer failed to load. Please reload the page.', 'aumframe') . '

'; } private static function enqueue(int $tpl_id, string $title, ?array $row = null): void { // Use WordPress's own media library rather than building an upload interface — the user's // existing media, whatever image-optimisation plugin they installed and whatever CDN offload // they configured all live in it, and starting over throws all of it away wp_enqueue_media(); // A bundle, not ESM — one file, one URL, and a single `?ver=` invalidates all of it, with no // bare submodules left to serve from cache (compare the trap in project docs 6.2.3) wp_enqueue_script( 'aumframe-editor', AUMFRAME_URL . 'assets/editor.js', // wp-i18n: the editor interface is translated through WordPress's own runtime // (`window.wp.i18n`); @wordpress/i18n is not bundled — bundling it creates a second, empty // translation registry, so the interface stays English forever and nothing is reported. // See packages/editor/src/i18n.js. ['wp-i18n'], aumframe_asset_ver('assets/editor.js'), true ); // Feed languages/aumframe--aumframe-editor.json to wp.i18n. // The third argument must be an absolute path: WP looks there for a JSON file named by // md5(relative path). wp_set_script_translations('aumframe-editor', 'aumframe', AUMFRAME_DIR . 'languages'); /* * The only point at which another plugin can add to the editor. * * This screen takes the request over on `admin_init` at priority 999: it enqueues, * prints its own shell and exits. It never reaches `admin_enqueue_scripts`, so an * extension hooked there would never run on this screen, and the symptom is a script * that is absent from the page with nothing logged. * * The hook has to fire after `aumframe-editor` is registered, so an extension can * declare it as a dependency and rely on the execution order, and before * `render_shell()` prints. */ do_action('aumframe/editor_enqueue'); wp_enqueue_style( 'aumframe-editor', AUMFRAME_URL . 'assets/editor.css', [], aumframe_asset_ver('assets/editor.css') ); $linkedPost = self::linked_post($row); wp_add_inline_script( 'aumframe-editor', 'window.AUMFRAME_EDITOR = ' . wp_json_encode([ 'tplId' => $tpl_id, 'title' => $title, /* * What saving here will change, in words. * * The buyer got here from one page's "Edit with Aumframe" and reasonably believes they are * editing that page. One template routinely renders several — Cart, Checkout and My account * shared one on a real site — so a save silently restyles pages they never opened. The * editor says so before the first edit rather than after the complaint. */ 'scope' => $row ? [ 'shared' => Scope::is_shared($row), 'text' => Scope::describe($row), ] : null, 'nonce' => wp_create_nonce('wp_rest'), 'sceneUrl' => esc_url_raw(rest_url(Rest::NS . '/templates/' . $tpl_id)), 'saveUrl' => esc_url_raw(rest_url(Rest::NS . '/templates')), 'restBase' => esc_url_raw(rest_url(Rest::NS)), /* * Back returns to where they came from, not always to the template list. * * The buyer arrived by pressing "edit with Aumframe" in the Pages list, and pressing back * would drop them into an "Aumframe templates" list they have never seen — a screen that * means nothing to them (they have no concept of a "template"; they are editing **that * page**). A design bound to content returns to that content type's list, and only an * unbound one returns to the template list. */ 'backUrl' => $linkedPost ? admin_url('edit.php?post_type=' . $linkedPost->post_type) : admin_url('admin.php?page=aumframe'), /* * Which WP post this design is bound to. * * Without it, the editor can only ever "save", never "publish" — saving writes the * template row, while the page `PageLink::handle_new()` creates is a `draft`. The buyer * finishes designing, saves, sees nothing on the front end, and has no idea they need to * go back to the Pages list and press publish: the flow simply breaks here. * (Confirmed by testing on 2026-08-28.) */ 'linkedPost' => $linkedPost ? [ 'id' => $linkedPost->ID, 'title' => $linkedPost->post_title, 'slug' => $linkedPost->post_name, 'status' => $linkedPost->post_status, 'type' => $linkedPost->post_type, 'typeLabel' => ($o = get_post_type_object($linkedPost->post_type)) ? $o->labels->singular_name : $linkedPost->post_type, ] : null, // The front-end address: without it there is no way to "look at the real thing", which // is the last check before publishing 'previewUrl' => esc_url_raw(self::preview_url($row)), // The site palette the theme declares. swatches feeds the colour picker's dropdown and // css is injected into the canvas iframe — the iframe has none of the theme's CSS, and // without injecting it every colour on the canvas resolves to nothing. 'siteTokens' => SiteTokens::swatches(), 'siteCss' => SiteTokens::css(), /* * The theme's own inline CSS — what it prints into wp_head rather than links (the * aumcreate theme's `:root{--container-width…}` block). The canvas already loads the * front end's linked stylesheets (canvas_stylesheets) but had no way to get inline * ones, so `var(--container-width)` in a kit's customCss resolved to nothing on the * canvas and the whole declaration dropped (a full-bleed track sat 40px further left * than on the front end — wysiwyg-audit 2026-09-13). Goes in before the links, in * the front end's order. Themes append through this filter. */ 'themeCss' => (string) apply_filters('aumframe/canvas_theme_css', ''), // The canvas has to use the front end's appearance mode, or the front end is dark while // the editor is light 'siteAppearance' => SiteTokens::appearance(), // Which content types, taxonomies and terms actually exist on this site — the query // panel's dropdowns run on it, and without it the user can only type `product_cat` from // memory 'siteData' => SiteData::all(), // Which forms the site has — the shortcode node's dropdown runs on it, so nobody has to // go back to the admin to copy an ID 'siteForms' => SiteForms::all(), // The component library, sent in one go — insertion is synchronous and cannot stop // halfway to make a request 'library' => Library::all(), 'shell' => self::shell_for_canvas($row), 'stylesheets' => self::canvas_stylesheets(), // The front end's runtime entry, for the canvas's live preview — same file, same version 'runtimeBoot' => add_query_arg('ver', aumframe_module_ver(['assets/runtime-boot.js', 'packages/runtime/src']), AUMFRAME_URL . 'assets/runtime-boot.js'), // core has no URL here — it is already bundled into editor.js ]) . ';', 'before' ); } /** * The site shell for the canvas: the two halves plus their CSS, or null when editing the shell * itself or when the site has no shell. */ private static function shell_for_canvas(?array $row): ?array { if (!$row || ($row['kind'] ?? '') === 'shell') { return null; } $shell = Router::shell(); if (!$shell) { return null; } /* * Render the shell against the same sample the canvas fills the page with (Rest::render_preview * derives it from the template's rules). The admin request has no queried object, so until * 2026-09-13 the header's "current item" branch never fired on the canvas: five plain nav links * where the front end shows four plus the current one, the whole row 60–90px off * (wysiwyg-audit, every page template). Same swap-and-restore as the preview endpoint. */ $rules = $row['rules'] ?? []; if (is_string($rules)) { $rules = json_decode($rules, true); } $sample = Rest::sample_query_args(is_array($rules) ? $rules : []); $parts = Rest::with_sample_query($sample['args'], static fn () => Renderer::shell_parts()); if (!$parts) { return null; } $css = (string) ($shell['baked_css'] ?? ''); if (preg_match_all('/