# Divi Essential Licensing Policy Pivot — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Pivot the Divi Essential licensing behavior so the frontend always renders modules and the lock lives inside the Divi Builder — unlicensed/expired/revoked users can view modules on published pages but cannot add new ones or edit existing ones.

**Architecture:** Revert the frontend-render gates added in the prior implementation, tighten the `Gate` predicates (`GRANDFATHERED` now counts as builder-locked), wrap each Divi 5 module's `edit` React renderer with a license-aware overlay, inject CSS to hide DE modules from the Divi Builder "Add Module" palette, and add a server-side REST filter as a save-block fallback.

**Tech Stack:** PHP 7.4+, WordPress 5.0+, EDD Software Licensing, TypeScript + React (Divi 5 visual builder), webpack (bundle build).

**Spec:** [docs/superpowers/specs/2026-04-19-licensing-policy-pivot-design.md](../specs/2026-04-19-licensing-policy-pivot-design.md)

**Prior spec (context only):** [docs/superpowers/specs/2026-04-19-licensing-system-design.md](../specs/2026-04-19-licensing-system-design.md)

**Commit policy:** Implementer subagents do NOT commit. User reviews and commits manually at the end.

---

## Pre-flight

Working directory: `/Users/ajantadas/Herd/divi-5/wp-content/plugins/divi-essential`
Branch: `feature/licensing-enforcement-5.5.1` (already checked out)

Confirm current state:

```bash
cd /Users/ajantadas/Herd/divi-5/wp-content/plugins/divi-essential
git branch --show-current
# expected: feature/licensing-enforcement-5.5.1

php tests/test-gate.php | tail -3
# expected: "32 tests, 0 failures"
```

This plan **assumes the prior implementation (Tasks 1-15 of the original licensing plan) was completed and the Gate tests pass**. If tests fail or the branch is different, stop and escalate.

---

## Task 1: Revert PHP frontend-render gates

Three files previously added server-side gates that prevented modules from rendering on the frontend. Under the new policy, modules always render — remove the gates.

**Files:**
- Modify: `divi-5/server/Modules/Modules.php:147-160` (remove early-return gate)
- Modify: `divi-5/divi-5.php` (remove `divi_module_wrapper_render` filter and `the_content` shortcode filter)
- Modify: `divi-essential.php:102-116` `dnxte_register_module()` (remove `should_register_modules` gate)

- [ ] **Step 1: Remove the gate in `Modules.php`**

Open `divi-5/server/Modules/Modules.php`. Locate lines 147-160 where the method currently looks like:

```php
public function init_modules( $dependency_tree ) {
    // License gate: when locked, skip registration entirely so DE modules
    // do not appear in the Divi 5 module-library palette and are unknown
    // to the builder. Existing module instances on pages will not render
    // (by design — the user is unlicensed).
    if (
        class_exists( '\\DNXTE\\Includes\\License\\Gate' )
        && ! \DNXTE\Includes\License\Gate::should_register_modules()
    ) {
        return;
    }

    $inactive_modules = get_option( 'dnxte_inactive_modules', array() );
```

Replace with:

```php
public function init_modules( $dependency_tree ) {
    $inactive_modules = get_option( 'dnxte_inactive_modules', array() );
```

(Delete the 9-line comment block and 5-line `if` block entirely. Modules always register under the new policy.)

- [ ] **Step 2: Remove the two filters in `divi-5/divi-5.php`**

Open `divi-5/divi-5.php`. Locate the license-gate section at the tail of the file. You'll find two `add_filter` calls:

1. `add_filter('divi_module_wrapper_render', ...)` — wraps module render with placeholder.
2. `add_filter('the_content', ...)` — strips DE shortcodes at priority 9 (note the comment "Must run BEFORE do_shortcode").

Delete both filter calls **including their preceding `// ---` section comments**. The file should end with whatever existed before these additions were made.

Run `tail -30 divi-5/divi-5.php` before and after to verify the section is cleanly removed.

- [ ] **Step 3: Remove the gate in `dnxte_register_module()`**

Open `divi-essential.php`. Locate `dnxte_register_module()` method (around line 102-116). It currently looks like:

```php
public function dnxte_register_module() {
    if (
        class_exists( '\\DNXTE\\Includes\\License\\Gate' )
        && ! \DNXTE\Includes\License\Gate::should_register_modules()
    ) {
        return;
    }
    if ( file_exists( DIVI_ESSENTIAL_DIR . 'divi-4/server/modules/modules.php' ) ) {
        require_once DIVI_ESSENTIAL_DIR . 'divi-4/server/modules/modules.php';
    }
}
```

Replace with:

```php
public function dnxte_register_module() {
    if ( file_exists( DIVI_ESSENTIAL_DIR . 'divi-4/server/modules/modules.php' ) ) {
        require_once DIVI_ESSENTIAL_DIR . 'divi-4/server/modules/modules.php';
    }
}
```

(Delete the 6-line `if` block.) Preserve the existing docblock above the method.

- [ ] **Step 4: Lint-check all three files**

```bash
php -l divi-5/server/Modules/Modules.php && \
php -l divi-5/divi-5.php && \
php -l divi-essential.php
```

Expected: all three report `No syntax errors detected`.

- [ ] **Step 5: Verify the Gate tests still pass** (they shouldn't be affected since we haven't touched Gate.php yet)

```bash
php tests/test-gate.php | tail -3
```

Expected: `32 tests, 0 failures`.

---

## Task 2: Refactor Gate predicates — TDD

Change the Gate's predicate API to match the new policy:
- Rename `is_locked()` → `is_builder_locked()` with tightened semantics (GRANDFATHERED now returns true).
- Add new `needs_license_page_redirect()` that only returns true for `STATE_LOCKED`.
- Delete `should_render_module()`, `should_register_modules()`, `placeholder_html()`.

**Files:**
- Modify: `tests/test-gate.php` (update tests for new predicate names and semantics)
- Modify: `includes/License/Gate.php` (refactor predicate methods)

- [ ] **Step 1: Update the existing predicate tests in `tests/test-gate.php`**

Locate the `// --- Predicates ---` section. The current tests reference `should_render_module`, `should_register_modules`, `extensions_enabled`, `is_locked`, and assertions about `placeholder_html`. Replace the entire `// --- Predicates ---` AND `// --- Placeholder HTML ---` sections with:

```php
// --- Predicates: is_builder_locked ---
reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_status']        = 'valid';
$GLOBALS['__mock_options']['dnext_essential_license_last_verified'] = time();
assert_equal( false, Gate::is_builder_locked(), 'LICENSED → is_builder_locked false' );
assert_equal( true,  Gate::extensions_enabled(),  'LICENSED → extensions_enabled true' );

reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_status']        = 'expired';
$GLOBALS['__mock_options']['dnext_essential_grandfather_version']   = '5.5.1';
$GLOBALS['__mock_options']['dnext_essential_license_last_verified'] = time();
assert_equal( true,  Gate::is_builder_locked(), 'GRANDFATHERED → is_builder_locked TRUE (tightened)' );
assert_equal( false, Gate::extensions_enabled(),  'GRANDFATHERED → extensions_enabled false (tightened)' );

reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_status']        = 'missing';
$GLOBALS['__mock_options']['dnext_essential_lock_deadline']         = time() + ( 5 * DAY_IN_SECONDS );
$GLOBALS['__mock_options']['dnext_essential_license_last_verified'] = time();
assert_equal( false, Gate::is_builder_locked(), 'LOCKED_MIGRATION → is_builder_locked false (grace)' );
assert_equal( true,  Gate::extensions_enabled(),  'LOCKED_MIGRATION → extensions_enabled true (grace)' );

reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_status']        = 'missing';
$GLOBALS['__mock_options']['dnext_essential_license_last_verified'] = time();
assert_equal( true,  Gate::is_builder_locked(), 'LOCKED → is_builder_locked true' );
assert_equal( false, Gate::extensions_enabled(),  'LOCKED → extensions_enabled false' );

reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_status']        = 'expired';
$GLOBALS['__mock_options']['dnext_essential_grandfather_version']   = '5.4.0';
$GLOBALS['__mock_options']['dnext_essential_license_last_verified'] = time();
assert_equal( true,  Gate::is_builder_locked(), 'LOCKED_BYPASSED → is_builder_locked true' );

reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_status']        = 'valid';
$GLOBALS['__mock_options']['dnext_essential_license_last_verified'] = time() - ( 15 * DAY_IN_SECONDS );
assert_equal( true,  Gate::is_builder_locked(), 'LOCKED_STALE → is_builder_locked true' );

// --- Predicates: needs_license_page_redirect ---
reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_status']        = 'valid';
$GLOBALS['__mock_options']['dnext_essential_license_last_verified'] = time();
assert_equal( false, Gate::needs_license_page_redirect(), 'LICENSED → no redirect' );

reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_status']        = 'expired';
$GLOBALS['__mock_options']['dnext_essential_grandfather_version']   = '5.5.1';
$GLOBALS['__mock_options']['dnext_essential_license_last_verified'] = time();
assert_equal( false, Gate::needs_license_page_redirect(), 'GRANDFATHERED → no redirect (keep dashboard access to renew)' );

reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_status']        = 'missing';
$GLOBALS['__mock_options']['dnext_essential_license_last_verified'] = time();
assert_equal( true, Gate::needs_license_page_redirect(), 'LOCKED → redirect' );

reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_status']        = 'valid';
$GLOBALS['__mock_options']['dnext_essential_license_last_verified'] = time() - ( 15 * DAY_IN_SECONDS );
assert_equal( false, Gate::needs_license_page_redirect(), 'LOCKED_STALE → no redirect (they might just be offline)' );

reset_mocks();
$GLOBALS['__mock_options']['dnext_essential_license_status']        = 'missing';
$GLOBALS['__mock_options']['dnext_essential_lock_deadline']         = time() + ( 5 * DAY_IN_SECONDS );
$GLOBALS['__mock_options']['dnext_essential_license_last_verified'] = time();
assert_equal( false, Gate::needs_license_page_redirect(), 'LOCKED_MIGRATION → no redirect (grace active)' );
```

Count the new assertions: 6 (is_builder_locked) + 5 (extensions_enabled inline) + 5 (needs_license_page_redirect) = 16 assertions replacing the prior 22. Plus the 12 state-resolver tests stay intact = **28 tests total** after this change.

- [ ] **Step 2: Run tests — expect failures (old predicates gone, new ones missing)**

```bash
php tests/test-gate.php
```

Expected: many failures or fatal errors referencing `is_builder_locked`, `needs_license_page_redirect` not defined.

- [ ] **Step 3: Refactor `includes/License/Gate.php`**

In `includes/License/Gate.php`, locate the predicate section (methods after `get_effective_state()`). Replace everything from `public static function is_locked()` through the end of `placeholder_html()` (the last method before the closing `}`) with:

```php
    /**
     * Returns true if the user should be blocked from editing DE modules
     * in the builder. Grandfathered users are blocked here even though
     * their existing content still renders on the frontend unchanged.
     *
     * @return bool
     */
    public static function is_builder_locked() {
        $state = self::get_effective_state();
        return ! in_array(
            $state,
            [ self::STATE_LICENSED, self::STATE_LOCKED_MIGRATION ],
            true
        );
    }

    /**
     * Returns true only when the user has never held a valid license and
     * is out of migration grace. Only these users get bounced off DE
     * admin pages to the license activation form; grandfathered users
     * keep dashboard access so they can renew from anywhere.
     *
     * @return bool
     */
    public static function needs_license_page_redirect() {
        return self::STATE_LOCKED === self::get_effective_state();
    }

    /**
     * Extensions are gated on the same predicate as builder edit access.
     *
     * @return bool
     */
    public static function extensions_enabled() {
        return ! self::is_builder_locked();
    }
```

(The old `is_locked()`, `should_render_module()`, `should_register_modules()`, `placeholder_html()` methods are all removed. `extensions_enabled()` survives but now delegates to `is_builder_locked()`.)

- [ ] **Step 4: Run tests — expect all pass**

```bash
php tests/test-gate.php
```

Expected: `28 tests, 0 failures`.

- [ ] **Step 5: Lint-check**

```bash
php -l includes/License/Gate.php
```

Expected: `No syntax errors detected`.

---

## Task 3: Update callers of the renamed predicates

Five files call the old Gate predicates and must be updated:
- `includes/Admin/Menu.php` — uses `Gate::is_locked()` in the admin redirect hook → change to `Gate::needs_license_page_redirect()`.
- `includes/Admin.php` — uses `Gate::extensions_enabled()` → unchanged (method still exists with same semantics).
- `includes/License/AdminBanners.php` — uses `Gate::STATE_*` constants only → unchanged (those still exist). Copy updates handled in Task 4.
- `divi-essential-updater.php` — uses `Gate::STATE_*` constants → unchanged.
- `divi-essential.php` — `dnxte_register_module()` gate already removed in Task 1.

Only one file actually needs a call change:

**Files:**
- Modify: `includes/Admin/Menu.php` (change `is_locked` → `needs_license_page_redirect`)

- [ ] **Step 1: Update `Menu.php` redirect guard**

Open `includes/Admin/Menu.php`. Near the bottom of the file (added in the prior task 10 work) there is an `admin_init` hook that starts:

```php
add_action(
    'admin_init',
    function () {
        if ( ! class_exists( '\\DNXTE\\Includes\\License\\Gate' ) ) {
            return;
        }
        if ( ! \DNXTE\Includes\License\Gate::is_locked() ) {
            return;
        }
```

Replace the `is_locked()` call with `needs_license_page_redirect()`:

```php
add_action(
    'admin_init',
    function () {
        if ( ! class_exists( '\\DNXTE\\Includes\\License\\Gate' ) ) {
            return;
        }
        if ( ! \DNXTE\Includes\License\Gate::needs_license_page_redirect() ) {
            return;
        }
```

Leave everything else in the hook (the `$_GET['page']` check, dual-prefix matching, license-page exception, and `wp_safe_redirect`) untouched.

- [ ] **Step 2: Lint-check**

```bash
php -l includes/Admin/Menu.php
```

Expected: `No syntax errors detected`.

- [ ] **Step 3: Confirm no other files reference the removed methods**

```bash
grep -rn "Gate::is_locked\|should_render_module\|should_register_modules\|placeholder_html" \
    --include="*.php" \
    /Users/ajantadas/Herd/divi-5/wp-content/plugins/divi-essential/ \
    2>/dev/null | grep -v "docs/superpowers"
```

Expected: **no matches** (the docs still contain these names for historical reference, and that's fine — the `grep -v docs/superpowers` filter excludes them).

If any matches appear outside `docs/`, update them. The most likely remaining reference is in the build directory (`build/divi-essential/`) — ignore those, they're stale artifacts from a prior build.

---

## Task 4: Update admin banner copy

`AdminBanners.php` currently has banner copy that was written for the old "plugin locked" semantics. Under the new policy, modules still render on the frontend — banner copy should reflect that and reduce customer panic.

**Files:**
- Modify: `includes/License/AdminBanners.php` (banner strings in the `render()` method)

- [ ] **Step 1: Update the `GRANDFATHERED` banner copy**

In `includes/License/AdminBanners.php`, locate the `case Gate::STATE_GRANDFATHERED:` block. Its sprintf currently reads:

```php
esc_html__(
    'Your Divi Essential license expired. The plugin remains functional at v%1$s. %2$sRenew to receive updates.%3$s',
    'dnxte-divi-essential'
),
```

Replace with:

```php
esc_html__(
    'Your Divi Essential license expired. Modules on your live site still render, but the builder is locked for editing until you renew. %2$sRenew License%3$s',
    'dnxte-divi-essential'
),
```

Note: the `%1$s` placeholder (pinned version) is no longer used in the message. Remove the `esc_html( $pinned )` argument from the sprintf call so the arg count matches. The sprintf call becomes:

```php
sprintf(
    /* translators: 1: renewal URL. */
    esc_html__(
        'Your Divi Essential license expired. Modules on your live site still render, but the builder is locked for editing until you renew. %1$sRenew License%2$s',
        'dnxte-divi-essential'
    ),
    '<a href="' . $license_url . '">',
    '</a>'
),
```

(Note the placeholders renumbered to `%1$s` / `%2$s` since the pinned-version arg was dropped.)

- [ ] **Step 2: Update the `LOCKED_BYPASSED` banner copy**

Locate `case Gate::STATE_LOCKED_BYPASSED:`. Current copy mentions "Plugin locked" — soften to reflect frontend still rendering:

```php
sprintf(
    /* translators: 1: grandfather version, 2: current version, 3: license URL. */
    esc_html__(
        'Version mismatch. Your grandfathered version is v%1$s; you installed v%2$s. Your live site still renders DE modules, but the builder is locked. %3$sRenew license%4$s or reinstall v%1$s.',
        'dnxte-divi-essential'
    ),
    esc_html( $pinned ),
    esc_html( $version ),
    '<a href="' . $license_url . '">',
    '</a>'
),
```

- [ ] **Step 3: Update the `LOCKED` banner copy**

Locate `case Gate::STATE_LOCKED:`. Replace:

```php
esc_html__(
    'Divi Essential is locked. %1$sActivate your license%2$s.',
    'dnxte-divi-essential'
),
```

with:

```php
esc_html__(
    'Divi Essential needs a license to use in the builder. Existing modules on published pages continue to display. %1$sActivate your license%2$s to add or edit modules.',
    'dnxte-divi-essential'
),
```

- [ ] **Step 4: Update the `LOCKED_STALE` banner copy**

Locate `case Gate::STATE_LOCKED_STALE:`. Replace:

```php
esc_html__(
    'License server unreachable for 14+ days. Divi Essential is locked until we can verify your license. %1$sRetry now%2$s.',
    'dnxte-divi-essential'
),
```

with:

```php
esc_html__(
    'License server unreachable for 14+ days. Your published modules still display, but the builder is locked until we can verify your license. %1$sRetry now%2$s.',
    'dnxte-divi-essential'
),
```

- [ ] **Step 5: `LOCKED_MIGRATION` banner stays as-is.**

The countdown copy ("Divi Essential will lock in N days...") is still accurate — full access during grace, then tight lock. No change needed.

- [ ] **Step 6: Lint-check**

```bash
php -l includes/License/AdminBanners.php
```

Expected: `No syntax errors detected`.

---

## Task 5: Extend wp_localize_script with `licensePageUrl`

The `LicenseLockOverlay` React component (Task 6) needs the admin URL for the license page. `wp_localize_script` already exposes `plugin_dir` and `licenseState`; add one more key: `licensePageUrl`.

**Files:**
- Modify: `includes/Frontend/Assets.php` (extend the `wp_localize_script` call added in prior task 12)

- [ ] **Step 1: Read the current localize block**

```bash
sed -n '44,60p' includes/Frontend/Assets.php
```

You should see a block with `$license_state` and a `wp_localize_script` call that includes `licenseState`.

- [ ] **Step 2: Add the `licensePageUrl` key**

The block currently looks approximately:

```php
$license_state = class_exists( '\\DNXTE\\Includes\\License\\Gate' )
    ? \DNXTE\Includes\License\Gate::get_effective_state()
    : 'LICENSED';

wp_localize_script(
    'divi_essential_main',
    'divi_essential',
    array(
        'plugin_dir'       => DIVI_ESSENTIAL_URL,
        'inactive_modules' => get_option( 'dnxte_inactive_modules', array() ),
        'licenseState'     => $license_state,
    )
);
```

Replace with:

```php
$license_state    = class_exists( '\\DNXTE\\Includes\\License\\Gate' )
    ? \DNXTE\Includes\License\Gate::get_effective_state()
    : 'LICENSED';
$license_page_url = defined( 'DNEXT_ESSENTIAL_PLUGIN_LICENSE_PAGE' )
    ? admin_url( 'admin.php?page=' . DNEXT_ESSENTIAL_PLUGIN_LICENSE_PAGE )
    : admin_url( 'admin.php' );

wp_localize_script(
    'divi_essential_main',
    'divi_essential',
    array(
        'plugin_dir'       => DIVI_ESSENTIAL_URL,
        'inactive_modules' => get_option( 'dnxte_inactive_modules', array() ),
        'licenseState'     => $license_state,
        'licensePageUrl'   => $license_page_url,
    )
);
```

- [ ] **Step 3: Lint-check**

```bash
php -l includes/Frontend/Assets.php
```

Expected: `No syntax errors detected`.

---

## Task 6: Create `LicenseLockOverlay` React component

Per the spec's "option B+" UX: a full-width card rendered in place of a module's edit panel when the builder is locked. Shows a lock icon, module label, state-specific copy, a "Renew License" CTA, and a reassurance line about the live-site rendering.

**Files:**
- Create: `divi-5/visual-builder/src/LicenseLockOverlay.tsx`

- [ ] **Step 1: Create the component file**

Create `divi-5/visual-builder/src/LicenseLockOverlay.tsx` with this exact content:

```tsx
/**
 * License Lock Overlay
 *
 * Rendered in place of a module's edit panel when the Divi Essential
 * license does not permit editing. The user still sees their existing
 * modules on the frontend; this overlay appears only inside the Divi
 * Visual Builder when they try to edit a DE module.
 */
import React from "react";

interface LicenseLockOverlayProps {
  moduleLabel: string;
}

const CONTAINER_STYLE: React.CSSProperties = {
  padding: "32px 28px",
  margin: "16px",
  border: "1px solid #e0d8b8",
  borderRadius: "8px",
  background: "#fffbe8",
  color: "#4a3f15",
  fontFamily:
    '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif',
  lineHeight: 1.5,
};

const HEADING_STYLE: React.CSSProperties = {
  display: "flex",
  alignItems: "center",
  gap: "10px",
  margin: "0 0 12px 0",
  fontSize: "18px",
  fontWeight: 600,
  color: "#4a3f15",
};

const LOCK_ICON_STYLE: React.CSSProperties = {
  fontSize: "22px",
};

const BODY_STYLE: React.CSSProperties = {
  margin: "0 0 20px 0",
  fontSize: "14px",
};

const BUTTON_ROW_STYLE: React.CSSProperties = {
  display: "flex",
  gap: "12px",
  marginBottom: "20px",
  flexWrap: "wrap",
};

const PRIMARY_BUTTON_STYLE: React.CSSProperties = {
  display: "inline-block",
  padding: "8px 16px",
  background: "#2271b1",
  color: "#fff",
  textDecoration: "none",
  borderRadius: "4px",
  fontSize: "13px",
  fontWeight: 500,
};

const SECONDARY_LINK_STYLE: React.CSSProperties = {
  display: "inline-block",
  padding: "8px 16px",
  color: "#2271b1",
  textDecoration: "none",
  fontSize: "13px",
};

const REASSURANCE_STYLE: React.CSSProperties = {
  margin: 0,
  paddingTop: "16px",
  borderTop: "1px solid #e0d8b8",
  fontSize: "12px",
  color: "#6b5900",
  fontStyle: "italic",
};

function getSublineForState(state: string | undefined): string {
  switch (state) {
    case "GRANDFATHERED":
    case "LOCKED_BYPASSED":
      return "Your Divi Essential license has expired. Renew to edit this module.";
    case "LOCKED_STALE":
      return "We can't verify your license right now. Retry once your connection to divinext.com is restored.";
    case "LOCKED":
    default:
      return "Activate your Divi Essential license to edit this module.";
  }
}

export function LicenseLockOverlay({ moduleLabel }: LicenseLockOverlayProps) {
  const licenseState = window.divi_essential?.licenseState;
  const licensePageUrl =
    window.divi_essential?.licensePageUrl ?? "/wp-admin/admin.php";
  const subline = getSublineForState(licenseState);

  return (
    <div style={CONTAINER_STYLE} className="dnxte-license-lock-overlay">
      <h3 style={HEADING_STYLE}>
        <span style={LOCK_ICON_STYLE} role="img" aria-label="locked">
          🔒
        </span>
        <span>{moduleLabel} — License required</span>
      </h3>
      <p style={BODY_STYLE}>{subline}</p>
      <div style={BUTTON_ROW_STYLE}>
        <a
          href={licensePageUrl}
          target="_blank"
          rel="noopener noreferrer"
          style={PRIMARY_BUTTON_STYLE}
        >
          Renew License
        </a>
        <a
          href="https://divinext.com/divi-essential"
          target="_blank"
          rel="noopener noreferrer"
          style={SECONDARY_LINK_STYLE}
        >
          Learn more
        </a>
      </div>
      <p style={REASSURANCE_STYLE}>
        Your existing module will continue to render for site visitors.
      </p>
    </div>
  );
}
```

- [ ] **Step 2: Verify TypeScript compiles this file in isolation**

Create a throwaway check — no changes to the file, just ensure the type imports work:

```bash
cd /Users/ajantadas/Herd/divi-5/wp-content/plugins/divi-essential/divi-5/visual-builder
npx tsc --noEmit src/LicenseLockOverlay.tsx 2>&1 | head -20
```

Expected output: either empty (no errors) OR errors referencing `window.divi_essential` type declarations (which will resolve when we wire it up in Task 7 — index.ts already declares the Window interface). If you see errors about the `window.divi_essential` type only, that's fine; it'll compile cleanly during the full build in Task 8.

If you see unrelated errors (e.g., "Cannot find module 'react'"), STOP — there's a dep config issue.

---

## Task 7: Rewrite `index.ts` — per-module edit wrapping + palette CSS

Replace the current "skip registration when locked" gate with a richer behavior:
- Always register modules (so existing instances display in the builder).
- If builder-locked, wrap each module's `renderers.edit` with a HOC that returns `<LicenseLockOverlay />` instead of the edit component.
- If builder-locked, inject a CSS rule to hide DE modules from the module library palette (DOM selector is best-effort).

**Files:**
- Modify: `divi-5/visual-builder/src/index.ts`

- [ ] **Step 1: Read current state of the `addAction` callback**

```bash
sed -n '285,325p' divi-5/visual-builder/src/index.ts
```

Confirm the callback currently has the early-return gate added in Task 12 of the original plan. You'll see something like:

```typescript
addAction(
  "divi.moduleLibrary.registerModuleLibraryStore.after",
  "diviEssential",
  () => {
    // License gate: only LICENSED and GRANDFATHERED states register modules.
    ...
    const allowRegistration = ...;
    if (!allowRegistration) {
      return;
    }

    // Register each module
    Object.entries(moduleMapping).forEach(...);
  }
);
```

- [ ] **Step 2: Update the Window type declaration to include `licensePageUrl`**

Locate the `declare global { interface Window { ... } }` block (around lines 152-169). The `divi_essential` object has `licenseState`, `plugin_dir`, `inactive_modules`. Add `licensePageUrl`:

```typescript
declare global {
  interface Window {
    divi_essential?: {
      licenseState?: string;
      plugin_dir?: string;
      inactive_modules?: string[];
      licensePageUrl?: string;
    };
    wp?: {
      hooks?: {
        addFilter: (
          hook: string,
          namespace: string,
          cb: Function,
          priority?: number
        ) => void;
      };
    };
  }
}
```

- [ ] **Step 3: Add imports at the top of the file (after existing imports)**

Find the import block near the top of `index.ts`. The existing imports include:

```typescript
import "./module-icons";
import "./license-gate";

import { omit } from "lodash";
import { addAction } from "@wordpress/hooks";

// Import all module components
import { Rating } from "./components/NextRating";
...
```

Replace `import "./license-gate";` (which will be deleted in Task 8) with:

```typescript
import React from "react";
import { LicenseLockOverlay } from "./LicenseLockOverlay";
```

The file should now have `import "./module-icons";` immediately followed by the two new imports, then the lodash and hooks imports.

- [ ] **Step 4: Add the `isBuilderLocked` helper and `wrapEditLocked` HOC after the moduleMapping block**

Just before the `addAction(...)` call (right after the `moduleMapping` object's closing `}`), add:

```typescript
// --- License-aware edit wrapping ---
// Builder is "locked" for any state except LICENSED and LOCKED_MIGRATION.
// LOCKED_MIGRATION grants 30-day grace for pre-5.5.1 installs; see Gate.php.
const BUILDER_UNLOCKED_STATES = new Set(["LICENSED", "LOCKED_MIGRATION"]);

function isBuilderLocked(): boolean {
  const state = window.divi_essential?.licenseState;
  // If no state flag is present, fail open (avoids breaking builder for users
  // whose license subsystem failed to load). Server-side SaveGuard still
  // enforces the lock if the JS somehow misses it.
  if (!state) return false;
  return !BUILDER_UNLOCKED_STATES.has(state);
}

function wrapEditLocked<P extends object>(
  OriginalEdit: React.ComponentType<P>,
  moduleLabel: string
): React.ComponentType<P> {
  return function LockedEdit(props: P) {
    if (!isBuilderLocked()) {
      return React.createElement(OriginalEdit, props);
    }
    return React.createElement(LicenseLockOverlay, { moduleLabel });
  };
}

// Inject CSS to hide DE modules from the Divi Builder's "Add Module" palette
// when the license is locked. Selector is best-effort — if Divi 5 changes
// its palette DOM, the edit overlay is still the authoritative lock.
function injectPaletteHidingCSS(): void {
  if (document.getElementById("dnxte-palette-hide")) return;
  const style = document.createElement("style");
  style.id = "dnxte-palette-hide";
  style.textContent = `
    /* Hide DE modules from the "Insert Module" palette when license-locked. */
    [data-module-name^="dnxte/"],
    [data-module-id^="dnxte/"],
    [data-module-type^="dnxte/"] {
      display: none !important;
    }
  `;
  document.head.appendChild(style);
}
```

- [ ] **Step 5: Replace the `addAction` callback body**

Find the entire `addAction("divi.moduleLibrary.registerModuleLibraryStore.after", ...)` call. Replace the ENTIRE callback body (including the license-state check added in prior task 12) with:

```typescript
addAction(
  "divi.moduleLibrary.registerModuleLibraryStore.after",
  "diviEssential",
  () => {
    const builderLocked = isBuilderLocked();

    if (builderLocked) {
      injectPaletteHidingCSS();
    }

    Object.entries(moduleMapping).forEach(([moduleKey, moduleComponent]) => {
      if (inactive_modules.includes(moduleKey)) {
        return;
      }

      const registerOne = (component: any) => {
        if (!component || !("metadata" in component)) return;
        const rest = omit(component, "metadata") as any;
        const moduleLabel =
          component.metadata?.settings?.title ??
          component.metadata?.name ??
          moduleKey;

        let renderers = rest.renderers;
        if (builderLocked && renderers && renderers.edit) {
          renderers = {
            ...renderers,
            edit: wrapEditLocked(renderers.edit, moduleLabel),
          };
        }

        registerModule(component.metadata, { ...rest, renderers });
      };

      if (Array.isArray(moduleComponent)) {
        moduleComponent.forEach(registerOne);
      } else {
        registerOne(moduleComponent);
      }
    });
  }
);
```

Key changes from the prior gate:
- No more early-return when unlocked → always iterate and register.
- Each module's edit renderer gets wrapped when locked.
- Palette-hiding CSS is injected into `<head>` when locked.
- Module label lookup falls back through `settings.title` → `name` → `moduleKey` to handle varied metadata shapes.

- [ ] **Step 6: Verify TypeScript compiles**

```bash
cd /Users/ajantadas/Herd/divi-5/wp-content/plugins/divi-essential/divi-5/visual-builder
npx tsc --noEmit 2>&1 | head -30
```

Expected: either empty output (no errors), or warnings only. Any errors referencing `React`, `LicenseLockOverlay`, or window.divi_essential need to be resolved before continuing. If `npx tsc` isn't configured to type-check the whole project, skip this step — the webpack build in Task 9 will catch real errors.

---

## Task 8: Delete the obsolete `license-gate.ts`

The `license-gate.ts` file added in the prior plan's Task 13 used an unverified Divi 5 filter hook name and attempted a different approach (hook-based module filtering). It's now redundant — the edit wrapping and CSS hiding in `index.ts` replace it.

**Files:**
- Delete: `divi-5/visual-builder/src/license-gate.ts`

- [ ] **Step 1: Confirm `index.ts` no longer imports it**

```bash
grep "license-gate" /Users/ajantadas/Herd/divi-5/wp-content/plugins/divi-essential/divi-5/visual-builder/src/index.ts
```

Expected: no matches (the `import "./license-gate";` line was replaced with the React + LicenseLockOverlay imports in Task 7).

If grep returns a match, the import wasn't fully replaced — go back to Task 7 Step 3.

- [ ] **Step 2: Delete the file**

```bash
rm /Users/ajantadas/Herd/divi-5/wp-content/plugins/divi-essential/divi-5/visual-builder/src/license-gate.ts
```

- [ ] **Step 3: Verify deletion**

```bash
ls /Users/ajantadas/Herd/divi-5/wp-content/plugins/divi-essential/divi-5/visual-builder/src/license-gate.ts 2>&1
```

Expected: `ls: cannot access ... No such file or directory` (or equivalent).

---

## Task 9: Rebuild the Divi 5 visual builder bundle

The TypeScript changes above need to be compiled into `divi-5/scripts/bundle.js` for the visual builder to pick them up.

**Files:**
- Produces: `divi-5/scripts/bundle.js` (updated)

- [ ] **Step 1: Run the build**

```bash
cd /Users/ajantadas/Herd/divi-5/wp-content/plugins/divi-essential/divi-5/visual-builder
npm run build 2>&1 | tail -20
```

Expected: webpack compiles with ~180-200 warnings (Dart Sass deprecation warnings are normal per project memory). **No errors.** Build should finish in ~10-15 seconds.

If the build errors out, capture the full output and escalate — common failures include missing React types, lodash type mismatches, or a webpack config issue. Do NOT proceed past this step with build errors.

- [ ] **Step 2: Verify the new code made it into the bundle**

```bash
cd /Users/ajantadas/Herd/divi-5/wp-content/plugins/divi-essential
grep -c "License required\|dnxte-license-lock-overlay\|dnxte-palette-hide\|BUILDER_UNLOCKED_STATES\|isBuilderLocked" divi-5/scripts/bundle.js
```

Expected: count **≥ 2** (string literals like `"License required"` and `"dnxte-license-lock-overlay"` survive minification; variable names like `BUILDER_UNLOCKED_STATES` get minified away).

- [ ] **Step 3: Quick sanity — bundle size didn't balloon**

```bash
ls -lh divi-5/scripts/bundle.js | awk '{print $5}'
```

Expected: ~4-5 MB (similar to pre-change size; the LicenseLockOverlay adds roughly 2 KB).

---

## Task 10: Create server-side SaveGuard

A client-side edit block can be bypassed by patching the bundle or crafting raw REST requests. Add a server-side REST filter that rejects save requests carrying `dnxte/*` module blocks when `Gate::is_builder_locked()`.

**Files:**
- Create: `includes/License/SaveGuard.php`
- Modify: `divi-essential.php` (register SaveGuard in `init_plugin()`)

- [ ] **Step 1: Investigate which REST route Divi 5 uses for saves**

Before writing code, confirm the endpoint. Divi 5 saves block data via the WordPress REST API. Inspect likely locations:

```bash
cd /Users/ajantadas/Herd/divi-5/wp-content/themes/Divi
grep -rn "register_rest_route" includes/builder-5/server/ 2>/dev/null | grep -iE "save|update|post|layout" | head -10
```

Look for route patterns like `/divi/v1/save`, `/divi/v1/update-post`, or similar. If nothing obvious surfaces, fall back to the generic WordPress post-save endpoint (`/wp/v2/posts/<id>` and `/wp/v2/pages/<id>`), which Divi 5 may also use.

Note the route pattern you find (or confirm the fallback). The filter below catches both specific Divi routes and generic post-save routes.

- [ ] **Step 2: Create `includes/License/SaveGuard.php`**

Create the file with this content:

```php
<?php
namespace DNXTE\Includes\License;

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * Server-side save blocker. Prevents saving posts that contain DE module
 * blocks when the license state does not permit editing. Acts as a
 * fallback defense if a user bypasses the client-side edit overlay.
 */
class SaveGuard {

    public static function register_hooks() {
        add_filter( 'rest_pre_dispatch', array( __CLASS__, 'block_locked_saves' ), 10, 3 );
    }

    /**
     * Intercept REST save requests that would write DE module content.
     *
     * @param mixed           $response
     * @param \WP_REST_Server $server
     * @param \WP_REST_Request $request
     * @return mixed WP_Error if blocked, original response otherwise.
     */
    public static function block_locked_saves( $response, $server, $request ) {
        // Fast-path: if Gate isn't loaded, never block. Protects against
        // an order-of-load issue during plugin activation.
        if ( ! class_exists( '\\DNXTE\\Includes\\License\\Gate' ) ) {
            return $response;
        }

        // Only block if builder is actually locked.
        if ( ! Gate::is_builder_locked() ) {
            return $response;
        }

        // Only act on write methods.
        $method = strtoupper( $request->get_method() );
        if ( 'POST' !== $method && 'PUT' !== $method && 'PATCH' !== $method ) {
            return $response;
        }

        // Only act on plausible post-save routes. Divi 5 uses /divi/v1/*
        // internal routes plus the standard /wp/v2/posts and /wp/v2/pages.
        // Match defensively — anything else passes through.
        $route = $request->get_route();
        $is_divi_route = 0 === strpos( $route, '/divi/v1/' ) || 0 === strpos( $route, '/divi/v2/' );
        $is_post_route = preg_match( '#^/wp/v2/(posts|pages)(/|$)#', $route );
        if ( ! $is_divi_route && ! $is_post_route ) {
            return $response;
        }

        // Inspect the payload for DE module references. We look at both the
        // raw body (for Divi's custom save payloads) and the 'content' param
        // (for the standard WP post endpoint).
        $body    = (string) $request->get_body();
        $content = (string) $request->get_param( 'content' );

        $contains_de =
            false !== strpos( $body, 'dnxte/' ) ||
            false !== strpos( $body, 'dnxte_' ) ||
            false !== strpos( $content, 'dnxte/' ) ||
            false !== strpos( $content, 'et_pb_dnxte_' );

        if ( ! $contains_de ) {
            return $response;
        }

        return new \WP_Error(
            'dnxte_license_required',
            __(
                'A Divi Essential license is required to save changes to DE modules. Activate or renew your license to continue.',
                'dnxte-divi-essential'
            ),
            array( 'status' => 403 )
        );
    }
}
```

- [ ] **Step 3: Register SaveGuard in `init_plugin()`**

Open `divi-essential.php`. Locate `init_plugin()` (around line 181-200). Find the line that registers AdminBanners hooks:

```php
\DNXTE\Includes\License\AdminBanners::register_hooks();
```

Add a new line immediately after it:

```php
\DNXTE\Includes\License\SaveGuard::register_hooks();
```

- [ ] **Step 4: Lint-check both files**

```bash
php -l includes/License/SaveGuard.php && php -l divi-essential.php
```

Expected: both report `No syntax errors detected`.

- [ ] **Step 5: Verify Gate tests still pass (SaveGuard doesn't change Gate, but confirm nothing broke)**

```bash
php tests/test-gate.php | tail -3
```

Expected: `28 tests, 0 failures`.

---

## Task 11: Manual verification

Run through all 10 testing scenarios from the spec on a local WordPress install. This is the acceptance gate.

**Files:** none modified. Verification only.

- [ ] **Step 1: Confirm Gate unit tests still pass**

```bash
cd /Users/ajantadas/Herd/divi-5/wp-content/plugins/divi-essential
php tests/test-gate.php | tail -3
```

Expected: `28 tests, 0 failures`.

- [ ] **Step 2: Scenario 1 — Fresh install, no key entered → `LOCKED`**

1. On a local WP staging install, deactivate and uninstall Divi Essential.
2. Delete these `wp_options` rows (via WP-CLI or phpMyAdmin): `dnext_essential_license_key`, `dnext_essential_license_status`, `dnext_essential_grandfather_version`, `dnext_essential_license_last_verified`, `dnext_essential_lock_deadline`, `divi_essential_version`, `divi_essential_installed`.
3. Re-install and activate the plugin.
4. Expected state: `LOCKED`. Verify:
   - DE admin menus redirect to the license page (try clicking "Modules" or "Extensions").
   - Red admin banner visible: "Divi Essential needs a license to use in the builder..."
   - If you place a DE module on a page via DB injection, it renders normally on the frontend (new policy).
   - Opening the Divi Builder on any page: DE modules hidden from "Insert Module" palette.
   - If an existing page has a DE module, clicking it in the builder shows the yellow LicenseLockOverlay instead of edit controls.

- [ ] **Step 3: Scenario 2 — Valid license active → `LICENSED`**

1. Activate a real valid license via the license page.
2. Expected: full access. No banners. DE modules appear in palette. Clicking a DE module opens the normal edit panel.
3. Verify `wp_options` row `dnext_essential_grandfather_version` = `5.5.1`; `dnext_essential_license_last_verified` ≈ `time()`.

- [ ] **Step 4: Scenarios 3/3a/3b — migration paths**

Simulate each by setting options manually then loading an admin page:

**3 (unlicensed existing, pre-5.5.1):**
- Set `divi_essential_version = '5.5.0'`, delete `dnext_essential_license_status`.
- Load `/wp-admin/`.
- Expected: `dnext_essential_lock_deadline` set to ~time()+30 days; state = `LOCKED_MIGRATION`. Full builder access during grace. Banner shows countdown.

**3a (already expired pre-5.5.1):**
- Set `divi_essential_version = '5.5.0'`, `dnext_essential_license_status = 'expired'`.
- Load `/wp-admin/`.
- Expected: `dnext_essential_grandfather_version` = `5.5.1`, state = `GRANDFATHERED`. **Yellow banner visible.** DE modules render on frontend. Palette hides DE modules. Clicking existing DE module shows LockOverlay.

**3b (currently valid pre-5.5.1):**
- Set `divi_essential_version = '5.5.0'`, `dnext_essential_license_status = 'valid'`.
- Load `/wp-admin/`.
- Expected: state stays `LICENSED`. No visible change.

- [ ] **Step 5: Scenario 4 — Grace deadline passes → `LOCKED`**

1. From Scenario 3 state, set `dnext_essential_lock_deadline = time() - 60`.
2. Load an admin page.
3. Expected: state transitions to `LOCKED`. Red banner. DE admin pages redirect. Palette hides DE modules. Existing DE modules on pages still render on frontend (new policy).

- [ ] **Step 6: Scenario 5 — License expires mid-session (valid → `GRANDFATHERED`)**

1. With a valid license active, manually set `dnext_essential_license_status = 'expired'` (simulates expiry detected by next cron).
2. Load an admin page.
3. Expected: state = `GRANDFATHERED`. Yellow banner. Frontend rendering unchanged. Builder opens module edit panel with LockOverlay.
4. WP Dashboard → Updates: no Divi Essential update showing (update transient filter active).

- [ ] **Step 7: Scenario 6 — Grandfathered user uploads newer ZIP → `LOCKED_BYPASSED`**

1. From Scenario 5 state, edit `divi-essential.php` line 6 to `Version:     5.6.0` (simulate upload of a version past the pin).
2. Reload admin.
3. Expected: state = `LOCKED_BYPASSED`. Red "Version mismatch" banner. Frontend renders. Builder still blocks edit.
4. Revert the version to `5.5.1` before continuing.

- [ ] **Step 8: Scenario 7 — Grandfathered user renews**

1. From Scenario 5 (`GRANDFATHERED`), activate a currently-valid license key.
2. Expected: status flips to `valid`. `grandfather_version` rewritten to `5.5.1`. `lock_deadline` deleted. `update_plugins` transient flushed. State = `LICENSED`. Banner disappears. Builder fully functional.

- [ ] **Step 9: Scenarios 8/9 — Server reachability**

**8 (< 14 days unreachable):**
- Temporarily edit `DNEXT_ESSENTIAL_STORE_URL` in `Licensing.php` to an invalid URL.
- Trigger cron manually: `wp cron event run dnext_essential_daily_license_check`.
- Expected: `license_status` unchanged; `last_check_error` timestamp updated; state preserved.

**9 (> 14 days unreachable):**
- Set `dnext_essential_license_last_verified = time() - ( 15 * DAY_IN_SECONDS )`.
- Load admin.
- Expected: state = `LOCKED_STALE`. Yellow banner specific to stale state. Frontend renders. Builder blocks edit.

Revert the URL hack and `last_verified` after these tests.

- [ ] **Step 10: Scenario 10 — SaveGuard bypass test**

1. Put the site in `LOCKED` state (Scenario 1).
2. Open the Divi Builder on a page with an existing DE module.
3. Using browser DevTools, manually patch `window.divi_essential.licenseState = "LICENSED"` in the console.
4. Reload the builder so module registers without the wrapper. Try to edit and save a DE module.
5. Expected: save request returns HTTP 403 with error message "A Divi Essential license is required...". The SaveGuard server-side rejects the save even though the client bypassed the overlay.

- [ ] **Step 11: Inspect git status for review**

```bash
cd /Users/ajantadas/Herd/divi-5/wp-content/plugins/divi-essential
git status --short
git diff --stat
```

Review the diff list. Compared to the pre-pivot state, you should see:
- **Reverted (diff should be smaller):** `divi-5/server/Modules/Modules.php`, `divi-5/divi-5.php`, `divi-essential.php` (dnxte_register_module gone back).
- **Modified:** `includes/License/Gate.php`, `tests/test-gate.php`, `includes/Admin/Menu.php`, `includes/License/AdminBanners.php`, `includes/Frontend/Assets.php`, `divi-5/visual-builder/src/index.ts`, `divi-5/scripts/bundle.js`, `divi-essential.php` (SaveGuard registration).
- **New:** `divi-5/visual-builder/src/LicenseLockOverlay.tsx`, `includes/License/SaveGuard.php`.
- **Deleted:** `divi-5/visual-builder/src/license-gate.ts`.

---

## Risks & rollback

**Highest-risk change:** the React edit-wrapper in `index.ts`. If the wrapper breaks module registration for any module, users won't be able to edit that module even with a valid license. Mitigations built in:
- `isBuilderLocked()` fails-open when `window.divi_essential` is missing (no gate applied).
- Each module's wrap is `try/catch`-free but isolated: a single module's wrap failure won't break others because each `registerOne` call is scoped.
- The SaveGuard server-side provides belt-and-suspenders; if the JS wrap fails silently, the server still prevents saves.

**Rollback:** if a released 5.5.1 with this pivot breaks customers:
1. Revert the five tasks touching `.tsx`/`.ts` files and rebuild.
2. Also revert `Gate.php` predicate rename and its callers.
3. Release 5.5.2 with just the `dnwoo_` typo fix from the original Task 4 and the admin banner work.

---

## Open concerns tracked to implementation

Three items from the spec's "Open concerns" section become implementation-time verifications:

1. **Palette selector** — the CSS in Task 7 Step 4 uses three data-attribute patterns. Manually verify in DevTools during Scenario 1 that at least one matches. If none match, inspect the palette DOM and update the selector.
2. **REST save route** — Task 10 Step 1 investigates. The filter in Step 2 matches `/divi/v1/*`, `/divi/v2/*`, and `/wp/v2/(posts|pages)/*` defensively. If Divi uses a different prefix, update the `$is_divi_route` check.
3. **Module label metadata path** — Task 7 Step 5 falls through `settings.title` → `name` → `moduleKey`. Verify during Scenario 3a: the overlay should show a human-readable module label, not `dnxte/text-animation`. If it shows the key, inspect module.json files and refine the path.

