Files
aurora-wordpress-admin/PROJECT_STATUS.md
T
nixpanelandClaude Sonnet 5 fdd252cfe2 Fix white native form fields on classic edit screens (v2.7.1)
Native form fields (title inputs, textareas, selects) on screens that
don't use the block editor — most visibly Edit Media's title field —
were showing stark white, breaking the dark theme. WP core styles some
fields via a 2-ID selector (#titlediv #title { background: #fff }),
which beats native-dark.css's existing form-field rule regardless of
class count, same specificity trap as an earlier block-editor
background bug. Added !important to the whole form-field rule block —
safe everywhere, no legitimate case for a native admin field to stay
white.

Verified this one rule fixes Edit Media, Edit Post (classic fields),
and Comment edit simultaneously; Add User was already clean. The
WYSIWYG editor body staying white is a separate, deliberate decision
(matches the block editor's own canvas) and wasn't touched.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-05 07:12:38 -05:00

61 KiB
Raw Blame History

Aurora Admin v2 — Project Status

Last updated: 2026-07-05 (latest session — fixed white native form fields on classic edit screens, e.g. Edit Media's title field)

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.

All four of this session's pages (PostsData.php+Posts.vue, PagesData.php+Pages.vue, MediaData.php+Media.vue, UsersData.php+Users.vue) follow one shared pattern, established with Posts and repeated for the other three:

  • 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.
  • SubApps.php: new PAGES entry, parent_slug()/capability() cases, and a branch in redirect_native_pages() — the native screen's file (edit.php, edit.php?post_type=page, upload.php, users.php) redirects to the Aurora page on direct visits (typed URL/bookmark), not just the sidebar link.
  • MenuSerializer.php: a matching rewrite so the sidebar's own link (which shares the same slug as the parent, per WP convention) points at the Aurora page too.
  • A Settings toggle (modern_posts/modern_pages/modern_media/ modern_users, all default: true) gating the redirect+link-rewrite, so the native screen can still be reached if turned off. modern_pages/ modern_media/modern_users are new categories/fields this session; modern_posts already existed (see below). All the modern_* toggles now consistently set default: true in the Settings UI to match their actual PHP default (previously some defaulted true server-side but showed off in the UI until explicitly saved — cosmetic mismatch, fixed across the board).

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.

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).
  • 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 and remote site passwords for use on non-admin-gated pages; Settings::get() (full) only used on the manage_options-gated settings page itself.
  • Global search: app/src/shell/components/SearchModal.vue, Ctrl+K, debounced wp/v2/search REST calls, gated on disable_global_search setting.
  • Remote site switcher: toolbar dropdown, gated on canSwitchSites (from remote_site_switcher_capability setting) + non-empty remote_sites.
  • 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.

Licensing (free vs. paid feature gating)

Added this session. The split, as decided with the user: the whole shell/ theming product is free (it's the adoption hook); Role Editor, Activity Log, Database Explorer, Analytics, White Label, and the remote site switcher are paid (the "operational" tools an agency/serious site owner pays for). Menu Creator, Admin Notices, Comments, and the Plugins page stay free.

  • admin/src/Licensing/License.php is the single gate every paid feature checks — PAID_FEATURES maps a feature id to a human label, is_active() and is_feature_available($feature) are the two calls everything else uses.
  • Real license-server integration (this session, supersedes the earlier "any non-empty key counts as active" placeholder): validates against the actual license server — the aurora-licensing WordPress plugin running on auroraplugins.com (a cPanel box at auroradragon.studio, SSH- accessible as root via a dedicated key, ~/.ssh/id_ed25519_auroradragon_root — the git-remote key is scoped git-only and can't get a shell). REST namespace /wp-json/aurora-licensing/v1/:
    • POST /validate — {license_key, domain, product_id?} → {valid, status, plan, max_sites, active_sites, active_on_domain, expires_at, product_id} on success, {valid:false, error, message} on failure (e.g. invalid_key).
    • POST /activate — same request shape → {activated, plan, max_sites, expires_at}, or {activated:true, message:"Already activated..."} if already active on this domain, or a 403 {valid:false, error:"limit_reached", message} if max_sites is exceeded.
    • POST /deactivate — {license_key, domain} → {deactivated, message}.
    • domain is normalized server-side (strips protocol/www./trailing slash) — License::domain() just sends the bare host from home_url().
    • GET /my-licenses (cookie-auth, lists all of a customer's licenses) is the account-portal-facing endpoint, not used by the plugin itself.
    • License::status() caches the /validate response in the aurora_admin_license_status option for 12 hours (STATUS_TTL) — is_active()/is_feature_available() read this cache rather than hitting the network on every admin page load.
    • License::on_settings_updated() hooks update_option_aurora_admin_settings (same hook ActivityLog.php uses) and re-activates/deactivates immediately whenever license_key actually changes, rather than waiting out the cache TTL — deactivates the old key's slot on this domain before activating the new one, so switching keys doesn't leak an activation. Instantiated in AuroraAdmin.php (new License();) so this hook actually registers — it wasn't needed when the class was static-only.
    • Network failures/non-JSON responses collapse into a synthetic {valid:false, status:"unreachable", ...} shape (License::call()) so callers only ever branch on response fields, never on WP_Error.
    • Settings UI feedback: SettingsPage.php passes License::status() to the Vue settings page as licenseStatus; Settings.vue forwards it only to the license_key field; SettingsField.vue shows a colored status line under the field (green "License active (plan) — expires X", amber "unreachable, will retry", red the server's own error message) — verified live against the real server with a fake test key and got back the exact {"valid":false,"error":"invalid_key","message": "License key not found."} shape.
    • Not yet done: no real license key was available to test the activate/site-limit-reached path end-to-end (only the invalid-key path was verified against the live server) — worth a follow-up pass once a real test key exists. No UI yet for the limit_reached case beyond surfacing its message string.
  • Sub-apps (SubApps.php): PAGES now has a "feature" key per page (null for the 4 free ones). Locked pages still fully register (add_submenu_page, real capability, real URL — same as before) so nav/ bookmarks/capability checks all keep working; render() and maybe_enqueue() just check License::is_feature_available() and swap in a shared upgrade prompt (below) instead of the real feature's mount div/JS bundle/REST data when unlicensed. This also means a locked page's sensitive data (e.g. Database Explorer's schema access) never gets fetched or serialized to the page in the first place.
  • Shared upgrade prompt: app/src/pages/_shared/UpgradeNotice.vue (lock icon, feature name, description, a CTA linking to admin.php?page=aurora-admin#license) + a dedicated app/src/entries/locked-feature.js Vite entry that mounts it onto [data-aurora-locked-feature]. SettingsField.vue gives the license key field id="license" so that CTA link scrolls straight to it (General is the default-active settings category, so the anchor resolves on a fresh page load too). Reused directly by the Dashboard's Analytics tab (below) rather than building a second locked-state component.
  • Analytics (paid): Analytics.php's constructor returns immediately (no hooks registered, no pageview tracking) when unlicensed. AnalyticsData.php's REST permission_callback also checks License::is_feature_available("analytics"), so the endpoint 403s even if someone hits it directly. The Dashboard's Analytics tab (Dashboard.vue) renders UpgradeNotice in place of the real charts when useAnalyticsData's fetch comes back non-OK (the 403, in practice).
  • White Label (paid): WhiteLabel.php's constructor returns immediately when unlicensed — favicon/plugin-rename/menu-rename/text-replacement filters simply never register. Its settings fields stay visible either way (so an admin can pre-configure before upgrading) — enforcement is entirely server-side, nothing in the UI needs to disable inputs.
  • Remote site switcher (paid): Shell.php's canSwitchSites flag now additionally requires License::is_feature_available("remote-site-switcher"), alongside its existing remote_site_switcher_capability check.
  • Sidebar lock badges: Shell::aurora_nav() adds a "locked" bool per nav item (true for Role Editor/Activity Log/Database Explorer when unlicensed); Sidebar.vue renders a small lock dashicon next to the label when set. The link still navigates normally — SubApps::render() is what actually shows the upgrade prompt there.
  • Settings page: White Label/Analytics category subtitles and the Remote Site Switcher Capability field's description now say "Pro feature — requires a license" (settingsConfig.js) — a plain-text note only, no functional gating in the settings UI itself (the features' own code is the actual boundary).
  • 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 (file-integrity check on the license gate)

Added this session — the user's concern was specifically someone stripping out the license check from a distributed copy and giving away a "cracked" version that shows every paid feature unlocked with no license at all.

Honest limit, stated up front (and to the user) before building this: nothing living in plain, editable PHP can make server-side code truly tamper-proof — whoever has file access to their own server can always edit whatever they want, including this integrity check itself. What this does stop is the realistic, low-effort case: someone finds the one if (!License::is_feature_available(...)) line and comments it out or inverts it. It does not stop someone who rewrites a whole file's logic to never call the gate at all, or who edits Integrity.php's own comparison logic — that's an unavoidable ceiling for this approach, only closable with a compiled/encoded extension (its own real costs: hosting compatibility, trust), which wasn't in scope here.

  • admin/src/Licensing/Integrity.php: Integrity::is_tampered() compares the sha256 of each "gate" file (the ones that implement or directly enforce the free/paid split) against admin/src/Licensing/integrity-manifest.json. No manifest present (e.g. a git checkout) → never treated as tampered — this only ever applies to a distributed zip, never blocks local dev. Result is cached in a static property per request (file hashing isn't free).
  • Protected files (kept in sync between Integrity.php's doc comment and bin/build-integrity-manifest.php's $protected array): aurora-admin.php, AuroraAdmin.php, License.php, Integrity.php itself, SubApps.php, Analytics.php, AnalyticsData.php, WhiteLabel.php, Shell.php.
  • License::is_feature_available() checks Integrity::is_tampered() before checking the actual license — a tampered install re-locks every paid feature regardless of an otherwise-valid, active license. This is the "re-lock paid features only" behavior the user explicitly chose over a harsher "block the whole plugin" option, specifically to avoid a false positive (a host mangling line endings, some other edge case) breaking the entire admin shell rather than just the paid tier.
  • bin/build-integrity-manifest.php <staged-plugin-root> generates the manifest — run against the staged zip-build copy, never against the git checkout (the manifest itself is a build artifact, gitignored, same "regenerate, don't version" pattern as app/dist). This is now a required step in the zip-build workflow (see "Zip packaging" above) — forgetting it means the shipped zip has no manifest and therefore no tamper protection at all.
  • Verified live, without a real license key, by faking an "active" license through a temporary mu-plugin that directly set the aurora_admin_license_status/aurora_admin_settings options (kept entirely outside any protected file, so it didn't itself trip the integrity check) — confirmed: (1) untampered + unlicensed → locked (baseline unaffected), (2) untampered + simulated-active-license → unlocked, (3) one protected file edited (a trailing comment appended to SubApps.php) while still "licensed" → re-locked immediately. All test artifacts (the mu-plugin, the tampered file, the test manifest) were removed from the LocalWP install afterward.

File Manager (new sub-app, Pro feature)

Added this 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.
  • 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), cross-directory move/copy (rename within the same directory is supported; a full move/copy tree is meaningfully more surface for a v1).
  • Gated as a paid feature (License::PAID_FEATURES["file-manager"]) — this was a judgment call, not something the user specified; reasoned as "even riskier than Database Explorer, which is already paid," and flagged to the user as a default they could override. They didn't object.
  • Real bug found and fixed while wiring this up, not specific to File Manager: SubApps.php's page-level lock (render() swaps in the upgrade prompt) only gates the page — it doesn't stop someone with manage_options from calling the REST endpoints directly and getting full functionality regardless of license status. File Manager's REST permission callbacks were fixed to also check License::is_feature_available('file-manager'). Database Explorer, Role Editor, and Activity Log's REST controllers have the exact same gap and are NOT yet fixed — flagged as a follow-up task (task_cb1ac5ca in this session) rather than fixed inline, since it's a pre-existing issue affecting three other features, out of scope for what was asked. If picking this back up: the fix is exactly what FileManagerData.php now does — add the license check to each permission_callback, matching the corresponding feature id in SubApps.php's PAGES const.

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

  • 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).
  • Porting the real license-key activation flow (built previously in the old aurora-admin-working project, not yet ported to v2 — currently just a text field).
  • 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.

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).

Not yet done: the activate/site-limit-reached path hasn't been tested with a real, valid license key (only the invalid-key path was verified against the live server) — worth a follow-up pass once a real test key is available. No purchase/checkout flow lives in this repo (that's entirely on the auroraplugins.com/WooCommerce side, which auto-generates keys on order completion) — this repo only ever consumes the license API.