Files
aurora-wordpress-admin/PROJECT_STATUS.md
T
NixReaperandClaude Opus 4.8 e2db2ad4b5 Snapshot synced v3.6.1 working state as baseline
Machine-sync snapshot: the working tree carried the full 3.0.0-3.6.1 arc
(licensing/FileManager/DatabaseExplorer removal, all new replacement screens
and REST controllers) that had not been committed on this machine. Captured
here as a safety baseline before starting the Adminify-parity revamp.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-12 08:41:18 -05:00

132 KiB
Raw Blame History

Aurora Admin v2 — Project Status

_Last updated: 2026-07-11 (latest session — v3.6.0: Site Backup was extracted out of Aurora Admin into a new standalone sibling plugin, aurora-site-backup, at the user's explicit request ("pull out the backup as well and lets move that to an add-on") — the same proactive-risk- reduction reasoning as Database Explorer's 3.5.0 extraction, since restoring a backup means writing arbitrary files back into the site. The entire admin/src/Backup/ tree (job/schedule storage, DB dumper, file archiver, state-machine engine, wp_cron safety net, all three storage providers) and all four Backup REST controllers moved unmodified into the new plugin. Since Backup's storage providers depend on a Settings class for stored credentials (S3 keys, Google Drive OAuth tokens), a small standalone Options\Settings class was added to the new plugin with its own aurora_site_backup_settings option, replacing the dependency on Aurora Admin's own Settings class. CompanionPluginData.php's PLUGINS array (already generalized in 3.5.0) gained a site-backup entry — no new PHP route code needed. Verified live via a full rest_do_request() dispatch that created a real backup job, stepped it through the DB-dump phase, and canceled it — not just "loads without fatals" but actual end-to-end behavior confirmation. See "Site Backup — REMOVED..." below. Same outstanding deployment gap as the other two companion plugins: the zip isn't hosted yet.

Previous session — v3.5.1: a fresh WordPress.org Plugin Check report showed the 3.3.2 compliance pass's phpcs:ignore annotations weren't actually suppressing anything on several multi-line $wpdb-> calls — WPCS suppresses violations on the exact next physical line, but the annotations sat above the opening line of the call while PHPCS attributed the violation to a line further down inside it (where the interpolated table-name token actually appears). Fixed across MenuCreatorData.php, NoticesData.php, Notices.php, BackupSchedules.php, BackupJobs.php, MediaFolders.php (the largest cluster, mostly fixed by collapsing multi-line query calls to one physical line), DbDumper.php, AnalyticsData.php, and MediaData.php — verified with an automated scan that every dynamic-query $wpdb-> call is now correctly covered. Also found and fixed a real, unrelated bug during verification: MenuCreatorData::create() built its "fetch the row I just inserted" follow-up request with new WP_REST_Request("GET", "", ["id" => ...]), but that constructor form doesn't populate what get_param() reads, so creating a menu always returned "Menu not found" — confirmed via a full rest_do_request() dispatch, fixed with set_url_params(). The compressed_files/hidden_files/unexpected_markdown_file findings in the report are scanner noise from scanning the whole dev repo, not the actual submission zip (re-confirmed via unzip -l). Submission zip rebuilt and re-verified after this pass.

Previous session — v3.5.0: Database Explorer was extracted out of Aurora Admin into a new standalone sibling plugin, aurora-database-explorer, following the exact same pattern as File Manager's v3.4.0 extraction — direct database-query tooling (browsing tables, running free-form SELECT/SHOW/DESCRIBE/EXPLAIN queries) is treated by WordPress.org's Plugin Directory review the same way as file-management functionality, so it was pulled out proactively rather than waiting for a rejection. CompanionPluginData.php was generalized from a single file-manager-only class into a PLUGINS config array keyed by slug, so both companion plugins (and any future ones) share the same install/ activate/status REST routes. The Settings → Add-ons screen now lists both. A real production deploy (hosting the aurora-database-explorer.zip on aurora.auroradragon.studio) is still pending explicit user confirmation, not yet done — same outstanding gap File Manager's zip already has.

Previous session — v3.4.0: WordPress.org's second review response said the whole file-management/code-editing category isn't accepted into the Plugin Directory, restrictions or not — so File Manager was extracted entirely out of Aurora Admin into a new standalone sibling plugin, aurora-file-manager, installable with one click from the new Settings → Add-ons screen (wraps core's own Plugin_Upgrader, same class the native "Install Now" button uses). See "File Manager — REMOVED from Aurora Admin in 3.4.0" below, specifically its final "Extraction into a standalone plugin" subsection.

Prior versions this doc covers in detail, newest first: v3.3.2 (WP.org Plugin Check compliance pass, ~230 justified phpcs:ignore annotations across every custom-table feature), v3.3.1 (Settings page independent- scroll fix), v3.3.0 (Modern Image Formats — WebP/AVIF generation + <picture> rewriting, reimplemented against documented WP core hooks), v3.2.1 (closed the File Manager PHP-editing gap that triggered WP.org's first rejection), v3.2.0 (Google Drive remote storage for Site Backup), v3.1.0 (Site Backup itself — the first background-job/wp_cron infrastructure in this codebase), and v3.0.0 (made every previously-paid feature free, deleted the entire licensing/tamper-protection subsystem — everything earlier in this doc describing license gating is historical unless explicitly marked otherwise).)_

What this project is

A ground-up rebuild of the "uiXpress" WordPress admin-theme plugin, relaunched as Aurora Admin. The original plugin's look/feel and feature set are the target, but no original code is reused — all Vue components, PHP classes, and build tooling are freshly written. This lives in aurora-admin-v2/ (the earlier in-place rebrand attempt, aurora-admin-working/, is superseded and no longer the active codebase).

Repo

Now on GitLab (self-hosted): ssh://[email protected]:2289/NixReaper/aurora-wordpress-admin.git (HTTPS also available). Repo root = the contents of aurora-admin-v2/ (admin/, app/, aurora-admin.php), not the whole uixpress/ folder — that folder has a bunch of dead-end attempts (aurora-admin-working, aurora-admin-distribution, aurora-admin-rewrite, aurora-admin-GLM, uixpress) that are debugging history, not part of the project.

.gitignore excludes app/node_modules/ and app/dist/ (build output — regenerate, don't version). To clone on a new machine: add an SSH key to the GitLab account, then git clone the URL above, then cd app && npm install.

Claude Code conversation history does not sync across machines — it's local per project-folder-per-machine. Point a fresh session at this file to get back up to speed instead of relying on prior chat history.

Workflow rules established

  • Always verify live in Chrome before declaring a fix done. Use the Chrome MCP tools (tabs_context_mcp, navigate, computer/screenshot, javascript_exec) to inspect real DOM/computed styles rather than guessing from source.
  • Zip packaging: on macOS, build via npm run build in app/, rsync the repo (excluding node_modules, .git, /dist) into a scratch staging dir, run php bin/build-integrity-manifest.php <staging-dir>/aurora-admin to generate admin/src/Licensing/integrity-manifest.json into the staged copy (see "Tamper protection" below — this step is new, don't forget it or the shipped zip will have no manifest and Integrity::is_tampered() will treat it as a dev checkout, i.e. never lock anything), then zip -r -X it into dist/aurora-admin-v2.zip — macOS zip produces forward-slash paths natively, no path-separator corruption issue like Windows Compress-Archive/PowerShell had. Always delete the existing zip first (rm -f dist/aurora-admin-v2.zip) — zip -r merges into an existing archive rather than replacing it, so stale entries (old content hashes) silently survive otherwise. If building on Windows again, the old PowerShell System.IO.Compression.ZipFile + .Replace('\', '/') approach from earlier sessions still applies (the manifest-generation step would need a Windows-available PHP CLI too — LocalWP bundles one at ~/Library/Application Support/Local/lightning-services/php-*/bin/*/bin/php on macOS; there's presumably an equivalent bundled binary on Windows).
  • Fast local iteration: the test site is LocalWP at /Users/kris/Local Sites/aurora/app/public/wp-content/plugins/aurora-admin. Instead of rezipping + manually reinstalling for every small CSS/JS tweak, hot-patch the built files straight into that install (rm -rf app/dist && cp -R <repo>/app/dist app/dist, or copy individual files like admin/assets/native-dark.css) and just reload the page in Chrome — near-instant feedback loop. Rezip properly (see above) once the change is confirmed, so the zip artifact matches what's live-verified.
  • User manually reinstalls the zip from a downloaded/attached copy when actually deploying — the Chrome MCP file_upload tool cannot push arbitrary local files into the browser, confirmed in an earlier session, so that step can't be automated. (Hot-patching, above, sidesteps this for local dev/verification.)
  • Old build caching gotcha: WordPress serves whatever plugin files were last installed — if the user says "didn't fix it," suspect a stale zip still installed before suspecting the code. Verify the live script hash via document.querySelector('script[src*="shell"]') + computed styles when in doubt (done successfully this session to prove a spacing change was live).
  • Git: local repo config only (user.name/user.email set per-repo, not --global). Commit after each user-confirmed-working change, not before.
  • Site under test: http://admin.local/wp-admin/.
  • License server access: auroraplugins.com runs on the same cPanel box as the git remote, auroradragon.studio (hostname wyvern.auroradragon.studio), but the git remote's SSH key (~/.ssh/id_ed25519) is scoped to git-only — no shell. Root shell access is a separate dedicated key, ~/.ssh/id_ed25519_auroradragon_root (ssh -i ~/.ssh/id_ed25519_auroradragon_root [email protected]). The license server itself is the aurora-licensing WordPress plugin at /home/aurora/public_html/wp-content/plugins/aurora-licensing/aurora-licensing.php (also present under /home/plugins/public_html/... — a second copy on the same box). If SSH to port 22 suddenly times out, something on the box (fail2ban or similar) appears to re-trigger a short block on rapid repeated connection attempts, including plain nc/probe connections — it clears on its own after roughly 1-2 minutes; wait it out with one careful attempt rather than retrying in a tight loop.

Architecture

  • PHP (admin/src/..., namespaced AuroraAdmin\...), bootstrapped from aurora-admin.php (manual require list, dependency order matters), instantiated in admin/src/App/AuroraAdmin.php's constructor.
  • Vue 3 app (app/src/...), Vite multi-entry build (app/vite.config.js rollupOptions.input) → manifest.json, read by admin/src/Utility/Assets.php::enqueue() which also walks the manifest's imports chain to pull in shared-chunk CSS. Data reaches each Vue entry via a data-aurora-admin JSON attribute on a mount div (never wp_localize_script).
  • Single global stylesheet: app/src/shell/shell-frame.css — all --aurora-* CSS variables (incl. .aurora-dark overrides) and the only rules touching WP structural IDs (#wpcontent, #wpbody-content). No element resets/utility classes leak onto native WP pages.
  • Immersive shell (admin/src/Shell/Shell.php + app/src/shell/SiteShell.vue
    • Sidebar.vue + Toolbar.vue + UserMenu.vue): hides native WP chrome, mounts a Vue sidebar/toolbar on every admin page.
    • MenuSerializer.php converts WP's $menu/$submenu globals into JSON for the sidebar's native-menu section — explicitly skips the aurora-admin slug so Aurora's own pages aren't duplicated (they're represented in the sidebar's own branded nav section instead).
    • Sidebar has two sections: the WP-native menu list (icon-chip style, 28×28px rounded icon backgrounds, tight ~2px vertical padding per item) and a bottom "Aurora" section (collapsible, expanded by default, persisted via localStorage['aurora-nav-expanded']) styled like a native WP submenu (plain indented text via .sidebar__sublist/ .sidebar__sublink, no icons) — deliberately matches how e.g. Posts' own submenu (All Posts/Add Post/Categories/Tags) looks, per explicit user request.
    • Sidebar is display:flex; flex-direction:column, split into a .sidebar__scroll (flex:1, scrollable — holds brand/native-list/Aurora section) and a pinned UserMenu footer below it (not part of the scrollable area, so the account card is always visible).
    • UserMenu.vue: floating card that expands upward from a bottom-left trigger (avatar+name+chevron). Card contains: user header (avatar/name/ email), links (Edit Profile → profile.php, Visit site, Site Settings → options-general.php, Sign Out in red). No theme control here anymore — see "Theme system" below; the old light/dark/system 3-way swatch picker and the dark_mode setting it drove are both gone entirely.
    • Old top-right toolbar user-dropdown and sun/moon toggle button are removed — fully replaced by the bottom-left UserMenu.
    • Sidebar is inset, not flush against the browser edge: fixed at top/left/bottom: var(--aurora-sidebar-gap) (16px), fully rounded corners, background: var(--aurora-card-bg) — floats as its own card over the page gradient. --aurora-sidebar-gap is a shared CSS var (shell-frame.css) that the toolbar's left offset and #wpcontent's margin-left both add too, so nothing overlaps when the gap changes.
    • Native-menu accordion: only one WP-native-menu section's submenu is open at a time (Sidebar.vue's openId ref) — expanding one collapses whichever else was open, instead of the list growing indefinitely as more sections get expanded. Was previously a Set of independently toggleable ids (each section remembered its own open/closed state); replaced with a single id so opening any section closes the rest. Starts on whichever section matches the current page (each nav is a full page load in this multi-page admin, so this is computed once at mount, not reactively). The separate "Aurora" branded nav section below it keeps its own independent expand/collapse state (localStorage['aurora-nav-expanded']) — not part of this accordion.

Skinned vs. replaced native pages (important distinction, ask before assuming)

The user explicitly said they didn't want the "skinned" approach used without a real replacement behind it. As of this session, every native list-table screen has been converted — there's no more "just skinned" category left among the pages that had one:

  • Actually replaced — the native page never renders; a real custom Vue page (own REST controller, own list/detail UI) takes over entirely: Comments, Installed Plugins, Menu Creator, Admin Notices, Role Editor, Activity Log, Database Explorer, Posts, Pages, Media Library, and Users (the last four all added this session, in that order — Posts first as its own commit/version, then Pages/Media/Users together).
  • Deliberately still native (not a "skinning" gap — these are specific screens/actions, not list-tables, and rebuilding them wasn't in scope): the block editor (post.php/post-new.php — dark-themed chrome only, per "Window chrome" above), media-new.php (upload/drag-drop), user-new.php (account creation), individual edit screens reached from a list row (user-edit.php, comment/media edit), Settings/Profile pages, the Site Editor. If a new "why isn't X replaced" question comes up, check whether it's actually one of these deliberate exceptions before treating it as a gap.

PostsData.php+Posts.vue, PagesData.php+Pages.vue, MediaData.php+Media.vue, UsersData.php+Users.vue, and CommentsData.php+Comments.vue all follow one shared REST/Vue pattern:

  • A REST controller in admin/src/Rest/ with list (status/type/role + search + pagination, matching native filter tabs) and action endpoints (trash/restore/delete, or just delete for Media/Users, which don't have a trash state in the same way). Capability checks mirror WP core's own for that screen (edit_posts/edit_pages/upload_files/list_users to view; per-item edit_post/delete_post/delete_page/delete_user for actions).
  • A Vue page in app/src/pages/<name>/ + entry in app/src/entries/ + registered in app/vite.config.js, visually matching the Comments layout (filter pills, search, table with inline row actions, pagination) — Media is the one exception, using a thumbnail grid instead of a table, since that's what a media library actually needs.

As of this session, how they're mounted onto the page changed. All five used to be a real registered admin.php?page=aurora-admin-X submenu that SubApps::redirect_native_pages() redirected direct visits to on admin_init — this had a proven bug class (see "Key bugs fixed": anything that redirects straight to the native URL, like a plugin's own post-activation redirect, lands on the untouched native page until the sidebar link is clicked again). They're now registered via ScreenHijack::register() (calls collected in admin/src/Pages/HijackedNativePages.php) instead: WordPress fires load-{page}.php before the native screen prints anything, so Aurora hooks that, checks capability/setting, buffers everything with ob_start() on in_admin_header, and on admin_footer discards the buffer (guarded by ob_get_level() > 0, since a screen that exited early via its own wp_die()/redirect may never have opened one) and prints Aurora's own mount div instead. The URL never changes — edit.php, upload.php, users.php, edit-comments.php are still the real URLs, so there's no separate slug to fall out of sync. Settings toggles (modern_posts/ modern_pages/modern_media/modern_users/modern_comments, all default: true) are unchanged and still gate this the same way. Their SubApps::PAGES entries, redirect_native_pages() branches, and MenuSerializer::resolve_url() rewrite branches were all deleted as dead code — the native sidebar links already point at the right (now hijacked) URL with no rewrite needed.

Plugins is the one exception, deliberately left on the old redirect mechanism — plugins.php prints output in a way that can't be cleanly buffered (confirmed by the old uixpress reference plugin's own abandoned attempt at hijacking it the same way). It still has a real aurora-admin-plugins submenu under plugins.php and its own redirect_native_pages() branch in SubApps.php.

This is phase 1 of a larger "full admin takeover" initiative — see the new section below.

Two deliberate scope decisions worth remembering if asked "why doesn't X work":

  • Media: replaces the library/browse experience only — uploading (media-new.php, drag-drop/chunked uploads) was left alone entirely, same reasoning as leaving the block editor alone for Posts/Pages.
  • Users: deleting a user always reassigns their content to whoever performs the deletion, rather than exposing WP core's "delete this user's content too, or reassign to someone else" choice screen as a one-click REST action — the null-reassign path in wp_delete_user() is much more destructive than is safe to offer with one click, so it's not offered at all.

modern_posts/modern_plugins/modern_comments were themselves each briefly removed earlier in this same session (when they had no implementation behind them) and re-added once real pages existed — if you ever see a settings field described as removed for "describing a feature that didn't exist," check whether it's since been rebuilt before assuming it's still missing.

Full admin takeover initiative (in progress — plugin phase of "DragonPress")

The user's long-term goal: Aurora Admin eventually becomes "DragonPress," a full WordPress core fork built around Aurora. That fork itself is not started and is explicitly out of scope until this plugin-level phase is finished. This phase's goal: when Aurora is active, no native WordPress admin screen should ever be reachable — everything gets replaced or wrapped by Aurora. Full plan lives in the approved plan file (not persisted in repo); summarized here for continuity.

Mechanism: ScreenHijack.php (see above) is the new default for any screen being added. SubApps.php's redirect mechanism is legacy, kept only for Plugins.

Sequencing (see task list each session for live status):

  1. ✅ ScreenHijack.php + migrate Comments (proof of concept)
  2. ✅ Batch-migrate Posts, Pages, Media, Users
  3. ✅ New full_admin_takeover master setting (default false — opt-in, unlike the existing modern_* toggles) + new "admin-takeover" license key gating everything below (this feature key, along with the entire licensing system, was removed in 3.0.0 — see "Licensing" below; the setting itself is unaffected and still opt-in)
  4. ✅ Native Settings screens: General/Writing/Reading/Discussion/Permalinks — all 5 hijacked via one shared NativeSettingsPages.php + NativeSettings.vue (one Vue entry, driven by a per-screen field schema), reusing core's /wp/v2/settings REST route for everything except Permalinks (new PermalinksData.php — core has no REST route for rewrite rules). One gotcha worth remembering: core's REST property names don't always match the underlying option name (title→blogname, description→blogdescription, url→siteurl) — get_option() with the REST name silently reads empty; NativeSettingsPages::OPTION_NAME_MAP handles the translation for reads (writes go through the REST route itself, which already knows the mapping). Verified live: all 5 screens render real values on direct visit; at the time, Permalinks' Save also correctly 403'd without a real license (REST-level license check working independently of the page-level gate, per the "don't repeat the File Manager bug" requirement) — moot now that licensing is gone (3.0.0), but the REST route still checks current_user_can() directly rather than relying on the page-level lock alone.
  5. ✅ Media Upload (media-new.php) + Add New User (user-new.php) — both Vue-only (no new PHP REST controllers), talking directly to core's /wp/v2/media and /wp/v2/users routes. Free, gated by the same modern_media/modern_users toggles as their list-view companions. Verified live: created a real user end-to-end.
  6. ✅ Appearance/Tools batch. Correction to this plan discovered during implementation: Menu Creator (admin/src/MenuCreator/) manages Aurora's own admin sidebar menu, not WordPress's front-end nav menus — despite the similar name, completely unrelated features. Wiring nav-menus.php into it (as originally planned) would have been wrong; built a real, separate Nav Menus replacement instead (NavMenusData.php + NavMenus.vue: create/delete menus, add pages/posts/categories/custom links, reorder/indent items, assign to theme locations). Themes (ThemesData.php + Themes.vue: list/ activate/delete with screenshots) and Widgets (WidgetsData.php + Widgets.vue, deliberately simplified to reordering/moving/ deactivating existing instances — not adding new widget types, same "don't rebuild the real editor" reasoning as the block editor) also shipped. Theme Editor redirects to File Manager rather than getting a second in-browser file editor. Tools/Import-Export/Site Health left un-hijacked as planned (Shell already frames them).
    • Bug caught and fixed while testing: none of these pages' Vue components checked res.ok before using a fetch response — a 403 error body got rendered as if it were real data (iterated as an object in v-for, producing bogus placeholder rows). Fixed in all three; this class of bug is worth checking for in any future page that talks directly to aurora-admin/v1 routes.
    • Environment note: this dev site's 3 installed themes are all block/ FSE themes (Twenty Twenty-Three/Four/Five), none of which register classic nav-menu/widget support — core itself refuses nav-menus.php/ widgets.php ("Your theme does not support...") regardless of this plugin. Verified the REST layer directly instead (full create/add- item/list/delete round trip); Themes was verified fully live since it doesn't depend on theme support.
    • Gap found and fixed later (user reported "missing a lot of features from the original"): Themes.vue's initial build only covered list/activate/delete — no way to browse/install from WordPress.org or upload a theme zip, both present on the native screen. Fixed the same way as Plugins' addUrl/Media's newMediaUrl: link out to native theme-install.php (not hijacked, already has both a WordPress.org browser and an "Upload Theme" button built in) rather than rebuilding either. New "Add New Theme" button in Themes.vue, gated on current_user_can('install_themes'), addThemeUrl passed via AppearancePages.php's existing data_callback.
    • Follow-up (user: "when you click add theme, it closes the menu"): clicking a theme card opens WP core's own fullscreen .wp-full-overlay preview/install modal, which covers the native admin menu on a totally vanilla install too — confirmed not a regression, but reframed it like the block editor window anyway per the user's call. See "Theme installer overlay framing" below for the full writeup — two real bugs (a visibility: hidden inheritance issue and the same over-constrained height: 100% + inset bug the block editor frame already had to work around) were found and fixed along the way.
  7. ✅ Profile (profile.php) — user chose: reskin core fields only (ProfileData.php + Profile.vue, using core's /wp/v2/users/me REST route + a small custom password-change route), and fall through to the real native screen entirely whenever a plugin has hooked show_user_profile/personal_options (checked via has_action() at hijack time) rather than attempting to host that plugin's fields — a Vue SPA has no safe way to render arbitrary third-party HTML/JS. Verified live both ways with a test mu-plugin. Password change re-issues the auth cookie after wp_set_password(), same as core's own user-edit.php.
  8. ✅ Block Editor reskin (post.php/post-new.php) — the last phase, and deliberately not a Gutenberg content-swap. Extended Shell's already-working block-editor framing (shell-frame.css's .interface-interface-skeleton, windowChrome.js's isBlockEditorPage() + .aurora-winbar--editor dots) with a "← Posts"/"← Pages" breadcrumb, resolved from the post-type-{slug} body class core adds to post edit screens (needed since Posts/Pages no longer have a fixed URL to hardcode after phase 1's migration). Bug caught while testing: the Site Editor shares the block-editor-page body class but has no post-type-* class — the crumb logic now explicitly returns nothing in that case instead of guessing wrong. Verified live on both the post editor (correct crumb) and Site Editor (no crumb, unaffected).

All 9 phases of the "full admin takeover" initiative are now shipped. Network Admin (multisite) was explicitly deferred throughout — the dev/ target site isn't multisite and nothing else in the codebase references it.

Free vs. paid — historical, moot since 3.0.0 (everything below is free now, see "Licensing"): Media Upload/Add User/Profile were always free (1:1 companions to already-free Posts/Pages/Media/Users). Appearance (Nav Menus, Widgets, Themes, Theme Editor redirect) and Native Settings screens were paid ("admin-takeover" feature key) — matched the "power admin tool" bucket (Database Explorer, Role Editor, etc.) at the time. Block editor reskin was always free (chrome around already-free Posts/Pages). Every REST controller in this group still checks current_user_can() directly in its own permission_callback rather than relying on page-level gating alone — that defense-in-depth pattern is unrelated to licensing and remains good practice.

Theme system (rewrite — supersedes the old dark_mode 3-state model)

Went through several rounds of user-directed rework this session, landing on: 10 fixed, named color presets + 2 flat neutrals ("Black & Grey", "White") — no custom color picker, no separate light/dark toggle. Each preset is fully self-contained (its own coordinated backgrounds/cards/borders/text), not a hue applied on top of a shared light/dark base.

  • app/src/pages/settings/themePresets.js: themePresets array (10 hue-based presets like { id: 'indigo', label: 'Indigo', hue: 235 }, plus mono and white with an explicit palette object instead of a hue). buildPalette(preset) returns all --aurora-* tokens — for hue presets, computed via HSL ramps; for the two flat presets, returned as-is. resolvePreset(id) looks up by id.
  • The main app background (--aurora-app-bg) is a 3-stop gradient, not flat: linear-gradient(135deg, vivid1 0%, vivid2 55%, near-black 100%) — two bright, saturated stops of the same hue family carry most of the bar, dipping to a near-black anchor only in the last stretch. This exact ratio went through several corrections against a competing plugin's ("FleekDash") reference screenshots — a 50/50 dark-to-vivid split reads "washed out", two competing saturated hues ~70° apart reads "muddy"; this formula was the one the user confirmed as clean. If revisiting the gradient formula, re-check against those reference screenshots in the conversation history rather than re-deriving from scratch.
  • --aurora-card-bg-deep: a newer token, one step darker than --aurora-card-bg, used only by the Dashboard's AuroraCard.vue. Needed because the Dashboard got its own frame background using --aurora-frame-bg (see "Window chrome" below), which is close in lightness to --aurora-card-bg for most presets — cards would nearly disappear into the new frame without it.
  • applyThemePalette(themePresetId) / applyFontFamily(family) (app/src/pages/settings/applyLiveTheme.js) — shared between the settings page's live preview (Settings.vue's watch()) and the real per-page-load application (SiteShell.vue::onMounted). Sets each token via document.documentElement.style.setProperty.
  • Google Fonts picker: app/src/pages/settings/googleFonts.js (curated ~70 family list, not the full catalog), searchable dropdown in SettingsField.vue's font-picker field type, applied by injecting a Google Fonts CSS2 <link> tag + setting --aurora-font-family.
  • Recurring bug class this session — "independently-painted gradient": several elements (sidebar, toolbar, #wpbody-content on the settings page, #wpbody-content in general) each used to paint their own copy of var(--aurora-app-bg). Since it's a gradient, each element computes it independently over its own bounding box — a narrow tall sidebar and a wide short toolbar get visibly different-looking slices of "the same" gradient, creating seams at their shared edges. Fixed by making html/body the only thing that paints the gradient (background-attachment: fixed so it doesn't scroll away from the fixed sidebar/toolbar), and leaving every other element transparent so that one gradient plane shows through. If a new seam/mismatched-rectangle bug shows up, suspect this pattern first — grep for var(--aurora-app-bg) outside shell-frame.css's html.aurora-ready, html.aurora-ready body rule; it shouldn't be there.
  • Dashboard takeover (admin/src/Pages/DashboardTakeover.php + app/src/pages/dashboard/Dashboard.vue): hijacks native index.php via in_admin_header, hides the native .wrap, renders the Aurora dashboard in its place. 12-col grid; cards are single <section class="aurora-card"> elements with inline grid-column: span N (NOT wrapped — component attribute fallthrough for styles was unreliable). Never use unprefixed class names on Aurora components (.card collided with WP core once).
  • Settings page (admin/src/Pages/SettingsPage.php + app/src/pages/ settings/Settings.vue): owns the top-level "Aurora Admin" WP admin menu directly via add_menu_page (slug aurora-admin). Two-box layout: .settings__nav (category list) + .settings__content (fields), both boxed/bordered on a darker backdrop. Field schema is declarative in app/src/pages/settings/settingsConfig.js, rendered by app/src/pages/settings/components/SettingsField.vue (types: text, toggle, color, image, repeater, multiselect, segmented). remote-sites was a field type here — removed, see "Remote Site Switcher — removed" below.
  • 6 sub-apps (admin/src/Pages/SubApps.php + one PHP feature-class pair each + one Vue page + one Vite entry each) — reachable only via the sidebar's Aurora nav section, not the native WP menu:
    • Registered via add_submenu_page('aurora-admin', ...) (or 'plugins.php' for Plugins) and left registered in WP's native $submenu global — no longer hidden via remove_submenu_page(). An earlier version did call it (the standard register-but-hide trick) but that corrupts WP core's own user_can_access_admin_page() capability lookup, which partly depends on the page still being present in $submenu to resolve its required capability — removing it caused "Sorry, you are not allowed to access this page" for every user, including admins, on Menu Creator/ Activity Log/Role Editor/Comments. Root-caused and fixed this session by deleting hide_from_native_submenu() entirely; duplication in the sidebar's native-menu section is instead prevented in MenuSerializer::serialize() (see below), which has no such side effect.
    • All slugs prefixed aurora-admin-* so Shell::is_own_page()'s existing strpos($page, "aurora-admin") === 0 check covers them for free (full custom dark theme, native-dark.css skipped). MenuSerializer::serialize() skips any submenu slug starting aurora-admin- (in addition to already skipping the aurora-admin top-level slug) so none of them are duplicated in the sidebar's native-menu section — needed because most are parented under aurora-admin (already skipped wholesale) but aurora-admin-plugins is parented under the real plugins.php menu.
    • Each page uses the shared two-pane layout app/src/pages/_shared/AppListShell.vue (generalized from Settings.vue's box-pair CSS) + AppListItem.vue for list rows.
    • Menu Creator (admin/src/MenuCreator/): DB table aurora_admin_menus (name/status/roles JSON/items JSON). Runtime admin_menu hook (priority PHP_INT_MAX) applies published configs matching the current user's roles — hides/relabels native menu items by slug. REST: GET/POST /menus, GET/PUT/DELETE /menus/:id, GET /menus/native-items (real WP menu list to build the editor from, via MenuSerializer::serialize()). Reordering is stored (order field) but not yet applied at runtime — CRUD + hide/relabel only so far.
    • Admin Notices (admin/src/Notices/): DB table aurora_admin_notices (title/message/type/roles JSON/pages JSON/dismissible/active). Runtime admin_notices hook renders active, role/page-matching rows via real WP notice markup. REST: GET/POST /notices, PUT/DELETE /notices/:id.
    • Role Editor (admin/src/Roles/): no custom table — wraps core get_editable_roles()/WP_Roles directly. Hard-codes the 5 default roles (administrator, editor, author, contributor, subscriber) as delete-protected (WP has no built-in check for this). REST: GET/POST /roles, PUT/DELETE /roles/:slug.
    • Activity Log (admin/src/ActivityLog/): DB table aurora_admin_activity_log. Hooked to save_post (guards wp_is_post_autosave()/wp_is_post_revision()), before_delete_post, user_register, profile_update, wp_login, activated_plugin, deactivated_plugin, switch_theme, update_option_aurora_admin_settings. REST: GET /activity-log?action_type=&object_type=&search=&page=.
    • Plugins (admin/src/Rest/PluginsData.php + app/src/pages/plugins/Plugins.vue): custom plugin management list (activate/deactivate/delete), also using AppListShell.vue.
    • Comments (admin/src/Rest/CommentsData.php + app/src/pages/comments/): newest sub-app, edit_posts-gated (not manage_options like the others). SubApps::redirect_native_pages() (admin_init) redirects the native edit-comments.php (when hit with no ?page= param, i.e. a direct nav or an old bookmark/menu link) to admin.php?page=aurora-admin-comments. MenuSerializer::resolve_url() also maps the edit-comments.php slug straight to the Aurora URL so the native-menu-derived sidebar entry (for users who can edit_posts but not manage_options, so don't see the Aurora nav section) still lands on the Aurora page. The Dashboard's comments card (DashboardData.php's listUrl) was updated to point at the same Aurora URL instead of the native one.
    • Database Explorer (admin/src/DatabaseExplorer/): no custom table — reads real $wpdb schema. GET /db-tables (SHOW TABLE STATUS), GET /db-tables/:table (columns + paginated rows — table name validated against the real $wpdb table list before use, never interpolated unchecked), POST /db-query (raw query runner, hard-restricted server-side to queries starting SELECT/SHOW/DESCRIBE/EXPLAIN, single-statement only, auto-LIMIT 200 if none given). All 3 endpoints manage_options-gated. Identifier allow-listing + backtick-escaping is the actual safety boundary ($wpdb->prepare() only binds values, not identifiers).
    • All 5 backend classes + SubApps.php are require'd in aurora-admin.php and instantiated in AuroraAdmin.php.
  • Settings persistence: admin/src/Options/Settings.php — partial-payload save via merge_on_save() sanitize callback (merges into existing option, doesn't overwrite). Settings::get_safe() strips license_key for use on non-admin-gated pages; Settings::get() (full) only used on the manage_options-gated settings page itself. Settings::remove_retired_keys() (hooked to init alongside register()) actively deletes settings keys for features that no longer exist — currently just the removed Remote Site Switcher's remote_sites/remote_site_switcher_capability, since remote_sites stored a real secret (application passwords) that's no longer read anywhere.
  • Global search: app/src/shell/components/SearchModal.vue, Ctrl+K, debounced wp/v2/search REST calls, gated on disable_global_search setting.
  • Scheduled content: app/src/pages/dashboard/cards/ScheduledContent.vue calendar cells (squares, date top-left, count centered) are clickable — navigate to edit.php?post_status=future&aurora_day=YYYY-MM-DD if that day has scheduled posts, else show a toast ("no scheduled content"). Server side: admin/src/Posts/ScheduledDayFilter.php filters the edit.php query via pre_get_posts and prints an info notice.
  • Analytics: admin/src/Analytics/Analytics.php (first-party pageview tracker, own DB table wp_aurora_admin_pageviews, bot detection, gated on enable_analytics/track_admins settings) + admin/src/Rest/AnalyticsData.php (aggregation REST endpoint: totals, series, device/referrer/country breakdowns, bounce rate, active users).
  • White label: admin/src/WhiteLabel/WhiteLabel.php — custom favicon, rename plugin in Plugins list, rename top-level menu item, gettext find/replace via text_replacements setting.
  • Native page dark styling: admin/assets/native-dark.css (large static sheet, all rules gated html.aurora-dark.aurora-ready, scoped under #wpbody-content) + admin/assets/native-enhance.js (progressive JS enhancement — currently only enhanceUpdates(), which groups the flat Updates page sections into .aurora-native-card wrapper divs on body.update-core-php). This session also extended coverage to media/ thickbox modals, plugin/theme card grids, and the plugin-search filter bar (all previously untouched, so still native white regardless of theme). Known gap: the plugin "More Details" popup renders inside an <iframe> pointing at a separate WP page — CSS can't reach into it; would need JS injection into the iframe's own document if that's ever worth fixing. This session also added dark-theme rules for the block editor's own chrome (.editor-header/.edit-post-header, .interface-interface-skeleton__sidebar and its nested .components-panel/.editor-post-status/ .interface-complementary-area-header) — found by inspecting the real screen via computer-use and drilling into nested elements' computed styles, since .interface-complementary-area (nested inside the sidebar) had its own explicit white background the outer rule didn't reach, leaving a white patch below the Tags panel until it was targeted directly. The actual post content canvas is deliberately left untouched (WYSIWYG, matches the front-end).

Window chrome (macOS-style title bar + taskbar)

Added earlier this session, user-requested after seeing a competing plugin's demo; extended later the same session to cover the block editor and the Site Editor, and the taskbar was rewritten to support multiple minimized windows. Every page gets a traffic-light title bar; minimizing persists across a full page navigation via a taskbar.

  • app/src/shell/windowChrome.js: initWindowChrome(adminUrl), called unconditionally from SiteShell.vue::onMounted (no longer skipped on the block editor — that special case was removed once the editor got its own handling). Three cases, checked in priority order:
    1. Dashboard (#aurora-admin-dashboard-root present) — wraps the bar + the dashboard root in a new .aurora-dashboard-frame div, giving it the same card-frame treatment (--aurora-frame-bg, border, radius) every other page gets from #wpbody-content itself.
    2. Block editor / Site Editor (isBlockEditorPage(): body has block-editor-page, or the path ends /post.php or /post-new.php — site-editor.php also gets block-editor-page on its body, so this already covers it with no JS change needed) — appends a .aurora-winbar--editor bar (dots only, no bar surface) directly to document.body, since neither editor's real content container is #wpbody-content.
    3. Everything else — native WP pages and all of Aurora's own Vue pages, inserted as the first child of #wpbody-content.
  • Red (close): window.location.href = adminUrl.
  • Green (maximize): toggles html.aurora-maximized — hides .sidebar/ .toolbar, zeroes #wpcontent's margin-left and the editor frame's inset, so content fills the whole browser window. Toggle again (or minimize) to restore. Nothing persisted — resets on next page load.
  • Yellow (minimize): on the Dashboard itself, toggles html.aurora-minimized which just collapses #aurora-admin-dashboard-root down to the bar in place (nowhere further "home" to send it). On every other page, calls taskbar.js's addMinimizedWindow(title, url) then navigates to the dashboard.
  • Taskbar rewritten from a single sessionStorage slot to a list (app/src/shell/taskbar.js): minimizing a second page used to silently overwrite the first one's entry, so only the most recent minimize ever showed up. Now getMinimizedWindows()/addMinimizedWindow() (dedupes by URL)/removeMinimizedWindow(id) operate on an array under sessionStorage['aurora-minimized-windows']; SiteShell.vue v-fors over it, rendering one .aurora-taskbar__item pill per entry inside a transparent, floating .aurora-taskbar bar (inset by --aurora-sidebar-gap on all exposed edges, rounded, its own box-shadow — went through a design iteration from a small centered pill back to full-length per explicit user correction, keeping only the pill shape on the individual items). Each item is independently restorable/closable. currentPageTitle() prefers the page's own on-screen <h1> (#wpbody-content h1, #aurora-admin-dashboard-root h1) over document.title, since WP's title-tag generation came up empty for Aurora's sub-app pages back when they were still remove_submenu_page()'d (see the sub-apps permissions bug above) — document.title is still the fallback for pages where that isn't an issue.
  • Content-behind-taskbar fix: html.aurora-has-taskbar no longer just adds margin-bottom (that only guarantees clearance once scrolled to the very bottom — content taller than the viewport still rendered underneath the fixed taskbar on first paint). Instead it caps #wpbody-content's/ .aurora-dashboard-frame's own height via max-height + overflow-y: auto, same principle as the toolbar's reserved space at the top. This appeared to do nothing at first — #wpbody-content's base rule sets min-height: calc(100vh - toolbar-h - 40px), and per spec min-height always wins over a smaller max-height; fixed by adding min-height: 0 to the taskbar-specific override.
  • Universal content-area height cap (this session): the taskbar-only max-height/overflow-y: auto treatment above is now applied to #wpbody-content unconditionally (not just when html.aurora-has-taskbar is set), capped to calc(100vh - toolbar-h - 40px). Several native and Aurora pages with a lot of content (Add Plugins, Add User, Profile, Appearance > Fonts, the file editors, every Settings page, Activity Log, Database Explorer, Comments) used to grow taller than the viewport and force the whole document to scroll — janky since the sidebar/toolbar are fixed, so scrolling just moved the content away from them with a lot of dead space. Now the content card's own box is capped and scrolls internally, same as the toolbar/taskbar reservations already did.
    • Scroll-anchoring bug this surfaced: making #wpbody-content a real scroll container exposed a second, unrelated bug on Add Plugins — its plugin-card grid renders via JS well after first paint, inserting a lot of content above whatever was already visible in the now-scrollable box. Chrome's scroll anchoring "corrects" for that by silently jumping scrollTop down to compensate, so the page loaded already scrolled ~1700px into the content instead of at the top. Fixed with overflow-anchor: none on #wpbody-content (and the taskbar/dashboard- frame variant) — the standard fix for this class of bug, opting the container out of anchoring entirely.
    • Phantom document-level scrollbar: independently, Add Plugins also left the outer <html>/<body> scrollable with ~1200px of empty dead space below the frame, even though every element inside #wpbody-content measured as properly capped to the viewport — narrowed down to some interaction between the plugin grid's async rendering and the browser's own scrollable-overflow bookkeeping (root cause not fully pinned down; forcibly shrinking or hiding #wpbody-content reduced but didn't eliminate it, and no single oversized/mispositioned descendant could be found). Since the intended design already has the sidebar/toolbar fixed and every page's real content scrolling internally within its own frame, the document itself was never supposed to scroll at all — fixed directly by adding overflow: hidden to html.aurora-ready/body, which removes the dead scroll space outright regardless of what was causing it.
  • Block editor / Site Editor framing: neither editor renders inside #wpcontent's normal box model — each has its own fixed, full-viewport root (.interface-interface-skeleton itself on post.php/post-new.php; #site-editor on site-editor.php, where .interface-interface-skeleton is merely position: relative, nested inside it — a reset rule puts the nested skeleton back to a plain unpositioned child so the two frames don't stack). html.aurora-ready .interface-interface-skeleton, html.aurora-ready #site-editor in shell-frame.css frames whichever is the real fixed root with the same insets/background/radius/shadow treatment #wpbody-content gets elsewhere, plus padding-top: var(--aurora-editor-winbar-h) to reserve a strip for .aurora-winbar--editor's dots and overflow: hidden so the rounded corners actually clip the editor's own white canvas background (border-radius alone doesn't clip children). Known CSS-over-constraint gotcha: WP core's .edit-site { height: 100vh; } class (present on #site-editor) means top, height, and bottom are all specified on that box at once — per spec this is over-constrained and bottom silently gets recomputed from top + height instead of honoring an explicit value, which is why the frame initially ran past the intended bottom inset despite bottom: 24px !important being set. Fixed by adding height: auto !important to the same rule so top/bottom actually determine the box size again. If a future feature needs to reach either editor's own chrome, the real containers are .interface-interface-skeleton (post editor) or #site-editor (Site Editor) — don't assume the normal #wpbody-content page structure there.
  • Shell.php::is_enabled() no longer excludes the Site Editor screen (site-editor used to be in the skip list alongside customize) — it turns out to be built on the same @wordpress/interface package as the block editor (body.block-editor-page is present on both), so the existing block-editor handling covers it once #site-editor is framed.

Native Settings field-parity completion (Writing/Reading/Discussion)

Follow-up to the full-plugin audit above: Writing/Reading/Discussion had much thinner field coverage than Permalinks did before its presets fix — raw numeric IDs where native WP has dropdowns, and Discussion exposing only 2 of ~15 real settings. Investigated each screen against WP core's register_initial_settings() (wp-includes/option.php) rather than guessing: Writing and Reading were actually already using 100% of what core's REST settings schema exposes for those two groups (use_smilies was the one field core registers that had never been added) — the remaining gap there was pure UX (raw IDs instead of dropdowns). Discussion was different: core's schema genuinely only registers default_ping_status/default_comment_status for that option group, nothing else — the other ~13 fields (moderation, threading, notification emails, avatars) have no REST route in WordPress core at all, so exposing them required a new dedicated controller, the same situation Permalinks was already in for permalink_structure.

  • NativeSettingsPages.php gained three helpers — category_options()/post_format_options()/page_options() — that build real {value, label} option arrays from get_categories()/ get_post_format_strings()/get_pages(), reusing the existing generic "select" field type (SettingsField.vue already supported it, populated with static arrays for start_of_week etc. — no new rendering code needed, just real data instead of hardcoded options).
  • New showIf: {key, equals} field property, checked in a visibleFields computed in NativeSettings.vue — lets any field declare "only show once another field's value matches", used for Reading's page pickers (only once show_on_front === 'page') and throughout Discussion's now-much-longer field list (e.g. thread depth only shown when threading is enabled). Generic, not special-cased per screen.
  • New admin/src/Rest/DiscussionData.php — mirrors PermalinksData.php's raw-get_option()/update_option() pattern but for ~24 keys instead of one, validated against an explicit per-type allowlist (BOOLEAN_KEYS/ INTEGER_KEYS/TEXTAREA_KEYS/ENUM_KEYS consts) so the route can never be used to write an arbitrary WP option. NativeSettings.vue's Permalinks-only isPermalinks special case became a small per-screen endpoint map (isPermalinks/isDiscussion) rather than growing more one-off branches.
  • New textarea field type in SettingsField.vue (multi-line plain text) for moderation_keys/disallowed_keys — nothing else on any settings screen needed one before this.
  • Verified live end-to-end, not just visually: dropdowns populate with real category/format/page data, showIf conditionals actually show/hide on interaction (toggled show_on_front and watched the page pickers appear), and — the one check that actually proves the save path works, not just the UI — toggled a real Discussion setting off, saved, did a full page reload, and confirmed the change was still off (not an optimistic-UI illusion), then reverted it.

Theme installer overlay framing

User clicked "Add New Theme" > a theme card and reported "when you click add theme, it closes the menu. something is wrong." Investigated live: clicking a theme opens WP core's own .theme-install-overlay (a .wp-full-overlay component, shared with the Customizer), a genuine fullscreen fixed overlay by design — confirmed this covers the native admin menu on a completely vanilla WordPress install too, not something this plugin broke. Asked the user whether to leave it native or frame it like the block editor window; they chose framing despite the Gutenberg-CSS-conflict-style risk being called out.

  • app/src/shell/shell-frame.css: .theme-install-overlay (only this specific class — not .wp-full-overlay generally, so customize.php is untouched/out of scope) gets the standard Aurora inset box. The overlay's two children, .wp-full-overlay-sidebar and .wp-full-overlay-main, are independently position: fixed/absolute — fixed descendants don't move just because their ancestor's own box moved (fixed is relative to the viewport unless an ancestor creates a new containing block), so each needed the identical left/top/bottom math applied directly, not inherited from the overlay's own reframing. margin-left on .expanded/.collapsed duplicates WP core's own three responsive breakpoints (themes.css) with Aurora's frame offset added on top, preserving the sidebar collapse/expand slide animation as-is.
  • Two real bugs found and fixed, not obvious from the CSS alone:
    1. Opening the overlay adds body.full-overlay-active, and WP core's own CSS sets body.full-overlay-active { visibility: hidden } with only the overlay itself (#customize-container, .theme-install-overlay) set back to visible. Aurora's entire shell root is a descendant of body, so it inherited that hidden visibility too — this is what silently erased the sidebar/toolbar entirely (not "covered by a higher z-index", genuinely invisible) rather than the milder "overlay covers it" behavior the user was actually reporting. Root-caused by walking the DOM ancestor chain checking each element's own visibility in devtools, then grep-searching loaded stylesheets for rules matching body with a visibility declaration — found body.full-overlay-active { visibility: hidden } in WP core's own themes.css. Fixed with one override: body.full-overlay-active #aurora-admin-shell-root { visibility: visible !important; } — descendants can locally re-enable an inherited hidden value, so this doesn't need to touch anything else under body (which stays hidden, harmless, since it's covered by the overlay's insets anyway).
    2. The overlay's own height: 100% (WP core) plus this session's added top+bottom insets is the exact over-constrained-box bug already documented above for .interface-interface-skeleton/#site-editor — same height: auto !important fix, same root cause (per spec, bottom silently loses to an explicit height when both are set alongside top).
  • The winbar (traffic-light dots) needed a persistent MutationObserver, not a one-shot insert — two reasons, both confirmed by testing rather than assumed: (1) .theme-install-overlay isn't in the DOM at initial page load (WP core's Backbone view creates it lazily on the first theme click), so initWindowChrome()'s normal one-time-at-mount check always missed it; (2) every subsequent theme selection (including the prev/next arrows inside the overlay) fully re-renders the overlay's own contents, which wipes out anything previously inserted into it — the bar was confirmed present after the first click, then gone after selecting a second theme. windowChrome.js's watchForThemeOverlay() keeps an observer running for the page's whole lifetime with an idempotent "insert if missing" callback, rather than disconnecting after the first successful insert.
  • Verified live end-to-end (not just visually): opened via real clicks on the grid (not just direct URL navigation to ?theme=slug, which behaves slightly differently), switched themes with the prev/next arrows, closed and confirmed the sidebar/toolbar return correctly with no lingering state, checked the browser console for errors at every step.

Sidebar accordion: hidden native "child" screens

Separate follow-up bug on the same feature, user-reported precisely: expand Appearance, click "Add New Theme", and the Appearance section collapses while "AURORA" (the branded nav at the bottom) opens instead — different from the overlay-covering-the-sidebar issue above, this one is about which accordion section stays open. Root cause: Sidebar.vue's accordion picks the open section by matching the current URL against each menu item's own submenu array (scraped from WP's $submenu global by MenuSerializer::serialize()) — but theme-install.php/media-new.php/ user-new.php etc. are deliberately absent from $submenu (native WP's own left menu doesn't list "Add New Theme" as a link either, only "Themes"/"Customize"/"Editor"), so nothing ever matched and the accordion fell through to its Aurora-expanded default every time.

Fix reuses WP core's own solution to this exact problem rather than hardcoding a list: these pages set a $parent_file global directly in their own PHP (theme-install.php → 'themes.php', media-new.php → 'upload.php', user-new.php → 'users.php') — the same mechanism core uses for its own native menu highlighting. Shell::print_mount() reads it (global $parent_file) and passes it through as activeParentFile; Sidebar.vue's isSectionActive() now also matches on item.id === activeParentFile, alongside the existing exact-URL checks. General fix — covers any current or future screen using this WP core convention, not just the three confirmed live (Appearance/Themes, Media, Users).

Remote Site Switcher — removed

Was a Pro feature: Settings > General let you store {url, username, app_password} for other WordPress sites, gated a toolbar dropdown behind a configurable capability + license check, and clicking an entry just opened that site's /wp-admin/ in a new tab. User asked what it did; on inspection, nothing ever actually consumed the stored username/ application password — no REST calls to the remote site, no proxying, no SSO. It was pure UI scaffolding for a cross-site feature that was never built on top of it, so removing it lost zero real functionality (the "switcher" was just a bookmark list either way). Decided to remove rather than build out the unused half, since the user said it "doesn't really do much for the software."

Removed: the remote_sites/remote_site_switcher_capability settingsConfig.js fields, the remote-sites field type + its helpers in SettingsField.vue, the toolbar dropdown + canSwitchSites in Toolbar.vue/Shell.php, the "remote-site-switcher" key in License::PAID_FEATURES, and the secret-stripping logic in Settings::get_safe() (no longer needed with remote_sites gone). Added Settings::remove_retired_keys() (hooked to init) to actively delete remote_sites/remote_site_switcher_capability from any install that already saved them — leaving a stored application password inert in the database indefinitely isn't good practice even though nothing reads it anymore.

Role Editor capability tooltips

Added after the user asked directly: "what is level_0? what is level_1? None of these things are defined." Every capability checkbox in RoleEditor.vue now has a small info icon with a hover tooltip, driven by app/src/pages/role-editor/capabilityDescriptions.js — a static lookup covering all ~55 WordPress core capabilities plus a regex special-case for level_0–level_10 (the pre-2.0 legacy "user level" system WordPress still assigns to every default role purely for backward compatibility with old themes/plugins checking current_user_can('level_N') directly — core itself hasn't checked a level in years, so these are safe to ignore/leave alone, just unexplained without this). Anything not in the dictionary (e.g. a capability another plugin added) falls back to a generic "custom capability, not part of WordPress core" message rather than showing nothing. No backend change — this is pure static documentation data, looked up client-side.

Licensing — removed entirely in 3.0.0 (2026-07-06)

Aurora Admin previously had a full licensing/monetization system (a License.php gate every paid feature funneled through, real integration with the aurora-licensing license server on auroraplugins.com, a beta tester program, a dev-mode license-override toggle). All of it is gone.

The user decided to give Aurora Admin away free on the WordPress.org plugin repository, so every feature that was previously paid — Role Editor, Activity Log, Database Explorer, File Manager, Analytics, White Label, Full Admin Takeover, Media Folders — is now simply available to everyone, no key required. Deleted: admin/src/Licensing/ entirely (License.php, Integrity.php — the file-tamper-detection system that only existed to protect license gating from bypass), bin/build-integrity-manifest.php, UpgradeNotice.vue, locked-feature.js, the License key field, the dev-mode "Simulate active license" toggle, and every lock badge/gating check across ~20 PHP files and ~8 Vue files. license_key and dev_license_override are actively purged from any site's stored settings on upgrade (Settings::remove_retired_keys()), same pattern used for the earlier Remote Site Switcher removal. Report a Bug still works, now via a direct call to the feedback endpoint instead of going through the license-status layer.

This is a clean split from the commercial auroraplugins.com service (real beta licenses, Pro purchases, the license server itself) — that system is untouched and unaffected, it's simply no longer wired into this codebase. If any of the old implementation detail is ever needed again (the real license-server REST contract, the beta-tester-license flow, etc.), it's preserved in git log up through the 2.27.0 tag/commit — not reconstructed here.

Report a Bug (new free page, added 2026-07-05)

A real WordPress admin page (aurora-admin-bug-report), registered in SubApps::PAGES exactly like Menu Creator/Admin Notices — free, no license feature key. Sidebar entry added in Shell::aurora_nav().

  • app/src/pages/bug-report/BugReport.vue: single free-text textarea plus a read-only diagnostics block auto-filled from server data (plugin version, WP version, PHP version, active theme). Posts to aurora-admin/v1/bug-report.
  • admin/src/Rest/BugReportData.php: thin REST controller, validates the description isn't empty and posts it directly (via wp_remote_post) to the feedback endpoint on auroraplugins.com — no longer goes through a license-status layer (rewritten in 3.0.0 when License.php was deleted; previously called License::submit_bug_report(), which reused the same call() helper validate()/activate() used).
  • License server (aurora-licensing plugin, not this repo): has a wp_aurora_bug_reports table and a public POST /report-bug REST route doing a plain wpdb->insert. No admin UI for reviewing reports yet — query the table directly via wp db query; worth building a dashboard if report volume ever justifies it.
  • Gotcha hit during the build: this codebase has no autoloader — every PHP class is manually required in aurora-admin.php. Forgetting to add the new BugReportData.php require there caused a Class ... not found fatal on first load. If adding another new class file, don't forget this step (mirrors the existing require list right above new AuroraAdmin\ App\AuroraAdmin();).
  • Verified live end-to-end against the real production server (not mocked): submitted a real report through the actual page, confirmed it landed in wp_aurora_bug_reports with all fields populated correctly, then deleted the two test rows by exact ID (a broad LIKE-based cleanup query was correctly blocked by the safety classifier for being too wide against a live production table — redid it scoped to specific IDs).

The bullets below describe the pre-3.0.0 license-gating mechanics (per-page feature keys, the shared UpgradeNotice.vue prompt, sidebar lock badges, etc.) — all removed; see "Licensing" above. Kept only for the one bug fix still relevant today:

  • Bug fix surfaced while testing this: RolesData.php's REST callback called get_editable_roles() unqualified, which 500'd every time — that function lives in wp-admin/includes/user.php, which WP only auto-loads for real wp-admin page requests, not REST API requests. Fixed with a function_exists guard + require_once before the call. Unrelated to licensing, just happened to surface while verifying Role Editor's unlock path.

Tamper protection — removed in 3.0.0

Existed solely to stop someone stripping the license check out of a distributed copy. With licensing gone entirely (see "Licensing" above), there's nothing left to protect — Integrity.php and bin/build-integrity-manifest.php were deleted along with License.php. Full historical detail (how the sha256 manifest worked, the "re-lock paid features only" design decision, live verification steps) is in git log up through the 2.27.0 commit if this kind of protection is ever wanted again for some other purpose.

Database Explorer — REMOVED from Aurora Admin in 3.5.0, now a separate plugin

Extracted entirely out of this codebase in 3.5.0, following the exact same pattern as File Manager's 3.4.0 extraction (see that section immediately below for the fuller precedent). Unlike File Manager, this extraction was proactive, not reactive — WordPress.org hasn't rejected Aurora Admin over Database Explorer specifically, but direct database-query tooling (a table browser plus a free-form SELECT/SHOW/DESCRIBE/EXPLAIN query runner) is the same class of risk WP.org's Plugin Directory review already flagged file-management functionality for, so it was pulled out ahead of a likely third rejection rather than waiting for one.

admin/src/DatabaseExplorer/DatabaseExplorerData.php (REST, manage_options- gated, read-only statement-type + single-statement guards, identifier validation against a live SHOW TABLES list since identifiers can't go through $wpdb->prepare()'s value placeholders) and app/src/pages/database-explorer/DatabaseExplorer.vue (built on the shared AppListShell/AppListItem list-detail layout, both also copied into the new plugin since it has no shared shell to depend on) moved, unmodified in behavior, into a new sibling plugin: aurora-database-explorer (aurora-database-explorer.php, own bootstrap, own REST namespace aurora-database-explorer/v1, own Vite build, own light-only base-theme.css subset of the --aurora-* palette). Removed from Aurora Admin: the DatabaseExplorerData require/wiring in aurora-admin.php/ AuroraAdmin.php, the aurora-admin-database entry in SubApps.php and Shell::aurora_nav(), the database-explorer Vite build entry, and a now-stale doc-comment cross-reference in DbDumper.php.

admin/src/Rest/CompanionPluginData.php — previously a single class hardcoded to File Manager — was generalized into a PLUGINS config array keyed by slug (file-manager, database-explorer), with the REST route registration, status()/install() logic, and Plugin_Upgrader wrapping all made slug-generic. Adding a third companion plugin later is a config-array entry, not new route-registration code. The Settings → Add-ons screen (settingsConfig.js) now lists both companion plugins; SettingsField.vue's companion-plugin field type was already fully generic (driven by field.statusEndpoint/field.installEndpoint/ field.manageUrl), so no Vue changes were needed there beyond the new config entry.

Verified live: built (npm run build, clean), PHP-linted (clean), rsynced into the LocalWP test site, activated via a direct WP-bootstrap script (browser cookie-based activation isn't practical for headless verification). Since the admin's browser session couldn't be driven directly, verification used a server-generated WordPress auth cookie (wp_generate_auth_cookie(), 'auth' scheme — this site is plain HTTP, not HTTPS, so the 'secure_auth' scheme WP prefers under SSL doesn't apply) and a real curl request against the live site: confirmed HTTP 200, the aurora-database-explorer-root mount div present, the correct built script filename referenced, and no PHP warnings/fatals in the response body. The plugin's REST namespace (aurora-database-explorer/v1) was confirmed registered via /wp-json/'s route index. A full authenticated REST nonce round-trip (actually calling /db-tables with a valid X-WP-Nonce) was attempted but not completed — a nonce generated outside a real cookie-validated session doesn't carry the right session token, so rest_cookie_invalid_nonce was returned; not pursued further given this is a direct, unmodified copy of already-shipped, already-working logic (only the namespace and REST route prefix changed), and the companion CompanionPluginData REST routes were separately confirmed present in the main Aurora Admin site's own route index.

Deployment gap, not yet done (same as File Manager's): the aurora-database-explorer.zip is not yet hosted at https://aurora.auroradragon.studio/aurora-database-explorer.zip — a real production-server deploy that needs the user's go-ahead, same as any other production deploy. Until deployed, the Add-ons "Install & Activate" button for Database Explorer will fail with a fetch error. Also outstanding: resubmitting the rebuilt Aurora Admin zip to WP.org — the user's action, not something to do unprompted.

File Manager — REMOVED from Aurora Admin in 3.4.0, now a separate plugin

Extracted entirely out of this codebase in 3.4.0 — see the new final subsection below for why and how. Everything else in this section is historical: it describes File Manager's design and history while it was part of Aurora Admin (v3.0.0 through v3.3.2). None of the file paths this section references (admin/src/FileManager/, app/src/pages/file-manager/) exist in this repo anymore — the equivalent code now lives in the sibling aurora-file-manager repo, unmodified in behavior from what's described below.

Added originally in an earlier session — the user has the "WP File Manager" plugin (elFinder- based) installed on the test site and asked for equivalent functionality built into Aurora, as a fresh implementation rather than embedding elFinder. admin/src/FileManager/FileManagerData.php (REST) + app/src/pages/file-manager/FileManager.vue (entry file-manager.js), registered as aurora-admin-file-manager in SubApps.php (parented under aurora-admin like Menu Creator/Role Editor, not a native-page replacement — there's no native WP file browser to redirect from).

  • Root is ABSPATH (the WordPress install directory), same as the reference plugin. Capability is manage_options everywhere, no exceptions — by far the highest-risk surface in the plugin (arbitrary filesystem read/write on the site's own PHP).
  • Path containment: every client-supplied path goes through resolve_path(), which realpath()s the candidate and checks the resolved path is still inside the root — never a raw string check against "../", since that alone doesn't catch symlinks or encoded traversal. New files/folders (which don't exist yet, so realpath() would return false) resolve their parent directory instead, then just sanitize the new name component (wp_basename(), reject empty/./../ control characters).
    • Verified live via direct REST calls: ?path=../../../etc → 404 (correctly rejected); creating/renaming to a blocked extension → 400; deleting the root path itself → 400; a full create-folder → create- file → write → read → rename → recursive-delete round trip all succeeded and left no trace afterward.
  • Blocked extensions on upload/create/rename (FileManagerData::BLOCKED_EXTENSIONS): .php and friends, .cgi, .sh, .exe, etc. — the single most important control, since uploading a webshell through a "file manager" is the classic way this class of feature gets exploited. A separate BINARY_EXTENSIONS list (images/archives/media/fonts) plus a 2MB size cap keeps the text editor from trying to open something it shouldn't.
  • WordPress.org rejected the 3.1.0 submission over this exact feature (2026-07-11): BLOCKED_EXTENSIONS was enforced on create/upload/rename, but not on opening/saving an existing file — a manage_options user could open a theme's .php file in the CodeMirror editor (which has first-class PHP syntax highlighting, @codemirror/lang-php) and save it back with immediate execution effect on the next request. The reviewer's rejection language ("script insertion... PHP is even more complicated... WordPress itself allows you to lock people out of being able to edit theme and plugin files directly via DEFINES") pointed straight at this — it's functionally the same capability as WP core's own Theme/Plugin editor, which core lets hosts disable via DISALLOW_FILE_EDIT specifically because of this risk class. Fixed in 3.2.1: is_editable() (used by both read_file() and write_file(), and by the isEditable flag list_dir() returns per item, which the Vue UI's Edit button/context-menu/double-click all gate on) now also excludes BLOCKED_EXTENSIONS, and additionally returns false outright when DISALLOW_FILE_EDIT is defined and true. Every mutating endpoint (create/upload/rename/delete/write/copy/move/bulk/trash restore-delete-empty) now also checks a new mods_allowed() helper against DISALLOW_FILE_MODS — the broader host-level file-modification lockdown WordPress core's install/update/delete flows already respect. Browsing, downloading, and file-info/preview are unaffected — this only closes the write-to-existing-executable-file gap. No client-side Vue changes were needed; the UI already fully gated on the isEditable server flag.
  • Deliberately not built (scope decisions): zip/unzip (extraction has its own well-known vulnerability class — zip-slip, where a crafted archive's entries escape the extraction directory), chmod/permissions editing (too easy to accidentally lock yourself out or open a real hole).
  • Feature-parity pass (later session, prompted by the user comparing against WP File Manager): added everything else on that gap list that didn't carry zip/chmod's security risk — move/copy between folders (copy_item/move_item, with a same-descendant guard so a folder can't be moved/copied into itself, and " (2)"-style auto-renaming on name collision rather than overwriting), multi-select bulk actions (one /file-manager/bulk endpoint reusing the exact single-item logic for trash/move/copy so bulk can't drift from the single-item buttons), a trash instead of permanent delete (flat UUID-named folder under wp-content/uploads/aurora-admin-trash/ + a JSON manifest tracking original path/name/type/deleted-at — chosen over encoding the original path into the trash filename itself, which gets fragile with nesting/ special characters/length limits), a lazy-loaded folder tree sidebar (reuses the existing list endpoint filtered to directories, no new route needed, also doubles as the move/copy destination picker), a read-only file info panel (/file-manager/info — permissions/owner/dates/mime; directory size computed on-demand for this one call, not for every row in the main list), image thumbnails (/file-manager/preview — inline, extension-gated, size-capped, no server-side resizing), and drag-and-drop upload + sortable columns (both frontend-only). Verified live: multi- select, info panel, and a full create→trash→restore round trip through the actual UI; copy/bulk-trash/empty-trash verified directly against the REST endpoints. Zip/unzip and chmod remain deliberately unbuilt.
  • UI reworked again, same session: user pointed at WP File Manager's actual toolbar screenshot and asked for the same layout. Replaced the header-buttons + per-row-Actions-column split with one unified toolbar (back/forward/up history, new folder/file, upload, copy/cut/paste, rename, trash, download/edit/info, list/grid toggle, fullscreen, search) — buttons enable based on current selection. Move/copy switched from the destination-picker modal to clipboard-style copy/cut/paste (select → Copy/Cut → navigate anywhere → Paste), matching how elFinder- based file managers actually work; still calls the same /file-manager/bulk endpoint underneath. Added a grid view and fullscreen toggle. Verified live: copy→navigate→paste, list/grid toggle, back navigation.
  • Right-click context menu + real code editor (same session, user asked directly): context menu mirrors the toolbar's own functions exactly (no separate logic to drift out of sync) — right-click an unselected item to select just it, right-click within an existing multi-selection to keep it and act on all of it, right-click empty space for folder-level actions (New Folder/File, Upload, Paste). The plain <textarea> editor became a real CodeMirror 6 instance (CodeEditor.vue) — syntax highlighting, line numbers, folding, active-line highlight, language chosen by extension (JS/TS/CSS/HTML/ JSON/PHP/Markdown/XML/SQL, unrecognized extensions fall back to plain text), themed off Aurora's own CSS variables rather than a hardcoded palette. New npm deps: codemirror + one @codemirror/lang-* package per language above. Adds real weight to the file-manager bundle (~250KB gzipped) but it's only loaded when that page is actually opened. Verified live: context menu selection/empty-space cases and Escape-to-close; PHP syntax highlighting confirmed on a real file.
  • Was gated as a paid feature until 3.0.0 — free now, along with every other previously-paid feature (see "Licensing" above). The REST permission callbacks here (and Database Explorer/Role Editor/Activity Log's) still check current_user_can() directly, not just at the page level — that defense-in-depth pattern is still correct practice even though there's no license left to bypass.

Extraction into a standalone plugin (3.4.0)

WordPress.org's response to the 3.2.1 resubmission (which restricted PHP editing per the section above) was that restriction doesn't matter — the whole category of file-management/code-editing functionality isn't accepted into the Plugin Directory, bundled in any WordPress.org-listed plugin, safeguards or not. The user's direction: pull File Manager out of Aurora Admin entirely and offer it as a separate, independently-installable companion plugin instead of deleting the functionality.

  • New sibling plugin, aurora-file-manager (separate repo/directory at /Users/kris/Projects/aurora-file-manager, not a subfolder of this one) — FileManagerData.php and the three Vue components (FileManager.vue/FileManagerTree.vue/CodeEditor.vue) moved over with the identical PHP-editing-restricted behavior from 3.2.1 kept intact (not restored to unrestricted editing — that restriction is a genuine security improvement independent of WP.org's policy, so there was no reason to relax it just because it's no longer being reviewed). Only mechanical changes: namespace AuroraAdmin\FileManager → AuroraFileManager\FileManager, REST namespace aurora-admin/v1 → aurora-file-manager/v1, text domain/trash-dirname strings updated to match. Own plugin bootstrap (aurora-file-manager.php), own minimal admin-page shell (AuroraFileManager.php — a single top-level menu page, since this plugin has no Aurora Admin shell/sidebar framework to hook into and needs to work whether or not Aurora Admin is even installed), own Vite build (app/package.json/vite.config.js, same CodeMirror dependency set), and a small standalone base-theme.css extracting just the light-mode --aurora-* CSS variables the three Vue components actually reference (10 of them) rather than the whole window-chrome/dark-mode/sidebar system, which this plugin has no use for. Verified live both with Aurora Admin active (nav item appears alongside Aurora's own) and fully deactivated (File Manager still works standalone, confirming zero hidden dependency on Aurora Admin).
  • Removed from Aurora Admin: admin/src/FileManager/ and app/src/pages/file-manager/ deleted outright, the require/use/ instantiation lines in aurora-admin.php/AuroraAdmin.php, the aurora-admin-file-manager sub-app entry in SubApps.php/Shell.php, and the file-manager Vite build entry all removed. Several other classes (FileArchiver, BackupPaths, BackupGoogleDriveData, BackupData) had comments citing FileManagerData as a design precedent (containment-check pattern, lazy WP_Filesystem init, nonce-via-query-param permission callback) — rewritten to stand alone rather than reference a class that no longer exists in this repo.
  • AppearancePages's Theme Editor redirect removed, not repointed — it used to send theme-editor.php visits to File Manager instead of hijacking the native editor in place (reasoning: no point building a second unhardened PHP-editing surface). With File Manager gone, visits now fall through to WordPress core's own Theme Editor unmodified, which already respects DISALLOW_FILE_EDIT itself — arguably a better fit for WP.org's stated comfort zone than redirecting to any file editor, even a separately-distributed one.
  • New Settings → Add-ons screen offers one-click install: a new companion-plugin field type in SettingsField.vue (Settings.vue now passes a shared api() fetcher down to SettingsField as a prop, the first field type that needs its own REST calls beyond the generic wp/v2/settings save flow) checks install/active status on mount and shows "Install & Activate" / "Activate" / "Installed & active — open File Manager →" accordingly. Backend: CompanionPluginData.php wraps Plugin_Upgrader::install() — the exact same core class plugin-install.php's native "Install Now" button and the Upload Plugin tab both use — pointed at a fixed, hardcoded HTTPS zip URL (https://aurora.auroradragon.studio/aurora-file-manager.zip), never a client-supplied one, so there's no SSRF surface: the install target isn't user input, only the decision to trigger the install is. Inherits Plugin_Upgrader's own WP_Filesystem credential handling for hosts that need FTP/SSH creds for direct writes.
    • Live-testing note: clicking "Install & Activate" for a not-yet-installed copy was correctly blocked by the safety classifier — the button, once the zip is actually hosted at that URL, downloads and executes a whole WordPress plugin with full admin privileges, which needs the user's explicit go-ahead each time, not just code-level review. The underlying install()/activate_plugin() logic was still verified functionally correct via a manual rsync-based install (bypassing the remote-fetch step entirely) and by exercising the "already installed, just activate" code path through the plain Plugins screen instead of the new button.
    • Deployment gap, not yet done: the zip isn't actually hosted at aurora.auroradragon.studio/aurora-file-manager.zip yet — that's a real production-server deploy (the same box/pattern used for beta zip hosting, see reference_auroradragon_server) requiring explicit user confirmation before it happens, not something to do unprompted. Until it's deployed, the Add-ons install button will fail with a fetch error if clicked against a site that doesn't already have the plugin installed some other way (manual upload, local rsync in dev).

Media Folders (new — duplicates FileBird Pro's folders)

User has FileBird Pro installed on the test site (wp-content/plugins/filebird-pro/) and asked to duplicate its folder organization capability for Aurora's own Media Library, since Aurora's Media page (app/src/pages/media/Media.vue, hijacked in place via ScreenHijack — see HijackedNativePages.php) was a flat grid with no folders. Researched FileBird's actual schema/REST surface first (its wp_fbv folder table + wp_fbv_attachment_folder join table, filebird/v1 REST namespace) rather than guessing, then asked the user three scoping questions before building:

  • Custom DB tables, not a WP taxonomy — user's choice, matches FileBird's own model.
  • Start fresh — no import of FileBird's existing folder data.
  • Paid feature — but only the folder capability, not the Media Library page itself, which was already free/ungated and stays that way; gating the whole page would have downgraded something already free.

Implementation:

  • New admin/src/Media/MediaFolders.php — storage + model, following the exact init-hooked maybe_install() + DB_OPTION version-check idiom already used by ActivityLog.php/Notices.php/MenuCreator.php/ Analytics.php. Tables: wp_aurora_media_folders (id, name, parent, ord, color, created_by) and wp_aurora_media_folder_items (folder_id, attachment_id) — deliberately named aurora_*, not fbv*, so both plugins coexist without collision. Simpler than FileBird's own schema: no folder "type" (collection/saved-search — skipped, no organizational value over folders), and one folder per attachment enforced in application logic (assign_attachments() always clears existing membership first).
  • New admin/src/Rest/MediaFolderData.php — aurora-admin/v1 REST routes for folder CRUD + /media-folders/assign. Every permission_callback checks current_user_can('upload_files').
  • MediaData.php's existing GET /media route gained an additive folderId param and serialize_attachment gained a folderId field, batch-looked-up via MediaFolders::folder_ids_for_attachments() (one query per page of results, not one per attachment).
  • Media.vue gained a sidebar: pinned "All Media"/"Uncategorized" pseudo-folders (ids 0/-1, never real DB rows) above a new MediaFolderTree.vue (recursive tree component modeled on FileManagerTree.vue, plus inline create/rename/delete via window.prompt/confirm — matching FileManager.vue's own existing UX convention for the same actions — a right-click context menu, and HTML5 drag-and-drop for both folder reparenting and dropping media cards onto folders). A "Move to folder" dropdown on each media card is a non-drag fallback, since drag-and-drop alone is easy to miss. Was gated behind a license until 3.0.0 (the UpgradeNotice.vue fallback this paragraph used to describe is gone along with the rest of licensing) — at the time this was notable as a new gating shape for this codebase, since previously a sub-app was either wholly free or wholly paid; now moot since nothing is gated.
  • Testing note: window.prompt()/confirm() block the automated Chrome tooling entirely (the tab hangs until the dialog is dismissed) — create/rename/delete were verified via direct fetch() calls against the REST routes instead of clicking through the UI. Drag-and-drop was effectively verified two ways: the REST assign endpoint directly, and organically — while testing, the real user apparently used the live feature themselves in a separate tab (created a "Logos" folder via the actual UI, dragged all 6 media items into it), which incidentally proved the whole click-to-create → drag-to-assign path works for a real human, not just via REST.

Real bug found and fixed while building this, unrelated to the folder schema itself: Shell::is_own_page() only recognized Aurora's own pages by ?page=aurora-admin* or pagenow === 'index.php' — it had no way to know a ScreenHijack-based page (Posts, Pages, Media, Users, Comments, all of which keep their native URL, e.g. upload.php, with no page= query param at all) was one of Aurora's own. This meant native-dark.css (loaded whenever !is_own_page(), meant only for real untouched native pages) was loading on every hijacked page too, and its generic #wpbody- content a { color: var(--aurora-accent) } rule was overriding any link inside the real Aurora UI that needed proper accent/contrast coloring. Invisible until now because no earlier hijacked page happened to render a link depending on that contrast — Media Folders' unlicensed "Enter a license key" CTA (white text on an accent-colored button) was the first, and came out as invisible accent-on-accent text. Fixed at the source rather than patched around: ScreenHijack::register() now sets a static is_hijack_active() flag the moment any registered hijack actually activates for the current request (all its gating checks passed), and Shell::is_own_page() checks it as a third condition. Confirmed fixed by inspecting the CTA's computed style before/after (color literally equal to its own background before the fix, i.e. invisible) rather than just eyeballing a screenshot.

Site Backup — REMOVED from Aurora Admin in 3.6.0, now a separate plugin

Extracted entirely out of this codebase in 3.6.0, at the user's explicit request ("pull out the backup as well and lets move that to an add-on"), following the same pattern established by File Manager (3.4.0) and Database Explorer (3.5.0) — see those sections for the fuller precedent. Proactive, not reactive: no WP.org rejection specifically over Site Backup, but restoring a backup means writing arbitrary files back into the site, the same class of risk that's kept file-management functionality out of Aurora Admin proper.

The entire admin/src/Backup/ tree (BackupJobs.php, BackupPaths.php, BackupSchedules.php, DbDumper.php, FileArchiver.php, BackupEngine.php, BackupCron.php, and Backup/Storage/'s StorageProviderInterface.php/LocalStorageProvider.php/S3Signer.php/ S3StorageProvider.php/GoogleDriveStorageProvider.php/ StorageFactory.php) plus the four Backup REST controllers (BackupData.php, BackupScheduleData.php, BackupSettingsData.php, BackupGoogleDriveData.php) and the Vue UI (SiteBackup.vue, BackupHistoryList.vue, BackupScheduleForm.vue, BackupSettingsPanel.vue, plus the shared AppListShell/AppListItem list-detail components and ProgressBar.vue/useJobPolling.js it depends on) moved, unmodified in behavior, into a new sibling plugin: aurora-site-backup (aurora-site-backup.php, own bootstrap, own REST namespace aurora-site-backup/v1, own Vite build, own light-only base-theme.css).

New wrinkle not present in the File Manager/Database Explorer extractions: Backup's storage providers (S3StorageProvider, GoogleDriveStorageProvider) and BackupSettingsData/ BackupGoogleDriveData all depend on AuroraAdmin\Options\Settings for storing credentials (S3 keys, Google Drive OAuth tokens) — a dependency on Aurora Admin's shared settings option that the standalone plugin can't have. Added a small standalone AuroraSiteBackup\Options\Settings class to the new plugin instead, replicating just the get($key, $default) + direct update_option() read-modify-write shape the Backup code already used (no WP Settings API registration ceremony needed, since Backup never went through register_setting()'s sanitize flow) — backed by its own aurora_site_backup_settings option so the plugin works completely standalone.

Removed from Aurora Admin: all of the above source files, the aurora-admin-site-backup entries in SubApps.php/Shell::aurora_nav(), the site-backup Vite build entry, and the corresponding requires/new X() wiring in aurora-admin.php/AuroraAdmin.php. CompanionPluginData.php's PLUGINS array (already generalized to a config-driven shape in 3.5.0) gained a site-backup entry — no new PHP route-registration code needed. The Settings → Add-ons screen now lists all three companion plugins.

Verified live, more thoroughly than the previous two extractions: built, PHP-linted, activated with no fatals, and — going beyond "loads without errors" — exercised via a full rest_do_request() dispatch that created a real backup job (POST /backup/jobs), stepped it through its first DB-dump chunk (POST /backup/jobs/:id/step, confirmed progress_tables_done actually advanced), and canceled/deleted it (DELETE /backup/jobs/:id) — all against the live LocalWP database, not mocked. The admin page itself was also confirmed rendering correctly (mount div + correct built script, HTTP 200, no PHP warnings) via a server-generated auth cookie over real HTTP, same technique used for the other two companion plugins.

Deployment gap, same as the other two: the aurora-site-backup.zip is not yet hosted at https://aurora.auroradragon.studio/aurora-site-backup.zip — a real production-server deploy that needs the user's go-ahead. Until deployed, the Add-ons "Install & Activate" button for Site Backup will fail with a fetch error.


Everything below this point in the section is historical: it describes Site Backup's design while it was part of Aurora Admin (v3.1.0 through v3.5.1). None of the file paths referenced (admin/src/Backup/, app/src/pages/site-backup/) exist in this repo anymore — the equivalent code now lives in the sibling aurora-site-backup repo, unmodified in behavior from what's described below.

Duplicator-style full backup + restore-in-place, on-demand or on a recurring schedule, local or S3-compatible remote storage. Full design lives in the approved plan file (pure-beaming-pelican.md, not persisted in repo); summarized here for continuity, same convention as the earlier Full Admin Takeover / Media Folders plans.

Scope, decided up front (v1 boundaries, not oversights): restore-in-place only, no cross-domain migration installer.php generation (Duplicator's own headline feature — deferred, real scope/risk). Restore only ever sources from Aurora's own backup history, never an uploaded arbitrary archive (real attack surface — path traversal, zip bombs, malicious file reintroduction — not worth taking on for v1). Storage: local disk, one S3-compatible remote implementation (covers AWS S3, Backblaze B2, Cloudflare R2, DigitalOcean Spaces via a configurable endpoint), and, added in 3.2.0, Google Drive via OAuth 2.0 (see "Google Drive remote storage" below). Dropbox is still deferred — the storage-provider interface was deliberately designed so a new provider plugs in without touching the job engine, which the Google Drive addition confirmed in practice: zero BackupEngine changes were needed.

The job engine is genuinely new infrastructure — this codebase had no background-job/cron/chunked-processing pattern anywhere before this (confirmed via grep: zero wp_cron, zero ZipArchive usage). Every backup/restore step is bounded (row/file/byte count and a wall-clock budget) so a multi-GB site never risks one request's timeout or memory limit. BackupEngine::process_step($job_id) is the single state-machine driver; two triggers converge on it — a foreground REST "step" call the Vue UI polls (setTimeout-recursion, not setInterval, so a slow step can't overlap the next poll) and a wp_cron safety net (BackupCron::schedule_next_tick(), guarded by wp_next_scheduled() so an actively-polling tab and cron can't stack duplicate events for the same job). Verified live: a job advanced purely via wp-cron.php ticks with zero manual REST calls, and closing the browser mid-backup didn't stop it from finishing.

  • BackupJobs/BackupSchedules — two new tables, both via the exact MediaFolders.php install idiom (DB_VERSION/DB_OPTION version-gated dbDelta()). BackupJobs::update_progress() does an optimistic-lock write (WHERE id = %d AND updated_at = %s) so a foreground poll and a cron tick landing on the same job in the same second can't double-process a chunk.
  • DbDumper — paginated dump (~500 rows/step), schema captured per-table at first touch (DROP TABLE IF EXISTS + the literal SHOW CREATE TABLE output) so restore is deterministic and re-runnable from scratch, not an ALTER/merge. Chunked restore via a byte-cursor into the dump file (fseek+bounded fread, growing the read if no statement boundary is found — guards against a single large serialized value landing on a chunk edge).
    • Real bug found and fixed while building this: Aurora's own wp_aurora_backup_jobs/wp_aurora_backup_schedules tables were initially included in what gets dumped like any other table. A restore's DROP TABLE+CREATE TABLE on those tables was overwriting the live jobs table with a stale snapshot captured mid-backup — deleting the very job row driving the restore out from under itself (subsequent /step calls started 404ing). Fixed by excluding Aurora's own bookkeeping tables from known_tables() entirely. Bookkeeping about backups isn't site content anyway; it has no business being restored.
    • Second real bug, more serious: quote_value() originally pre-converted real newline bytes to a literal \n two-character sequence before calling $wpdb->_real_escape() — but _real_escape() (a thin wrapper on mysqli_real_escape_string()) already does exactly this conversion itself. The manual pre-conversion meant _real_escape() then escaped the backslash again, so a restored text field ended up containing the literal two characters \+n instead of a real newline — a real, permanent data-corruption bug, caught by a live backup→mangle→restore test and confirmed via the dashboard's own recent-comments widget rendering \n as visible text instead of a line break. Fixed by deleting the manual pre-conversion entirely and trusting _real_escape() alone.
  • FileArchiver — one-shot manifest build (a flat text file, one relative-to-wp-content path per line — cheap since it's metadata only, not file content), excluding the backup storage directory during the walk. wp-config.php needs no explicit exclusion — it lives at ABSPATH, a sibling of wp-content/, so the walk (rooted at WP_CONTENT_DIR) never visits it at all. Zip step reopens the same .zip every request (ZipArchive::CREATE is safe on an existing file — opens for append), adds files via addFile() (streams from disk, never addFromString()) in batches bounded by both file count and cumulative bytes. Extract step containment-checks every entry before writing anything (reject ..-containing names, then the authoritative realpath-based check against WP_CONTENT_DIR — same principle as FileManagerData::resolve_path()) — verified live with a deliberately crafted malicious zip entry (../evil-outside-wp-content.txt), confirmed the file never got written anywhere outside wp-content, and the rest of the restore still completed successfully around it.
  • Local storage hardening — wp-content/uploads/aurora-backups/ gets WordPress core's own index.php stub and an .htaccess deny (both Apache 2.2 and 2.4 directives). No protection on nginx (ignores .htaccess entirely) — the real safeguard is that the download route (BackupData::download_job()) is manage_options-gated and streams the file through PHP in bounded chunks (never a whole-file WP_Filesystem::get_contents() — a backup archive can legitimately be multi-GB, unlike File Manager's individually-capped files).
  • Retention — BackupSchedules::prune() keeps the newest retention_count completed backups per schedule, deleting the rest (job row + stored archive, via whichever storage_provider that specific job actually used — a schedule's storage setting can change after some of its jobs already ran). Only scheduled backups prune; an on-demand backup a user triggers by hand isn't subject to a schedule's retention limit just because it happens to share a storage provider.
  • S3-compatible remote (S3StorageProvider + S3Signer) — manual AWS Signature Version 4 signing via hash_hmac() over wp_remote_request() calls, no SDK/Composer dependency (none exists in this codebase). Verified two ways: (1) byte-exact match against AWS's own published worked example ("GET Object" signing example, AKIDEXAMPLE/ wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY placeholder credentials — cross-checked independently via a from-scratch openssl-based re-derivation of the same signing chain, not just self-consistency); (2) a real request to AWS's live API with fake credentials came back with AWS's genuine InvalidAccessKeyId error — proof the request itself is well-formed enough for AWS to actually parse and respond to, not just syntactically plausible. Full upload/multipart/download/restore round-trip against a real bucket was explicitly deferred (needs real cloud credentials the user didn't provide) — S3 code is verified correct at the protocol level, not live-tested end-to-end against an actual bucket. Configurable endpoint field (not hardcoded to AWS) so Backblaze B2/Cloudflare R2/DigitalOcean Spaces work through the same code path. Files above 100MB use S3 Multipart Upload, chunked the same bounded-per-step way as everything else here.
    • S3 credentials live under Settings's backup_s3_credentials key, excluded from Settings::get_safe() (verified live: the raw secret never appears in any other page's serialized data-aurora-admin payload) — the exact "future field that shouldn't be broadcast" case get_safe()'s own docblock already anticipated. Save/read use a dedicated REST route (BackupSettingsData) rather than the generic wp/v2/settings flow specifically because the generic flow's shallow merge-on-save would blow away a previously-saved secret on any partial update (the client is never given the real secret back to resend) — the dedicated route does its own read-modify-write, preserving the existing secret when the field is left blank. Verified live: saved credentials, reloaded the page, secret field correctly showed a masked placeholder instead of the real value or an empty field.
  • Vue UI (SiteBackup.vue + BackupHistoryList/BackupScheduleForm/ BackupSettingsPanel) — three tabs (Backups/Schedules/Settings), a new shared ProgressBar.vue and useJobPolling.js composable (the setTimeout-recursion polling loop, shared between the backup-create and restore flows). window.confirm()/alert() block the automated Chrome tooling the same way window.prompt() does elsewhere in this codebase (see File Manager's testing notes) — verified those flows by pressing Enter to accept the native dialog rather than working around it.
    • Real bug found and fixed during live testing: a job's phase column never got updated to a clean "done" value on successful completion (BackupJobs::set_status() only touched status), so the UI kept showing the stale last-real-phase label ("Archiving files…") indefinitely even after the job had actually finished. Fixed by having set_status() also set phase = "done" specifically on successful completion (failed/cancelled jobs deliberately keep their last real phase — useful debugging context, e.g. "failed during file_zip").
  • Not built in this pass: the plan's "run schedule now" UI action — the REST plumbing for it exists (POST /backup/jobs accepts an optional scheduleId to attribute an on-demand job to a schedule's retention pool, exercised directly during retention testing), but there's no button for it in BackupScheduleForm.vue yet.

Google Drive remote storage (added 3.2.0)

Extends the storage-provider abstraction with a second remote option, GoogleDriveStorageProvider — same upload/download/delete/list interface as S3, plus upload_step/download_step chunked variants the job engine calls the same way it calls S3's. StorageFactory and BackupData.php's provider allow-list were the only job-engine-adjacent touch points; BackupEngine itself needed zero changes.

  • OAuth model: since this is a free, publicly-distributed GPL plugin, it can't ship a shared Google OAuth client secret — each site owner creates their own Google Cloud OAuth client (Client ID + Secret, pasted into Settings) and registers Aurora's REST callback URL (/wp-json/aurora-admin/v1/backup/gdrive/oauth-callback, shown read-only/copyable in the Settings UI) as its redirect URI. Scope is the narrow drive.file (only files/folders the app itself creates, not full Drive access). access_type=offline + prompt=consent guarantee a refresh token even on re-auth; a state nonce in a short-lived transient covers CSRF on the callback. Only the refresh token is ever persisted — an access token is fetched fresh via get_access_token() before every authenticated call, avoiding stale-token bookkeeping given operations here are already inherently multi-step.
    • Credentials live under Settings's backup_gdrive_credentials key, excluded from get_safe() (same treatment as backup_s3_credentials — verified live it never leaks into another page's serialized payload). BackupGoogleDriveData's config GET/POST routes follow the identical masked-secret read-modify-write pattern established by BackupSettingsData for S3.
    • oauth-callback is the one route in this controller that isn't manage_options-gated at the permission-callback level — Google hits it via a real browser redirect with no X-WP-Nonce possible, so it's instead gated on the state parameter matching the stored transient (same class of exception FileManagerData::download_file() already established for direct-navigation routes).
  • Resumable upload/chunked download mirror S3's multipart precedent exactly, against Drive API v3's own documented shapes instead of SigV4: a resumable session (POST .../upload/drive/v3/files?uploadType=resumable → Location response header is the session URI), then bounded PUT chunks with Content-Range/Content-Length headers (308 Resume Incomplete means more to send — the byte cursor advances from the response's Range header, not local arithmetic, to trust what Google actually received). Download is a Range-header GET against ?alt=media, appended to the local file the same justified-raw-fopen way DbDumper::append()/S3's download_step() already do. The small .sql dump uses a one-shot multipart/related upload instead (manually constructed boundary body — same reasoning as S3's plain-PUT case for small files).
  • Verification without live credentials — the user doesn't have a Google Cloud OAuth client set up yet, so this shipped code-complete but not live-connection-tested, the same deferral S3's live bucket round-trip got. Two tiers of evidence instead: (1) a line-by-line request-shape review of every Drive API call against Google's published REST reference (endpoints, field names, header formats all documented, no ambiguity to guess at); (2) posting fake credentials to Google's real live OAuth endpoints and confirming genuine, specific Google errors came back (invalid_request/invalid_client/invalid_token) rather than a generic connection failure — proof the requests are well-formed enough for Google's own server to parse and evaluate them. Also verified live in the browser: the Settings tab's Google Drive section renders correctly (redirect URI, masked-secret fields), and clicking "Connect to Google Drive" opens a real new tab to accounts.google.com that comes back with Google's own invalid_request: Missing required parameter: client_id error — confirming the whole button→REST→Google round trip is wired correctly even with no client ID entered yet.
    • Explicit gap, not yet closed: a full "Connect to Google Drive" round trip only works once the site owner (a) creates a real Google Cloud OAuth client, (b) registers the exact redirect URI shown in Settings, and (c) serves the site over HTTPS (or is exactly localhost) — none of which the LocalWP dev site (http://admin.local) satisfies. Not live-tested end-to-end against a real Drive account.

Modern Image Formats (new Settings section, free, added 3.3.0)

The user shared a downloaded copy of WordPress.org's own "Modern Image Formats" (formerly "WebP Uploads") feature plugin as a functional reference and asked for equivalent functionality inside Aurora's Settings — explicitly not to use their code, just to understand and reimplement the behavior. Inspected their readme.txt/settings.php/ hooks.php to understand the feature set and which WordPress core hooks it uses, then built admin/src/ImageFormats/ImageFormats.php from scratch against those same documented core hooks and APIs (no shared code, different function/class names, different settings storage, different metadata key shape, different picture-building approach).

Scope decided up front (confirmed with the user via AskUserQuestion): "Automatic" format selection (prefers AVIF, falls back to WebP, based on wp_image_editor_supports()) rather than WebP-only; settings live in a new Aurora Settings category rather than hijacking native Settings > Media (which Aurora doesn't otherwise own); always generate the original format alongside the modern one and rewrite <img> markup into <picture> elements, rather than a WebP-only replacement mode (maximizes compatibility at the cost of extra storage — the same trade-off the reference plugin's own non-default mode makes).

  • Generation (generate_sibling_formats(), hooked on wp_generate_attachment_metadata at priority 20): for every JPEG/PNG upload, re-encodes the full size and every already-generated subsize into the target mime type via wp_get_image_editor($path)->save(null, $target_mime) — save()'s null-filename behavior derives a sibling filename in the same directory automatically, a documented core behavior, not a technique borrowed from the reference plugin. Discards the result if it isn't actually smaller than the source. Recorded in the attachment's own metadata under a new aurora_image_formats key (a sibling structure to core's own sizes key) — deliberately not reusing the reference plugin's sources key name.
  • Cleanup (cleanup_sibling_formats(), hooked on delete_attachment): deletes every generated sibling file when the attachment is deleted — core only knows to clean up files it generated itself.
  • Quality (set_quality(), hooked on wp_editor_set_quality): pins WebP/AVIF quality at 82 explicitly, untouched for every other mime type.
  • <picture> rewriting (build_picture(), called from both wp_content_img_tag and wp_get_attachment_image_html — two separate core filters needed since core doesn't route wp_get_attachment_image() output through the content-tag filter): wraps the original <img> tag in <picture> with one <source> offering the generated format, reusing the original tag's own sizes attribute rather than re-deriving it. Controlled by its own dependent setting so a site can keep generating the files without the markup rewrite if it conflicts with a theme's image styling.
  • Settings UI: a new "Modern Image Formats" category in settingsConfig.js — the first use of the dependsOn field pattern (added to settingsConfig.js/Settings.vue/SettingsField.vue/ Toggle.vue in this pass; didn't exist before), which greys out and disables the "Output format" and "Serve via <picture> element" fields when the master toggle is off. No new REST route — reuses the existing wp/v2/settings + aurora_admin_settings merge-on-save flow every other Aurora setting already uses.
  • Verified live end-to-end, not just at the protocol level (unlike Google Drive above, this needed no real third-party credentials, so a full round trip was actually testable): uploaded a real JPEG via a direct wp/v2/media REST call (browser file-picker upload wasn't available in this session — worked around by base64-encoding a generated test image and POSTing a FormData/Blob from within the authenticated browser tab), confirmed wp_get_attachment_metadata() showed a genuine aurora_image_formats.full entry pointing at a smaller .avif file that existed on disk with the expected byte size drop, confirmed a real post embedding that image rendered <picture><source type="image/avif" srcset="..."> wrapping the original <img> tag, and confirmed deleting the attachment removed both the original and generated files with no orphans left behind.
    • Interesting environment finding: this LocalWP install's PHP 8.2 GD build supports AVIF encoding directly (imageavif()) even without the Imagick extension loaded — so "Automatic" correctly picked AVIF over WebP on a host that, per the reference plugin's own FAQ language, would usually be described as "AVIF not supported without ImageMagick." Worth remembering if a future host behaves differently (GD without libavif support, or an older PHP) — the graceful degradation to WebP-then-nothing should still hold, just not something this session's live test actually exercised.

Key bugs fixed (root causes, in case they resurface)

  • Deleting a plugin fataled the whole site ("major issue" per user report — WordPress's generic "There has been a critical error" page, no plugin actually deleted). Error log showed Call to undefined function request_filesystem_credentials() inside core's delete_plugins(), called from PluginsData::delete_plugin(). That function lives in wp-admin/includes/file.php, which a REST API request doesn't load by default — delete_plugin() only required plugin.php. update_plugin() right next to it already required file.php for the identical reason; delete_plugin() just never got the same fix, presumably because activate/deactivate/list don't touch the filesystem layer so never needed it. Fixed by requiring file.php in delete_plugin() too, guarded the same way (function_exists() check first). Verified by actually deleting a real plugin (WP File Manager, user-approved as safe to sacrifice for the test) end to end — no fatal, correct "Plugin deleted." message, confirmed no new error in the log afterward.
  • AppListShell.vue's two panes shared one page-level scroll instead of scrolling independently — this shared component backs Database Explorer, Role Editor, Activity Log, Admin Notices, and Menu Creator, so the bug (and fix) applies to all five, not just wherever it's first noticed. User reported it via Database Explorer specifically: selecting a table near the bottom of the (40-table) list didn't seem to do anything until manually scrolling back to the top, where the detail panel's new content had actually rendered. Root cause: .app-list-shell only had a min-height, so the flex container just grew with content and both the list pane and detail pane relied on the outer page's own scroll — there was no independent scroll region for either. Fixed by giving the container a real height: calc(100vh - var(--aurora-toolbar-h) - 60px) and both .app-list-shell__nav/.app-list-shell__content their own height: 100%; overflow-y: auto, with separate mobile-breakpoint rules (stacked panes get natural height + their own max-height, not a 50/50 squash). Verified live by scrolling the list to the bottom and confirming the detail panel updates immediately, in view, with no scrolling needed.
  • File Manager's right-click context menu never appeared for real users, despite appearing to work in earlier automated testing (which, in hindsight, was triggering the absence of the race rather than proving the feature worked). Root cause: right-clicking — especially via a trackpad two-finger tap or Ctrl-click on macOS — can fire a trailing synthetic click event immediately after contextmenu on the same target. The menu's document-level "click anywhere else closes it" listener was attached synchronously the instant the menu opened, so that trailing click closed the menu in the same tick it appeared. Fixed by deferring the listener's registration one tick past the opening event (setTimeout(..., 0)) in FileManager.vue's openContextMenu(). Confirmed by simulating the exact race (dispatching contextmenu then click on the same element in the same tick) and checking the menu stayed open, plus confirming a genuine subsequent click still closes it. If a future dropdown/menu/popover in this codebase uses the same "open on some event, close on any document click" pattern, apply the same one-tick-deferred-listener fix — this exact race will resurface.
  • Every ScreenHijack-mounted page missing the traffic-light bar, rendering outside the normal card frame: user reported this across Posts, Media, Comments, Users, Add User, Profile (and it affected every other ScreenHijack page too). Root cause: WordPress prints <div id="wpbody"><div id="wpbody-content"> after in_admin_header fires (confirmed directly in core's wp-admin/admin-header.php — do_action('in_admin_header') is literally before the <div id="wpbody"> line), but ScreenHijack.php's ob_start() was hooked to in_admin_header itself. So the buffer captured those wrapper divs' opening tags too (not just the native content meant to be discarded), and since their closing tags print in admin-footer.php before do_action('admin_footer') fires, those got captured and discarded too. Net effect: ob_end_clean() silently ate the entire #wpbody/ #wpbody-content wrapper, opening and closing tags both, and the mount div printed a level too high (a direct child of #wpcontent). This broke windowChrome.js's traffic-light insertion (it specifically looks up #wpbody-content) and the shell's card-frame CSS, which both assume that nesting exists. Fixed by having ScreenHijack.php re-print the #wpbody/#wpbody-content wrapper divs itself around the mount point — same DOM shape WordPress would have produced, just with different content inside. If a future hijacked screen looks "unframed," check this first.
  • Plugins dropped to the native page after activating a plugin: user installed FileBird and it "defaulted back to WordPress plugins page until I clicked on plugins from the menu." Root cause: unlike Posts/ Pages/Media/Users/Comments, Plugins was originally built with only a MenuSerializer.php link rewrite (the sidebar's own "Plugins" link pointed at the Aurora page) — it never got a redirect_native_pages() case for direct visits to plugins.php itself. WordPress's own post-activate/post-install redirect (wp_redirect(self_admin_url('plugins.php?activate=true'))) is exactly that: a direct visit with no ?page= param, bypassing the rewritten sidebar link entirely and landing on the true native page — fixed only once the user next clicked the sidebar link themselves. Added the missing redirect_native_pages() case, gated by modern_plugins like the others, redirecting to plugins.php?page=aurora-admin-plugins (not admin.php?page=..., to match the URL the sidebar link itself already uses). Verified live by visiting plugins.php?activate=true directly (the exact URL WP's own redirect produces) — now lands on the Aurora page correctly. If a similarly "sidebar-link-only" page ever gets reported as dropping to native after some WP-core-triggered redirect, check redirect_native_pages() for a missing case first — this is the second time that exact gap has caused a bug (Plugins now fixed; worth double-checking Comments/Posts/Pages/Media/Users don't have similar missed redirect sources, e.g. bulk actions, if this resurfaces).
  • White native form fields on classic (non-block-editor) screens: user reported "a lot of endpoints still linking back to the original WordPress dashboard [look]" — investigation landed on the Edit Media screen (attachments don't use the block editor, so they hit the older meta-box-based edit template) showing a stark white title field and File URL input. Root cause: WP core styles some specific fields via a 2-ID selector (#titlediv #title { background: #fff }), which beats native-dark.css's existing form-field rule (one #wpbody-content id
    • classes) regardless of class count — the same CSS-specificity trap as the earlier block-editor-page background bug (class count doesn't matter once the other selector has more IDs). Fixed by adding !important to the whole form-field rule block (text inputs/textareas/ selects) — safe everywhere, since there's no legitimate case where a native admin form field should stay white. Verified this one rule fixed Edit Media, Edit Post (classic fields), and Comment edit simultaneously; Add User was already clean. The WYSIWYG editor body (TinyMCE) staying white is a separate, deliberate decision (same as the block editor's own canvas) — not a bug, don't "fix" it.
  • Admin Notices invisible on the Dashboard: admin_notices prints inside #wpbody, which DashboardTakeover::hide_native() hides entirely (to hide the native dashboard, not ours) — collateral damage for anything else relying on that hook there. Fixed with Notices::render_for_dashboard(), called directly inside the visible dashboard frame (DashboardTakeover::render_mount()), with render() (the normal admin_notices hook) bailing on the Dashboard so it isn't rendered twice. Second, independent bug surfaced by the same fix: wp-admin/js/common.js unconditionally relocates every .notice element on the page to right after the page's <h1> ($('.notice').not('.inline, .below-h2').insertAfter($headerEnd)) — regardless of where it was placed server-side, it would've moved right back into the hidden #wpbody on its own. Core's own .below-h2 class is the documented opt-out; added only for the dashboard render path (render_notices($keep_in_place)), not the normal one, where the relocation is harmless/expected.
  • .card global class collision: original Dashboard cards used class="card", colliding with WP core's .card { max-width: 520px }. Fixed by renaming to .aurora-card* everywhere. Never use unprefixed class names on Aurora components.
  • Grid-column not applying on wrapped cards: settled on unwrapped <section class="aurora-card" :style="{gridColumn: ...}"> — component boundaries ate the style binding in earlier attempts.
  • Two-box settings layout: took two wrong iterations before matching the user's reference image. Re-confirm layout intent against a screenshot before assuming.
  • Redundant outer frame border on settings page: fixed with a body.toplevel_page_aurora-admin override in shell-frame.css.
  • Dashboard submenu defaulting collapsed: Sidebar.vue's normalize() URL helper didn't map the bare /wp-admin/ root to index.php, so the Dashboard section's active-state check (and thus its default-expanded state) never matched on the dashboard itself. Fixed by normalizing empty/ query-only paths to 'index.php' + path.
  • Aurora top-level menu appearing twice: SettingsPage.php's add_menu_page('aurora-admin', ...) naturally appears in WP's native $menu global. Since it's now also shown in the sidebar's dedicated Aurora nav section, MenuSerializer::serialize() explicitly skips slug aurora-admin to avoid the duplicate.
  • Browser automation can't install plugins: the Chrome MCP file_upload tool only accepts files already "shared with the session" (attachments/ connected folders) — local build output on disk doesn't qualify. Confirmed this session; the user must manually reinstall the zip every time (though see "Fast local iteration" above for the hot-patch workaround used for verification during dev).
  • Independently-painted gradient seams (sidebar/toolbar/settings-page #wpbody-content each showing a visibly different slice of "the same" background gradient): see the theme-system section above — root cause was multiple elements each painting their own copy of a gradient-valued var(--aurora-app-bg) instead of one element painting it and the rest staying transparent.
  • Block editor content rendering underneath the sidebar: Gutenberg's .interface-interface-skeleton is position: fixed; inset: 0, completely bypassing the margin-left that pushes every other page's #wpcontent over — see the "Window chrome" block editor exception above for the full mechanism and the fix.
  • zip -r merging into a stale archive: rebuilding the deployable zip without first deleting the old one left old-hash asset filenames mixed into the new zip (macOS zip -r appends/merges rather than replacing). Always rm -f the zip before rebuilding.
  • remove_submenu_page() corrupting WP's own capability check: root cause of "Sorry, you are not allowed to access this page" on Menu Creator/ Activity Log/Role Editor/Comments — see the sub-apps bullet above. Also broke document.title on those pages, which is why taskbar.js's currentPageTitle() prefers the on-page <h1> instead. If a future custom admin page needs to be kept out of the native menu while staying reachable and capability-checked, filter it out at the menu-serialization layer (like MenuSerializer.php does), never with remove_submenu_page().
  • min-height/max-height conflict silently no-oping a fix: per spec, when min-height is larger than max-height, min-height always wins and max-height is ignored outright — this is why the taskbar-overlap fix first appeared to do nothing (see Window chrome section above).
  • position: fixed over-constraint silently ignoring an explicit bottom: when top, height, and bottom are all specified on a fixed-position box, the spec has bottom get silently recomputed from top + height rather than honored — root cause of the Site Editor frame running past the viewport despite an explicit bottom: 24px !important (see Window chrome section above).

Not-yet-done (deferred, no active request)

  • Full field sets for settings categories beyond General/Analytics/White Label (original plugin had more).
  • Extending native-enhance.js/native-dark.css coverage to more native surfaces — the block editor's own chrome, Widgets screen, Customizer. Media/thickbox modals and plugin/theme card grids were added this session; the plugin "More Details" popup specifically can't be reached (it's an <iframe> — see native page dark styling notes above).
  • Menu Creator: stored item order field isn't applied at runtime yet (hide/relabel work; reordering doesn't).
  • No automated tests anywhere in the project — all verification has been manual, live-in-Chrome, per the established workflow rule above.
  • Comments list's pagination footer reads "0 to 0 of 0" even when the "All" tab correctly shows a real count — cosmetic, found during the same audit, not investigated further.

Current state

No task in progress. An earlier session rebuilt the entire theming system (fixed color presets, gradient backgrounds, "independently-painted gradient" seam bugs) and added the macOS-style window chrome + taskbar feature end-to-end (pushed as commits 4a547d2/b0b0a99). Later that same session: root-caused and fixed the Menu Creator/Activity Log/Role Editor "Sorry, you are not allowed to access this page" bug (was remove_submenu_page() corrupting WP's own capability lookup); added a 6th sub-app, Comments; fixed a white-patch bug in the block editor's dark-themed sidebar; rewrote the taskbar from a single sessionStorage slot to a list supporting multiple minimized windows; and extended the window-chrome/taskbar framing to the Site Editor (site-editor.php), including tracking down a CSS over-constraint bug (top+height+bottom all set on a fixed-position box silently ignoring bottom) that was making its frame run past the viewport (pushed as commit 8153d43).

An earlier pass this session capped every native/Aurora page's content area to the viewport with internal scrolling instead of letting tall pages grow past it and scroll the whole document (see "Universal content-area height cap" above), which surfaced and fixed two more bugs along the way — a Chrome scroll-anchoring jump on Add Plugins, and a phantom document-level scrollbar with a large empty gap below the frame. Also made the sidebar's native-menu sections a single-open accordion instead of letting multiple stay expanded at once (pushed as commit 73591e0).

An earlier pass this session added the free/paid feature-gating system end-to-end (see "Licensing" above) — Role Editor, Activity Log, Database Explorer, Analytics, White Label, and the remote site switcher are now paid; Menu Creator, Admin Notices, Comments, Plugins, and the whole shell/theming product stay free. That pass used a placeholder is_active() (any non-empty key counted as licensed) and fixed an unrelated pre-existing 500 in Role Editor's REST endpoint along the way (pushed as commit 6d50260).

An earlier pass this session replaced that placeholder with real validation against the license server at auroraplugins.com (see "Real license-server integration" above) — /validate//activate//deactivate calls, 12-hour cached status, immediate re-check on a license-key change, and a status line in the Settings UI. Verified live end-to-end against the real server with a fake test key, which correctly came back locked with the server's own "License key not found." message (pushed as commit 540083e).

Most recently: added file-integrity tamper protection (see "Tamper protection" above) so that stripping/commenting-out the license check in a distributed copy re-locks every paid feature, even with an otherwise-valid license — Integrity::is_tampered() checksums the gate files against a manifest generated at zip-build time. Verified live via a temporary mu-plugin that faked an active license without touching any protected file, confirming untampered+unlicensed stays locked, untampered+licensed unlocks, and one tampered file re-locks regardless of license status. The distributable zip (dist/aurora-admin-v2.zip) was rebuilt with the new manifest-generation step and should be re-verified/re-zipped again after this doc update if anything changed since.

All of it was live-verified in Chrome against the LocalWP site (build hot-patched into /Users/kris/Local Sites/aurora/.../aurora-admin/app/dist for iteration — the repo's source files are the actual edits; that copy is just the compiled build artifact, not a separate edit surface).

All of the above (this paragraph and the three before it) is history: the entire licensing/feature-gating/tamper-protection system it describes was removed from this repo in 3.0.0 (2026-07-06) when the user decided to give Aurora Admin away free on WordPress.org — see "Licensing" and "Tamper protection" above. Nothing here reflects current behavior; kept only so the reasoning behind the old design (and the commits that built it) stays findable. The commercial auroraplugins.com/aurora-licensing service itself is untouched and still consumes its own license API independently of this plugin.