﻿/**
 * HoverPanel 1.1.2 - Cursor-driven hover interactions for After Effects
 *
 * Package:  ae-hoverpanel
 * Host:     aftereffects (min 16.0)
 * Built:    2026-07-30
 *
 * GENERATED FILE - DO NOT EDIT.
 * Edit the sources under packages/ae-hoverpanel/src/ and rebuild:
 *     python3 tools/bundle.py ae-hoverpanel
 *
 * Composed from 30 modules:
 *   - shared/jsx/global/10-json-polyfill.jsxinc
 *   - shared/jsx/scoped/00-package-meta.jsxinc
 *   - shared/jsx/scoped/10-expression-escape.jsxinc
 *   - shared/jsx/scoped/30-undo-group.jsxinc
 *   - shared/jsx/scoped/40-http.jsxinc
 *   - shared/jsx/scoped/50-updater.jsxinc
 *   - packages/ae-hoverpanel/src/00-state.jsxinc
 *   - packages/ae-hoverpanel/src/05-lookup-cache.jsxinc
 *   - packages/ae-hoverpanel/src/10-name-hash.jsxinc
 *   - packages/ae-hoverpanel/src/20-settings.jsxinc
 *   - packages/ae-hoverpanel/src/30-ui-helpers.jsxinc
 *   - packages/ae-hoverpanel/src/32-property-helpers.jsxinc
 *   - packages/ae-hoverpanel/src/40-corner-radius.jsxinc
 *   - packages/ae-hoverpanel/src/50-controller.jsxinc
 *   - packages/ae-hoverpanel/src/55-button-registry.jsxinc
 *   - packages/ae-hoverpanel/src/60-expr-hover.jsxinc
 *   - packages/ae-hoverpanel/src/62-expr-guide.jsxinc
 *   - packages/ae-hoverpanel/src/64-expr-cursor.jsxinc
 *   - packages/ae-hoverpanel/src/66-guide-layer.jsxinc
 *   - packages/ae-hoverpanel/src/70-button-ops.jsxinc
 *   - packages/ae-hoverpanel/src/72-button-batch.jsxinc
 *   - packages/ae-hoverpanel/src/74-settings-io.jsxinc
 *   - packages/ae-hoverpanel/src/76-hover-presets.jsxinc
 *   - packages/ae-hoverpanel/src/78-teardown.jsxinc
 *   - packages/ae-hoverpanel/src/80-ui-panel.jsxinc
 *   - packages/ae-hoverpanel/src/82-ui-actions.jsxinc
 *   - packages/ae-hoverpanel/src/84-validate.jsxinc
 *   - packages/ae-hoverpanel/src/86-analytics.jsxinc
 *   - packages/ae-hoverpanel/src/88-settings-dialog.jsxinc
 *   - packages/ae-hoverpanel/src/99-init.jsxinc
 */

// ==== shared/jsx/global/10-json-polyfill.jsxinc ====
/**
 * JSON polyfill for ExtendScript.
 *
 * Never shows UI. An earlier version called alert() on a parse failure, which meant a
 * blank or hand-edited settings blob produced a modal "JSON Parse Error" dialog on every
 * single launch — and because the dialog is modal, it can block other panels from
 * loading at all. Every caller already handles null by falling back to defaults, so
 * failure is reported by the return value and nothing else.
 *
 * ExtendScript has no built-in JSON. This must stay in the prelude, outside the IIFE:
 * it assigns to an undeclared global, which throws under the "use strict" that the IIFE
 * body runs with.
 *
 * This is the ONLY module that belongs in shared/jsx/global/. After Effects evaluates
 * every panel script into one shared ExtendScript scope, so anything declared out here
 * is visible to — and collides with — every other installed panel. A polyfill is the one
 * legitimate case: it is idempotent, guarded by `typeof JSON === "undefined"`, and
 * supplies a standard object that should have been there anyway. Everything else lives
 * in shared/jsx/scoped/ and is bundled inside the IIFE.
 */
// =============================================================================
// JSON POLYFILL (ExtendScript safe)
// =============================================================================
if (typeof JSON === 'undefined') {
    JSON = {};
    JSON.stringify = function (obj) {
        var t = typeof obj;
        if (t !== "object" || obj === null) {
            if (t === "string") {
                return '"' + obj.replace(/[\\"\x00-\x1F\x7F]/g, function (ch) {
                    var m = {'\b':'\\b','\f':'\\f','\n':'\\n','\r':'\\r','\t':'\\t','"':'\\"','\\':'\\\\'};
                    return m[ch] || '\\u' + ('0000' + ch.charCodeAt(0).toString(16)).slice(-4);
                }) + '"';
            }
            if (t === "number") return isFinite(obj) ? String(obj) : "null";
            if (t === "boolean") return String(obj);
            // undefined and functions have no JSON representation. String(undefined) is
            // the bare token "undefined", which makes the entire document unparseable —
            // so one stray key would discard every saved setting on the next launch.
            if (t === "undefined" || t === "function") return "null";
            return String(obj);
        } else {
            var n, v, arr = (obj && obj.constructor === Array), json = [];
            for (n in obj) if (obj.hasOwnProperty(n)) {
                v = obj[n]; t = typeof v;
                if (t === "string") {
                    v = '"' + v.replace(/[\\"\x00-\x1F\x7F]/g, function (ch) {
                        var m = {'\b':'\\b','\f':'\\f','\n':'\\n','\r':'\\r','\t':'\\t','"':'\\"','\\':'\\\\'};
                        return m[ch] || '\\u' + ('0000' + ch.charCodeAt(0).toString(16)).slice(-4);
                    }) + '"';
                } else if (t === "object" && v !== null) v = JSON.stringify(v);
                else if (t === "number") v = isFinite(v) ? String(v) : "null";
                else if (t === "undefined" || t === "function") v = "null";
                json.push((arr ? "" : '"' + n + '":') + String(v));
            }
            return (arr ? "[" : "{") + String(json) + (arr ? "]" : "}");
        }
    };
    JSON.parse = function (str) {
        var s = String(str || '');
        // A freshly created settings layer, an unset preference and a zero-byte file all
        // arrive here as empty. That is the normal first-run state, not a failure.
        if (!/[^\s]/.test(s)) return null;
        // The json2.js validation, and ONLY it.
        //
        // A second test used to be ANDed on here:
        //     /^[\],:{}\s]*$/.test(s.replace(/\\./g,'@').replace(/"[^"\\\n\r]*"/g,''))
        // which strips strings but never strips numbers, true, false or null. So
        // {"a":1} reduced to {:1}, and '1' is not in the permitted character class —
        // meaning ANY document containing a number was rejected. Settings are almost
        // entirely numbers, so parsing failed every time: no saved setting was ever
        // loaded from any scope, and the old alert() in this function fired on launch.
        // Three passes below: neutralise escapes, replace every literal with ']', drop
        // array openings. What survives must be only structural punctuation.
        var ok = /^[\],:{}\s]*$/.test(
            s.replace(/\\["\\\/bfnrtu]/g, '@')
             .replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g, ']')
             .replace(/(?:^|:|,)(?:\s*\[)+/g, '')
        );
        if (!ok) return null;
        try { return eval('(' + s + ')'); } catch (e) { return null; }
    };
}

(function (thisObj) {
    "use strict";

// ==== shared/jsx/scoped/00-package-meta.jsxinc ====
/**
 * Build-stamped package identity.
 *
 * Bundled INSIDE the IIFE (see shared/jsx/global/ for the one exception). After Effects
 * evaluates every panel script into a single shared ExtendScript scope, so a function
 * declared at global scope is visible to every other installed panel and silently
 * overwrites — or is overwritten by — any other script using the same name. This module
 * set previously leaked 25 globals, including names as generic as http_getText and
 * undo_begin.
 *
 * The bundler substitutes these tokens from the package's manifest.json, which
 * is the single source of truth for the version. The 16.10.6 script carried
 * three disagreeing version strings — a 16.10.6 header, a "v16.5.0" panel title,
 * and a "v1631" preferences key — which is harmless as cosmetics but fatal once
 * an updater compares versions to decide whether to upgrade.
 *
 * Deliberately NOT used to key any persistent storage. Preference keys and
 * settings filenames must stay version-independent, otherwise every OTA release
 * would silently orphan the user's saved settings.
 */
var PKG_INFO = {
    id: "ae-hoverpanel",
    name: "HoverPanel",
    version: "1.1.2",
    host: "aftereffects",
    artifact: "HoverPanel.jsx",
    built: "2026-07-30",
    updateBaseUrl: "https://adobe.baddad.dev"
};

/** Human label for window titles, dialogs and log lines. */
function pkg_title() {
    return PKG_INFO.name + " " + PKG_INFO.version;
}

// ==== shared/jsx/scoped/10-expression-escape.jsxinc ====
/**
 * Escape a layer/comp name for embedding in an After Effects expression.
 *
 * Generated expressions reference layers by name inside single-quoted
 * strings, so a name containing a quote or backslash would produce an
 * expression that fails to compile on the user's timeline.
 */
// === Utility: escape names for expressions (global) ===
function util_escapeNameForExpression(s){
    if (s == null) return "";
    s = String(s);
    // double the backslashes, then escape single quotes; strip control chars
    s = s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
    s = s.replace(/[\r\n\t\u0000-\u001F\u007F]/g, " ");
    return s;
}

// ==== shared/jsx/scoped/30-undo-group.jsxinc ====
/**
 * Re-entrant undo grouping for Adobe host apps.
 *
 * After Effects undo groups do not nest. Calling app.beginUndoGroup() while one is
 * already open, then app.endUndoGroup(), closes the OUTER group early — so every
 * change the enclosing operation makes afterwards lands outside any group. The user
 * presses Ctrl+Z once and gets a partial rollback: half the operation undone, half
 * still applied, with no way to tell.
 *
 * That is easy to cause by accident, because a helper that legitimately wants its
 * own group (writing the project-settings layer, say) has no way to know whether a
 * caller already opened one.
 *
 * So depth is tracked here and the host API is touched only at the outermost
 * boundary. Nested callers join the enclosing group instead of splitting it.
 *
 * Every mutating operation should go through this rather than calling
 * app.beginUndoGroup() directly.
 */

/** Depth of nesting. The host API is called only on the 0 -> 1 and 1 -> 0 edges. */
var __ehUndoDepth = 0;

/** Open an undo group, or join the one already open. */
function undo_begin(label) {
    if (__ehUndoDepth === 0) {
        try { app.beginUndoGroup(label || "Operation"); } catch (e) {}
    }
    __ehUndoDepth++;
}

/**
 * Leave the current nesting level, closing the group only when the outermost
 * caller leaves. Never throws: an unbalanced call must not mask the original error
 * on a failure path.
 */
function undo_end() {
    // Return early on a stray call. Decrementing a floor of zero and then testing
    // for zero would close a group this script never opened — terminating whatever
    // the host itself had open, which is worse than the leak it tries to avoid.
    if (__ehUndoDepth === 0) return;
    __ehUndoDepth--;
    if (__ehUndoDepth === 0) {
        try { app.endUndoGroup(); } catch (e) {}
    }
}

/**
 * Force the counter back to zero and close any open group.
 *
 * A throw between undo_begin() and undo_end() would otherwise leave the depth
 * permanently above zero, and every later operation would silently join a group
 * that is never closed. Called from the top-level operation wrapper.
 */
function undo_reset() {
    var wasOpen = __ehUndoDepth > 0;
    __ehUndoDepth = 0;
    if (wasOpen) {
        try { app.endUndoGroup(); } catch (e) {}
    }
}

// ==== shared/jsx/scoped/40-http.jsxinc ====
/**
 * HTTPS for ExtendScript, by shelling out.
 *
 * WHY NOT Socket
 * ExtendScript's Socket object has no TLS. It can speak plain HTTP only, so a .jsx
 * simply cannot fetch an https:// URL itself. Every real Adobe script updater works
 * around this the same way: hand the request to a tool the OS already ships.
 *   Windows 10 1803+ : curl.exe, with PowerShell's Invoke-WebRequest as fallback
 *   macOS            : /usr/bin/curl
 *
 * WINDOW FLASHING
 * system.callSystem() on Windows pops a console window for the lifetime of the child
 * process. Repeated on every update check that is unacceptable, so the command is
 * wrapped in a throwaway VBScript launched with WScript.Shell.Run(cmd, 0, True) —
 * window style 0 is hidden, and True waits for completion.
 *
 * SECURITY
 * Building a shell command from a URL is a command-injection vector, and one of these
 * URLs arrives in a *server response*. So http_isSafeUrl() enforces an allow-list:
 * the URL must sit under an expected prefix and contain only characters legal in a
 * URL. Anything else is refused before it can reach a shell. Never pass an
 * unvalidated URL to these functions.
 *
 * Responses are written to a file rather than read from stdout: callSystem's stdout
 * handling is inconsistent across platforms and mangles encodings.
 */

/** True on Windows. */
function http_isWindows() {
    return ($.os.toLowerCase().indexOf("windows") !== -1);
}

/**
 * Whether `url` is safe to interpolate into a shell command.
 *
 * Requires https, an expected prefix, and a conservative character set — no quotes,
 * spaces, backslashes, semicolons, ampersands outside query separators, backticks or
 * shell metacharacters that could break out of the quoting.
 */
function http_isSafeUrl(url, requiredPrefix) {
    if (!url) return false;
    url = String(url);
    if (url.length > 2000) return false;
    if (url.indexOf("https://") !== 0) return false;

    if (requiredPrefix) {
        // Compare against the ORIGIN, not merely the leading characters. A plain
        // prefix test accepts https://adobe.baddad.dev.evil.example.com, because the
        // expected host is literally a prefix of the attacker's. Requiring the next
        // character to begin a path or query pins it to the real host.
        var pre = String(requiredPrefix).replace(/\/+$/, "");
        if (url.indexOf(pre) !== 0) return false;
        var rest = url.substring(pre.length);
        if (rest.length > 0 && rest.charAt(0) !== "/" && rest.charAt(0) !== "?") return false;
    }

    // Allow-list of characters legal, unencoded, in a URL. '$' is deliberately absent
    // even though RFC 3986 permits it: this string is interpolated into a
    // double-quoted shell command, where $(...) and ${...} are command substitution.
    if (!/^[A-Za-z0-9:\/?#\[\]@!&'()*+,;=._~%\-]+$/.test(url)) return false;

    // Second, explicit pass over the characters that matter to a shell or to the
    // VBScript wrapper. Redundant against the pattern above by design: this is the
    // check that must not be accidentally loosened when the allow-list is edited.
    if (/["'`\\|<>^${}\s]/.test(url)) return false;
    return true;
}

/** A fresh temp file path, used for command output and downloads. */
function http_tempFile(suffix) {
    var name = "eh_" + (new Date().getTime()) + "_" + Math.floor(Math.random() * 100000) + (suffix || "");
    return new File(Folder.temp.fsName + File.separator + name);
}

/**
 * Run a shell command, hidden on Windows. Returns the process exit code, or -1.
 *
 * On Windows the command is written into a temporary .vbs and run through cscript so
 * no console window appears. `command` must already be fully quoted for cmd.exe.
 */
function http_runHidden(command) {
    if (!http_isWindows()) {
        try { return system.callSystem(command) ? 0 : 0; } catch (e) { return -1; }
    }
    var vbs = http_tempFile(".vbs");
    try {
        // Escape embedded double quotes for a VBScript string literal.
        var vbsCommand = String(command).replace(/"/g, '""');
        vbs.open("w");
        vbs.writeln('Dim sh, rc');
        vbs.writeln('Set sh = CreateObject("WScript.Shell")');
        vbs.writeln('rc = sh.Run("cmd /c " & Chr(34) & "' + vbsCommand + '" & Chr(34), 0, True)');
        vbs.writeln('WScript.Quit rc');
        vbs.close();
        system.callSystem('cscript //nologo //B "' + vbs.fsName + '"');
        return 0;
    } catch (e) {
        return -1;
    } finally {
        try { if (vbs.exists) vbs.remove(); } catch (e2) {}
    }
}

/**
 * Fetch `url` into `destFile`. Returns true only if the file exists and is non-empty.
 *
 * -f makes curl fail on an HTTP error rather than writing the error body to the file,
 * which would otherwise be "successfully downloaded" and then parsed as an update.
 */
function http_downloadTo(url, destFile, requiredPrefix) {
    if (!http_isSafeUrl(url, requiredPrefix)) return false;
    try { if (destFile.exists) destFile.remove(); } catch (e) {}

    var dest = destFile.fsName;
    var ok = false;

    if (http_isWindows()) {
        http_runHidden('curl.exe -fsSL --max-time 60 "' + url + '" -o "' + dest + '"');
        ok = http_downloadSucceeded(destFile);
        if (!ok) {
            // curl.exe is absent before Windows 10 1803.
            http_runHidden(
                'powershell -NoProfile -ExecutionPolicy Bypass -Command "' +
                "[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; " +
                "Invoke-WebRequest -UseBasicParsing -Uri '" + url + "' -OutFile '" + dest + "'" +
                '"'
            );
            ok = http_downloadSucceeded(destFile);
        }
    } else {
        try {
            system.callSystem('/usr/bin/curl -fsSL --max-time 60 "' + url + '" -o "' + dest + '"');
        } catch (e) {}
        ok = http_downloadSucceeded(destFile);
    }
    return ok;
}

/** A zero-byte file means the transfer failed even if the tool reported success. */
function http_downloadSucceeded(f) {
    try {
        f = new File(f.fsName); // refresh cached stat
        return f.exists && f.length > 0;
    } catch (e) {
        return false;
    }
}

/** Fetch `url` and return its body as text, or null. */
function http_getText(url, requiredPrefix) {
    var tmp = http_tempFile(".txt");
    try {
        if (!http_downloadTo(url, tmp, requiredPrefix)) return null;
        tmp.open("r");
        tmp.encoding = "UTF-8";
        var body = tmp.read();
        tmp.close();
        return body;
    } catch (e) {
        return null;
    } finally {
        try { if (tmp.exists) tmp.remove(); } catch (e2) {}
    }
}

/** Fetch `url` and parse it as JSON, or return null. */
function http_getJson(url, requiredPrefix) {
    var body = http_getText(url, requiredPrefix);
    if (!body) return null;
    try { return JSON.parse(body); } catch (e) { return null; }
}

// ==== shared/jsx/scoped/50-updater.jsxinc ====
/**
 * In-app updates for an ExtendScript package.
 *
 * THE INSTALL PROBLEM
 * A script cannot reliably rewrite itself where it was installed. The system-wide
 * Scripts folder lives under Program Files (or /Applications) and needs administrator
 * rights, so an in-place overwrite fails for most users — silently, since ExtendScript
 * reports little.
 *
 * So updates are never written back over the installed file. A downloaded version goes
 * into a user-writable payload directory, and the installed file *hands off* to it on
 * next launch when it is newer than itself:
 *
 *     Folder.userData/baddad/adobe/<id>/
 *         current.txt            the version to run, if any
 *         versions/<version>/<artifact>
 *
 * The shipped artifact is therefore both the v1 engine and its own loader. That keeps
 * installation to a single drag-and-drop with no administrator rights, no separate
 * loader file, and no elevation prompt — and the previous version stays on disk, so a
 * bad release is recoverable by deleting one directory.
 *
 * After Effects evaluates panel scripts only at launch, so an update always takes
 * effect on restart. That is normal for Adobe tooling and is stated in the UI rather
 * than worked around.
 */

/** Root of this package's payload directory, created on demand. */
function updater_payloadRoot() {
    // Folder.userData is null on some installs — a documented After Effects issue when
    // Dropbox's "Backup This Mac" or OneDrive relocates the user data directory. Reading
    // .fsName off it then throws "null is not an object", which is one of the most
    // common script failure reports there is. Return null and let callers degrade to
    // "no update available" rather than breaking the panel.
    var root = null;
    try { root = Folder.userData; } catch (e) { root = null; }
    if (!root) return null;

    var parts = ["baddad", "adobe", PKG_INFO.id];
    var path = root.fsName;
    for (var i = 0; i < parts.length; i++) {
        path += File.separator + parts[i];
        var f = new Folder(path);
        if (!f.exists) {
            try { f.create(); } catch (e) { return null; }
        }
    }
    return new Folder(path);
}

/** Pointer file naming the version to run. */
function updater_pointerFile() {
    var root = updater_payloadRoot();
    if (!root) return null;
    return new File(root.fsName + File.separator + "current.txt");
}

/** Directory holding one downloaded version's files. */
function updater_versionFolder(version) {
    var root = updater_payloadRoot();
    if (!root) return null;
    var versions = new Folder(root.fsName + File.separator + "versions");
    if (!versions.exists) {
        try { versions.create(); } catch (e) { return null; }
    }
    return new Folder(versions.fsName + File.separator + String(version));
}

/** The version recorded in current.txt, or null. */
function updater_installedVersion() {
    var p = updater_pointerFile();
    if (!p || !p.exists) return null;
    try {
        p.open("r");
        var v = p.read();
        p.close();
        v = String(v).replace(/^\s+|\s+$/g, "");
        return v || null;
    } catch (e) {
        return null;
    }
}

/**
 * Compare dotted versions. Returns >0 if a is newer, <0 if older, 0 if equal.
 *
 * Numeric per component, so 1.10.0 correctly beats 1.9.0 — a string compare would
 * get that backwards and strand users on the newer release.
 */
function updater_compareVersions(a, b) {
    var pa = String(a || "0").replace(/^[vV]/, "").split(/[-+]/)[0].split(".");
    var pb = String(b || "0").replace(/^[vV]/, "").split(/[-+]/)[0].split(".");
    var n = Math.max(pa.length, pb.length);
    for (var i = 0; i < n; i++) {
        var na = parseInt(pa[i], 10); if (isNaN(na)) na = 0;
        var nb = parseInt(pb[i], 10); if (isNaN(nb)) nb = 0;
        if (na > nb) return 1;
        if (na < nb) return -1;
    }
    return 0;
}

/** The payload artifact for `version`, if it is actually on disk. */
function updater_payloadFile(version) {
    var dir = updater_versionFolder(version);
    if (!dir || !dir.exists) return null;
    var f = new File(dir.fsName + File.separator + PKG_INFO.artifact);
    return f.exists ? f : null;
}

/**
 * If a newer downloaded version exists, evaluate it and return true.
 *
 * Called at the very top of the engine, before any UI is built. The host object is
 * passed through $.global because the payload's own wrapper receives whatever `this`
 * is at eval scope, which is not the Panel — without this the panel would open as a
 * floating window instead of docking.
 *
 * Any failure falls through to running this copy: a corrupt payload must not leave the
 * user with no panel at all.
 */
function updater_handoffIfNewer(hostObj) {
    try {
        var target = updater_installedVersion();
        if (!target) return false;
        if (updater_compareVersions(target, PKG_INFO.version) <= 0) return false;

        var payload = updater_payloadFile(target);
        if (!payload) return false;

        $.global.__EH_HOST_OBJ = hostObj;
        $.global.__EH_HANDOFF_FROM = PKG_INFO.version;
        try {
            $.evalFile(payload);
            return true;
        } catch (e) {
            // The payload is broken. Forget the pointer so the next launch does not
            // retry it, and let this copy run.
            try {
                var p = updater_pointerFile();
                if (p && p.exists) p.remove();
            } catch (e2) {}
            return false;
        }
    } catch (e) {
        return false;
    }
}

/**
 * Ask the hub whether a newer release exists.
 *
 * Returns the server's manifest with `available` added, or null when the check could
 * not be made. A null result is not an error worth interrupting the user for — being
 * offline is the common case.
 */
function updater_check() {
    if (!PKG_INFO.updateBaseUrl) return null;
    var base = PKG_INFO.updateBaseUrl.replace(/\/+$/, "");
    var url = base + "/api/update/check?id=" + encodeURIComponent(PKG_INFO.id) +
        "&current_version=" + encodeURIComponent(PKG_INFO.version);

    var info = http_getJson(url, base);
    if (!info) return null;
    if (info.configured === false) return null;
    if (!info.latest_version) return null;

    info.available = (updater_compareVersions(info.latest_version, PKG_INFO.version) > 0);
    return info;
}

/**
 * Download and stage the release described by `info`. Returns true on success.
 *
 * The artifact lands in a temp file first and is only moved into place once it looks
 * like a real script, and current.txt is written last. A half-written payload with the
 * pointer already flipped would break the panel on next launch.
 */
function updater_install(info) {
    if (!info || !info.latest_version || !info.download_url) return false;
    var base = PKG_INFO.updateBaseUrl.replace(/\/+$/, "");

    var tmp = http_tempFile(".jsx");
    if (!http_downloadTo(info.download_url, tmp, base)) return false;

    try {
        // A truncated or error-page download must not be staged. Every artifact this
        // hub ships is an ExtendScript file, so require it to look like one.
        tmp.open("r");
        tmp.encoding = "UTF-8";
        var head = tmp.read(4096);
        tmp.close();
        if (!head || head.length < 64) return false;
        if (head.indexOf("function") === -1 && head.indexOf("(function") === -1) return false;

        var dir = updater_versionFolder(info.latest_version);
        if (!dir) return false;
        if (!dir.exists) {
            try { dir.create(); } catch (e) { return false; }
        }

        var dest = new File(dir.fsName + File.separator + PKG_INFO.artifact);
        try { if (dest.exists) dest.remove(); } catch (e2) {}
        if (!tmp.copy(dest.fsName)) return false;
        if (!http_downloadSucceeded(dest)) return false;

        // Pointer last: until this line the update is invisible and harmless.
        var p = updater_pointerFile();
        if (!p) return false;
        p.open("w");
        p.encoding = "UTF-8";
        p.write(String(info.latest_version));
        p.close();
        return true;
    } catch (e) {
        return false;
    } finally {
        try { if (tmp.exists) tmp.remove(); } catch (e3) {}
    }
}

/**
 * Check, and on a newer release offer to install it.
 *
 * `silent` suppresses the "you are up to date" and failure notices so this can run on
 * launch without nagging; an available update is always surfaced.
 */
function updater_checkAndPrompt(silent) {
    var info = updater_check();
    if (!info) {
        if (!silent) {
            try { alert(PKG_INFO.name + "\n\nCould not reach the update service."); } catch (e) {}
        }
        return false;
    }
    if (!info.available) {
        if (!silent) {
            try {
                alert(PKG_INFO.name + " " + PKG_INFO.version + "\n\nYou are up to date.");
            } catch (e) {}
        }
        return false;
    }

    var notes = info.notes ? String(info.notes) : "";
    if (notes.length > 600) notes = notes.substring(0, 600) + "\u2026";
    var message = PKG_INFO.name + " " + info.latest_version + " is available." +
        "\n\nInstalled: " + PKG_INFO.version +
        (notes ? "\n\n" + notes : "") +
        "\n\nInstall it now? After Effects must be restarted to load it.";

    var proceed = false;
    try {
        proceed = info.mandatory ? true : confirm(message, false, PKG_INFO.name + " update");
    } catch (e) {
        proceed = false;
    }
    if (!proceed) return false;

    if (updater_install(info)) {
        try {
            alert(PKG_INFO.name + " " + info.latest_version +
                " installed.\n\nRestart After Effects to start using it.");
        } catch (e) {}
        return true;
    }
    try { alert(PKG_INFO.name + "\n\nThe update could not be installed."); } catch (e) {}
    return false;
}

// ==== packages/ae-hoverpanel/src/00-state.jsxinc ====
/**
 * Runtime state and the CONFIG defaults tree.
 *
 * CONFIG doubles as the schema for persisted settings: settings_merge() walks
 * it to fill gaps in whatever was loaded, so adding a key here is all that is
 * needed to make it configurable and forward-compatible.
 */
    // -------------------------
    // Globals & Config
    // -------------------------
    var ui = {}, myPanel = null, scriptSettings = {};
    var uiState = {
        mainCompName: "",
        cursorCompName: "",
        cursorLayerName: "",
        cursorDefaultLayerName: "",
        cursorHoverLayerName: "",
        cursorTextLayerName: "",
        cursorLoadingLayerName: "",
        controllerLayer: null,
        registeredButtons: [],
        isProcessing: false,
        batchVizCoreColor: [1.0, 0.4, 0.0],
        batchVizInflColor: [0.0, 0.27, 0.71]
    };

    var CONFIG = {
        controller: {
            name: "[EH_TIMEREMAP_CONTROLLER]",
            comment: "EH_TIMEREMAP_CONTROLLER_LAYER",
            effects: {
                cursorLayer: "Cursor Layer Reference",
                buttonPrefix: "Button_",
                buttonDataPrefix: "ButtonData_",
                buttonBoundsPrefix: "ButtonBounds_",
                buttonAnchorPrefix: "ButtonAnchor_",
                buttonOriginalNamePrefix: "ButtonOriginalName_",
                buttonAnimDuration: "ButtonAnimDuration_",
                buttonGroupIdPrefix: "ButtonGroupId_",
                cursorActiveType: "Cursor Active Type",
                // One scan publishes which button is hovered; everything derived from
                // it is an O(1) read rather than a second scan.
                cursorHoverButton: "Cursor Hover Button",
                cursorHoverAmount: "Cursor Hover Amount",
                cursorMagnetTarget: "Cursor Magnet Target",
                cursorMagnetStrength: "Cursor Magnet Strength",
                cursorHoverScale: "Cursor Hover Scale",
                forceCursorType: "Force Cursor Type",
                cursorHoverProgress: "Cursor Hover Progress",
                cursorSwapFrames: "Cursor Swap Frames"
            }
        },
        naming: {
            precomp: "[Button] ",
            animationLayer: "[ANIMATION] ",
            templateComp: "Button Template "
        },
        behavior: {
            openComp: false,
            showConfirm: true,
            collapseTransformations: true,
            hideController: true,
            autoUpdateReferences: true,
            defaultAnimDuration: 1.0,
            templateWidth: 200,
            templateHeight: 100,
            maxButtons: 200,
            guideBelowButton: false,
            dynamicTimeSampling: false // Issue #2: false=sample at t=0 (faster), true=sample at time (accurate for animated buttons)
        },
        effects: {
            layerControl: "ADBE Layer Control",
            slider: "ADBE Slider Control",
            checkbox: "ADBE Checkbox Control",
            point: "ADBE Point Control",
            color: "ADBE Color Control"
        },
        viz: {
            coreStroke: 1.0,
            inflStroke: 2.0,
            dash: 8,
            gap: 6,
            corner: 10,
            coreColor: [1.0, 0.4, 0.0],
            inflColor: [0.0, 0.27, 0.71]
        },
        detection: { cornerFudge: 1.0 },
        // Cursor response to hover. Deliberately subtle rather than off, so the
        // behaviour is discoverable without being obtrusive; both are exposed on the
        // controller so a project can dial them to taste or to zero.
        cursor: {
            magnetStrength: 15,
            hoverScale: 110
        },
        buttonComment: "EH_TIMEREMAP_BUTTON_V16",
        guidePrefix: "[EH_GUIDE] " // unused by legacy guide finder; kept to retain settings compatibility
    };

// ==== packages/ae-hoverpanel/src/05-lookup-cache.jsxinc ====
/**
 * Per-operation caches for the two lookups that dominate creation cost.
 *
 * WHY
 * Creating a single button reached comp_findByName() 110 times and ctrl_find() 70 times.
 * Each of those walks a whole collection — every item in the project, or every layer in
 * the main comp — so the cost of adding one button scaled with the size of the user's
 * project rather than with the work being done. That is the lag when creating buttons and
 * cursors.
 *
 * VALIDATE ON READ, DO NOT INVALIDATE ON WRITE
 * A cache keyed on names would go stale the moment an operation creates a comp, renames a
 * layer or deletes the controller — and tracking every such mutation is exactly the kind
 * of bookkeeping that rots. Instead a cached hit is verified before being returned:
 * reading a property of a deleted After Effects object throws, and a renamed one no longer
 * matches. Either way the entry is dropped and the real scan runs. Staleness therefore
 * self-heals and correctness never depends on remembering to invalidate.
 *
 * cache_reset() is still called at the start of each wrapped operation, so a cache can
 * never outlive the operation that built it.
 */

/** name -> CompItem. Reset per operation, validated on read. */
var __compCache = {};

/** { comp: <main comp name>, layer: <controller layer> } or null. */
var __ctrlCache = null;

/**
 * Drop every cached lookup.
 *
 * Called from util_wrapOperation before the operation body runs, and from the controller
 * create/remove paths where the identity being cached is the thing that changed.
 */
function cache_reset() {
    __compCache = {};
    __ctrlCache = null;
}

/**
 * True if `item` still exists and still carries `name`.
 *
 * Touching a property of a removed After Effects object throws, which is the only reliable
 * liveness test available — there is no isValid().
 */
/**
 * Forget one cached name.
 *
 * Absence is cached too — otherwise looking up a comp that does not exist rescans the
 * whole project on every call, which is the common case for an unset Cursor Composition.
 * That means creating a comp has to drop any cached "not found" for its name, or the rest
 * of the operation would keep believing it is absent. Called from every comp-creation site.
 */
function cache_forget(name) {
    try { if (__compCache.hasOwnProperty(name)) delete __compCache[name]; } catch (e) {}
}

function cache_stillValid(item, name) {
    try {
        return !!item && item.name === name;
    } catch (e) {
        return false;
    }
}

// ==== packages/ae-hoverpanel/src/10-name-hash.jsxinc ====
/**
 * Layer-name fingerprinting (FNV-1a 32-bit, stored as a Point [hi, lo]).
 *
 * Lets a renamed button still be matched back to its controller entry.
 */
    // Name hash helpers (FNV-1a 32-bit split into a Point [hi, lo])
    // -------------------------
    function util_hashString(str){
        var h = 0x811c9dc5;
        for (var i=0;i<str.length;i++){
            h ^= (str.charCodeAt(i) & 0xFF);
            h = (h + ((h<<1)+(h<<4)+(h<<7)+(h<<8)+(h<<24))) >>> 0;
        }
        return h >>> 0;
    }
    function btn_storeNameHash(ctrl, idNum, name){
        try{
            var h = util_hashString(String(name||""));
            var hi = (h>>>16)&0xFFFF, lo = h & 0xFFFF;
            var eff = ctrl.Effects.property('ButtonNameHash_'+idNum) || ctrl.Effects.addProperty(CONFIG.effects.point);
            eff.name = 'ButtonNameHash_'+idNum;
            eff.property('Point').setValue([hi, lo]);
        }catch(_){}}
    function btn_readNameHash(ctrl, idNum){
        try{
            var eff = ctrl.Effects.property('ButtonNameHash_'+idNum);
            if (!eff) return null;
            var pt = eff.property('Point').value;
            return (((pt[0]&0xFFFF)<<16) | (pt[1]&0xFFFF)) >>> 0;
        }catch(_){}
        return null;
    }


// Merge settings object into the runtime uiState safely.

// ==== packages/ae-hoverpanel/src/20-settings.jsxinc ====
/**
 * Settings persistence across three scopes.
 *
 * Precedence on load is file < app < project, so a project carries its own
 * look when handed to someone else while app prefs act as personal defaults.
 * Project scope is a hidden text layer on the controller, because AE has no
 * per-project script storage.
 */
function ui_mergeStateFromSettings(s){
    if (!s) return uiState;
    try {
        if (typeof s.mainCompName !== 'undefined') uiState.mainCompName = s.mainCompName;
        if (typeof s.cursorCompName !== 'undefined') uiState.cursorCompName = s.cursorCompName;
        if (typeof s.cursorLayerName !== 'undefined') uiState.cursorLayerName = s.cursorLayerName;
        if (typeof s.cursorDefaultLayerName !== 'undefined') uiState.cursorDefaultLayerName = s.cursorDefaultLayerName;
        if (typeof s.cursorHoverLayerName !== 'undefined') uiState.cursorHoverLayerName = s.cursorHoverLayerName;
        if (typeof s.cursorTextLayerName !== 'undefined') uiState.cursorTextLayerName = s.cursorTextLayerName;
        if (typeof s.cursorLoadingLayerName !== 'undefined') uiState.cursorLoadingLayerName = s.cursorLoadingLayerName;
        // add any other keys your UI reads here as needed
    } catch(e){}
    return uiState;
}
// ---- Global/App Preferences keys for AE ----
// These are storage addresses, so they must NOT contain the product version:
// keying them by version means every OTA release lands on a fresh, empty key and
// the user silently loses their preferences. The legacy key is still read once so
// settings saved by 16.x carry forward instead of being abandoned.
var PREFS_SECTION = "TimeRemapHoverPanel";
var PREFS_KEY = "settings";
var PREFS_KEY_LEGACY = "v1631";


    // -------------------------
    // Settings I/O
    // -------------------------

// ---- Project-local settings stored on controller as hidden Text layer ----
function settings_ensureProjectLayer(ctrl){
    if (!ctrl) return null;
    var comp = ctrl.containingComp;
    // Try to find existing prefs layer
    for (var i=1;i<=comp.layers.length;i++){
        var L = comp.layer(i);
        if (L && L.name === "EH_TIMEREMAP_PROJECT_PREFS" && L.matchName === "ADBE Text Layer"){
            // Sanitize its text document if possible
            try{
                var src = L.property("Source Text");
                var cur = src ? src.value : null;
                if (cur && typeof cur === 'object'){
                    try{ if (typeof cur.resetCharStyle === 'function') cur.resetCharStyle(); }catch(__){}
                    try{ if (typeof cur.resetParagraphStyle === 'function') cur.resetParagraphStyle(); }catch(__){}
                    try{ if (!isFinite(cur.fontSize) || cur.fontSize <= 0) cur.fontSize = 12; }catch(__){}
                    try{ if (!isFinite(cur.strokeWidth) || cur.strokeWidth < 0) cur.strokeWidth = 0; }catch(__){}
                    try{ if (!isFinite(cur.leading) || cur.leading < 0) cur.leading = 0; }catch(__){}
                    try{ if (!isFinite(cur.tracking)) cur.tracking = 0; }catch(__){}
                    try{ if (!isFinite(cur.baselineShift)) cur.baselineShift = 0; }catch(__){}
                    try { src.setValue(cur); }catch(__){}
                }
            }catch(__){}
            return L;
        }
    }
    // Create a new hidden text layer with safe defaults
    // undo_begin, not app.beginUndoGroup: this runs from settings_save(), which is
    // itself often called inside util_wrapOperation's group. A raw begin/end pair
    // here would close that outer group early and leave the rest of the operation
    // outside any group, giving the user a partial undo.
    undo_begin("Ensure Project Prefs Layer");
    var t = comp.layers.addText("{}");
    t.name = "EH_TIMEREMAP_PROJECT_PREFS"; t.enabled=false; t.locked=true; t.shy=true; try{ t.guideLayer=true; }catch(_){ }
    try{
        var src2 = t.property("Source Text");
        if (src2){
            var td2 = new TextDocument("{}");
            try{ if (!isFinite(td2.fontSize) || td2.fontSize <= 0) td2.fontSize = 12; }catch(__){}
            try{ if (!isFinite(td2.strokeWidth) || td2.strokeWidth < 0) td2.strokeWidth = 0; }catch(__){}
            try{ if (!isFinite(td2.leading) || td2.leading < 0) td2.leading = 0; }catch(__){}
            try{ if (!isFinite(td2.tracking)) td2.tracking = 0; }catch(__){}
            try{ if (!isFinite(td2.baselineShift)) td2.baselineShift = 0; }catch(__){}
            try{ src2.setValue(td2); }catch(__){}
        }
    }catch(__){}
    undo_end();
    return t;
}
function settings_readFromProject(ctrl){ try{ var t=settings_ensureProjectLayer(ctrl); var v=t.property("Source Text").value.text; return JSON.parse(v)||{}; }catch(e){ return {}; } }
function settings_writeToProject(ctrl, obj){
    try{
        var t = settings_ensureProjectLayer(ctrl); if (!t) return false;
        var str = JSON.stringify(obj||{});
        var src = t.property("Source Text"); if (!src) return false;
        // Build a fresh TextDocument to avoid inheriting any bad numeric styles
        var td = null;
        try { td = new TextDocument(str); }
        catch(_){
            // Fallback: clone current then sanitize
            try { td = src.value; } catch(__){ td = null; }
            if (!td || typeof td !== 'object') return false;
            try{ if (typeof td.resetCharStyle === 'function') td.resetCharStyle(); }catch(__){}
            try{ if (typeof td.resetParagraphStyle === 'function') td.resetParagraphStyle(); }catch(__){}
            td.text = str;
        }
        // Sanitize unsafe numeric fields to avoid AE "invalid numeric result"
        try{ if (!isFinite(td.fontSize) || td.fontSize <= 0) td.fontSize = 12; }catch(__){}
        try{ if (!isFinite(td.strokeWidth) || td.strokeWidth < 0) td.strokeWidth = 0; }catch(__){}
        try{ if (!isFinite(td.leading) || td.leading < 0) td.leading = 0; }catch(__){}
        try{ if (!isFinite(td.tracking)) td.tracking = 0; }catch(__){}
        try{ if (!isFinite(td.baselineShift)) td.baselineShift = 0; }catch(__){}

        // Temporarily relax layer flags while writing
        var prev = { locked:false, shy:false, enabled:true, guide:false };
        try{ prev.locked = t.locked; }catch(__){}
        try{ prev.shy = t.shy; }catch(__){}
        try{ prev.enabled = t.enabled; }catch(__){}
        try{ prev.guide = t.guideLayer; }catch(__){}
        try{ t.locked = false; }catch(__){}
        try{ t.enabled = true; }catch(__){}
        try{ t.shy = false; }catch(__){}
        try{ t.guideLayer = false; }catch(__){}
        // Apply text with fallback attempts
        try{
            src.setValue(td);
        }catch(eSet){
            try{ src.setValue(new TextDocument("{}")); }catch(__){}
            try{ src.setValue(td); }catch(__){ return false; }
        }
        // Restore flags
        try{ t.locked = prev.locked; }catch(__){}
        try{ t.enabled = prev.enabled; }catch(__){}
        try{ t.shy = prev.shy; }catch(__){}
        try{ t.guideLayer = prev.guide; }catch(__){}
        return true;
    }catch(e){ return false; }
}

    // -------------------------
    // Settings file location. Version-independent for the same reason as
    // PREFS_KEY: an updater that changes this path would strand the old file.
    function settings_getSettingsFolder() {
        // See the note in updater_payloadRoot: Folder.userData can be null when a cloud
        // backup tool has relocated the user data directory. This runs on every launch,
        // so an unguarded read here would stop the panel opening at all.
        var root = null;
        try { root = Folder.userData; } catch (e) { root = null; }
        if (!root) return null;
        var folder = new Folder(root.fsName + File.separator + "AE_Script_Settings");
        if (!folder.exists) folder.create();
        return folder;
    }
    function settings_getFilePath() {
        var dir = settings_getSettingsFolder();
        if (!dir) return null;
        return new File(dir.fsName + File.separator + "HoverPanelSettings.json");
    }
    // Written by 16.x. Read-only fallback, so upgrading keeps existing settings.
    function settings_getLegacyFilePath() {
        var dir = settings_getSettingsFolder();
        if (!dir) return null;
        return new File(dir.fsName + File.separator + "TimeRemapHoverSettings_v1631.json");
    }
    // ExtendScript: Array.isArray may be undefined. Provide a safe fallback.
    function util_isArray(a){
        try { return a && a.constructor === Array; } catch(_){ return false; }
    }
    function settings_merge(loaded, defaults, _depth) {
        _depth = _depth || 0;
        if (_depth > 20) return loaded; // Guard against circular references
        for (var k in defaults) if (defaults.hasOwnProperty(k)) {
            if (!loaded.hasOwnProperty(k)) loaded[k] = defaults[k];
            else if (typeof defaults[k] === 'object' && defaults[k] !== null && !util_isArray(defaults[k])) {
                loaded[k] = settings_merge(loaded[k], defaults[k], _depth + 1);
            }
        }
        return loaded;
    }
    function settings_load() {
		var s = {};
		try {
			var ctrl = ctrl_find();
			if (ctrl) {
				var pj = settings_readFromProject(ctrl);
				if (pj) s = pj;
			}
		} catch(e){}

    // 2) AE application-level prefs (lower priority than project).
    //    Falls back to the 16.x key so an upgrade inherits existing prefs; the
    //    next save writes them to the stable key.
    var appPrefs = {};
    try {
        var appKey = null;
        if (app.settings.haveSetting(PREFS_SECTION, PREFS_KEY)) appKey = PREFS_KEY;
        else if (app.settings.haveSetting(PREFS_SECTION, PREFS_KEY_LEGACY)) appKey = PREFS_KEY_LEGACY;
        if (appKey){
            var raw = app.settings.getSetting(PREFS_SECTION, appKey);
            var parsed = JSON.parse(raw);
            if (parsed) appPrefs = parsed;
        }
    } catch(e){}

    // 3) JSON file (lowest priority), legacy filename as fallback.
    var filePrefs = {};
    try {
        var f = settings_getFilePath();
        if (f && !f.exists) f = settings_getLegacyFilePath();
        if (f && f.exists){ f.open("r"); var content=f.read(); f.close(); var j = JSON.parse(content); if (j) filePrefs = j; }
    } catch(e){}

    // Merge in priority order: file < app < project (s already has project prefs)
    for (var kf in filePrefs) if (filePrefs.hasOwnProperty(kf) && s[kf]===undefined) s[kf]=filePrefs[kf];
    for (var ka in appPrefs) if (appPrefs.hasOwnProperty(ka) && s[ka]===undefined) s[ka]=appPrefs[ka];

    // 4) Merge with CONFIG defaults
    scriptSettings = settings_merge(s, CONFIG);
    uiState = ui_mergeStateFromSettings(scriptSettings);
    ui_updateStatus("Settings loaded.", 'info');

}

// ==== packages/ae-hoverpanel/src/30-ui-helpers.jsxinc ====
/**
 * Status line, input validation, comp lookups, dropdown population, and the
 * undo-group wrapper every mutating operation runs inside.
 */
    function ui_updateStatus(msg, type) {
        if (!ui.statusText) return;
        ui.statusText.text = msg;
        try {
            var g = ui.statusText.graphics;
            var col = [0.8, 0.8, 0.8];
            if (type === 'success') col = [0.4, 0.8, 0.4];
            else if (type === 'error') col = [0.9, 0.3, 0.3];
            else if (type === 'warning') col = [0.9, 0.7, 0.2];
            g.foregroundColor = g.newPen(g.PenType.SOLID_COLOR, col, 1);
        } catch (_) {}
    }
    
    // Input validation helpers with visual feedback
    function ui_validateNumericInput(editField, min, max, defaultValue, fieldName) {
        var val = parseFloat(editField.text);
        try {
            var g = editField.graphics;
            if (isNaN(val)) {
                g.backgroundColor = g.newBrush(g.BrushType.SOLID_COLOR, [1, 0.9, 0.9]);
                ui_updateStatus(fieldName + ": Invalid number. Enter a valid value.", 'error');
                return defaultValue;
            }
            if (val < min || val > max) {
                g.backgroundColor = g.newBrush(g.BrushType.SOLID_COLOR, [1, 0.95, 0.85]);
                ui_updateStatus(fieldName + ": Value must be " + min + "-" + max + ". Auto-corrected.", 'warning');
                editField.text = String(Math.max(min, Math.min(max, val)));
                return Math.max(min, Math.min(max, val));
            }
            g.backgroundColor = g.newBrush(g.BrushType.SOLID_COLOR, [1, 1, 1]);
            return val;
        } catch(_) { return isNaN(val) ? defaultValue : Math.max(min, Math.min(max, val)); }
    }
    
    function ui_validateIntegerInput(editField, min, max, defaultValue, fieldName) {
        var val = parseInt(editField.text, 10);
        try {
            var g = editField.graphics;
            if (isNaN(val)) {
                g.backgroundColor = g.newBrush(g.BrushType.SOLID_COLOR, [1, 0.9, 0.9]);
                ui_updateStatus(fieldName + ": Invalid integer. Enter a whole number.", 'error');
                return defaultValue;
            }
            if (val < min || val > max) {
                g.backgroundColor = g.newBrush(g.BrushType.SOLID_COLOR, [1, 0.95, 0.85]);
                ui_updateStatus(fieldName + ": Integer must be " + min + "-" + max + ". Auto-corrected.", 'warning');
                editField.text = String(Math.max(min, Math.min(max, val)));
                return Math.max(min, Math.min(max, val));
            }
            g.backgroundColor = g.newBrush(g.BrushType.SOLID_COLOR, [1, 1, 1]);
            return val;
        } catch(_) { return isNaN(val) ? defaultValue : Math.max(min, Math.min(max, val)); }
    }
    
    function ui_addHelpText(control, helpText) {
        try { control.helpTip = helpText; } catch(_) {}
    }
    // Memoised: this used to be called 110 times to create one button, each call walking
    // every item in the project. See src/05-lookup-cache.jsxinc.
    /**
     * AABB of a layer's visible content, in the space of the comp containing it.
     *
     * Recurses into precomps because sourceRectAtTime() on a precomp layer reports that
     * comp's whole rectangle rather than the artwork inside. Returns null when nothing
     * measurable was found, so callers can distinguish "empty" from "zero-sized".
     */
    function util_contentRect(L, depth) {
        depth = depth || 0;
        if (!L) return null;

        // sourcePointToComp, NOT toComp.
        //
        // toComp/toWorld/fromComp/fromWorld are EXPRESSION-only. The ExtendScript scripting
        // DOM has no such methods on AVLayer — it has sourcePointToComp, compPointToSource
        // and sourceRectAtTime. Calling toComp here threw on every invocation, so
        // util_contentRect always returned null, and btn_createAndSetupFromLayer fell back
        // to `btnComp.width/2, btnComp.height/2`: the PRECOMP CENTRE.
        //
        // That single failure produced both reported symptoms at once. The button's anchor
        // point ended up at the middle of its precomp, so the hover DETECTION region was
        // centred on the composition rather than the artwork, and the guide — correctly
        // placed at its parent's anchor — followed it there.
        function mapRect(layer, r) {
            try {
                var a = layer.sourcePointToComp([r[0], r[1]]), b = layer.sourcePointToComp([r[2], r[1]]);
                var c = layer.sourcePointToComp([r[2], r[3]]), d = layer.sourcePointToComp([r[0], r[3]]);
                return [
                    Math.min(a[0], b[0], c[0], d[0]), Math.min(a[1], b[1], c[1], d[1]),
                    Math.max(a[0], b[0], c[0], d[0]), Math.max(a[1], b[1], c[1], d[1])
                ];
            } catch (e) { return null; }
        }

        var isComp = false;
        try { isComp = !!(L.source && L.source instanceof CompItem); } catch (e0) { isComp = false; }

        if (isComp && depth < 4) {
            var c2 = L.source, acc = null;
            for (var i = 1; i <= c2.numLayers; i++) {
                var inner = null;
                try { inner = c2.layer(i); } catch (e1) { inner = null; }
                if (!inner) continue;
                var vis = false;
                try { vis = inner.hasVideo && inner.enabled; } catch (e2) { vis = false; }
                if (!vis) continue;
                var ir = util_contentRect(inner, depth + 1);
                if (!ir) continue;
                acc = acc ? [Math.min(acc[0], ir[0]), Math.min(acc[1], ir[1]),
                             Math.max(acc[2], ir[2]), Math.max(acc[3], ir[3])] : ir;
            }
            if (!acc) return null;
            return mapRect(L, acc);
        }

        var sr = null;
        try { sr = L.sourceRectAtTime(0, true); } catch (e3) { return null; }
        if (!sr) return null;
        if (sr.width <= 0 && sr.height <= 0) return null;
        return mapRect(L, [sr.left, sr.top, sr.left + sr.width, sr.top + sr.height]);
    }

    function comp_findByName(name) {
        if (!name || !app.project) return null;
        if (__compCache.hasOwnProperty(name)) {
            var hit = __compCache[name];
            // A cached null means "already looked, not there" — a real answer. Treating it
            // as a miss made every lookup of an absent comp rescan the entire project.
            if (hit === null) return null;
            if (cache_stillValid(hit, name)) return hit;
            delete __compCache[name];
        }
        var found = null;
        for (var i=1;i<=app.project.numItems;i++) {
            var it = app.project.item(i);
            if (it instanceof CompItem && it.name === name) { found = it; break; }
        }
        __compCache[name] = found;
        return found;
    }
    function comp_getAll() {
        var out = [];
        for (var i=1;i<=app.project.numItems;i++) if (app.project.item(i) instanceof CompItem) out.push(app.project.item(i).name);
        return out;
    }
    // `fallbackIndex` is which item to select when `desired` is absent or no longer
    // exists. It exists because two dropdowns that must hold DIFFERENT values both fell
    // back to index 0, which made validation fail on every action until the user
    // happened to change one by hand.
    function ui_populateDropdown(dd, items, desired, fallbackIndex) {
        if (!dd) return;
        dd.removeAll();
        for (var i=0;i<items.length;i++) dd.add("item", items[i]);
        if (dd.items.length > 0) {
            var sel = -1;
            if (desired) for (var j=0;j<dd.items.length;j++) if (dd.items[j].text === desired) { sel=j; break; }
            if (sel < 0) {
                sel = (typeof fallbackIndex === 'number') ? fallbackIndex : 0;
                if (sel < 0) sel = 0;
                if (sel > dd.items.length - 1) sel = dd.items.length - 1;
            }
            dd.selection = sel;
        }
    }
    // Dropdown with an explicit '(None)' option as the first item. Used for optional fields.
    function ui_populateDropdownWithNone(dd, items, desired){
        if (!dd) return;
        dd.removeAll();
        dd.add("item", "(None)");
        for (var i=0;i<items.length;i++) dd.add("item", items[i]);
        var sel = 0; // default to (None)
        if (desired){
            for (var j=1;j<dd.items.length;j++) if (dd.items[j].text === desired) { sel=j; break; }
        }
        dd.selection = sel;
    }
    function util_wrapOperation(fn, label) {
        if (uiState.isProcessing) { ui_updateStatus("Another operation is running...", 'warning'); return; }
        uiState.isProcessing = true;
        try {
            // A cache must never outlive the operation that built it.
            cache_reset();
            // Expression rejections are per-operation news, not cumulative history.
            expr_clearErrors();
            undo_begin(label || "EH Operation");
            fn();
            undo_end();
            // After Effects reports a rejected expression nowhere a script can see unless
            // it asks. Surfacing it here turns "the guide is in the wrong place" into the
            // actual reason, which is the difference between a five-minute fix and a
            // reinstall-and-guess cycle.
            if (expr_errorCount() > 0) {
                ui_updateStatus(
                    expr_errorCount() + " expression(s) rejected - see the dialog (Settings > " +
                    "Report Expression Errors to see it again)",
                    'warning'
                );
                // The status line truncates, and a truncated expression error is close to
                // useless — the property name and the dimension it wanted are the whole
                // diagnosis. Shown in full once per operation, and kept for re-reading.
                expr_showErrorReport();
            }
        } catch (e) {
            // undo_reset rather than undo_end: fn() may have thrown between a
            // nested undo_begin and its undo_end, leaving the depth above zero.
            // Decrementing once would strand it there and every later operation
            // would silently join a group that is never closed.
            undo_reset();
            try { alert("An error occurred:\n" + e.message + (e.line ? "\nLine: " + e.line : "")); } catch(_) {}
            ui_updateStatus("Error: " + e.message, 'error');
        }
        uiState.isProcessing = false;
    }
    function ui_validateSelections() {
        var mc = comp_findByName(uiState.mainCompName);
        if (!mc) throw new Error("Main Composition not found.");
        var cc = comp_findByName(uiState.cursorCompName);
        if (!cc) throw new Error("Cursor Composition not found.");
        
        try { if (!mc.layer(uiState.cursorLayerName)) throw new Error("Cursor Layer not found in Main Comp."); } 
        catch(e) { throw new Error("Cursor Layer '"+uiState.cursorLayerName+"' not found in Main Comp."); }
        
        try { if (!cc.layer(uiState.cursorDefaultLayerName)) throw new Error("Default Cursor Layer not found."); }
        catch(e) { throw new Error("Default Cursor Layer '"+uiState.cursorDefaultLayerName+"' not found."); }
        
        try { if (!cc.layer(uiState.cursorHoverLayerName)) throw new Error("Hover Cursor Layer not found."); }
        catch(e) { throw new Error("Hover Cursor Layer '"+uiState.cursorHoverLayerName+"' not found."); }
        
        if (uiState.cursorDefaultLayerName === uiState.cursorHoverLayerName) {
            // Two distinct layers are required: their opacities are driven inversely, so
            // one layer cannot be both states. Distinguish "impossible to satisfy" from
            // "you picked the same one twice" — the old message said neither.
            var nLayers = 0;
            try { nLayers = cc.numLayers; } catch (eN) { nLayers = 0; }
            if (nLayers < 2) {
                throw new Error(
                    "Cursor Composition \"" + uiState.cursorCompName + "\" has only " + nLayers +
                    " layer(s).\n\nIt needs at least two: one for the default cursor and one for " +
                    "the hover cursor. Add a second layer to that comp, then press Refresh."
                );
            }
            throw new Error(
                "Default and Hover cursor layers are both set to \"" +
                uiState.cursorDefaultLayerName + "\".\n\nPick a different layer for one of them " +
                "under Setup \u2192 2. Define Cursor States."
            );
        }
    }

// ==== packages/ae-hoverpanel/src/32-property-helpers.jsxinc ====
/**
 * Safe property and expression assignment.
 *
 * AE refuses expression edits on locked, shy or disabled layers, so
 * ui_setExpressionVisible() temporarily clears those flags and restores them.
 */

    // ---- shared helpers ----
    function util_getPropertyByMatchName(group, matchName){
        try{ for (var i=1;i<=group.numProperties;i++){ var p=group.property(i); if (p && p.matchName===matchName) return p; } }catch(_){}
        return null;
    }
    function util_addPropertySafe(group, matchName){
        try{ return group.addProperty(matchName); }catch(_){ return null; }
    }
    function expr_setSafe(prop, expr, fallback){
        try{
            if (!prop) return;
            if (prop.canSetExpression===false){
                if (typeof fallback!=="undefined") prop.setValue(fallback);
            }else{
                prop.expression = expr;
            }
        }catch(e){
            try{ if (typeof fallback!=="undefined") prop.setValue(fallback); }catch(_){}
        }
    }

    // -------------------------
    // Expression write batching
    //
    // ui_setExpressionVisible() has to clear locked/shy/enabled before After Effects will
    // accept an expression, then restore them. That is six layer-flag writes per property.
    // Registering one button set 64 expressions, i.e. ~384 flag writes on the same layer,
    // each one marking the project dirty.
    //
    // A batch clears the flags once around a group of writes on the same layer and
    // restores them once at the end. Writers inside the batch skip their own toggling.
    // -------------------------

    /** { comp, index, prev } while a batch is open, else null. */
    var __exprBatch = null;

    /** Cheap identity for an AE layer: comp name plus timeline index. */
    function expr_layerKey(L){
        try { return L.containingComp.name + "\u0000" + L.index; } catch (e) { return null; }
    }

    /**
     * Open a batch on `layer`. Nesting is refused rather than stacked: two overlapping
     * batches on different layers would restore the wrong flags.
     */
    function expr_batchBegin(layer){
        if (__exprBatch || !layer) return false;
        var key = expr_layerKey(layer);
        if (!key) return false;
        var prev = null;
        try { prev = { locked: layer.locked, shy: layer.shy, enabled: layer.enabled }; }
        catch (e) { return false; }
        try { layer.locked = false; } catch (e1) {}
        try { layer.shy = false; } catch (e2) {}
        try { layer.enabled = true; } catch (e3) {}
        __exprBatch = { key: key, layer: layer, prev: prev };
        return true;
    }

    /** Restore the flags a batch cleared. Safe to call when no batch is open. */
    function expr_batchEnd(){
        if (!__exprBatch) return;
        var b = __exprBatch;
        __exprBatch = null; // cleared first, so a throw below cannot strand the batch open
        try { b.layer.locked = b.prev.locked; } catch (e) {}
        try { b.layer.shy = b.prev.shy; } catch (e1) {}
        try { b.layer.enabled = b.prev.enabled; } catch (e2) {}
    }

    /** True when `L` is already unlocked by the open batch. */
    function expr_batchCovers(L){
        if (!__exprBatch || !L) return false;
        var key = expr_layerKey(L);
        return !!key && key === __exprBatch.key;
    }
    // -------------------------
    // Expression error reporting
    //
    // These are the failures that cost the most time, because After Effects reports them
    // nowhere a script can see unless it asks: the expression text is accepted, evaluation
    // then fails, the expression is silently disabled, and the property keeps its old value.
    // Collected here so an operation can tell the user what actually went wrong.
    // -------------------------

    /** [{ where, message }] since the last expr_clearErrors(). */
    var __exprErrors = [];

    function expr_clearErrors() { __exprErrors = []; }

    function expr_errorCount() { return __exprErrors.length; }

    /** A short, human-readable summary, or "" when nothing failed. */
    function expr_errorSummary(limit) {
        if (!__exprErrors.length) return "";
        limit = limit || 3;
        var parts = [];
        for (var i = 0; i < __exprErrors.length && i < limit; i++) {
            parts.push(__exprErrors[i].where + ": " + __exprErrors[i].message);
        }
        var more = __exprErrors.length - parts.length;
        return parts.join(" | ") + (more > 0 ? " (+" + more + " more)" : "");
    }

    /**
     * Show every recorded error in full.
     *
     * A rejected expression names the property and the dimension it expected, and both are
     * the entire diagnosis — so truncating it into the status line threw away the only
     * useful part. This is the difference between "the guide is in the wrong place" and
     * "Stroke Color wanted 4 components and got 3".
     */
    function expr_showErrorReport() {
        if (!__exprErrors.length) return;
        var lines = [
            __exprErrors.length + " expression(s) were rejected by After Effects.",
            "",
            "After Effects accepts the expression text and then rejects the RESULT, so these",
            "fail silently: the expression is disabled and the property keeps its old value.",
            ""
        ];
        for (var i = 0; i < __exprErrors.length && i < 12; i++) {
            lines.push((i + 1) + ". " + __exprErrors[i].where);
            lines.push("   " + __exprErrors[i].message);
        }
        if (__exprErrors.length > 12) lines.push("... and " + (__exprErrors.length - 12) + " more.");
        lines.push("");
        lines.push("Please send this to whoever maintains the script.");
        try { alert(lines.join("\n"), PKG_INFO.name); } catch (e) {}
    }

    /** Note the error After Effects reports for `prop`, if any. */
    function expr_recordError(prop, expr) {
        try {
            var err = "";
            try { err = prop.expressionError; } catch (e) { err = ""; }
            if (!err) return;
            var where = "";
            try { where = prop.name; } catch (e1) { where = "expression"; }
            try {
                var owner = prop.propertyGroup(prop.propertyDepth);
                if (owner && owner.name) where = owner.name + " > " + where;
            } catch (e2) {}
            __exprErrors.push({ where: where, message: String(err).replace(/\s+/g, " ").substring(0, 160) });
        } catch (e3) {}
    }

    function ui_setExpressionVisible(prop, expr, fallback){
        try{
            if (!prop) return;
            function _getLayer(p){
                try{
                    var g = p, guard=0;
                    while (g && guard < 32){
                        if (typeof g.locked !== 'undefined' && typeof g.enabled !== 'undefined' && typeof g.shy !== 'undefined') return g;
                        if (!g.propertyGroup) break;
                        g = g.propertyGroup(g.propertyDepth);
                        guard++;
                    }
                }catch(_){ }
                return null;
            }
            var L = _getLayer(prop);
            var prev = null;
            // Inside a batch the flags are already cleared for this layer, so skip the
            // six writes per property that dominated creation cost.
            if (L && !expr_batchCovers(L)){
                prev = { locked: L.locked, shy: L.shy, enabled: L.enabled };
                try{ L.locked = false; }catch(_){}
                try{ L.shy = false; }catch(_){}
                try{ L.enabled = true; }catch(_){}
            }
            if (prop.canSetExpression===false){
                if (typeof fallback!=="undefined") { try{ prop.setValue(fallback); }catch(_){ } }
            } else {
                try{ prop.expression = expr; }
                catch(e){ try{ if (typeof fallback!=="undefined") prop.setValue(fallback); }catch(_){} }
                // Assignment succeeding does NOT mean the expression works. After Effects
                // accepts the text, then evaluates it and may reject the RESULT — a wrong
                // dimension count, a missing layer — disabling the expression and leaving
                // the property at whatever it held. Nothing throws, so every failure of
                // this kind was invisible: the guide sitting at the comp centre instead of
                // on its button was exactly this. expressionError carries the reason.
                expr_recordError(prop, expr);
            }
            if (L && prev){
                try{ L.locked = prev.locked; }catch(_){}
                try{ L.shy = prev.shy; }catch(_){}
                try{ L.enabled = prev.enabled; }catch(_){}
            }
        }catch(__){}
    }

// ==== packages/ae-hoverpanel/src/40-corner-radius.jsxinc ====
/**
 * Corner-radius detection from shape layers.
 *
 * Feeds the rounded-rectangle term of the hover distance field so the hover
 * region follows the button's actual silhouette instead of its bounding box.
 */

    // =============================================================================
    // LEGACY VIZ CONTROLS
    // =============================================================================
    function guide_ensureControls_LEGACY(precompLayer){
        // v16.10.1: Only add corner radius controls (needed for hover detection)
        // Visualization controls are now on the guide layer, not the button!
        function util_hasProperty(n){ try{ return !!precompLayer.Effects.property(n); }catch(_){ return false; } }
        function add(n, match){ try{ var p=precompLayer.Effects.addProperty(match); p.name=n; return p; }catch(_){ return null; } }

        // Corner radius controls (used by Time Remap expression for hover detection)
        if (!util_hasProperty("Hover Corner Radius")){
            var s = add("Hover Corner Radius", "ADBE Slider Control");
            if (s){ try{ s.property("Slider").setValue(0); }catch(_){ } }
        }
        if (!util_hasProperty("Auto Corner Radius")){
            var ac = add("Auto Corner Radius", "ADBE Checkbox Control");
            if (ac){ try{ ac.property("Checkbox").setValue(0); }catch(_) { } }
        }
        if (!util_hasProperty("Detected Corner Radius")){
            var dr = add("Detected Corner Radius", "ADBE Slider Control");
            if (dr){ try{ dr.property("Slider").setValue(0); }catch(_){ } }
        }
        // Visualization controls (colors, widths, dash/gap) are now added to guide layer in guide_add()
    }

    // Detect rounded corner radius from the precomp's source content (rectangles and circles)
    function btn_detectCornerRadius(precompLayer){
        try{
            if (!precompLayer || !(precompLayer.source instanceof CompItem)) return 0;
            var c = precompLayer.source;
            // Prefer the animation layer prefix if present
            var prefer = (scriptSettings && scriptSettings.naming && scriptSettings.naming.animationLayer) ? scriptSettings.naming.animationLayer : "";
            var L = null;
            for (var i=1;i<=c.numLayers;i++){
                var t = c.layer(i);
                if (!t || !t.active || !t.enabled || !t.hasVideo) continue;
                if (!L) L = t;
                if (prefer && t.name && t.name.indexOf(prefer)===0){ L=t; break; }
            }
            if (!L) return 0;
            
            // Check if it's a shape layer
            var root = null; 
            try{ root = L.property("ADBE Root Vectors Group"); }catch(_){ root=null; }
            
            if (root) {
                // It's a shape layer - scan for shapes
                function ui_scanGroupLayers(g){
                    var best = 0;
                    try{
                        for (var j=1;j<=g.numProperties;j++){
                            var p = g.property(j); if (!p) continue;
                            var mn = p.matchName || "";
                            if (mn === "ADBE Vector Shape - Rect"){
                                // Rounded rectangle
                                try{ var r = p.property("ADBE Vector Rect Roundness").value; if (r>best) best=r; }catch(__){}
                            } else if (mn === "ADBE Vector Shape - Ellipse"){
                                // Circle/Ellipse - use radius (half of smaller dimension = end cap radius)
                                try{ 
                                    var size = p.property("ADBE Vector Ellipse Size").value; 
                                    // For pill shapes, corner radius = radius of the semicircular ends
                                    var radius = Math.min(size[0], size[1]) / 2;
                                    if (radius>best) best=radius;
                                }catch(__){}
                            } else if (mn === "ADBE Vector Filter - RC"){
                                // Rounded Corners filter on shape layer
                                try{ var rc = p.property("ADBE Vector RoundCorner Radius").value; if (rc>best) best=rc; }catch(__){}
                            } else if (mn === "ADBE Vectors Group" || mn === "ADBE Vector Group" || mn === "ADBE Root Vectors Group"){
                                var sub = ui_scanGroupLayers(p); if (sub>best) best=sub;
                            }
                        }
                    }catch(__){}
                    return best;
                }
                var rr = ui_scanGroupLayers(root);
                if (!isFinite(rr) || rr<0) rr=0;
                return rr;
            } else {
                // Not a shape layer - try to estimate from bounds for precomp/image layers
                try{
                    var sr = L.sourceRectAtTime(0, false);
                    var w = sr.width;
                    var h = sr.height;
                    // If width and height are similar, might be a circle
                    if (Math.abs(w - h) < 5) {
                        // Likely a circle
                        return Math.min(w, h) / 2;
                    }
                    // If one dimension is much smaller, might be a pill
                    var ratio = Math.max(w, h) / Math.min(w, h);
                    if (ratio > 2) {
                        // Likely a pill shape - use end cap radius
                        return Math.min(w, h) / 2;
                    }
                }catch(_){}
                return 0;
            }
        }catch(__){ return 0; }
    }

    // Apply detection: sets Detected Corner Radius, toggles Auto if manual is not set
    function btn_applyDetectedCornerRadius(preLayer){
        try{
            guide_ensureControls_LEGACY(preLayer);
            var rr = btn_detectCornerRadius(preLayer) || 0;
            try{ preLayer.effect("Detected Corner Radius")("Slider").setValue(rr); }catch(_){ }
            var man = 0; try{ man = preLayer.effect("Hover Corner Radius")("Slider").value; }catch(_){ man = 0; }
            // Only toggle Auto based on detection if manual is effectively not set
            try{ if (Math.abs(man) <= 0.001) preLayer.effect("Auto Corner Radius")("Checkbox").setValue(rr>0 ? 1 : 0); }catch(_){ }
        }catch(__){}
    }

// ==== packages/ae-hoverpanel/src/50-controller.jsxinc ====
/**
 * The controller null: create, locate, auto-setup, and reference upkeep.
 *
 * One hidden null owns every button reference, so expressions resolve through
 * a single stable layer instead of hard-coded layer names.
 */

    // =============================================================================
    // Controller helpers
    // =============================================================================
    // Memoised: 70 calls to create one button, each walking every layer in the main comp.
    // The cached entry records which comp it came from, so switching Main Composition
    // cannot return the previous comp's controller.
    function ctrl_find() {
        if (__ctrlCache && __ctrlCache.comp === uiState.mainCompName) {
            try {
                var c = __ctrlCache.layer;
                if (c && c.comment === CONFIG.controller.comment && c.name === CONFIG.controller.name) return c;
            } catch (_e) {}
            __ctrlCache = null;
        }
        var mc = comp_findByName(uiState.mainCompName); if (!mc) return null;
        for (var i=1;i<=mc.numLayers;i++) {
            try {
                var L = mc.layer(i);
                if (L && L.comment === CONFIG.controller.comment && L.name === CONFIG.controller.name) {
                    __ctrlCache = { comp: uiState.mainCompName, layer: L };
                    return L;
                }
            } catch (_) {}
        }
        return null;
    }
    function ctrl_ensureExists(){
    var c = ctrl_find();
    if (c) return c;
    try { ctrl_create(); } catch(e){}
    return ctrl_find();
}
    function ctrl_create() {
        cache_reset(); // the cached controller identity is what just changed (created)
        ui_validateSelections();
        var mc = comp_findByName(uiState.mainCompName);
        if (ctrl_find()) { ui_updateStatus("Controller already exists.", 'info'); return; }
        var ctrl = mc.layers.addNull();
        ctrl.name = CONFIG.controller.name;
        ctrl.comment = CONFIG.controller.comment;
        ctrl.label = 14;
        if (scriptSettings.behavior.hideController) { ctrl.enabled=false; ctrl.shy=true; }
        try { var lc = ctrl.Effects.addProperty(CONFIG.effects.layerControl); lc.name = CONFIG.controller.effects.cursorLayer; } catch(_) {}
        try { var bc = ctrl.Effects.addProperty(CONFIG.effects.slider); bc.name = "Max Button Id"; bc.property("Slider").setValue(0); } catch(_) {}
        ui_updateStatus("Controller created.", 'success'); ctrl_updateStatus();
    }
    
    // -------------------------
    // Auto-Create Workflow (v16.10.0)
    // -------------------------
    function ctrl_autoCreateCursorComp() {
        // Create a cursor composition with default cursor layers
        var cursorCompName = "EH_Cursor_Comp";
        var cursorWidth = 50;
        var cursorHeight = 50;
        
        // Check if it already exists
        for (var i = 1; i <= app.project.items.length; i++) {
            var item = app.project.items[i];
            if (item instanceof CompItem && item.name === cursorCompName) {
                throw new Error("Cursor comp '" + cursorCompName + "' already exists. Select it manually or rename the existing one.");
            }
        }
        
        // Create cursor comp
        var cursorComp = app.project.items.addComp(cursorCompName, cursorWidth, cursorHeight, 1.0, 1.0, 30);
        cache_forget(cursorCompName); // a cached "not found" for this name is now wrong
        
        // Create Default cursor layer (circle)
        var defaultLayer = cursorComp.layers.addShape();
        defaultLayer.name = "Cursor Default";
        var defaultRoot = defaultLayer.property("ADBE Root Vectors Group");
        var defaultGroup = defaultRoot.addProperty("ADBE Vector Group");
        defaultGroup.name = "Default Cursor";
        var defaultContents = defaultGroup.property("ADBE Vectors Group");
        var defaultEllipse = defaultContents.addProperty("ADBE Vector Shape - Ellipse");
        defaultEllipse.property("ADBE Vector Ellipse Size").setValue([30, 30]);
        var defaultFill = defaultContents.addProperty("ADBE Vector Graphic - Fill");
        defaultFill.property("ADBE Vector Fill Color").setValue([1, 1, 1]); // White
        
        // Create Link cursor layer (hand/pointer)
        var linkLayer = cursorComp.layers.addShape();
        linkLayer.name = "Cursor Link";
        var linkRoot = linkLayer.property("ADBE Root Vectors Group");
        var linkGroup = linkRoot.addProperty("ADBE Vector Group");
        linkGroup.name = "Link Cursor";
        var linkContents = linkGroup.property("ADBE Vectors Group");
        var linkEllipse = linkContents.addProperty("ADBE Vector Shape - Ellipse");
        linkEllipse.property("ADBE Vector Ellipse Size").setValue([35, 35]);
        var linkStroke = linkContents.addProperty("ADBE Vector Graphic - Stroke");
        linkStroke.property("ADBE Vector Stroke Color").setValue([0.3, 0.6, 1]); // Blue
        linkStroke.property("ADBE Vector Stroke Width").setValue(3);
        
        // Create Text cursor layer (I-beam indicator)
        var textLayer = cursorComp.layers.addShape();
        textLayer.name = "Cursor Text";
        var textRoot = textLayer.property("ADBE Root Vectors Group");
        var textGroup = textRoot.addProperty("ADBE Vector Group");
        textGroup.name = "Text Cursor";
        var textContents = textGroup.property("ADBE Vectors Group");
        var textRect = textContents.addProperty("ADBE Vector Shape - Rect");
        textRect.property("ADBE Vector Rect Size").setValue([2, 30]);
        var textFill = textContents.addProperty("ADBE Vector Graphic - Fill");
        textFill.property("ADBE Vector Fill Color").setValue([1, 0.8, 0.2]); // Yellow
        
        // Create Loading cursor layer (spinner)
        var loadingLayer = cursorComp.layers.addShape();
        loadingLayer.name = "Cursor Loading";
        var loadingRoot = loadingLayer.property("ADBE Root Vectors Group");
        var loadingGroup = loadingRoot.addProperty("ADBE Vector Group");
        loadingGroup.name = "Loading Cursor";
        var loadingContents = loadingGroup.property("ADBE Vectors Group");
        var loadingEllipse = loadingContents.addProperty("ADBE Vector Shape - Ellipse");
        loadingEllipse.property("ADBE Vector Ellipse Size").setValue([35, 35]);
        var loadingStroke = loadingContents.addProperty("ADBE Vector Graphic - Stroke");
        loadingStroke.property("ADBE Vector Stroke Color").setValue([0.9, 0.3, 0.3]); // Red
        loadingStroke.property("ADBE Vector Stroke Width").setValue(3);
        // Add rotation animation for loading spinner
        try {
            var loadingTransform = loadingLayer.property("Transform");
            var loadingRotation = loadingTransform.property("Rotation");
            // Via the helper for consistency: this layer is created unlocked here, but
            // re-running Quick Setup on a project where the user has locked it would
            // otherwise fail silently and the spinner would simply not spin.
            ui_setExpressionVisible(loadingRotation, "time * 360", 0); // Rotate continuously
        } catch(_) {}
        
        return cursorComp;
    }
    
    function ctrl_autoCreateController() {
        // Create controller without validation (for quick setup)
        var mc = comp_findByName(uiState.mainCompName);
        if (!mc) throw new Error("Please select a Main Composition first.");
        if (ctrl_find()) throw new Error("Controller already exists.");
        
        var ctrl = mc.layers.addNull(); 
        ctrl.name = CONFIG.controller.name; 
        ctrl.comment = CONFIG.controller.comment;
        ctrl.label = 5; // Cyan label
        if (scriptSettings.behavior.hideController) ctrl.enabled = false;
        try { var bc = ctrl.Effects.addProperty(CONFIG.effects.slider); bc.name = "Max Button Id"; bc.property("Slider").setValue(0); } catch(_) {}
        
        return ctrl;
    }
    
    function ctrl_quickSetup() {
        // One-click setup: create everything and configure
        var mc = comp_findByName(uiState.mainCompName);
        if (!mc) throw new Error("Please select a Main Composition from the dropdown first.");
        
        // Step 1: Create cursor comp
        var cursorComp;
        try {
            cursorComp = ctrl_autoCreateCursorComp();
        } catch(e) {
            throw new Error("Failed to create cursor comp: " + e.message);
        }
        
        // Step 2: Ensure the cursor comp is in the main comp — reusing the existing
        // instance rather than adding another.
        //
        // This used to add a layer unconditionally, so a second run left two cursor
        // layers driving the same states. The panel worked around that by disabling
        // One-Click Setup as soon as a controller existed, which made the button dead for
        // the rest of the project's life with nothing explaining why. Being idempotent is
        // the actual fix, and it makes the button usable for repair.
        var cursorLayer = null;
        try {
            for (var li = 1; li <= mc.numLayers; li++) {
                var cand = mc.layer(li);
                if (cand && cand.source === cursorComp) { cursorLayer = cand; break; }
            }
            if (!cursorLayer) {
                cursorLayer = mc.layers.add(cursorComp);
                cursorLayer.name = "EH_Cursor_Layer";
            }
        } catch(e) {
            throw new Error("Failed to add cursor to main comp: " + e.message);
        }
        
        // Step 3: Create controller
        var ctrl;
        try {
            ctrl = ctrl_autoCreateController();
        } catch(e) {
            throw new Error("Failed to create controller: " + e.message);
        }
        
        // Step 4: Auto-configure dropdowns
        uiState.cursorCompName = cursorComp.name;
        uiState.cursorLayerName = cursorLayer.name;
        uiState.cursorDefaultLayerName = "Cursor Default";
        uiState.cursorHoverLayerName = "Cursor Link";
        uiState.cursorTextLayerName = "Cursor Text";
        uiState.cursorLoadingLayerName = "Cursor Loading";
        
        // Step 5: Refresh UI (populate all dropdowns)
        try {
            ui_refreshAllLists(); // Populates main comp and cursor comp dropdowns
            ui_scanForCursorLayer(); // Populates cursor layer dropdown
            ui_scanCursorCompLayers(); // Populates cursor state dropdowns
        } catch(e) {
            // Non-critical if UI refresh fails
        }
        
        // Step 6: Update controller references
        try {
            ctrl_updateReferences();
        } catch(_) {}
        
        ui_updateStatus("✨ Quick Setup complete! Ready to create hover buttons.", 'success');
        return {cursor: cursorComp, controller: ctrl, cursorLayer: cursorLayer};
    }
    
    function ctrl_remove() {
        cache_reset(); // the cached controller identity is what just changed (removed)
        var ctrl = ctrl_find();
        if (!ctrl) { ui_updateStatus("No controller found.", 'info'); return; }
        if (confirm("Remove the controller layer? Hover functionality will break.")) {
            ctrl.remove(); ui_updateStatus("Controller removed.", 'info'); ctrl_updateStatus();
        }
    }
    function ctrl_updateReferences() {
        // Note: This function has a side effect of creating the controller if it doesn't exist
        var ctrl = ctrl_ensureExists(); if (!ctrl) { ui_updateStatus("No controller - Create first.", 'warning'); return false; }
        var mc = comp_findByName(uiState.mainCompName);
        var cursorLayer = mc.layer(uiState.cursorLayerName); if (!cursorLayer) return false;
        // Validate cursor layer source matches selected cursor comp (Issue #37)
        try {
            if (cursorLayer.source && cursorLayer.source.name !== uiState.cursorCompName) {
                ui_updateStatus("Cursor layer source mismatch: expected '"+uiState.cursorCompName+"', got '"+cursorLayer.source.name+"'", 'warning');
                return false;
            }
        } catch(_) {}
        var eff = ctrl.Effects.property(CONFIG.controller.effects.cursorLayer);
        if (!eff) { try { eff = ctrl.Effects.addProperty(CONFIG.effects.layerControl); eff.name = CONFIG.controller.effects.cursorLayer; } catch(_) { return false; } }
        try { eff.property("Layer").setValue(cursorLayer.index); } catch(_) { return false; }
        uiState.controllerLayer = ctrl; return true;
    }

// ==== packages/ae-hoverpanel/src/55-button-registry.jsxinc ====
/**
 * Button registration on the controller: id allocation, per-button effect
 * creation, bounds, and unregistration.
 */
    // Every per-button effect name is "<prefix><id>". Listed here so id
    // allocation can see *any* leftover data for an id, not just its Layer
    // control, before treating that id as free.
    function btn_idEffectPrefixes(){
        var fx = CONFIG.controller.effects;
        return [
            fx.buttonPrefix, fx.buttonDataPrefix, fx.buttonBoundsPrefix,
            fx.buttonAnchorPrefix, fx.buttonOriginalNamePrefix,
            fx.buttonAnimDuration, fx.buttonGroupIdPrefix, 'ButtonNameHash_'
        ];
    }

    // Map of ids that have any effect on the controller -> true.
    function btn_collectUsedIds(ctrl){
        var used = {}, prefixes = btn_idEffectPrefixes();
        for (var i=1;i<=ctrl.Effects.numProperties;i++){
            var e = ctrl.Effects.property(i);
            if (!e || !e.name) continue;
            for (var p=0;p<prefixes.length;p++){
                var pre = prefixes[p];
                if (e.name.indexOf(pre) !== 0) continue;
                var rest = e.name.substring(pre.length);
                // Guard against "Button_" also matching "ButtonData_12": the
                // remainder must be digits only.
                if (!/^[0-9]+$/.test(rest)) continue;
                var id = parseInt(rest, 10);
                if (!isNaN(id)) used[id] = true;
                break;
            }
        }
        return used;
    }

    // Lowest free id, NOT highest+1.
    //
    // Monotonic allocation meant ids drifted upward forever across add/remove
    // cycles while the live button count stayed low. The cursor "Active Type"
    // expression only scans ids 1..behavior.maxButtons, so once an id passed that
    // bound the button kept animating via its own time remap but became invisible
    // to cursor detection — a failure that looks like "the cursor state randomly
    // stopped working" and is essentially undiagnosable from the panel.
    //
    // An id counts as free only when no per-button effect of any kind remains for
    // it, so a partially cleaned entry is never reused underneath leftover data.
    function btn_getNextId(ctrl) {
        var used = btn_collectUsedIds(ctrl);
        var limit = (scriptSettings && scriptSettings.behavior && scriptSettings.behavior.maxButtons) || CONFIG.behavior.maxButtons;
        for (var id=1; id<=limit; id++) if (!used[id]) return id;
        throw new Error("No free button id below the maximum of " + limit + ". Remove unused buttons, or raise Max Buttons in Settings.");
    }
    // Recalculate and persist the highest Button id on the controller (aka "Max Button Id")
    function btn_recalcMaxId(ctrl){
        try{
            if (!ctrl) ctrl = ctrl_find();
            if (!ctrl) return 0;
            var maxId = 0;
            for (var i=1;i<=ctrl.Effects.numProperties;i++){
                var e = ctrl.Effects.property(i);
                if (e && e.name && e.name.indexOf(CONFIG.controller.effects.buttonPrefix)===0){
                    var id = parseInt(String(e.name).replace(CONFIG.controller.effects.buttonPrefix, ""),10);
                    if (!isNaN(id) && id>maxId) maxId = id;
                }
            }
            var newEff = ctrl.Effects.property("Max Button Id");
            var oldEff = ctrl.Effects.property("Button Count");
            if (newEff) { try { newEff.property("Slider").setValue(maxId); } catch(_){} }
            if (oldEff) { try { oldEff.property("Slider").setValue(maxId); } catch(_){} }
            return maxId;
        }catch(__){ return 0; }
    }

    // One-time migration: rename legacy "Button Count" effect to "Max Button Id" and refresh expressions
    function ctrl_migrateCounterName(ctrl){
        try{
            if (!ctrl) ctrl = ctrl_find();
            if (!ctrl) return false;
            var hasNew = !!ctrl.Effects.property("Max Button Id");
            var oldEff = ctrl.Effects.property("Button Count");
            if (!hasNew){
                try {
                    var val = 0; try{ if (oldEff) val = oldEff.property("Slider").value||0; }catch(__){}
                    var bc = ctrl.Effects.addProperty(CONFIG.effects.slider); bc.name = "Max Button Id"; bc.property("Slider").setValue(val);
                    return true;
                } catch(_){ return false; }
            }
        }catch(_){ }
        return false;
    }
    function btn_register(precompLayer) {
        var ctrl = ctrl_ensureExists(); if (!ctrl) throw new Error("No controller layer found. Use Controller → Create first.");
        var count = 0; for (var i=1;i<=ctrl.Effects.numProperties;i++) { var e=ctrl.Effects.property(i); if (e && e.name.indexOf(CONFIG.controller.effects.buttonPrefix)===0) count++; }
        if (count >= scriptSettings.behavior.maxButtons) throw new Error("Max buttons reached.");
        var id = btn_getNextId(ctrl), buttonId = CONFIG.controller.effects.buttonPrefix + id;

        // One unlock/relock around every write below, instead of one per property.
        expr_batchBegin(ctrl);
        try {
        var lc = ctrl.Effects.addProperty(CONFIG.effects.layerControl); lc.name = buttonId; lc.property("Layer").setValue(precompLayer.index);
        var data = ctrl.Effects.addProperty(CONFIG.effects.point); data.name = CONFIG.controller.effects.buttonDataPrefix + id; data.property("Point").setValue([id, 0]);
        var bnd = ctrl.Effects.addProperty(CONFIG.effects.point); bnd.name = CONFIG.controller.effects.buttonBoundsPrefix + id; bnd.property("Point").setValue([precompLayer.width||200, precompLayer.height||100]);
        // Live bounds, so the hover area follows the button's real content.
        //
        // MUST go through ui_setExpressionVisible. ctrl_create() sets the controller to
        // enabled=false and shy=true, and After Effects refuses expression edits on a
        // layer in that state. A raw assignment here therefore threw, was swallowed by
        // the empty catch, and left ButtonBounds_ holding the static value set just
        // above — the precomp's comp size. The visible symptom was a hover area the size
        // of the whole comp instead of the artwork inside it, with nothing reported.
        ui_setExpressionVisible(
            bnd.property("Point"),
            expr_generateBoundsPoint(buttonId),
            [precompLayer.width || 200, precompLayer.height || 100]
        );
        var anc = ctrl.Effects.addProperty(CONFIG.effects.point); anc.name = CONFIG.controller.effects.buttonAnchorPrefix + id;
        try { var ap = precompLayer.property("Transform").property("Anchor Point").value; anc.property("Point").setValue([ap[0], ap[1]]); } catch(_) { anc.property("Point").setValue([0,0]); }
        var dur = ctrl.Effects.addProperty(CONFIG.effects.slider); dur.name = CONFIG.controller.effects.buttonAnimDuration + id; dur.property("Slider").setValue(scriptSettings.behavior.defaultAnimDuration);
        var nm = ctrl.Effects.addProperty(CONFIG.effects.point); nm.name = CONFIG.controller.effects.buttonOriginalNamePrefix + id;
        var n = precompLayer.name || "", c1 = n.length? n.charCodeAt(0):0, c2 = n.length>1? n.charCodeAt(1):0; nm.property("Point").setValue([c1,c2]);
                // also store robust name hash
        btn_storeNameHash(ctrl, id, precompLayer.name||"");
var gid = ctrl.Effects.addProperty(CONFIG.effects.slider); gid.name = CONFIG.controller.effects.buttonGroupIdPrefix + id; gid.property("Slider").setValue(0);

        var newEff = ctrl.Effects.property("Max Button Id");
        var oldEff = ctrl.Effects.property("Button Count");
        var newVal = id;
        try{ if (oldEff) { var ov = Math.round(oldEff.property("Slider").value||0); if (ov>newVal) newVal = ov; } }catch(__){}
        try{ if (newEff) { var nv = Math.round(newEff.property("Slider").value||0); if (nv>newVal) newVal = nv; } }catch(__){}
        if (newEff) { try{ newEff.property("Slider").setValue(newVal); }catch(__){} }
        if (oldEff) { try{ oldEff.property("Slider").setValue(newVal); }catch(__){} }
        } finally { expr_batchEnd(); }
        return buttonId;
    }
    
    function btn_recalculateBounds(layer, buttonId){
        var ctrl = ctrl_ensureExists(); if (!ctrl) return;
        var bn = String(buttonId).replace(CONFIG.controller.effects.buttonPrefix, '');
        var bnd = ctrl.Effects.property(CONFIG.controller.effects.buttonBoundsPrefix + bn);
        if (!bnd) { try{ bnd = ctrl.Effects.addProperty(CONFIG.effects.point); bnd.name = CONFIG.controller.effects.buttonBoundsPrefix + bn; }catch(_){ return; } }
        var w = 0, h = 0;
        try { w = Math.max(1, layer.width||0); h = Math.max(1, layer.height||0); } catch(_) {}
        try { if (layer.source && layer.source.width) { w = Math.max(w, layer.source.width); h = Math.max(h, layer.source.height); } } catch(_){ }
        bnd.property('Point').setValue([w, h]);
    }
function btn_unregister(buttonId) {
        var ctrl = ctrl_ensureExists(); if (!ctrl) return;
        var idNum = buttonId.replace(CONFIG.controller.effects.buttonPrefix,"");
        var names = [
            buttonId,
            CONFIG.controller.effects.buttonDataPrefix + idNum,
            CONFIG.controller.effects.buttonBoundsPrefix + idNum,
            CONFIG.controller.effects.buttonAnchorPrefix + idNum,
            CONFIG.controller.effects.buttonOriginalNamePrefix + idNum,
            CONFIG.controller.effects.buttonAnimDuration + idNum,
            CONFIG.controller.effects.buttonGroupIdPrefix + idNum,
            'ButtonNameHash_' + idNum
        ];
        for (var i=0;i<names.length;i++) {
            try { var e = ctrl.Effects.property(names[i]); if (e) e.remove(); } catch(_) {}
        }
        var maxId = 0;
        for (var j=1;j<=ctrl.Effects.numProperties;j++) {
            var eff = ctrl.Effects.property(j);
            if (eff && eff.name.indexOf(CONFIG.controller.effects.buttonPrefix)===0) {
                var id = parseInt(eff.name.replace(CONFIG.controller.effects.buttonPrefix,""),10);
                if (!isNaN(id) && id > maxId) maxId = id;
            }
        }
        var newEff2 = ctrl.Effects.property("Max Button Id");
        var oldEff2 = ctrl.Effects.property("Button Count");
        if (newEff2) try { newEff2.property("Slider").setValue(maxId); } catch(_) {}
        if (oldEff2) try { oldEff2.property("Slider").setValue(maxId); } catch(_) {}
        // Ensure we persist the recomputed value and UI reflects it
        try { btn_recalcMaxId(ctrl); } catch(_) {}
    }
    function ctrl_getRegisteredButtons() {
        var ctrl = ctrl_find(); if (!ctrl) return [];
        var mc = comp_findByName(uiState.mainCompName); if (!mc) return [];
        var out = [];
        for (var i=1;i<=ctrl.Effects.numProperties;i++) {
            var e = ctrl.Effects.property(i);
            if (e && e.name.indexOf(CONFIG.controller.effects.buttonPrefix)===0) {
                try {
                    var idx = e.property("Layer").value;
                    if (idx>0 && idx<=mc.numLayers) {
                        var L = mc.layer(idx);
                        if (L) out.push({buttonId:e.name, layer:L, idx:idx});
                    }
                } catch(_) {}
            }
        }
        return out;
    }

// ==== packages/ae-hoverpanel/src/60-expr-hover.jsxinc ====
/**
 * Hover expression generators: time remap, hover progress, and live bounds.
 *
 * NOTE ON THE "v16.3.1" MARKERS IN THE EMITTED EXPRESSIONS
 * These are format markers, not product versions, and are intentionally left
 * frozen while PKG_INFO.version moves to 1.0.0. They are deliberately NOT
 * stamped with the live version: expr_ensureLiveBounds() rewrites a property
 * whenever the generated text differs from what is already there, so embedding
 * the release version would rewrite expressions across every existing project on
 * every update and mark saved work as modified for a comment change.
 *
 * If the expression format ever changes incompatibly, bump these markers
 * deliberately and treat it as a migration.
 */

    // =====================================================================
    // Shared hover geometry
    //
    // 16.10.6 carried this maths verbatim in THREE places: the time remap
    // generator, the hover progress generator, and the per-button loop inside
    // the cursor Active Type expression. Three copies of one distance field are
    // three chances to drift, and drift here is not cosmetic — it means the
    // cursor reacts to a different region than the animation does, so the two
    // disagree about where the button actually is.
    //
    // Emitted as line arrays rather than one string so each generator can splice
    // its own terms in between (time remap needs animation duration before the
    // geometry; hover progress does not).
    // =====================================================================

    // Time at which to sample the button's transform.
    //   "0"    - cheap, and correct while the button itself is not animated
    //   "time" - follows animated buttons, at the cost of per-frame evaluation
    function expr_sampleTime() {
        var dynamic = (scriptSettings && scriptSettings.behavior && scriptSettings.behavior.dynamicTimeSampling);
        return dynamic ? "time" : "0";
    }

    // Default per-button Corner Fudge, in the button's local pixels. Widens the
    // hover threshold slightly so a cursor grazing a rounded corner is not counted
    // as a miss. Read from settings with a hard fallback, because a NaN here would
    // poison the whole distance comparison.
    function expr_defaultCornerFudge() {
        var v = 1.0;
        try {
            v = (scriptSettings && scriptSettings.detection && isFinite(scriptSettings.detection.cornerFudge))
                ? scriptSettings.detection.cornerFudge
                : (CONFIG.detection && CONFIG.detection.cornerFudge);
            if (!isFinite(v)) v = 1.0;
        } catch (e) { v = 1.0; }
        return v;
    }

    // Comp-name prefix marking a button whose *source comp* size is authoritative
    // rather than its measured content bounds.
    function expr_templatePrefix() {
        return (scriptSettings && scriptSettings.naming && scriptSettings.naming.templateComp)
            ? scriptSettings.naming.templateComp
            : "Button Template ";
    }

    // Resolve controller, cursor layer, this button, its hover inputs and the
    // content size to treat as the button's extent.
    function expr_hoverLookupLines(buttonId) {
        var bn = buttonId.replace(CONFIG.controller.effects.buttonPrefix, "");
        var s = [];
        s.push("  var controller = thisComp.layer('" + util_escapeNameForExpression(CONFIG.controller.name) + "');");
        s.push("  var cursorLayer = controller.effect('" + util_escapeNameForExpression(CONFIG.controller.effects.cursorLayer) + "')('Layer');");
        s.push("  var thisButtonLayer = controller.effect('" + util_escapeNameForExpression(buttonId) + "')('Layer');");
        s.push("  var pad = thisButtonLayer.effect('Hover Padding')('Point');");
        s.push("  var infl = thisButtonLayer.effect('Hover Influence Radius')('Slider');\n  infl = Math.max(infl, 0.001);");
        s.push("  var rev = 0; try { rev = thisButtonLayer.effect('Hover Reverse')('Checkbox'); } catch(e) { rev = 0; }");
        s.push("  var pillMode = 0; try { pillMode = thisButtonLayer.effect('Pill Mode')('Checkbox'); } catch(e) { pillMode = 0; }");
        s.push("  var bounds = controller.effect('" + CONFIG.controller.effects.buttonBoundsPrefix + bn + "')('Point');");
        s.push("  var src = thisButtonLayer.source; var preferComp=false; try{ preferComp = src && (src.name.indexOf('" + util_escapeNameForExpression(expr_templatePrefix()) + "')===0); }catch(e){ preferComp=false; }");
        s.push("  var useW = preferComp ? src.width : bounds[0];");
        s.push("  var useH = preferComp ? src.height : bounds[1];");
        return s;
    }

    // Reduce the cursor position to `dist`: signed distance from the button's
    // padded edge in the button's own pixels, negative inside.
    //
    // Scale is measured from the layer's own world transform rather than read off
    // transform.scale, so parent chains and nested precomp scaling are included —
    // padding and influence stay the size the user typed regardless of how the
    // button is nested. `cr` is clamped to half the box so a corner radius larger
    // than the button cannot invert the field.
    function expr_hoverDistanceLines() {
        var sampleTime = expr_sampleTime();
        var dynamic = (sampleTime === "time");
        var s = [];
        s.push("  // Sampling at " + sampleTime + " - " + (dynamic ? "accurate for animated buttons" : "faster, static"));
        s.push("  var w0 = thisButtonLayer.toWorld([0,0], " + sampleTime + ");");
        s.push("  var wx = thisButtonLayer.toWorld([100,0], " + sampleTime + ");");
        s.push("  var wy = thisButtonLayer.toWorld([0,100], " + sampleTime + ");");
        s.push("  var sx = length(wx - w0)/100; if (sx<=0) sx=1;");
        s.push("  var sy = length(wy - w0)/100; if (sy<=0) sy=1;");
        s.push("  var padLx = pad[0]/sx; var padLy = pad[1]/sy;");
        s.push("  var halfW = (useW/(sx*2)) + padLx;");
        s.push("  var halfH = (useH/(sy*2)) + padLy;");
        s.push("  var auto=0; try{ auto = thisButtonLayer.effect('Auto Corner Radius')(\"Checkbox\"); }catch(e){ auto=0; }");
        s.push("  var crDet=0; try{ crDet = thisButtonLayer.effect('Detected Corner Radius')(\"Slider\"); }catch(e){ crDet=0; }");
        s.push("  var crMan=0; try{ crMan = thisButtonLayer.effect('Hover Corner Radius')(\"Slider\"); }catch(e){ crMan=0; }");
        s.push("  var cr = (auto > 0) ? crDet : crMan;");
        s.push("  cr = Math.max(0, Math.min(cr, halfW, halfH));");
        s.push("  var cW = cursorLayer.toWorld(cursorLayer.anchorPoint);");
        s.push("  var cL = thisButtonLayer.fromWorld(cW, " + sampleTime + ");");
        s.push("  var ap = thisButtonLayer.transform.anchorPoint.valueAtTime(" + sampleTime + ");");
        s.push("  var d = cL - ap;");
        s.push("  var ax = Math.max(0, halfW - cr); var ay = Math.max(0, halfH - cr);");
        s.push("  var qx = Math.abs(d[0]) - ax; var qy = Math.abs(d[1]) - ay;");
        s.push("  var ox = Math.max(qx, 0); var oy = Math.max(qy, 0);");
        s.push("  var dist = 0;");
        s.push("  if (pillMode > 0) {");
        s.push("    // Pill/Ellipse mode: proper elliptical distance");
        s.push("    var dx = d[0]; var dy = d[1];");
        s.push("    var normX = (halfW > 0) ? (dx / halfW) : 0;");
        s.push("    var normY = (halfH > 0) ? (dy / halfH) : 0;");
        s.push("    var ellipseDist = Math.sqrt(normX * normX + normY * normY);");
        s.push("    var avgRadius = (halfW + halfH) / 2;");
        s.push("    dist = (ellipseDist - 1) * avgRadius;");
        s.push("  } else {");
        s.push("    // Rounded rectangle mode");
        s.push("    dist = length([ox,oy]) - cr;");
        s.push("  }");
        // Influence radius, converted into the button's local pixels.
        //
        // `dist` above is in local pixels, but `infl` comes straight off a slider in
        // comp pixels. Comparing them directly — as 16.10.6 did here — meant a
        // scaled button's falloff covered the wrong distance, and disagreed with the
        // cursor expression, which had always corrected it. Padding was corrected
        // (padLx/padLy) while the radius was not, so the two inputs to the same
        // region were in different units.
        var defCF = expr_defaultCornerFudge();
        s.push("  var cf = " + defCF + "; try{ cf = thisButtonLayer.effect('Corner Fudge')(\"Slider\"); }catch(e){ cf = " + defCF + "; } if (!isFinite(cf)) cf = " + defCF + ";");
        s.push("  var inflLocal = Math.max(infl / Math.min(sx, sy) + cf, 0.001); // scale-correct radius + configurable per-button fudge");
        s.push("  var clamped = Math.min(Math.max(dist, 0), inflLocal);");
        return s;
    }

    // -------------------------
    // Time Remap: maps hover distance onto the button precomp's timeline.
    // -------------------------
    function expr_generateTimeRemap(buttonId) {
        var bn = buttonId.replace(CONFIG.controller.effects.buttonPrefix,"");
        var s = [];
        s.push("// v16.3.1 - Time Remap hover mapping");
        s.push("try {");
        s = s.concat(expr_hoverLookupLines(buttonId));
        s.push("  var animDur = controller.effect('" + CONFIG.controller.effects.buttonAnimDuration + bn + "')('Slider');");
        s.push("  if (isNaN(animDur) || animDur <= 0) animDur = 1.0;"); // Guard against NaN/negative (Issue #6)
        s.push("  var fd = thisLayer.source.frameDuration;");
        // Stop one frame short: remapping to exactly animDur lands past the last
        // frame of the source comp.
        s.push("  var tEnd = Math.max(animDur - fd, 0);");
        s = s.concat(expr_hoverDistanceLines());
        s.push("  var t = ease(clamped, inflLocal, 0, 0, tEnd);");
        s.push("  (rev > 0) ? t : (tEnd - t);");
        s.push("} catch(e) { 0; }");
        return s.join("\n");
    }

    // -------------------------
    // Hover Progress (v16.9.0): the same field expressed as 0-100 so any
    // property can be driven from hover via a pick-whip.
    // -------------------------
    function expr_generateHoverProgress(buttonId) {
        var s = [];
        s.push("// v16.9.0 - Hover Progress (0-100 universal slider)");
        s.push("// Link any property to this slider using pick-whip or expressions");
        s.push("try {");
        s = s.concat(expr_hoverLookupLines(buttonId));
        s = s.concat(expr_hoverDistanceLines());
        s.push("  var progress = ease(clamped, inflLocal, 0, 0, 100);"); // 0 to 100 instead of 0 to tEnd
        s.push("  (rev > 0) ? progress : (100 - progress);"); // Reverse if needed
        s.push("} catch(e) { 0; }");
        return s.join("\n");
    }
    
    function expr_generateBoundsPoint(buttonId) {
        var templatePrefix = scriptSettings && scriptSettings.naming && scriptSettings.naming.templateComp ? scriptSettings.naming.templateComp : "Template ";
        var s = [];
        s.push("// v16.5.0 - Live Bounds (recursive union of visible content)\ntry {");
        s.push("  var ctrl = thisLayer;");
        s.push("  var btn = ctrl.effect('" + util_escapeNameForExpression(buttonId) + "')('Layer');");
        s.push("  var preferComp = false;");
        s.push("  try { preferComp = btn && btn.source && (btn.source.name.indexOf('" + util_escapeNameForExpression(templatePrefix) + "') === 0); } catch(e) { preferComp = false; }");
        s.push("  if (preferComp) { try { [btn.source.width, btn.source.height]; } catch(e0){ [100,50]; } } else {");
        s.push("    try {");
        // Map a rect from a layer's own space into the space of the comp containing it.
        s.push("      function mapRect(L, r) {");
        s.push("        var a = L.toComp([r[0], r[1]], 0), b = L.toComp([r[2], r[1]], 0);");
        s.push("        var c2 = L.toComp([r[2], r[3]], 0), d = L.toComp([r[0], r[3]], 0);");
        s.push("        return [Math.min(a[0],b[0],c2[0],d[0]), Math.min(a[1],b[1],c2[1],d[1]),");
        s.push("                Math.max(a[0],b[0],c2[0],d[0]), Math.max(a[1],b[1],c2[1],d[1])];");
        s.push("      }");
        // The recursion. A precomp layer is measured by measuring its contents, so
        // nesting no longer collapses to the comp rectangle.
        s.push("      function rectIn(L, depth) {");
        s.push("        var isComp = false;");
        s.push("        try { isComp = !!(L.source && L.source.numLayers); } catch (e1) { isComp = false; }");
        s.push("        if (isComp && depth < 4) {");
        s.push("          var c = L.source, acc = null;");
        s.push("          for (var i = 1; i <= c.numLayers; i++) {");
        s.push("            var inner = null;");
        s.push("            try { inner = c.layer(i); } catch (e2) { inner = null; }");
        s.push("            if (!inner) continue;");
        s.push("            var vis = false;");
        s.push("            try { vis = inner.hasVideo && inner.enabled; } catch (e3) { vis = false; }");
        s.push("            if (!vis) continue;");
        s.push("            var ir = rectIn(inner, depth + 1);");
        s.push("            if (!ir) continue;");
        s.push("            if (!acc) acc = ir;");
        s.push("            else acc = [Math.min(acc[0],ir[0]), Math.min(acc[1],ir[1]), Math.max(acc[2],ir[2]), Math.max(acc[3],ir[3])];");
        s.push("          }");
        s.push("          if (!acc) return null;");
        s.push("          return mapRect(L, acc);");
        s.push("        }");
        s.push("        var sr = null;");
        s.push("        try { sr = L.sourceRectAtTime(0, true); } catch (e4) { return null; }");
        s.push("        if (!sr) return null;");
        s.push("        if (sr.width <= 0 && sr.height <= 0) return null;");
        s.push("        return mapRect(L, [sr.left, sr.top, sr.left + sr.width, sr.top + sr.height]);");
        s.push("      }");
        s.push("      var bc = btn.source, total = null;");
        // Two passes: prefer what is visible at t=0, but measure regardless rather than
        // falling back to the comp size, which is the worst possible answer.
        s.push("      for (var pass = 0; pass < 2 && total === null; pass++) {");
        s.push("        for (var k = 1; k <= bc.numLayers; k++) {");
        s.push("          var top = null;");
        s.push("          try { top = bc.layer(k); } catch (e5) { top = null; }");
        s.push("          if (!top) continue;");
        s.push("          var use = false;");
        s.push("          try { use = top.hasVideo && top.enabled && (pass === 1 || top.active); } catch (e6) { use = false; }");
        s.push("          if (!use) continue;");
        s.push("          var tr = null;");
        s.push("          try { tr = rectIn(top, 0); } catch (e7) { tr = null; }");
        s.push("          if (!tr) continue;");
        s.push("          if (!total) total = tr;");
        s.push("          else total = [Math.min(total[0],tr[0]), Math.min(total[1],tr[1]), Math.max(total[2],tr[2]), Math.max(total[3],tr[3])];");
        s.push("        }");
        s.push("      }");
        s.push("      if (total === null) { [bc.width, bc.height]; }");
        s.push("      else { [Math.max(1, total[2] - total[0]), Math.max(1, total[3] - total[1])]; }");
        s.push("    } catch(e8) { try { [btn.source.width, btn.source.height]; } catch(e9) { [100,50]; } }");
        s.push("  }");
        s.push("} catch(__) { [100,50]; }");
        return s.join("\n");
    }
    // Ensure all ButtonBounds_* have live expressions
    function expr_ensureLiveBounds(){
        try{
            var ctrl = ctrl_find();
            if (!ctrl) return;
            // One unlock/relock around every repair, not one per button.
            expr_batchBegin(ctrl);
            try {
            for (var i=1;i<=ctrl.Effects.numProperties;i++){
                var e = ctrl.Effects.property(i);
                if (!e || !e.name) continue;
                if (e.name.indexOf(CONFIG.controller.effects.buttonBoundsPrefix)!==0) continue;
                var idNum = String(e.name).replace(CONFIG.controller.effects.buttonBoundsPrefix, '');
                var btnId = CONFIG.controller.effects.buttonPrefix + idNum;
                try {
                    var p = e.property('Point');
                    var want = expr_generateBoundsPoint(btnId);
                    var cur = '';
                    try{ cur = p.expression || ''; }catch(_e){ cur=''; }
                    // Same reason as btn_register: the controller is disabled and shy,
                    // so a raw assignment fails silently and the repair never happens.
                    if (cur !== want) { ui_setExpressionVisible(p, want, p.value); }
                } catch(__){}
            }
            } finally { expr_batchEnd(); }
        }catch(_){ }
    }

// ==== packages/ae-hoverpanel/src/62-expr-guide.jsxinc ====
/**
 * Guide-layer expression generators (size, roundness, pill-mode visibility).
 */
    // Legacy hover guide expression builders (global)
    function guide_generateSizeExpr_LEGACY(buttonId, includeInfluence){
        var num = buttonId.replace(CONFIG.controller.effects.buttonPrefix, "");
        var add = includeInfluence ? " + (infl*2)" : "";
        return [
            "// EH Hover Viz size",
            "try{",
            "  var ctrl = thisComp.layer('"+util_escapeNameForExpression(CONFIG.controller.name)+"');",
            "  var bounds = ctrl.effect('"+CONFIG.controller.effects.buttonBoundsPrefix+num+"')('Point');",
            "  var btn    = ctrl.effect('"+CONFIG.controller.effects.buttonPrefix+num+"')('Layer');",
            "  var pad    = btn.effect('Hover Padding')('Point');",
            "  var infl   = btn.effect('Hover Influence Radius')('Slider');",
            "  var coreW = 0; try{ coreW = thisLayer.effect('EH Viz – Core Width')('Slider'); }catch(e){ coreW = 0; }",
            "  var inflW = 0; try{ inflW = thisLayer.effect('EH Viz – Infl Width')('Slider'); }catch(e){ inflW = 0; }",
            "  var baseW = bounds[0] + (pad[0]*2)"+add+";",
            "  var baseH = bounds[1] + (pad[1]*2)"+add+";",
            "  var stroke = "+(includeInfluence?"inflW":"coreW")+";",
            "  var w = Math.max(1, baseW - stroke);",
            "  var h = Math.max(1, baseH - stroke);",
            "  [w,h];",
            "}catch(e){[100,50];}"
        ].join("\n");
    }
    function guide_generateRoundnessExpr_LEGACY(buttonId, includeInfluence){
        var num = buttonId.replace(CONFIG.controller.effects.buttonPrefix, "");
        var add = includeInfluence ? " + (infl*2)" : "";
        return [
            "// EH Hover Viz roundness (manual overrides detected)",
            "try{",
            "  var ctrl = thisComp.layer('"+util_escapeNameForExpression(CONFIG.controller.name)+"');",
            "  var bounds = ctrl.effect('"+CONFIG.controller.effects.buttonBoundsPrefix+num+"')('Point');",
            "  var btn    = ctrl.effect('"+CONFIG.controller.effects.buttonPrefix+num+"')('Layer');",
            "  var pad    = btn.effect('Hover Padding')('Point');",
            "  var infl   = btn.effect('Hover Influence Radius')('Slider');",
            "  var coreW = 0; try{ coreW = thisLayer.effect('EH Viz – Core Width')(\"Slider\"); }catch(e){ coreW = 0; }",
            "  var inflW = 0; try{ inflW = thisLayer.effect('EH Viz – Infl Width')(\"Slider\"); }catch(e){ inflW = 0; }",
            "  var stroke = "+(includeInfluence?"inflW":"coreW")+";",
            "  var baseW = bounds[0] + (pad[0]*2)"+add+";",
            "  var baseH = bounds[1] + (pad[1]*2)"+add+";",
            "  var w = Math.max(1, baseW - stroke);",
            "  var h = Math.max(1, baseH - stroke);",
            "  var pillMode = 0; try{ pillMode = btn.effect('Pill Mode')(\"Checkbox\"); }catch(e){ pillMode=0; }",
            "  var auto = 0; try{ auto = btn.effect('Auto Corner Radius')(\"Checkbox\"); }catch(e){ auto=0; }",
            "  var radDet = 0; try{ radDet = btn.effect('Detected Corner Radius')(\"Slider\"); }catch(e){ radDet = 0; }",
            "  var radMan = 0; try{ radMan = btn.effect('Hover Corner Radius')(\"Slider\"); }catch(e){ radMan = 0; }",
            "  var rad = (auto > 0) ? radDet : radMan;",
            "  var maxR = Math.min(w,h)/2;",
            "  if (pillMode > 0) { maxR; } else { Math.max(0, Math.min(rad, maxR)); }",
            "}catch(e){8;}"
        ].join("\n");
    }
    function guide_generateExpression_LEGACY(buttonId, effectName, propKind){
        // v16.10.1: Read from guide layer itself, not button layer
        return [
            "// EH Hover Viz (self-contained)",
            "try{",
            "  thisLayer.effect('"+effectName+"')('"+propKind+"');",
            "}catch(e){",
            // FOUR components for a Color. A shape stroke/fill Color is RGBA, and
            // returning [1,1,1] made After Effects reject the whole expression
            // ("expression result must be of dimension 4, not 3") and disable it — so the
            // guide silently lost its colour AND, because the same catch is reached
            // whenever the control is missing, every guide on every button was affected.
            (propKind==="Color" ? "[1,1,1,1]" : "10"), ";",
            "}"]
        .join("\n");
    }
    function guide_generatePillModeToggle(buttonId, showWhenPillMode){
        // v16.10.6: Toggle visibility between rectangle and ellipse shapes
        var num = buttonId.replace(CONFIG.controller.effects.buttonPrefix, "");
        var result = showWhenPillMode ? "100" : "0";
        var opposite = showWhenPillMode ? "0" : "100";
        return [
            "// EH Pill Mode Toggle",
            "try{",
            "  var ctrl = thisComp.layer('"+util_escapeNameForExpression(CONFIG.controller.name)+"');",
            "  var btn = ctrl.effect('"+CONFIG.controller.effects.buttonPrefix+num+"')('Layer');",
            "  var pillMode = 0; try{ pillMode = btn.effect('Pill Mode')(\"Checkbox\"); }catch(e){ pillMode=0; }",
            "  (pillMode > 0) ? "+result+" : "+opposite+";",
            "}catch(e){ "+opposite+"; }"
        ].join("\n");
    }
    // Update roundness/size expressions on an existing guide layer for a given button
    /**
     * Convert an existing guide from expression-driven placement to parenting.
     *
     * guide_updateExpressions only ever refreshed size, roundness and opacity, so a guide
     * created by an earlier build kept its original Position expression indefinitely and no
     * amount of re-applying would move it. Without this, only newly created buttons would
     * benefit from the fix.
     */
    function guide_migrateToParenting(vizLayer, buttonLayer) {
        if (!vizLayer || !buttonLayer) return false;
        var wasLocked = false;
        try { wasLocked = vizLayer.locked; } catch (e0) { wasLocked = false; }
        try {
            try { vizLayer.locked = false; } catch (e1) {}
            var tr = vizLayer.property("Transform");

            // Clear the expressions that used to do this job, or they fight the parent.
            var pos = tr.property("Position");
            var rot = tr.property("Rotation");
            // Through the helper, like every other expression write: it clears
            // locked/shy/enabled, which a guide layer has set, and restores them.
            try { if (pos.expressionEnabled) ui_setExpressionVisible(pos, "", pos.value); } catch (e2) {}
            try { if (rot.expressionEnabled) ui_setExpressionVisible(rot, "", rot.value); } catch (e3) {}

            try { if (vizLayer.parent !== buttonLayer) vizLayer.parent = buttonLayer; } catch (e4) {}
            // The parent's anchor point, not [0,0]. Parent space origin is the parent's
            // layer-space origin: child_world = P + R*S*(p - A), so p = A lands on P while
            // p = [0,0] lands at P - A — the comp origin for a precomp that maps 1:1 onto
            // its comp, which is exactly where 1.1.0 put every guide.
            var bap = null;
            try { bap = buttonLayer.property("Transform").property("Anchor Point").value; } catch (eB) { bap = null; }
            tr.property("Anchor Point").setValue([0,0]);
            tr.property("Position").setValue(bap ? [bap[0], bap[1]] : [0,0]);
            tr.property("Scale").setValue([100,100]);
            tr.property("Rotation").setValue(0);
            return true;
        } catch (e) {
            return false;
        } finally {
            try { vizLayer.locked = wasLocked; } catch (e5) {}
        }
    }

    function guide_updateExpressions(vizLayer, buttonId){
        try{
            // Update opacity expression for "Show Hover Area" checkbox (v16.10.2 fix)
            try{
                var opacityProp = vizLayer.property("Transform").property("Opacity");
                if (opacityProp){
                    ui_setExpressionVisible(
                        opacityProp,
                        "effect(\"Show Hover Area\")(\"Checkbox\") * 100",
                        100
                    );
                }
            }catch(_){}
            
            var root = vizLayer.property("ADBE Root Vectors Group"); if (!root) return false;
            function setFor(groupName, sizeExpr, roundExpr){
                try{
                    var grp = root.property(groupName); if (!grp) return false;
                    var vg = grp.property("ADBE Vectors Group"); if (!vg) return false;
                    var rect = null;
                    for (var i=1;i<=vg.numProperties;i++){ var p = vg.property(i); if (p && p.matchName==="ADBE Vector Shape - Rect"){ rect=p; break; } }
                    if (!rect) return false;
                    var sz = rect.property("ADBE Vector Rect Size"); if (!sz) return false;
                    var rr = rect.property("ADBE Vector Rect Roundness"); if (!rr) return false;
                    ui_setExpressionVisible(sz, sizeExpr, sz.value);
                    ui_setExpressionVisible(rr, roundExpr, rr.value);
                    return true;
                }catch(__){ return false; }
            }
            var ok1 = setFor("Core Box", guide_generateSizeExpr_LEGACY(buttonId, false), guide_generateRoundnessExpr_LEGACY(buttonId, false));
            var ok2 = setFor("Influence Band", guide_generateSizeExpr_LEGACY(buttonId, true), guide_generateRoundnessExpr_LEGACY(buttonId, true));
            return (ok1||ok2);
        }catch(__){ return false; }
    }

// ==== packages/ae-hoverpanel/src/64-expr-cursor.jsxinc ====
/**
 * Cursor state swapping: the controller's Active Type expression plus the
 * per-state opacity wiring on the cursor comp's layers.
 */
    // Removed: globalCursorExpr - dead code, never called (Issue #36)
    function expr_applyCursorStateSwap(silent) {
        ui_validateSelections();
        if (!ctrl_updateReferences()) throw new Error("Controller reference update failed (check Main/Cursor/Cursor Layer selections).");
        try{ ctrl_migrateCounterName(ctrl_ensureExists()); }catch(_){ }
        var mcName = uiState.mainCompName;
        var ctrl = ctrl_ensureExists(); if (!ctrl) throw new Error("No controller.");

        // Ensure controller outputs: Force Cursor Type (manual), Cursor Active Type (expr)
        var effForce = ctrl.Effects.property(CONFIG.controller.effects.forceCursorType);
        if (!effForce) { try { effForce = ctrl.Effects.addProperty(CONFIG.effects.slider); effForce.name = CONFIG.controller.effects.forceCursorType; effForce.property("Slider").setValue(-1); } catch(_) { } }

        var effType = ctrl.Effects.property(CONFIG.controller.effects.cursorActiveType);
        if (!effType) { try { effType = ctrl.Effects.addProperty(CONFIG.effects.slider); effType.name = CONFIG.controller.effects.cursorActiveType; } catch(_) {} }
        // Remove deprecated progress/swap controls if present
        try{ var _p = ctrl.Effects.property(CONFIG.controller.effects.cursorHoverProgress); if (_p) _p.remove(); }catch(_){}
        try{ var _s = ctrl.Effects.property(CONFIG.controller.effects.cursorSwapFrames); if (_s) _s.remove(); }catch(_){}

        // Ensure each registered button has a per-button 'Corner Fudge' effect (for fine-grained tuning)
        try{
            var _btns = ctrl_getRegisteredButtons();
            for (var bi=0; bi<_btns.length; bi++){
                var _L = _btns[bi].layer;
                if (_L){
                    try{
                        var _cf = _L.effect('Corner Fudge');
                        if (!_cf){
                            _cf = _L.Effects.addProperty(CONFIG.effects.slider); _cf.name = 'Corner Fudge';
                            try{ _cf.property('Slider').setValue((scriptSettings && scriptSettings.detection && scriptSettings.detection.cornerFudge) || (CONFIG && CONFIG.detection && CONFIG.detection.cornerFudge) || 1.0); }catch(__){}
                        }
                    }catch(__){}
                }
            }
        }catch(__){}

        // Build Active Type expression (instant, no lookback)
        // Ensure the derived cursor controls exist, then wire all four expressions.
        // Order matters only in that Hover Button must exist before anything reads it;
        // ui_setExpressionVisible tolerates a missing reference, but a missing effect
        // would leave the others permanently at 0.
        var fx = CONFIG.controller.effects;
        function ctrl_ensureSlider(name, initial){
            var eff = null;
            try { eff = ctrl.Effects.property(name); } catch (e) { eff = null; }
            if (!eff) {
                try {
                    eff = ctrl.Effects.addProperty(CONFIG.effects.slider);
                    eff.name = name;
                    if (typeof initial === "number") eff.property("Slider").setValue(initial);
                } catch (e2) { return null; }
            }
            return eff;
        }
        function ctrl_ensurePoint(name){
            var eff = null;
            try { eff = ctrl.Effects.property(name); } catch (e) { eff = null; }
            if (!eff) {
                try {
                    eff = ctrl.Effects.addProperty(CONFIG.effects.point);
                    eff.name = name;
                } catch (e2) { return null; }
            }
            return eff;
        }

        expr_batchBegin(ctrl);
        var cfgCursor = (scriptSettings && scriptSettings.cursor) ? scriptSettings.cursor : CONFIG.cursor;
        var effHoverBtn = ctrl_ensureSlider(fx.cursorHoverButton, 0);
        var effAmount   = ctrl_ensureSlider(fx.cursorHoverAmount, 0);
        var effTarget   = ctrl_ensurePoint(fx.cursorMagnetTarget);
        // Only seeded on creation, so re-applying never overrides a value the user set.
        var effStrength = ctrl_ensureSlider(fx.cursorMagnetStrength, cfgCursor.magnetStrength);
        var effScale    = ctrl_ensureSlider(fx.cursorHoverScale, cfgCursor.hoverScale);

        try{ if (effHoverBtn) ui_setExpressionVisible(effHoverBtn.property("Slider"), expr_generateCursorHoverButton(), 0); }catch(_){ }
        try{ if (effAmount)   ui_setExpressionVisible(effAmount.property("Slider"), expr_generateCursorHoverAmount(), 0); }catch(_){ }
        try{ if (effTarget)   ui_setExpressionVisible(effTarget.property("Point"), expr_generateCursorMagnetTarget(), [0,0]); }catch(_){ }
        try{ ui_setExpressionVisible(effType.property("Slider"), expr_generateCursorActiveType(), 0); }catch(_){ }
        expr_batchEnd();

        // Magnetism and hover scale live on the cursor layer in the main comp, because
        // they modify that layer's own animated transform.
        try{
            var mcForCursor = comp_findByName(uiState.mainCompName);
            var cursorInMain = mcForCursor ? mcForCursor.layer(uiState.cursorLayerName) : null;
            if (cursorInMain){
                var tr = cursorInMain.property("Transform");
                ui_setExpressionVisible(tr.property("Position"), expr_generateCursorMagnetPosition(), tr.property("Position").value);
                ui_setExpressionVisible(tr.property("Scale"), expr_generateCursorHoverScale(), tr.property("Scale").value);
            }
        }catch(_){ }

        // Wire cursor comp layers' opacity
        var cc = comp_findByName(uiState.cursorCompName);
        var defL = null, linkL = null;
        try { defL = cc ? cc.layer(uiState.cursorDefaultLayerName) : null; } catch(_) { defL = null; }
        try { linkL = cc ? cc.layer(uiState.cursorHoverLayerName) : null; } catch(_) { linkL = null; }
        var textL = null, loadL = null;
        if (cc){
            var textSel = uiState.cursorTextLayerName || "";
            var loadSel = uiState.cursorLoadingLayerName || "";
            var noneText = (textSel === "(None)");
            var noneLoad = (loadSel === "(None)");
            // Text (type 2)
            if (!noneText){
                try{ if (textSel) textL = cc.layer(textSel); }catch(_){ textL=null; }
                if (!textL && !textSel){ // only fallback if user did not explicitly choose
                    try{ textL = cc.layer("Cursor Text"); }catch(__){ textL=null; }
                }
            }
            // Loading (type 3)
            if (!noneLoad){
                try{ if (loadSel) loadL = cc.layer(loadSel); }catch(_l){ loadL=null; }
                if (!loadL && !loadSel){ // only fallback if user did not explicitly choose
                    try{ loadL = cc.layer("Cursor Loading"); }catch(__l){ loadL=null; }
                }
            }
        }

        // Validation: warn if the same layer is selected for multiple states
        var _dupHint = "";
        try{
            var pairs = [["Default", defL],["Link", linkL],["Text", textL],["Loading", loadL]];
            var map = {}, dups = [];
            for (var pi=0; pi<pairs.length; pi++){
                var nm = pairs[pi][0], ly = pairs[pi][1];
                if (!ly) continue;
                var key = ly.index;
                if (!map[key]) map[key] = [nm]; else map[key].push(nm);
            }
            for (var k in map) if (map.hasOwnProperty(k) && map[k].length>1) dups.push(map[k].join(", "));
            if (dups.length) _dupHint = "⚠ Duplicate cursor layers: ["+dups.join("] [")+"] - Only last assignment will work!";
        }catch(__){ }

        function expr_generateTypedOpacity(mainName, typeCode){
            var s=[];
            s.push("// EH Typed Cursor Opacity (instant)");
            s.push("try{ var ctrl = comp('"+util_escapeNameForExpression(mainName)+"').layer('"+util_escapeNameForExpression(CONFIG.controller.name)+"');");
            s.push(" var t = Math.round(ctrl.effect('"+util_escapeNameForExpression(CONFIG.controller.effects.cursorActiveType)+"')('Slider'));");
            s.push(" (t==="+typeCode+") ? 100 : 0; }");
            s.push("catch(e){ 0; }");
            return s.join("\n");
        }
        function expr_generateDefaultOpacity(mainName){
            var s=[];
            s.push("// EH Default Cursor Opacity (instant)");
            s.push("try{ var ctrl = comp('"+util_escapeNameForExpression(mainName)+"').layer('"+util_escapeNameForExpression(CONFIG.controller.name)+"');");
            s.push(" var t = Math.round(ctrl.effect('"+util_escapeNameForExpression(CONFIG.controller.effects.cursorActiveType)+"')('Slider'));");
            s.push(" (t===0) ? 100 : 0; }");
            s.push("catch(e){ 100; }");
            return s.join("\n");
        }

        if (linkL) ui_setExpressionVisible(linkL.property("Transform").property("Opacity"), expr_generateTypedOpacity(mcName, 1), 0);
        if (textL) ui_setExpressionVisible(textL.property("Transform").property("Opacity"), expr_generateTypedOpacity(mcName, 2), 0);
        if (loadL) ui_setExpressionVisible(loadL.property("Transform").property("Opacity"), expr_generateTypedOpacity(mcName, 3), 0);
        if (defL)  ui_setExpressionVisible(defL.property("Transform").property("Opacity"),  expr_generateDefaultOpacity(mcName), 100);

        // Hide all other layers in the cursor comp that are not assigned to a state
        try{
            if (cc){
                var keep = {};
                try{ if (defL)  keep[defL.index]  = true; }catch(_){ }
                try{ if (linkL) keep[linkL.index] = true; }catch(_){ }
                try{ if (textL) keep[textL.index] = true; }catch(_){ }
                try{ if (loadL) keep[loadL.index] = true; }catch(_){ }
                for (var ii=1; ii<=cc.numLayers; ii++){
                    if (!keep[ii]){
                        try{
                            var Lx = cc.layer(ii);
                            // Skip guide and adjustment layers – keep them visible/unmodified
                            var skip = false;
                            try{ if (Lx.adjustmentLayer) skip = true; }catch(__a){}
                            try{ if (Lx.guideLayer) skip = true; }catch(__g){}
                            // Skip by configurable name prefixes (scriptSettings.skipLayerPrefixes or CONFIG.skipLayerPrefixes); always include guidePrefix if present
                            try{
                                if (!skip){
                                    var nm = String(Lx.name||"");
                                    var pref = [];
                                    try{ pref = (scriptSettings && scriptSettings.skipLayerPrefixes) || []; }catch(_p){}
                                    if (!util_isArray(pref)) pref = [];
                                    try{ var gp = (scriptSettings && scriptSettings.guidePrefix) || (CONFIG && CONFIG.guidePrefix) || ""; if (gp && gp.length) pref.push(gp); }catch(_gp){}
                                    try{ var pref2 = (CONFIG && CONFIG.skipLayerPrefixes) || []; if (util_isArray(pref2)) { for (var _i2=0; _i2<pref2.length; _i2++) pref.push(pref2[_i2]); } }catch(_p2){}
                                    for (var _i=0; _i<pref.length; _i++){
                                        var px = pref[_i];
                                        if (px && nm.indexOf(px)===0){ skip = true; break; }
                                    }
                                }
                            }catch(__n){}
                            if (skip) continue;
                            ui_setExpressionVisible(Lx.property("Transform").property("Opacity"), "0", 0);
                        }catch(__){}
                    }
                }
            }
        }catch(__){ }

        // If optional states are explicitly set to '(None)' or not found, force-hide any fallback-named layers
        try{
            if (!textL && cc){ var lt = null; try{ lt = cc.layer("Cursor Text"); }catch(__t){ lt=null; } if (lt) ui_setExpressionVisible(lt.property("Transform").property("Opacity"), "0", 0); }
        }catch(__){ }
        try{
            if (!loadL && cc){ var ll = null; try{ ll = cc.layer("Cursor Loading"); }catch(__l){ ll=null; } if (ll) ui_setExpressionVisible(ll.property("Transform").property("Opacity"), "0", 0); }
        }catch(__){ }

        if (!silent){
            var baseMsg = "Cursor states linked (multi-type).";
            var hints = [];
            if (!textL) hints.push("Text state (2) not assigned");
            if (!loadL) hints.push("Loading state (3) not assigned");
            if (_dupHint) hints.push(_dupHint);
            if (hints.length) ui_updateStatus(baseMsg+" "+hints.join(" | "), 'info');
            else ui_updateStatus(baseMsg, 'success');
        }
    }

    // =====================================================================
    // Cursor expressions
    //
    // 16.10.6 put the whole per-button scan inside the "Cursor Active Type"
    // expression, so finding the cursor's state cost a loop over every button, every
    // frame. Anything else that needed to know which button was hovered — magnetism,
    // a hover amount — would have had to repeat that scan.
    //
    // So the scan now runs ONCE and publishes the hovered button's id. Active Type,
    // hover amount and the magnet target all read that id and do O(1) work. Total
    // per-frame cost is about what it was, and two features come out of it for free.
    // =====================================================================

    /** Default per-button Corner Fudge, resolved at generation time. */
    function expr_cursorCornerFudge() {
        var v = 1.0;
        try {
            v = (scriptSettings && scriptSettings.detection && isFinite(scriptSettings.detection.cornerFudge))
                ? scriptSettings.detection.cornerFudge
                : (CONFIG.detection && CONFIG.detection.cornerFudge);
            if (!isFinite(v)) v = 1.0;
        } catch (e) { v = 1.0; }
        return v;
    }

    /**
     * Per-button hover geometry for the cursor side, as expression lines.
     *
     * Emits `distLocal` (signed distance from the padded edge, in the button's local
     * pixels) and `inflLocal` (the threshold, scale-corrected plus Corner Fudge) given
     * `btn`, `b`, `cW` and `t` already in scope. Shared by the scan and by the hover
     * amount so the two can never disagree about where the button is —
     * test_geometry_consistency.py holds this equal to the animation's own field.
     */
    function expr_cursorGeometryLines(indent) {
        var q = indent || "          ";
        var cf = expr_cursorCornerFudge();
        var s = [];
        s.push(q + "var infl = btn.effect('Hover Influence Radius')(\"Slider\"); infl = Math.max(infl, 0.001);");
        s.push(q + "var pad  = btn.effect('Hover Padding')(\"Point\");");
        s.push(q + "var src  = btn.source; var preferComp=false; try{ preferComp = src && (src.name.indexOf('" + util_escapeNameForExpression(scriptSettings.naming.templateComp) + "')===0); }catch(_e){ preferComp=false; }");
        s.push(q + "var w0 = btn.toWorld([0,0], t); var wx = btn.toWorld([100,0], t); var wy = btn.toWorld([0,100], t);");
        s.push(q + "var sx = length(wx - w0)/100; if (sx<=0) sx=1; var sy = length(wy - w0)/100; if (sy<=0) sy=1;");
        // /sx and /sy on BOTH branches: the division used to sit only on the
        // measured-bounds branch, so a scaled button matching the template prefix kept
        // an uncorrected src.width and had a hotspot of the wrong size.
        s.push(q + "var wL = (preferComp ? src.width : b[0])/sx; var hL = (preferComp ? src.height : b[1])/sy;");
        s.push(q + "var padLx = pad[0]/sx; var padLy = pad[1]/sy;");
        s.push(q + "var hw = (wL/2) + padLx; var hh = (hL/2) + padLy;");
        s.push(q + "var cL = btn.fromWorld(cW, t); var ap = btn.transform.anchorPoint.valueAtTime(t); var d = cL - ap;");
        s.push(q + "var pillMode=0; try{ pillMode=btn.effect('Pill Mode')(\"Checkbox\"); }catch(_pm){}");
        s.push(q + "var auto=0; try{ auto=btn.effect('Auto Corner Radius')(\"Checkbox\"); }catch(_a){}");
        s.push(q + "var radDet=0; try{ radDet=btn.effect('Detected Corner Radius')(\"Slider\"); }catch(_b){}");
        s.push(q + "var radMan=0; try{ radMan=btn.effect('Hover Corner Radius')(\"Slider\"); }catch(_c){}");
        s.push(q + "var cr = (auto > 0) ? radDet : radMan;");
        s.push(q + "cr = Math.max(0, Math.min(cr, hw, hh));");
        s.push(q + "var distLocal = 0;");
        s.push(q + "if (pillMode > 0) {");
        s.push(q + "  var dx = d[0]; var dy = d[1];");
        s.push(q + "  var normX = (hw > 0) ? (dx / hw) : 0;");
        s.push(q + "  var normY = (hh > 0) ? (dy / hh) : 0;");
        s.push(q + "  var ellipseDist = Math.sqrt(normX * normX + normY * normY);");
        s.push(q + "  var avgRadius = (hw + hh) / 2;");
        s.push(q + "  distLocal = (ellipseDist - 1) * avgRadius;");
        s.push(q + "} else {");
        s.push(q + "  var ax = Math.max(0, hw - cr); var ay = Math.max(0, hh - cr);");
        s.push(q + "  var qx = Math.abs(d[0]) - ax; var qy = Math.abs(d[1]) - ay;");
        s.push(q + "  var ox = Math.max(qx, 0); var oy = Math.max(qy, 0);");
        s.push(q + "  distLocal = length([ox,oy]) - cr;");
        s.push(q + "}");
        s.push(q + "var cf = " + cf + "; try{ cf = btn.effect('Corner Fudge')(\"Slider\"); }catch(_f){} if (!isFinite(cf)) cf = " + cf + ";");
        s.push(q + "var inflLocal = infl / Math.min(sx, sy) + cf; // scale-correct radius + configurable per-button fudge");
        return s;
    }

    /** Shared opening: resolve the controller and the cursor layer. */
    function expr_cursorHeaderLines() {
        return [
            "var ctrl = thisComp.layer('" + util_escapeNameForExpression(CONFIG.controller.name) + "');",
            "var cur = ctrl.effect('" + util_escapeNameForExpression(CONFIG.controller.effects.cursorLayer) + "')('Layer');"
        ];
    }

    /**
     * The one scan. Returns the id of the hovered button, or 0.
     *
     * Returns on the first match, so the common case costs far less than the worst.
     * Ids are allocated lowest-free, so the range is dense and the loop rarely visits
     * an id that does not exist.
     */
    function expr_generateCursorHoverButton() {
        var s = [];
        s.push("// Hovered button id (0 = none). The single per-frame scan; every other");
        s.push("// cursor expression reads this instead of scanning again.");
        s.push("try{");
        s = s.concat(expr_cursorHeaderLines());
        s.push("  function hoveredAt(cW, t){");
        s.push("    var maxId = Math.min(ctrl.effect('Max Button Id')(\"Slider\"), " + scriptSettings.behavior.maxButtons + ");");
        s.push("    for (var i=1;i<=maxId;i++){");
        s.push("      try{");
        s.push("        var btn = ctrl.effect('" + CONFIG.controller.effects.buttonPrefix + "'+i)(\"Layer\");");
        s.push("        if (btn && btn.active && btn.enabled && btn.hasVideo){");
        s.push("          var b = ctrl.effect('" + CONFIG.controller.effects.buttonBoundsPrefix + "'+i)(\"Point\");");
        s = s.concat(expr_cursorGeometryLines("          "));
        s.push("          if (distLocal <= inflLocal) return i;");
        s.push("        }");
        s.push("      }catch(__){}");
        s.push("    }");
        s.push("    return 0;");
        s.push("  }");
        s.push("  hoveredAt(cur.toWorld(cur.anchorPoint), time);");
        s.push("}catch(_){ 0; }");
        return s.join("\n");
    }

    /**
     * Cursor state code, now O(1): read the hovered id, then that button's type.
     *
     * Force Cursor Type still overrides everything, so a manual state can be keyframed.
     */
    function expr_generateCursorActiveType() {
        var s = [];
        s.push("// Cursor Active Type - derived from Cursor Hover Button (no scan).");
        s.push("try{");
        s = s.concat(expr_cursorHeaderLines());
        s.push("var forced = -1; try{ forced = ctrl.effect('" + util_escapeNameForExpression(CONFIG.controller.effects.forceCursorType) + "')('Slider'); }catch(e){ forced=-1; }");
        s.push("if (forced>=0) { Math.round(forced); } else {");
        s.push("  var id = 0; try{ id = Math.round(ctrl.effect('" + util_escapeNameForExpression(CONFIG.controller.effects.cursorHoverButton) + "')(\"Slider\")); }catch(e){ id = 0; }");
        s.push("  if (id <= 0) { 0; } else {");
        s.push("    var tcode = 1;");
        s.push("    try{");
        s.push("      var btn = ctrl.effect('" + CONFIG.controller.effects.buttonPrefix + "'+id)(\"Layer\");");
        s.push("      tcode = Math.round(btn.effect('EH Cursor Type')(\"Slider\"));");
        s.push("    }catch(e2){ tcode = 1; }");
        s.push("    if (!isFinite(tcode)) tcode = 1;");
        s.push("    tcode;");
        s.push("  }");
        s.push("}");
        s.push("}catch(_){ 0; }");
        return s.join("\n");
    }

    /**
     * How strongly the cursor is over the hovered button, 0-100.
     *
     * Recomputes geometry for the ONE button the scan named, so this is O(1). Drives
     * magnetism and cursor scale, giving both a smooth falloff instead of a snap.
     */
    function expr_generateCursorHoverAmount() {
        var s = [];
        s.push("// Hover amount 0-100 for the button named by Cursor Hover Button.");
        s.push("try{");
        s = s.concat(expr_cursorHeaderLines());
        s.push("var id = 0; try{ id = Math.round(ctrl.effect('" + util_escapeNameForExpression(CONFIG.controller.effects.cursorHoverButton) + "')(\"Slider\")); }catch(e){ id = 0; }");
        s.push("if (id <= 0) { 0; } else {");
        s.push("  var t = time;");
        s.push("  var cW = cur.toWorld(cur.anchorPoint);");
        s.push("  var btn = ctrl.effect('" + CONFIG.controller.effects.buttonPrefix + "'+id)(\"Layer\");");
        s.push("  var b = ctrl.effect('" + CONFIG.controller.effects.buttonBoundsPrefix + "'+id)(\"Point\");");
        s = s.concat(expr_cursorGeometryLines("  "));
        s.push("  var clamped = Math.min(Math.max(distLocal, 0), inflLocal);");
        s.push("  ease(clamped, inflLocal, 0, 0, 100);");
        s.push("}");
        s.push("}catch(_){ 0; }");
        return s.join("\n");
    }

    /**
     * Comp-space point the cursor is pulled toward: the hovered button's anchor.
     *
     * Falls back to the cursor's own position when nothing is hovered, so the offset a
     * consumer computes is zero even before the hover amount is applied.
     */
    function expr_generateCursorMagnetTarget() {
        var s = [];
        s.push("// Magnet target: the hovered button's anchor in comp space.");
        s.push("try{");
        s = s.concat(expr_cursorHeaderLines());
        s.push("var id = 0; try{ id = Math.round(ctrl.effect('" + util_escapeNameForExpression(CONFIG.controller.effects.cursorHoverButton) + "')(\"Slider\")); }catch(e){ id = 0; }");
        s.push("var home = cur.toComp(cur.transform.anchorPoint, time);");
        s.push("if (id <= 0) { home; } else {");
        s.push("  try{");
        s.push("    var btn = ctrl.effect('" + CONFIG.controller.effects.buttonPrefix + "'+id)(\"Layer\");");
        s.push("    btn.toComp(btn.transform.anchorPoint, time);");
        s.push("  }catch(e2){ home; }");
        s.push("}");
        s.push("}catch(_){ [0,0]; }");
        return s.join("\n");
    }

    /**
     * Cursor Position: nudge toward the hovered button, on top of its own animation.
     *
     * Relative to `value`, never absolute. `value` is the cursor's own keyframed
     * position — the whole point of the layer — so replacing it would throw away the
     * animation the user built. The 16.x variant's equivalent also tried to set layer
     * opacity from inside this expression, which is a silent no-op: AE expressions are
     * pure and cannot assign to properties.
     */
    function expr_generateCursorMagnetPosition() {
        var e = util_escapeNameForExpression;
        var s = [];
        s.push("// Cursor magnetism - offsets this layer's own animated position.");
        s.push("try{");
        s.push("  var ctrl = thisComp.layer('" + e(CONFIG.controller.name) + "');");
        s.push("  var strength = 0; try{ strength = ctrl.effect('" + e(CONFIG.controller.effects.cursorMagnetStrength) + "')(\"Slider\")/100; }catch(e1){ strength = 0; }");
        s.push("  var amount = 0; try{ amount = ctrl.effect('" + e(CONFIG.controller.effects.cursorHoverAmount) + "')(\"Slider\")/100; }catch(e2){ amount = 0; }");
        s.push("  var target = ctrl.effect('" + e(CONFIG.controller.effects.cursorMagnetTarget) + "')(\"Point\");");
        s.push("  var k = strength * amount;");
        s.push("  if (!isFinite(k)) k = 0;");
        s.push("  k = Math.max(0, Math.min(1, k));");
        s.push("  value + (target - value) * k;");
        s.push("}catch(_){ value; }");
        return s.join("\n");
    }

    /** Cursor Scale: grow toward Cursor Hover Scale while hovering, multiplicatively. */
    function expr_generateCursorHoverScale() {
        var e = util_escapeNameForExpression;
        var s = [];
        s.push("// Cursor hover scale - multiplies this layer's own animated scale.");
        s.push("try{");
        s.push("  var ctrl = thisComp.layer('" + e(CONFIG.controller.name) + "');");
        s.push("  var hs = 100; try{ hs = ctrl.effect('" + e(CONFIG.controller.effects.cursorHoverScale) + "')(\"Slider\"); }catch(e1){ hs = 100; }");
        s.push("  var amount = 0; try{ amount = ctrl.effect('" + e(CONFIG.controller.effects.cursorHoverAmount) + "')(\"Slider\")/100; }catch(e2){ amount = 0; }");
        s.push("  if (!isFinite(hs)) hs = 100;");
        s.push("  if (!isFinite(amount)) amount = 0;");
        s.push("  amount = Math.max(0, Math.min(1, amount));");
        s.push("  var f = 1 + ((hs/100) - 1) * amount;");
        s.push("  var out = [];");
        s.push("  for (var i = 0; i < value.length; i++) out[i] = value[i] * f;");
        s.push("  out;");
        s.push("}catch(_){ value; }");
        return s.join("\n");
    }

// ==== packages/ae-hoverpanel/src/66-guide-layer.jsxinc ====
/**
 * The visual guide layer: build, find, remove, and batch toggling.
 */
    function guide_find(mainComp, precompLayer){
        var expected = "[EH] Hover Area - " + precompLayer.name;
        // Try tagged guide first
        var bid = btn_findIdForLayer(precompLayer);
        var idNum = null;
        try { idNum = parseInt(String(bid || "").replace(CONFIG.controller.effects.buttonPrefix, ""), 10); } catch(_){ }
        if (!isNaN(idNum) && idNum !== null){
            for (var i=1;i<=mainComp.numLayers;i++){
                var L = mainComp.layer(i);
                try {
                    if (L && L.name === expected){
                        var effs = L.property("Effects");
                        var tag = effs ? effs.property("EH Button Id") : null;
                        if (tag){ var v = Math.round(tag.property("Slider").value||0); if (v === idNum) return L; }
                    }
                } catch(_){ }
            }
        }
        // If this layer is registered (has a buttonId), do NOT use legacy fallback.
        // This ensures a new button with the same name as an existing one still gets its own new guide.
        if (bid) return null;

        // Fallback: by name, but ONLY for legacy untagged guides
        for (var j=1;j<=mainComp.numLayers;j++){
            var L2 = mainComp.layer(j);
            try{
                if (L2 && L2.name === expected){
                    var effs2 = L2.property("Effects");
                    var tag2 = effs2 ? effs2.property("EH Button Id") : null;
                    if (!tag2) return L2; // legacy, untagged
                }
            }catch(_){ }
        }
        return null;
    }
    function guide_add(precompLayer, buttonId){
        if (precompLayer.comment !== CONFIG.buttonComment) throw new Error("Selected layer is not an EH hover button.");
        var mainComp = precompLayer.containingComp;
        var idStr = buttonId || btn_findIdForLayer(precompLayer);
        if (!idStr) throw new Error("Button isn’t registered in the controller.");
        var bn = String(idStr).replace(CONFIG.controller.effects.buttonPrefix, "");
        var existing = guide_find(mainComp, precompLayer);
        if (existing) {
            // If existing is legacy (untagged), tag it now to lock it to this button id
            try {
                var effs = existing.Effects || existing.property("Effects");
                if (effs && (!effs.property("EH Button Id"))) {
                    var tag = effs.addProperty("ADBE Slider Control");
                    tag.name = "EH Button Id";
                    tag.property("Slider").setValue(parseInt(bn,10)||0);
                }
            } catch(_){ }
            // Keep stacking consistent per setting
            try {
                if (scriptSettings.behavior.guideBelowButton) existing.moveAfter(precompLayer);
                else existing.moveBefore(precompLayer);
            } catch(__){}
            // The controls live on the guide itself; a guide from an older build may not
            // have them, which is what made every colour expression fall into its catch.
            try{ guide_ensureControls_LEGACY(existing); }catch(_){ }
            // Convert to parenting. Without this an existing guide keeps the Position
            // expression it was created with and stays where it is forever.
            try{ guide_migrateToParenting(existing, precompLayer); }catch(_){ }
            // Update expressions to latest logic (size + roundness)
            try{ guide_updateExpressions(existing, idStr); }catch(_){ }
            return existing;
        }

        var viz = mainComp.layers.addShape();
        viz.name = "[EH] Hover Area - " + precompLayer.name;
        // Place guide above or under based on setting
        if (scriptSettings.behavior.guideBelowButton) viz.moveAfter(precompLayer);
        else viz.moveBefore(precompLayer);
        viz.shy = true; viz.label = 9; try{ viz.guideLayer=true; }catch(_){ }

        // The guide's own appearance controls. Every expression below reads them as
        // `thisLayer.effect(...)`, i.e. off the GUIDE — but guide_ensureControls_LEGACY was
        // only ever called with the BUTTON layer, so the lookups always missed and every
        // one of them fell into its catch. For the colours that catch returned a
        // 3-component value into a 4-component property, which After Effects rejects
        // outright, so the guide lost its colour and its geometry in one go.
        try{ guide_ensureControls_LEGACY(viz); }catch(_){ }

        // Follow the button by PARENTING, not by expression.
        //
        // A parented layer's Position is expressed in its parent's coordinate system, whose
        // origin is the parent's anchor point — which btn_createAndSetupFromLayer has
        // already centred on the artwork. So anchor [0,0] plus position [0,0] puts the guide
        // exactly on the button, and After Effects maintains it.
        //
        // This replaces two expressions that read the button out of a Layer Control and
        // rebuilt its transform by hand. Every guide-placement bug so far was a different
        // way for that reconstruction to break — a wrong dimension count, a property object
        // where a point was needed, a control looked up on the wrong layer — and each failed
        // identically: the expression caught its own error, fell back to the guide's own
        // position, and reported nothing. Parenting has no fallback to get wrong.
        //
        // It also corrects the size. The shapes are sized from ButtonBounds_, measured in the
        // button's LOCAL pixels, while the guide was drawn at 100% scale — so the outline was
        // the wrong size for any button not at scale 100 and did not follow a hover scale
        // animation. Inheriting the parent's scale is exactly the region hover detection
        // uses, since detection divides by that same scale.
        try{
            viz.property("Transform").property("Anchor Point").setValue([0,0]);
            viz.parent = precompLayer;
            // Position = the PARENT'S ANCHOR POINT, not [0,0].
            //
            // Parent space origin is not the parent's anchor point — it is the parent's
            // layer-space origin. After Effects composes a child as
            //     child_world = P + R*S*(p - A)
            // for parent position P, parent anchor A and child position p. So p = [0,0]
            // lands at P - A, and p = A lands exactly on P.
            //
            // 1.1.0 used [0,0] and put every guide at P - A. For a button precomp that maps
            // 1:1 onto its comp, P equals A, so P - A is the comp ORIGIN — which is where the
            // guide appeared, outside the frame at the top-left.
            var pap = null;
            try { pap = precompLayer.property("Transform").property("Anchor Point").value; } catch (eA) { pap = null; }
            // Set AFTER parenting: assigning a parent does not re-express the existing
            // position in the new coordinate system.
            viz.property("Transform").property("Position").setValue(pap ? [pap[0], pap[1]] : [0,0]);
            viz.property("Transform").property("Scale").setValue([100,100]);
            viz.property("Transform").property("Rotation").setValue(0);
            try{ viz.property("Transform").property("Z Rotation").setValue(0); }catch(_){ }
        }catch(_){ }

        var root = viz.property("ADBE Root Vectors Group");
        // Core Box (Rectangle)
        var coreGroup = root.addProperty("ADBE Vector Group"); coreGroup.name = "Core Box";
        var coreContents = coreGroup.property("ADBE Vectors Group");
        var r1 = coreContents.addProperty("ADBE Vector Shape - Rect");
        r1.property("ADBE Vector Rect Position").setValue([0,0]);
        ui_setExpressionVisible(r1.property("ADBE Vector Rect Size"), guide_generateSizeExpr_LEGACY(idStr, false), r1.property("ADBE Vector Rect Size").value);
        ui_setExpressionVisible(r1.property("ADBE Vector Rect Roundness"), guide_generateRoundnessExpr_LEGACY(idStr, false), r1.property("ADBE Vector Rect Roundness").value);
        var st1 = coreContents.addProperty("ADBE Vector Graphic - Stroke");
        ui_setExpressionVisible(st1.property("ADBE Vector Stroke Width"), guide_generateExpression_LEGACY(idStr, "EH Viz – Core Width", "Slider"), st1.property("ADBE Vector Stroke Width").value);
        ui_setExpressionVisible(st1.property("ADBE Vector Stroke Color"), guide_generateExpression_LEGACY(idStr, "EH Viz – Core Color", "Color"), st1.property("ADBE Vector Stroke Color").value);
        try{ var d1 = st1.property("ADBE Vector Stroke Dashes"); var dash1 = d1.addProperty("ADBE Vector Stroke Dash 1"); var gap1 = d1.addProperty("ADBE Vector Stroke Gap 1");
            ui_setExpressionVisible(dash1, guide_generateExpression_LEGACY(idStr, "EH Viz – Dash Length", "Slider"), dash1.value);
            ui_setExpressionVisible(gap1,  guide_generateExpression_LEGACY(idStr, "EH Viz – Gap Length",  "Slider"), gap1.value);
        }catch(_){ }
        // Hide rectangle when pill mode is on
        try{ 
            var coreGroupTransform = coreGroup.property("ADBE Vector Transform Group");
            ui_setExpressionVisible(coreGroupTransform.property("ADBE Vector Group Opacity"), guide_generatePillModeToggle(idStr, false), 100);
        }catch(_){ }

        // Core Ellipse (for Pill Mode)
        var coreEllipseGroup = root.addProperty("ADBE Vector Group"); coreEllipseGroup.name = "Core Ellipse";
        var coreEllipseContents = coreEllipseGroup.property("ADBE Vectors Group");
        var e1 = coreEllipseContents.addProperty("ADBE Vector Shape - Ellipse");
        e1.property("ADBE Vector Ellipse Position").setValue([0,0]);
        ui_setExpressionVisible(e1.property("ADBE Vector Ellipse Size"), guide_generateSizeExpr_LEGACY(idStr, false), e1.property("ADBE Vector Ellipse Size").value);
        var st1e = coreEllipseContents.addProperty("ADBE Vector Graphic - Stroke");
        ui_setExpressionVisible(st1e.property("ADBE Vector Stroke Width"), guide_generateExpression_LEGACY(idStr, "EH Viz – Core Width", "Slider"), st1e.property("ADBE Vector Stroke Width").value);
        ui_setExpressionVisible(st1e.property("ADBE Vector Stroke Color"), guide_generateExpression_LEGACY(idStr, "EH Viz – Core Color", "Color"), st1e.property("ADBE Vector Stroke Color").value);
        try{ var d1e = st1e.property("ADBE Vector Stroke Dashes"); var dash1e = d1e.addProperty("ADBE Vector Stroke Dash 1"); var gap1e = d1e.addProperty("ADBE Vector Stroke Gap 1");
            ui_setExpressionVisible(dash1e, guide_generateExpression_LEGACY(idStr, "EH Viz – Dash Length", "Slider"), dash1e.value);
            ui_setExpressionVisible(gap1e,  guide_generateExpression_LEGACY(idStr, "EH Viz – Gap Length",  "Slider"), gap1e.value);
        }catch(_){ }
        // Show ellipse only when pill mode is on
        try{ 
            var coreEllipseGroupTransform = coreEllipseGroup.property("ADBE Vector Transform Group");
            ui_setExpressionVisible(coreEllipseGroupTransform.property("ADBE Vector Group Opacity"), guide_generatePillModeToggle(idStr, true), 0);
        }catch(_){ }

        // Influence Band (Rectangle)
        var bandGroup = root.addProperty("ADBE Vector Group"); bandGroup.name = "Influence Band";
        var bandContents = bandGroup.property("ADBE Vectors Group");
        var r2 = bandContents.addProperty("ADBE Vector Shape - Rect");
        r2.property("ADBE Vector Rect Position").setValue([0,0]);
        ui_setExpressionVisible(r2.property("ADBE Vector Rect Size"), guide_generateSizeExpr_LEGACY(idStr, true), r2.property("ADBE Vector Rect Size").value);
        ui_setExpressionVisible(r2.property("ADBE Vector Rect Roundness"), guide_generateRoundnessExpr_LEGACY(idStr, true), r2.property("ADBE Vector Rect Roundness").value);

        var st2 = bandContents.addProperty("ADBE Vector Graphic - Stroke");
        ui_setExpressionVisible(st2.property("ADBE Vector Stroke Width"), guide_generateExpression_LEGACY(idStr, "EH Viz – Infl Width", "Slider"), st2.property("ADBE Vector Stroke Width").value);
        ui_setExpressionVisible(st2.property("ADBE Vector Stroke Color"), guide_generateExpression_LEGACY(idStr, "EH Viz – Infl Color", "Color"), st2.property("ADBE Vector Stroke Color").value);
        try{ var d2 = st2.property("ADBE Vector Stroke Dashes"); var dash2 = d2.addProperty("ADBE Vector Stroke Dash 1"); var gap2  = d2.addProperty("ADBE Vector Stroke Gap 1");
            ui_setExpressionVisible(dash2, guide_generateExpression_LEGACY(idStr, "EH Viz – Dash Length", "Slider"), dash2.value);
            ui_setExpressionVisible(gap2,  guide_generateExpression_LEGACY(idStr, "EH Viz – Gap Length",  "Slider"), gap2.value);
        }catch(_){ }
        // Hide rectangle when pill mode is on
        try{ 
            var bandGroupTransform = bandGroup.property("ADBE Vector Transform Group");
            ui_setExpressionVisible(bandGroupTransform.property("ADBE Vector Group Opacity"), guide_generatePillModeToggle(idStr, false), 100);
        }catch(_){ }

        // Influence Ellipse (for Pill Mode)
        var bandEllipseGroup = root.addProperty("ADBE Vector Group"); bandEllipseGroup.name = "Influence Ellipse";
        var bandEllipseContents = bandEllipseGroup.property("ADBE Vectors Group");
        var e2 = bandEllipseContents.addProperty("ADBE Vector Shape - Ellipse");
        e2.property("ADBE Vector Ellipse Position").setValue([0,0]);
        ui_setExpressionVisible(e2.property("ADBE Vector Ellipse Size"), guide_generateSizeExpr_LEGACY(idStr, true), e2.property("ADBE Vector Ellipse Size").value);
        var st2e = bandEllipseContents.addProperty("ADBE Vector Graphic - Stroke");
        ui_setExpressionVisible(st2e.property("ADBE Vector Stroke Width"), guide_generateExpression_LEGACY(idStr, "EH Viz – Infl Width", "Slider"), st2e.property("ADBE Vector Stroke Width").value);
        ui_setExpressionVisible(st2e.property("ADBE Vector Stroke Color"), guide_generateExpression_LEGACY(idStr, "EH Viz – Infl Color", "Color"), st2e.property("ADBE Vector Stroke Color").value);
        try{ var d2e = st2e.property("ADBE Vector Stroke Dashes"); var dash2e = d2e.addProperty("ADBE Vector Stroke Dash 1"); var gap2e = d2e.addProperty("ADBE Vector Stroke Gap 1");
            ui_setExpressionVisible(dash2e, guide_generateExpression_LEGACY(idStr, "EH Viz – Dash Length", "Slider"), dash2e.value);
            ui_setExpressionVisible(gap2e,  guide_generateExpression_LEGACY(idStr, "EH Viz – Gap Length",  "Slider"), gap2e.value);
        }catch(_){ }
        // Show ellipse only when pill mode is on
        try{ 
            var bandEllipseGroupTransform = bandEllipseGroup.property("ADBE Vector Transform Group");
            ui_setExpressionVisible(bandEllipseGroupTransform.property("ADBE Vector Group Opacity"), guide_generatePillModeToggle(idStr, true), 0);
        }catch(_){ }
        // faint fill for ellipse
        try{
            var fillE = bandEllipseContents.addProperty("ADBE Vector Graphic - Fill");
            ui_setExpressionVisible(fillE.property("ADBE Vector Fill Color"), guide_generateExpression_LEGACY(idStr, "EH Viz – Infl Color", "Color"), fillE.property("ADBE Vector Fill Color").value);
            fillE.property("ADBE Vector Fill Opacity").setValue(10);
        }catch(_){ }

        // faint fill for rectangle
        try{
            var fill = bandContents.addProperty("ADBE Vector Graphic - Fill");
            ui_setExpressionVisible(fill.property("ADBE Vector Fill Color"), guide_generateExpression_LEGACY(idStr, "EH Viz – Infl Color", "Color"), fill.property("ADBE Vector Fill Color").value);
            fill.property("ADBE Vector Fill Opacity").setValue(10);
        }catch(_){ }

        // Add visualization controls to guide layer (not button!)
        try{
            var effGroup = viz.Effects || viz.property("Effects");
            
            // Visibility toggle
            var cb = effGroup.addProperty("ADBE Checkbox Control");
            cb.name = "Show Hover Area"; cb.property("Checkbox").setValue(1);
            ui_setExpressionVisible(
                viz.property("Transform").property("Opacity"),
                "effect(\"Show Hover Area\")(\"Checkbox\") * 100",
                100
            );
            
            // Button ID tag (for identification)
            try{ var tag = effGroup.addProperty("ADBE Slider Control"); tag.name = "EH Button Id"; tag.property("Slider").setValue(parseInt(bn,10)||0); }catch(__){}
            
            // Visualization style controls (v16.10.1: Now on guide layer!)
            try {
                var coreColor = effGroup.addProperty(CONFIG.effects.color);
                coreColor.name = "EH Viz – Core Color";
                coreColor.property("Color").setValue([0.2, 1.0, 0.4]); // Green
            } catch(_){}
            
            try {
                var inflColor = effGroup.addProperty(CONFIG.effects.color);
                inflColor.name = "EH Viz – Infl Color";
                inflColor.property("Color").setValue([1.0, 0.8, 0.2]); // Yellow
            } catch(_){}
            
            try {
                var coreWidth = effGroup.addProperty(CONFIG.effects.slider);
                coreWidth.name = "EH Viz – Core Width";
                coreWidth.property("Slider").setValue(2);
            } catch(_){}
            
            try {
                var inflWidth = effGroup.addProperty(CONFIG.effects.slider);
                inflWidth.name = "EH Viz – Infl Width";
                inflWidth.property("Slider").setValue(1);
            } catch(_){}
            
            try {
                var dashLen = effGroup.addProperty(CONFIG.effects.slider);
                dashLen.name = "EH Viz – Dash Length";
                dashLen.property("Slider").setValue(12);
            } catch(_){}
            
            try {
                var gapLen = effGroup.addProperty(CONFIG.effects.slider);
                gapLen.name = "EH Viz – Gap Length";
                gapLen.property("Slider").setValue(6);
            } catch(_){}
        }catch(_){ }

        return viz;
    }
    function guide_remove(precompLayer){
        var mainComp = precompLayer.containingComp;
        var viz = guide_find(mainComp, precompLayer);
        if (viz) try{ viz.remove(); }catch(_){ }
    }
    // Thin wrapper so existing calls keep working
    function guide_ensure(preLayer, buttonId){
        return guide_add(preLayer, buttonId);
    }

    // --- Toggle using legacy find/add/remove ---
    function batch_toggleVizForSelection(){
        var mc = comp_findByName(uiState.mainCompName);
        if (!mc || app.project.activeItem !== mc) throw new Error("Make the main comp active first.");
        if (mc.selectedLayers.length === 0) throw new Error("Select one or more EH hover buttons.");

        var added=0, removed=0, skipped=[];
        for (var i=0;i<mc.selectedLayers.length;i++){
            var L = mc.selectedLayers[i];
            if (L.comment !== CONFIG.buttonComment){ skipped.push(L.name); continue; }
            var g = guide_find(mc, L);
            if (g){ guide_remove(L); removed++; }
            else { var bid = btn_findIdForLayer(L); guide_add(L, bid); added++; }
        }
        var msg = [];
        if (added) msg.push("added "+added);
        if (removed) msg.push("removed "+removed);
        if (skipped.length) msg.push("skipped: " + skipped.join(", "));
        ui_updateStatus("Hover Viz: " + (msg.length?msg.join(" | "):"no changes"), added||removed ? "success":"info");
    }

// ==== packages/ae-hoverpanel/src/70-button-ops.jsxinc ====
/**
 * Button lifecycle: create from a layer, create from template, adopt an
 * existing precomp, and remove.
 */

    // -------------------------
    // Create / Template / Remove / Duplicate / Batch
    // -------------------------
    function btn_createAndSetupFromLayer(selectionLayer) {
        var mc = comp_findByName(uiState.mainCompName);
        var src = selectionLayer; if (!src) return null;
        var origName = src.name;
        // Guards: skip controller or cursor layers/precomp to avoid breaking
        try{ var ctrl = ctrl_find(); if (ctrl && src === ctrl) { ui_updateStatus("Cannot create from controller layer.", 'warning'); return null; } }catch(__){}
        try{ if (src && src.comment === CONFIG.controller.comment) { ui_updateStatus("Cannot create from controller layer.", 'warning'); return null; } }catch(__){}
        try{ if (uiState && uiState.cursorLayerName && src && src.name === uiState.cursorLayerName) { ui_updateStatus("Cannot create from the Cursor layer.", 'warning'); return null; } }catch(__){}
        try{ var _cc = comp_findByName(uiState.cursorCompName); if (_cc && src && src.source === _cc) { ui_updateStatus("Cannot create from the Cursor precomp.", 'warning'); return null; } }catch(__){}
        if (src.comment === CONFIG.buttonComment) return null;

        var preName = scriptSettings.naming.precomp + origName;
        var targetIndex = src.index;
        mc.layers.precompose([src.index], preName, true);
        var preLayer = mc.layer(targetIndex);
        if (!(preLayer && preLayer.source instanceof CompItem)) throw new Error("Could not create precomp for '"+origName+"'.");

        var btnComp = preLayer.source;
        btnComp.duration = scriptSettings.behavior.defaultAnimDuration;

        if (btnComp.numLayers < 1) throw new Error("Precomp '" + origName + "' has no layers.");
        var animLayer = btnComp.layer(1);
        animLayer.name = scriptSettings.naming.animationLayer + origName;
        var sc = animLayer.property("Transform").property("Scale");
        // Set demo hover scale based on the original scale value: start at current, end at +20%.
        // If the layer already has keyed scale (moved inside by precompose), do not override it.
        try{
            var base = sc.value; // original scale inside precomp after 'moveAllAttributes=true'
            if (!base || !base.length) base = [100,100];
            var end;
            if (base.length>=3) end = [base[0]*1.2, base[1]*1.2, base[2]*1.2];
            else end = [base[0]*1.2, base[1]*1.2];
            if (sc.numKeys === 0) {
                // Record original scale on the precomp layer for robust revert
                try{
                    var pEff = preLayer.Effects.addProperty(CONFIG.effects.point); pEff.name = "EH Orig Scale"; pEff.property("Point").setValue([base[0]||100, base[1]||100]);
                    if (base.length>=3) { var zEff = preLayer.Effects.addProperty(CONFIG.effects.slider); zEff.name = "EH Orig Scale Z"; zEff.property("Slider").setValue(base[2]||100); }
                }catch(__){}
                sc.setValueAtTime(0, base);
                sc.setValueAtTime(btnComp.duration, end);
            }
        }catch(_){ }

        preLayer.timeRemapEnabled = true;
        var tr = preLayer.property("Time Remap");
        tr.setValueAtTime(0, 0);
        var almost = btnComp.duration - (1/btnComp.frameRate);
        tr.setValueAtTime(almost, btnComp.duration);
        if (tr.numKeys > 2) tr.removeKey(tr.numKeys);

        var minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity, ok=false;
        // util_contentRect rather than a bare sourceRectAtTime: when the selection was
        // already a precomp, animLayer is a nested precomp layer and sourceRectAtTime
        // reports that comp's whole rectangle, so the anchor was centred on the comp
        // instead of on the artwork.
        try {
            var cr = util_contentRect(animLayer, 0);
            if (cr && isFinite(cr[0]) && isFinite(cr[1]) && isFinite(cr[2]) && isFinite(cr[3])) {
                minX = cr[0]; minY = cr[1]; maxX = cr[2]; maxY = cr[3]; ok = true;
            }
        } catch(_) {}
        var cx = ok ? (minX+(maxX-minX)/2) : btnComp.width/2;
        var cy = ok ? (minY+(maxY-minY)/2) : btnComp.height/2;
        // 2 components, not 3. A 2D layer's Anchor Point is a 2D property, and writing
        // [cx, cy, 0] made everything reading it back — the guide's position expression
        // among them — see a 3-component value.
        var newAP = [cx,cy], world = preLayer.sourcePointToComp([cx,cy]);
        preLayer.property("Transform").property("Anchor Point").setValue(newAP);
        preLayer.property("Transform").property("Position").setValue(world);

        if (scriptSettings.behavior.collapseTransformations) try{ preLayer.collapseTransformation=true; }catch(_){}
        preLayer.comment = CONFIG.buttonComment;

        function addSlider(name,val){ try{var e=preLayer.Effects.addProperty(CONFIG.effects.slider); e.name=name; e.property("Slider").setValue(val); return e;}catch(_){return null;} }
        function addPoint(name,arr){ try{var e=preLayer.Effects.addProperty(CONFIG.effects.point); e.name=name; e.property("Point").setValue(arr); return e;}catch(_){return null;} }
        function addCheck(name,val){ try{var e=preLayer.Effects.addProperty(CONFIG.effects.checkbox); e.name=name; e.property("Checkbox").setValue(val?1:0); return e;}catch(_){return null;} }

        addPoint("Hover Padding", [0,0]);
        addSlider("Hover Influence Radius", 6);
        addCheck("Hover Reverse", 0);
        addCheck("Pill Mode", 0); // v16.10.6: Ellipse/pill detection mode
        addSlider("Group ID", 0);
        // Per-button cursor type: 0=Default, 1=Link, 2=Text, 3=Loading
        addSlider("EH Cursor Type", 1);

        // Ensure legacy viz controls
        guide_ensureControls_LEGACY(preLayer);
        // Auto-detect rounded corners from content (selection-based buttons only)
        btn_applyDetectedCornerRadius(preLayer);

        var id = btn_register(preLayer);
        if (id && ok) {
            var ctrl = ctrl_ensureExists(); if (!ctrl) return null;
            var num = id.replace(CONFIG.controller.effects.buttonPrefix,"");
            var bnd = ctrl.Effects.property(CONFIG.controller.effects.buttonBoundsPrefix + num);
            // Enforce LIVE bounds expression immediately (selection uses content bbox)
            if (bnd) ui_setExpressionVisible(
                bnd.property("Point"),
                expr_generateBoundsPoint(id),
                [Math.max(1,maxX-minX), Math.max(1,maxY-minY)]
            );
        }
        ui_setExpressionVisible(preLayer.property("Time Remap"), expr_generateTimeRemap(id), 0);

        // Build legacy guide (world-locked)
        guide_ensure(preLayer, id);
        return preLayer;
    }
    function btn_createFromLayer() {
        ui_validateSelections();
        if (!ctrl_find()) throw new Error("No controller. Create first.");
        if (!ctrl_updateReferences()) throw new Error("Controller ref update failed.");

        var mc = comp_findByName(uiState.mainCompName);
        if (!mc) throw new Error("Select a Main Composition in the Setup tab first.");
        if (app.project.activeItem !== mc) throw new Error("Make '" + uiState.mainCompName + "' active.");
        if (mc.selectedLayers.length === 0) { throw new Error("Select at least one layer to create from selection. Use 'Create Template' if you want a template button instead."); }
        var layers = mc.selectedLayers, made = 0, skipped = [];
        for (var i=0;i<layers.length;i++) {
            var L = layers[i]; if (!L) { skipped.push("(unknown)"); continue; }
            // Pre-filter obvious invalid selections to avoid breaking
            var bad=false;
            try{ var ctrl=ctrl_find(); if (ctrl && L===ctrl) bad=true; }catch(_){ }
            try{ if (L && L.comment === CONFIG.controller.comment) bad=true; }catch(_){ }
            try{ if (uiState && uiState.cursorLayerName && L && L.name === uiState.cursorLayerName) bad=true; }catch(_){ }
            try{ var cc=comp_findByName(uiState.cursorCompName); if (cc && L && L.source === cc) bad=true; }catch(_){ }
            try{ if (L && L.comment === CONFIG.buttonComment) bad=true; }catch(_){ }
            if (bad) { skipped.push(L.name); continue; }
            if (btn_createAndSetupFromLayer(L)) made++;
        }
        if (made === 0) {
            var msg = "No valid selection. Avoid selecting the Cursor layer ('"+(uiState.cursorLayerName||"")+"') or the controller ('"+(CONFIG.controller.name||"")+"').";
            throw new Error(msg);
        }
        expr_applyCursorStateSwap(true);
        ctrl_updateStatus();
        if (skipped.length) ui_updateStatus("Created "+made+" hover button(s). Skipped: "+skipped.join(", ")+".", 'warning');
        else ui_updateStatus("Created "+made+" hover button(s).", 'success');
    }
    function btn_createTemplate() {
        ui_validateSelections();
        if (!ctrl_find()) throw new Error("No controller. Create first.");
        if (!ctrl_updateReferences()) throw new Error("Controller ref update failed.");
        var mc = comp_findByName(uiState.mainCompName);
        if (app.project.activeItem !== mc) throw new Error("Make '"+mc.name+"' active.");

        var name = scriptSettings.naming.templateComp + Math.floor(Math.random()*1000);
        var c = app.project.items.addComp(
            name,
            scriptSettings.behavior.templateWidth,
            scriptSettings.behavior.templateHeight,
            1.0,
            scriptSettings.behavior.defaultAnimDuration,
            30
        );
        cache_forget(c.name); // a cached "not found" for this name is now wrong

        var sl = c.layers.addShape();
        sl.name = "Button Content";
        var root = sl.property("ADBE Root Vectors Group");
        var g = root.addProperty("ADBE Vector Group");
        g.name = "Rect";
        var vg = g.property("ADBE Vectors Group");

        var rect = vg.addProperty("ADBE Vector Shape - Rect");
        rect.property("ADBE Vector Rect Size")
            .setValue([scriptSettings.behavior.templateWidth-40, scriptSettings.behavior.templateHeight-40]);

        var fill = vg.addProperty("ADBE Vector Graphic - Fill");
        fill.property("ADBE Vector Fill Color").setValue([0.4,0.6,0.9]);

        var st = vg.addProperty("ADBE Vector Graphic - Stroke");
        st.property("ADBE Vector Stroke Color").setValue([0.2,0.4,0.8]);
        st.property("ADBE Vector Stroke Width").setValue(3);

        var tl = c.layers.addText("Hover Button");
        tl.name = "Button Text";
        tl.property("Transform").property("Position")
            .setValue([scriptSettings.behavior.templateWidth/2, scriptSettings.behavior.templateHeight/2]);

        var L = mc.layers.add(c);
        L.name = scriptSettings.naming.precomp + "Template Button";
        L.startTime = 0; L.inPoint = 0; L.outPoint = mc.duration;

        // Mark this precomp layer as a template-generated button for robust removal detection
        try{ var tf=L.Effects.addProperty(CONFIG.effects.slider); tf.name="EH Template Flag"; tf.property("Slider").setValue(1);}catch(_){ }

        btn_setupFromPrecomp(L);
        if (scriptSettings.behavior.openComp) c.openInViewer();
        ui_updateStatus("Template button created. Edit inside the precomp.", 'success');
    }
	function btn_setupFromPrecomp(preLayer) {
		var btnComp = preLayer.source; 
		if (!btnComp) throw new Error("Button comp missing.");
		
		btnComp.duration = scriptSettings.behavior.defaultAnimDuration;

		preLayer.timeRemapEnabled = true;
		var tr = preLayer.property("Time Remap");
		tr.setValueAtTime(0, 0);
		var almost = btnComp.duration - (1/btnComp.frameRate);
		tr.setValueAtTime(almost, btnComp.duration);
		if (tr.numKeys > 2) tr.removeKey(tr.numKeys);

		// ✨ FIX: Make the template layer span the entire main comp timeline
		try {
			var mainComp = preLayer.containingComp;
			preLayer.startTime = 0;                    // layer begins at comp start
			preLayer.inPoint   = 0;                    // visible from frame 0
			preLayer.outPoint  = mainComp.duration;    // lasts to comp end
		} catch (_) {}

		var cx = btnComp.width/2, cy = btnComp.height/2;
		preLayer.property("Transform").property("Anchor Point").setValue([cx,cy,0]);
		preLayer.property("Transform").property("Position").setValue(preLayer.sourcePointToComp([cx,cy]));

		if (scriptSettings.behavior.collapseTransformations) try{ preLayer.collapseTransformation=true; }catch(_){}
		preLayer.comment = CONFIG.buttonComment;

		function addSlider(name,val){ try{var e=preLayer.Effects.addProperty(CONFIG.effects.slider); e.name=name; e.property("Slider").setValue(val); return e;}catch(_){return null;} }
		function addPoint(name,arr){ try{var e=preLayer.Effects.addProperty(CONFIG.effects.point); e.name=name; e.property("Point").setValue(arr); return e;}catch(_){return null;} }
		function addCheck(name,val){ try{var e=preLayer.Effects.addProperty(CONFIG.effects.checkbox); e.name=name; e.property("Checkbox").setValue(val?1:0); return e;}catch(_){return null;} }

		addPoint("Hover Padding", [0,0]);
		addSlider("Hover Influence Radius", 6);
		addCheck("Hover Reverse", 0);
		addCheck("Pill Mode", 0); // v16.10.6: Ellipse/pill detection mode
		addSlider("Group ID", 0);
		// Per-button cursor type: 0=Default, 1=Link, 2=Text, 3=Loading
		addSlider("EH Cursor Type", 1);

		// Ensure legacy viz controls
		guide_ensureControls_LEGACY(preLayer);
        // Template buttons must start with NO rounded radius: force zero and auto OFF
        try{ preLayer.effect("Detected Corner Radius")("Slider").setValue(0); }catch(_){ }
        try{ preLayer.effect("Hover Corner Radius")("Slider").setValue(0); }catch(_){ }
        try{ preLayer.effect("Auto Corner Radius")("Checkbox").setValue(0); }catch(_){ }

		var id = btn_register(preLayer);
		var ctrl = ctrl_ensureExists();
		if (ctrl) {
			var num = id.replace(CONFIG.controller.effects.buttonPrefix,"");
			var bnd = ctrl.Effects.property(CONFIG.controller.effects.buttonBoundsPrefix + num);
			// Enforce LIVE bounds expression immediately (templates use comp size)
			if (bnd) ui_setExpressionVisible(
				bnd.property("Point"),
				expr_generateBoundsPoint(id),
				[btnComp.width, btnComp.height]
			);
		}
		ui_setExpressionVisible(preLayer.property("Time Remap"), expr_generateTimeRemap(id), 0);
		// Make sure all bounds expressions are live now (no stale first-read)
		try{ expr_ensureLiveBounds(); }catch(_){}

		// Build legacy guide
		guide_ensure(preLayer, id);
	}
	
    function btn_findIdForLayer(layer) {
        var ctrl = ctrl_ensureExists(); if (!ctrl) return null;
        for (var i=1;i<=ctrl.Effects.numProperties;i++) {
            var e = ctrl.Effects.property(i);
            if (e && e.name.indexOf(CONFIG.controller.effects.buttonPrefix)===0) {
                try { if (e.property("Layer").value === layer.index) return e.name; } catch(_) {}
            }
        }
        return null;
    }
    function util_countCompUsageInProject(compItem) {
        var uses=0;
        for (var i=1;i<=app.project.numItems;i++) {
            var it = app.project.item(i);
            if (!(it instanceof CompItem)) continue;
            for (var j=1;j<=it.numLayers;j++) { try { if (it.layer(j).source === compItem) uses++; } catch(_) {} }
        }
        return uses;
    }
    function util_cloneLayerFlags(fromL, toL) {
        try{toL.enabled=fromL.enabled;}catch(_){}
        try{toL.audioEnabled=fromL.audioEnabled;}catch(_){}
        try{toL.shy=fromL.shy;}catch(_){}
        try{toL.solo=fromL.solo;}catch(_){}
        try{toL.motionBlur=fromL.motionBlur;}catch(_){}
        try{toL.adjustmentLayer=fromL.adjustmentLayer;}catch(_){}
        try{toL.guideLayer=fromL.guideLayer;}catch(_){}
        try{toL.label=fromL.label;}catch(_){}
        try{toL.quality=fromL.quality;}catch(_){}
        try{toL.blendingMode=fromL.blendingMode;}catch(_){}
        try{toL.frameBlending=fromL.frameBlending; toL.frameBlendingType=fromL.frameBlendingType;}catch(_){}
        try{toL.trackMatteType=fromL.trackMatteType;}catch(_){}
        try{toL.threeDLayer=fromL.threeDLayer;}catch(_){}
    }
    function btn_remove() {
        var mc = comp_findByName(uiState.mainCompName);
        if (!mc || app.project.activeItem !== mc) throw new Error("Make '"+(mc?mc.name:uiState.mainCompName)+"' active.");
        if (mc.selectedLayers.length===0) throw new Error("Select at least one Hover Button.");

        var list=[], invalid=[];
        for (var i=0;i<mc.selectedLayers.length;i++) {
            var L = mc.selectedLayers[i];
            if (!(L.source instanceof CompItem)) invalid.push(L.name+" (not a precomp)");
            else if (L.comment !== CONFIG.buttonComment) invalid.push(L.name+" (not a hover button)");
            else {
                var bid = btn_findIdForLayer(L);
                if (bid) list.push({layer:L, buttonId:bid}); else invalid.push(L.name+" (not registered)");
            }
        }
        if (!list.length) { var m="No valid Hover Buttons."; if (invalid.length) m+="\n\nInvalid:\n"+invalid.join("\n"); throw new Error(m); }

        if (scriptSettings.behavior.showConfirm) {
            if (!confirm("Remove "+list.length+" button(s)?")) { ui_updateStatus("Cancelled.", 'info'); return; }
        }

        var removedTemplates=0, reverted=0;
        for (var k=0;k<list.length;k++) {
            var pre = list[k].layer, comp = pre.source;

            // Detect template-generated buttons robustly
            var isTemplate = false;
            try {
                try { isTemplate = Math.round(pre.effect("EH Template Flag")("Slider").value||0) > 0; } catch(__) { isTemplate = false; }
                if (!isTemplate) isTemplate = !!(comp && comp.name && scriptSettings && scriptSettings.naming && comp.name.indexOf(scriptSettings.naming.templateComp)===0);
            } catch(_){ isTemplate=false; }
            if (isTemplate){
                try{ guide_remove(pre); }catch(__){}
                try{ btn_unregister(list[k].buttonId); }catch(__){}
                try{ pre.remove(); }catch(__){}
                try{ if (util_countCompUsageInProject(comp)===0) comp.remove(); }catch(__){}
                removedTemplates++;
                continue;
            }

            var world = null;
            // sourcePointToComp, not toWorld: toWorld is expression-only and threw here on
            // every revert, so `world` always came from the catch. That happened to be close
            // enough for an unparented layer, which is why it went unnoticed.
            try { world = pre.sourcePointToComp(pre.property("Transform").property("Anchor Point").value); }
            catch(_) { world = pre.property("Transform").property("Position").value; }

            var prePrefix = scriptSettings.naming.precomp;
            var original = comp.name; if (original.indexOf(prePrefix)===0) original = original.substring(prePrefix.length);

            var expect = scriptSettings.naming.animationLayer + original, inner=null;
            for (var i2=1;i2<=comp.numLayers;i2++){ var l2=comp.layer(i2); if (l2 && l2.name===expect){ inner=l2; break; } }
            if (!inner) try { inner = comp.layer(1); } catch(_) {}
            if (!inner) { invalid.push(pre.name+" (internal layer missing)"); continue; }

            comp.openInViewer();
            for (var a=1;a<=comp.numLayers;a++) comp.layer(a).selected=false;
            inner.selected=true; app.executeCommand(app.findMenuCommandId("Copy"));

            mc.openInViewer();
            for (var b=1;b<=mc.numLayers;b++) mc.layer(b).selected=false;
            app.executeCommand(app.findMenuCommandId("Paste"));
            if (mc.selectedLayers.length===0) continue;
            var restored = mc.selectedLayers[0];

            util_cloneLayerFlags(pre, restored);
            try { restored.parent = pre.parent; } catch(_) {}
            restored.moveBefore(pre);

            // Restore original scale on the reverted layer.
            // Priority 1: use stored original scale from the precomp layer's effects.
            // Priority 2: detect our demo pattern (base -> base*1.2) and restore base.
            try {
                var scOut = restored.property("Transform").property("Scale");
                var restoredBase = null;
                try {
                    var effBase = pre.effect("EH Orig Scale");
                    if (effBase) {
                        var p = effBase("Point").value; restoredBase = [p[0]||100, p[1]||100];
                        var effZ = pre.effect("EH Orig Scale Z");
                        if (effZ) { var z = effZ("Slider").value; restoredBase = [restoredBase[0], restoredBase[1], z||100]; }
                    }
                } catch(__){}
                if (restoredBase) {
                    try { while (scOut.numKeys>0) scOut.removeKey(scOut.numKeys); }catch(__){}
                    try { scOut.setValue(restoredBase); }catch(__){}
                } else {
                    // Fallback: pattern detection from the inner layer
                    var scIn  = inner.property("Transform").property("Scale");
                    var shouldClear = false; var v1 = null;
                    if (scIn && scOut && scIn.numKeys === 2) {
                        var t1 = scIn.keyTime(1), t2 = scIn.keyTime(2);
                        v1 = scIn.keyValue(1); var v2 = scIn.keyValue(2);
                        var dur = (comp && comp.duration) ? comp.duration : 0;
                        function _approx(a,b){ return Math.abs(a-b) < 0.001; }
                        function _approxArray(a,b){ if (!a || !b) return false; var n=Math.min(a.length,b.length); for (var i=0;i<n;i++) if (!_approx(a[i],b[i])) return false; return true; }
                        if (_approx(t1,0) && _approx(t2,dur) && v1 && v2) {
                            var exp = []; for (var i=0;i<v1.length;i++) exp[i] = v1[i]*1.2;
                            if (_approxArray(v2, exp)) shouldClear = true;
                        }
                    }
                    if (shouldClear) {
                        try { while (scOut.numKeys>0) scOut.removeKey(scOut.numKeys); }catch(__){}
                        try { if (v1) scOut.setValue(v1); }catch(__){}
                    } else {
                        // Last resort: check the restored layer's own keys for the same +20% pattern
                        try{
                            if (scOut && scOut.numKeys === 2) {
                                var ov1 = scOut.keyValue(1), ov2 = scOut.keyValue(2);
                                var oexp=[]; for (var i=0;i<ov1.length;i++) oexp[i] = ov1[i]*1.2;
                                if (ov1 && ov2 && _approxArray(ov2, oexp)){
                                    while (scOut.numKeys>0) scOut.removeKey(scOut.numKeys);
                                    scOut.setValue(ov1);
                                }
                            }
                        }catch(__){}
                    }
                }
            } catch(__){}

            try {
                // compPointToSource is the scripting-DOM inverse of sourcePointToComp;
                // fromWorld is expression-only and threw, losing the restored position.
                var loc = restored.compPointToSource(world);
                if (restored.threeDLayer) {
                    if (loc.length<3) loc=[loc[0],loc[1],0];
                    restored.property("Transform").property("Position").setValue([loc[0],loc[1],loc[2]||0]);
                } else {
                    restored.property("Transform").property("Position").setValue([loc[0],loc[1]]);
                }
            } catch(_){ }

            try { restored.name = original; } catch(_) {}
            try { restored.comment = ""; } catch(_){ }

            for (var ci=1;ci<=mc.numLayers;ci++) {
                try { var ch=mc.layer(ci); if (ch && ch.parent === pre) ch.parent = restored; } catch(_) {}
            }

            try{ guide_remove(pre); }catch(__){}
            try{ btn_unregister(list[k].buttonId); }catch(__){}
            try{ pre.remove(); }catch(__){}
            try{ if (util_countCompUsageInProject(comp)===0) comp.remove(); }catch(__){}
            reverted++;
        }

        if ((removedTemplates+reverted)>0) { expr_applyCursorStateSwap(true); ctrl_updateStatus(); }
        var parts=[];
        if (reverted) parts.push("reverted "+reverted+" button(s)");
        if (removedTemplates) parts.push("removed "+removedTemplates+" template button(s)");
        var msg = parts.length ? ("Done: "+parts.join(", ")+".") : "No changes.";
        if (invalid.length) msg += "\nSkipped:\n• " + invalid.join("\n• ");
        ui_updateStatus(msg, 'success');
    }

// ==== packages/ae-hoverpanel/src/72-button-batch.jsxinc ====
/**
 * Batch and maintenance operations: duplicate, bulk property edits, name
 * resync, and repair of broken controller references.
 */
    // Removed: ensureHoverProgressControl - obsolete in v16.3.1 multi-cursor system

    function btn_duplicate(srcLayer, opts) {
        // Guard: must be an EH hover button precomp layer
        if (!srcLayer || srcLayer.comment !== CONFIG.buttonComment || !(srcLayer.source instanceof CompItem)) {
            try { ui_updateStatus("Skipping non-hover layer '"+(srcLayer && srcLayer.name || "")+"'.", 'warning'); } catch(_){ }
            return null;
        }
        opts = opts || {};
        var dup = srcLayer.duplicate();
        try {
            var p = dup.property("Transform").property("Position").value;
            var dx = (opts.offset && opts.offset[0])||0, dy = (opts.offset && opts.offset[1])||0;
            if (dup.threeDLayer && p.length>=3) dup.property("Transform").property("Position").setValue([p[0]+dx,p[1]+dy,p[2]]);
            else dup.property("Transform").property("Position").setValue([p[0]+dx,p[1]+dy]);
        } catch(_) {}
        try { dup.name = srcLayer.name + (opts.suffix||" Copy"); } catch(_) {}
        if (opts.independent) { try{ var newSrc = srcLayer.source.duplicate(); dup.source = newSrc; cache_forget(newSrc.name); }catch(_){ } }
        var id = btn_register(dup);
        ui_setExpressionVisible(dup.property("Time Remap"), expr_generateTimeRemap(id), 0);
        // ensure legacy controls + guide
        guide_ensureControls_LEGACY(dup);
        // Re-run detection for duplicates
        btn_applyDetectedCornerRadius(dup);
        guide_ensure(dup, id);
        try { btn_recalculateBounds(dup, id); } catch(_){ }
        return dup;
    }
    function batch_applyToLayers(layers, props) {
        for (var i=0;i<layers.length;i++) {
            var L = layers[i]; if (L.comment !== CONFIG.buttonComment) continue;

            // Ensure legacy controls exist
            guide_ensureControls_LEGACY(L);

            function setSlider(n,v){ try{ L.effect(n)("Slider").setValue(v); }catch(_){ try{ var e=L.Effects.addProperty(CONFIG.effects.slider); e.name=n; e.property("Slider").setValue(v);}catch(__){} } }
            function setPoint(n,arr){ try{ L.effect(n)("Point").setValue(arr); }catch(_){ try{ var e=L.Effects.addProperty(CONFIG.effects.point); e.name=n; e.property("Point").setValue(arr);}catch(__){} } }
            function setCheck(n,v){ try{ L.effect(n)("Checkbox").setValue(v?1:0); }catch(_){ try{ var e=L.Effects.addProperty(CONFIG.effects.checkbox); e.name=n; e.property("Checkbox").setValue(v?1:0);}catch(__){} } }
            function setColorOld(name, rgb01){
                try{ L.effect(name)("Color").setValue(rgb01); }
                catch(_){
                    try{ var c=L.Effects.addProperty(CONFIG.effects.color); c.name=name; c.property("Color").setValue(rgb01);}catch(__){}
                }
            }

            if (props.padding) setPoint("Hover Padding", props.padding);
            if (typeof props.influence === "number") setSlider("Hover Influence Radius", props.influence);
            if (typeof props.reverse === "number") setCheck("Hover Reverse", props.reverse>0);
            if (typeof props.groupId === "number") setSlider("Group ID", props.groupId);
            if (typeof props.cursorType === "number") setSlider("EH Cursor Type", props.cursorType);

            if (props.viz) {
                // legacy names
                setSlider("Hover Corner Radius", props.viz.corner);
                setSlider("EH Viz – Core Width", props.viz.coreW);
                setSlider("EH Viz – Infl Width", props.viz.inflW);
                setSlider("EH Viz – Dash Length", props.viz.dash);
                setSlider("EH Viz – Gap Length",  props.viz.gap);
                if (props.viz.coreColor) setColorOld("EH Viz – Core Color", props.viz.coreColor);   // [0..1,0..1,0..1]
                if (props.viz.inflColor) setColorOld("EH Viz – Infl Color", props.viz.inflColor);

                // keep new sliders too (for users who rely on them in expressions elsewhere)
                setSlider("Viz Corner", props.viz.corner);
                setSlider("Viz Core W", props.viz.coreW);
                setSlider("Viz Infl W", props.viz.inflW);
                setSlider("Viz Dash",   props.viz.dash);
                setSlider("Viz Gap",    props.viz.gap);
                if (props.viz.coreColor){
                    setSlider("Viz Core R", Math.round(props.viz.coreColor[0]*255));
                    setSlider("Viz Core G", Math.round(props.viz.coreColor[1]*255));
                    setSlider("Viz Core B", Math.round(props.viz.coreColor[2]*255));
                }
                if (props.viz.inflColor){
                    setSlider("Viz Infl R", Math.round(props.viz.inflColor[0]*255));
                    setSlider("Viz Infl G", Math.round(props.viz.inflColor[1]*255));
                    setSlider("Viz Infl B", Math.round(props.viz.inflColor[2]*255));
                }
            }
            var id = btn_findIdForLayer(L); if (id) guide_ensure(L, id);
        }
    }

    function btn_resyncNames() {
        var ctrl = ctrl_find(), mc = comp_findByName(uiState.mainCompName);
        if (!ctrl || !mc) return 0;
        var n=0;
        for (var i=1;i<=ctrl.Effects.numProperties;i++) {
            var e = ctrl.Effects.property(i);
            if (!e || e.name.indexOf(CONFIG.controller.effects.buttonPrefix)!==0) continue;
            try {
                var idx = e.property("Layer").value; if (idx<=0 || idx>mc.numLayers) continue;
                var L = mc.layer(idx); if (!L || L.comment !== CONFIG.buttonComment) continue;
                var idNum = e.name.replace(CONFIG.controller.effects.buttonPrefix,"");
                var nameCtrl = ctrl.Effects.property(CONFIG.controller.effects.buttonOriginalNamePrefix + idNum);
                if (!nameCtrl) continue;
                var nm = L.name, c1=nm.length?nm.charCodeAt(0):0, c2=nm.length>1?nm.charCodeAt(1):0;
                nameCtrl.property("Point").setValue([c1,c2]); n++;
            } catch(_) {}
        }
        return n;
    }
    /**
     * Repair Button_ references whose layer index no longer resolves.
     *
     * Two identification strategies, in order of trustworthiness:
     *   1. ButtonNameHash_  - FNV-1a of the full layer name. Reliable.
     *   2. ButtonOriginalName_ - the first TWO characters only, stored as char codes.
     *      Kept for entries written before the hash existed.
     *
     * The second is weak enough to be dangerous. It used to relink to the FIRST layer
     * whose name started with those two characters, so a project with "Btn Home" and
     * "Btn About" gave every broken reference the same match — silently wiring buttons to
     * the wrong layers, which is worse than leaving them broken because the user has no
     * reason to look. It now requires a UNIQUE match and otherwise reports the reference
     * as ambiguous and leaves it alone.
     *
     * Layers already claimed by another Button_ entry are excluded from both strategies:
     * one layer can only be one button, and two entries pointing at the same layer would
     * make one button drive the other.
     *
     * Returns { fixed, ambiguous, unresolved }.
     */
    function ctrl_fixBrokenReferences(){
        var ctrl = ctrl_ensureExists(); if (!ctrl) return { fixed:0, ambiguous:0, unresolved:0 };
        var mc = comp_findByName(uiState.mainCompName); if (!mc) return { fixed:0, ambiguous:0, unresolved:0 };

        var fixed = 0, ambiguous = 0, unresolved = 0;
        var broken = [];
        var claimed = {}; // layer index -> true, for references that still resolve

        // First pass: separate healthy references from broken ones, and record which
        // layers are already spoken for. Done before any repair so a relink cannot claim
        // a layer that a later healthy entry already owns.
        for (var i=1;i<=ctrl.Effects.numProperties;i++){
            var e = ctrl.Effects.property(i);
            if (!e || !e.name || e.name.indexOf(CONFIG.controller.effects.buttonPrefix)!==0) continue;
            var rest = e.name.substring(CONFIG.controller.effects.buttonPrefix.length);
            if (!/^[0-9]+$/.test(rest)) continue; // ButtonData_ etc. share the "Button" stem
            var healthy = false, idx = 0;
            try { idx = e.property('Layer').value; var probe = mc.layer(idx).name; healthy = !!probe; }
            catch(_){ healthy = false; }
            if (healthy) claimed[idx] = true;
            else broken.push({ eff: e, idNum: rest });
        }

        for (var b=0;b<broken.length;b++){
            var eff = broken[b].eff, idNum = broken[b].idNum;

            // --- Strategy 1: full-name hash ---
            var hit = null;
            var wantHash = btn_readNameHash(ctrl, idNum);
            if (wantHash !== null){
                for (var j=1;j<=mc.numLayers;j++){
                    try{
                        var c = mc.layer(j);
                        if (!c || claimed[c.index]) continue;
                        if (c.comment !== CONFIG.buttonComment) continue;
                        if (util_hashString(c.name||'') === wantHash){ hit = c; break; }
                    }catch(__){}
                }
            }

            // --- Strategy 2: two-character hint, unique match only ---
            if (!hit){
                var nameCtrl = ctrl.Effects.property(CONFIG.controller.effects.buttonOriginalNamePrefix + idNum);
                if (nameCtrl){
                    var s = '';
                    try{
                        var pt = nameCtrl.property('Point').value;
                        if (Math.round(pt[0])>0) s += String.fromCharCode(Math.round(pt[0]));
                        if (Math.round(pt[1])>0) s += String.fromCharCode(Math.round(pt[1]));
                    }catch(__){ s = ''; }

                    if (s.length > 0){
                        var candidates = [];
                        for (var j2=1;j2<=mc.numLayers;j2++){
                            try{
                                var c2 = mc.layer(j2);
                                if (!c2 || claimed[c2.index]) continue;
                                if (c2.comment !== CONFIG.buttonComment) continue;
                                if (String(c2.name||'').indexOf(s) === 0) candidates.push(c2);
                            }catch(__){}
                        }
                        if (candidates.length === 1) hit = candidates[0];
                        else if (candidates.length > 1) ambiguous++;
                    }
                }
            }

            if (hit){
                try{
                    eff.property('Layer').setValue(hit.index);
                    claimed[hit.index] = true;
                    fixed++;
                }catch(__){ unresolved++; }
            }
        }

        unresolved = broken.length - fixed - ambiguous;
        if (unresolved < 0) unresolved = 0;
        return { fixed: fixed, ambiguous: ambiguous, unresolved: unresolved };
    }

    function ctrl_cleanupInvalidRefs(){
    var mc = comp_findByName(uiState.mainCompName); if (!mc) { ui_updateStatus("Select a Main Comp first.", 'error'); return; }
    var ctrl = ctrl_find(); if (!ctrl) { ui_updateStatus("No controller to clean.", 'warning'); return; }
    var cleaned = 0;
    for (var k = ctrl.Effects.numProperties; k >= 1; k--) {
        var e = ctrl.Effects.property(k);
        if (!e) continue;
        if (e.name.indexOf(CONFIG.controller.effects.buttonPrefix) === 0) {
            try { var idx = e.property('Layer').value; if (idx === 0 || idx > mc.numLayers) { e.remove(); cleaned++; } }
            catch(_) { try{ e.remove(); cleaned++; }catch(__){} }
        }
    }
    var maxId = btn_recalcMaxId(ctrl);
    ui_updateStatus('Cleaned ' + cleaned + ' invalid references. Max Button Id: ' + maxId + '.', cleaned ? 'success' : 'info');
    try{ ctrl_updateStatus(); }catch(_){ }
}

// ==== packages/ae-hoverpanel/src/74-settings-io.jsxinc ====
/**
 * Save and reset settings across scopes.
 */
    // -------------------------
    // UI with PER-TAB STYLE
    // -------------------------
    
function settings_reset(scope){
    scope = scope || "both";
    var mc = comp_findByName(uiState.mainCompName) || app.project.activeItem;
    var ctrl = ctrl_find();
    if (scope==="project" || scope==="both"){
        try { if (ctrl) settings_writeToProject(ctrl, {}); } catch(e){}
    }
    if (scope==="app" || scope==="both"){
        try { if (app.settings.haveSetting(PREFS_SECTION, PREFS_KEY)) app.settings.saveSetting(PREFS_SECTION, PREFS_KEY, "{}"); } catch(e){}
    }
    try { var f = settings_getFilePath(); if (f && f.exists) { var bak=new File(f.fullName + ".bak"); if (bak.exists) bak.remove(); f.copy(bak.fullName); f.remove(); } } catch(e){}
    scriptSettings = settings_merge({}, CONFIG);
    uiState = ui_mergeStateFromSettings(scriptSettings);
    ui_updateStatus("Settings reset ("+scope+").", 'warning');
}
// Global: save settings to project/app/file
function settings_save(scope){
    scope = (scope || "both").toLowerCase(); // "project" | "app" | "both"
    // Keep runtime mirrors tidy (never throw here)
    try { scriptSettings = settings_merge(scriptSettings || {}, CONFIG); } catch(_){ }
    try { uiState = ui_mergeStateFromSettings(scriptSettings); } catch(_){ }

    var payload = "{}";
    try { payload = JSON.stringify(scriptSettings); } catch(_){ payload = "{}"; }

    var issues = [];
    // Project-local (hidden text layer on the controller)
    if (scope === "project" || scope === "both") {
        try {
            var ctrl = null; try{ ctrl = ctrl_find(); }catch(__){}
            if (ctrl) {
                if (!settings_writeToProject(ctrl, scriptSettings)) issues.push("project prefs write failed");
            } else {
                // No controller → skip silently
            }
        } catch(eProj){ issues.push("project: "+(eProj && eProj.message ? eProj.message : "unknown error")); }
    }

    // App-level AE prefs
    if (scope === "app" || scope === "both") {
        try { app.settings.saveSetting(PREFS_SECTION, PREFS_KEY, payload); } catch(eApp){ issues.push("app prefs: "+(eApp && eApp.message ? eApp.message : "unknown error")); }
    }

    // Legacy JSON file (keep it as a portable backup)
    try {
        var f = settings_getFilePath();
        if (!f) throw new Error("Cannot locate a writable settings folder (Folder.userData is unavailable).");
        try{ f.encoding = "UTF-8"; }catch(_){ }
        if (!f.open("w")) throw new Error("Could not open file for writing");
        f.write(payload);
        f.close();
    } catch(eFile) { issues.push("file backup: "+(eFile && eFile.message ? eFile.message : "unknown error")); }

    if (issues.length){
        try { ui_updateStatus("Settings saved with warnings: "+issues.join("; "), 'warning'); }catch(_){ }
    } else {
        try { ui_updateStatus("Settings saved.", 'success'); }catch(_){ }
    }
    return true; // never block user with modal alert here
}
// -------------------------
// Settings dialog (modal) - scope controls live here
// -------------------------
function ui_showSettingsDialog(){
    // Unified: delegate to the full settings dialog
    return ui_openSettingsDialog();
}

// ==== packages/ae-hoverpanel/src/76-hover-presets.jsxinc ====
/**
 * Hover presets: one click to drive a common property from hover.
 *
 * WHAT THIS REPLACES
 * The 16.x "Original" variant had addButtonEffects(), which stamped Default/Hover
 * Scale, Opacity, Rotation, Blur and Colour controls onto a button and wired each to
 * its own expression. The idea was good; that implementation was not usable:
 *   - comp names were hardcoded to "EH - Easy Hover" and "Cursor", so it only worked
 *     in the project it was written in;
 *   - it created two different effects both named "Hover Blur" (a slider and a
 *     Gaussian Blur), so effect("Hover Blur") resolved to whichever came first;
 *   - hover was a hard boolean, so properties snapped instead of easing;
 *   - it overwrote transform.scale outright, discarding the user's own animation.
 *
 * These presets instead ride the existing Hover Progress slider (0-100), which already
 * carries the scale-corrected, corner-aware, pill-aware distance field. So a preset is
 * only a mapping from that number onto one property, and every preset inherits correct
 * geometry for free.
 *
 * Every generated expression is relative to `value`, never absolute. `value` is the
 * property's own keyframed result, so a preset layers on top of existing animation
 * rather than replacing it — a button that already scales up over time still does, and
 * hover multiplies it.
 */

    // Effect names are prefixed so a preset's controls group together in the Effect
    // Controls panel and cannot collide with the hover-geometry controls.
    var PRESET_PREFIX = "EH ";

    /**
     * The available presets.
     *
     * `target` names where the expression goes; `apply` builds it. Kept as data so the
     * UI list and the apply logic cannot disagree about what exists.
     */
    function preset_definitions() {
        return [
            {
                id: "scale",
                label: "Scale",
                control: "Hover Scale",
                unit: "%",
                defaultValue: 110,
                help: "Scales the button toward this percentage while hovered.",
                target: "scale"
            },
            {
                id: "opacity",
                label: "Opacity",
                control: "Hover Opacity",
                unit: "%",
                defaultValue: 100,
                help: "Fades the button toward this opacity while hovered.",
                target: "opacity"
            },
            {
                id: "rotation",
                label: "Rotation",
                control: "Hover Rotation",
                unit: "degrees",
                defaultValue: 15,
                help: "Rotates the button by this many degrees while hovered.",
                target: "rotation"
            },
            {
                id: "blur",
                label: "Blur",
                control: "Hover Blur",
                unit: "px",
                defaultValue: 10,
                help: "Blurs the button toward this radius while hovered.",
                target: "blur"
            }
        ];
    }

    function preset_find(id) {
        var all = preset_definitions();
        for (var i = 0; i < all.length; i++) if (all[i].id === id) return all[i];
        return null;
    }

    /**
     * The shared opening of every preset expression: hover as a 0..1 amount.
     *
     * Reads Hover Progress off this same layer rather than recomputing distance, so a
     * preset costs one property read per frame and can never disagree with the hover
     * region the cursor uses.
     */
    function preset_amountLines() {
        return [
            "// " + PKG_INFO.name + " hover preset",
            "// Driven by this layer's Hover Progress slider (0-100).",
            "var amount = 0;",
            "try { amount = effect('Hover Progress')('Slider') / 100; } catch (e) { amount = 0; }",
            "if (!isFinite(amount)) amount = 0;",
            "amount = Math.max(0, Math.min(1, amount));"
        ];
    }

    /** Build the expression for one preset. */
    function preset_generateExpression(def) {
        var control = util_escapeNameForExpression(PRESET_PREFIX + def.control);
        var s = preset_amountLines();
        s.push("var target = " + def.defaultValue + ";");
        s.push("try { target = effect('" + control + "')('Slider'); } catch (e) {}");
        s.push("if (!isFinite(target)) target = " + def.defaultValue + ";");

        if (def.target === "scale") {
            // Multiplicative and per-component, so a non-uniform or animated scale keeps
            // its proportions and its keyframes.
            s.push("var factor = 1 + ((target / 100) - 1) * amount;");
            s.push("var out = [];");
            s.push("for (var i = 0; i < value.length; i++) out[i] = value[i] * factor;");
            s.push("out;");
        } else if (def.target === "opacity") {
            s.push("value + (target - value) * amount;");
        } else if (def.target === "rotation") {
            s.push("value + target * amount;");
        } else if (def.target === "blur") {
            // Blur starts from the property's own value so a button that is already
            // blurred stays blurred at rest.
            s.push("value + target * amount;");
        } else {
            s.push("value;");
        }
        return s.join("\n");
    }

    /** Add the preset's slider to the layer if it is not already there. */
    function preset_ensureControl(layer, def) {
        var name = PRESET_PREFIX + def.control;
        var eff = null;
        try { eff = layer.Effects.property(name); } catch (e) { eff = null; }
        if (eff) return eff;
        try {
            eff = layer.Effects.addProperty(CONFIG.effects.slider);
            eff.name = name;
            eff.property("Slider").setValue(def.defaultValue);
        } catch (e2) {
            return null;
        }
        return eff;
    }

    /**
     * The property a preset drives.
     *
     * Blur needs an effect to exist first. It is created with a distinct name — the
     * 16.x version gave the slider and the Gaussian Blur the same name, so
     * effect("Hover Blur") was ambiguous and resolved to whichever came first.
     */
    function preset_targetProperty(layer, def) {
        try {
            if (def.target === "scale") return layer.property("Transform").property("Scale");
            if (def.target === "opacity") return layer.property("Transform").property("Opacity");
            if (def.target === "rotation") return layer.property("Transform").property("Rotation");
            if (def.target === "blur") {
                var blurName = PRESET_PREFIX + "Hover Blur Effect";
                var fx = null;
                try { fx = layer.Effects.property(blurName); } catch (e) { fx = null; }
                if (!fx) {
                    fx = layer.Effects.addProperty("ADBE Gaussian Blur 2");
                    fx.name = blurName;
                    try { fx.property("Blurriness").setValue(0); } catch (e2) {}
                    try { fx.property("Repeat Edge Pixels").setValue(1); } catch (e3) {}
                }
                return fx.property("Blurriness");
            }
        } catch (e4) {}
        return null;
    }

    /**
     * Apply a preset to one layer. Returns true on success.
     *
     * Requires a Hover Progress slider, and adds one if missing, so a preset works on a
     * button that was only ever set up for time remap.
     */
    function preset_apply(layer, presetId) {
        var def = preset_find(presetId);
        if (!def || !layer) return false;

        // Without this the expression reads a missing effect and the preset silently
        // does nothing.
        var hasProgress = false;
        try { hasProgress = !!layer.Effects.property("Hover Progress"); } catch (e) { hasProgress = false; }
        if (!hasProgress) {
            var buttonId = btn_findIdForLayer(layer);
            if (!buttonId) return false;
            try {
                var slider = layer.Effects.addProperty(CONFIG.effects.slider);
                slider.name = "Hover Progress";
                ui_setExpressionVisible(slider.property("Slider"), expr_generateHoverProgress(buttonId), 0);
            } catch (e2) {
                return false;
            }
        }

        if (!preset_ensureControl(layer, def)) return false;
        var prop = preset_targetProperty(layer, def);
        if (!prop) return false;

        ui_setExpressionVisible(prop, preset_generateExpression(def), prop.value);
        return true;
    }

    /**
     * Remove a preset from a layer: clear the expression it set and delete its control.
     *
     * The property keeps whatever value it had, so removing a preset does not move the
     * button.
     */
    function preset_remove(layer, presetId) {
        var def = preset_find(presetId);
        if (!def || !layer) return false;
        var prop = null;
        try { prop = preset_targetProperty(layer, def); } catch (e) { prop = null; }
        if (prop) {
            // Via the helper: a locked button layer would refuse the clear and the throw
            // would be swallowed, leaving the preset's expression driving a property whose
            // control has just been deleted — the property would then fall back to its
            // catch value and appear to jump.
            try { if (prop.expressionEnabled) ui_setExpressionVisible(prop, "", prop.value); } catch (e2) {}
        }
        var names = [PRESET_PREFIX + def.control];
        if (def.target === "blur") names.push(PRESET_PREFIX + "Hover Blur Effect");
        for (var i = 0; i < names.length; i++) {
            try {
                var eff = layer.Effects.property(names[i]);
                if (eff) eff.remove();
            } catch (e3) {}
        }
        return true;
    }

    /** Which presets are currently applied to a layer. */
    function preset_appliedTo(layer) {
        var out = [];
        var all = preset_definitions();
        for (var i = 0; i < all.length; i++) {
            try {
                if (layer.Effects.property(PRESET_PREFIX + all[i].control)) out.push(all[i].id);
            } catch (e) {}
        }
        return out;
    }

    /** Apply one preset across a selection, reporting how many succeeded. */
    function preset_applyToSelection(presetId) {
        var mc = comp_findByName(uiState.mainCompName);
        if (!mc) throw new Error("Main Composition not found.");
        var sel = mc.selectedLayers;
        if (!sel || !sel.length) throw new Error("Select one or more button layers first.");

        var done = 0, skipped = 0;
        for (var i = 0; i < sel.length; i++) {
            if (btn_findIdForLayer(sel[i])) {
                if (preset_apply(sel[i], presetId)) done++; else skipped++;
            } else {
                skipped++;
            }
        }
        return { applied: done, skipped: skipped };
    }

// ==== packages/ae-hoverpanel/src/78-teardown.jsxinc ====
/**
 * Complete removal of HoverPanel from a project.
 *
 * WHY THIS NEEDS TO EXIST
 * Removing buttons one at a time leaves residue: expressions on properties nobody
 * thinks to check, guide layers, preset controls, the hidden preferences layer, the
 * controller null. The residue is worse than untidy — an expression referencing a
 * deleted controller throws on every frame, so After Effects reports errors on a
 * project the user believes is clean, and rendering slows down.
 *
 * Handing a project to a client, or to a renderer that has no scripts installed, needs
 * one action that leaves no trace.
 *
 * The recursive property walk is the part the per-button removal never had: an
 * expression can sit on any property at any depth, including inside shape groups and
 * effect parameters, so a flat pass over Transform misses most of them.
 */

    /**
     * Clear expressions from every property under `group`, depth-first.
     *
     * Returns how many were cleared. `depth` guards against the pathological nesting a
     * deeply grouped shape layer can produce; ExtendScript has no tail calls and a
     * runaway recursion takes After Effects down rather than raising.
     */
    function teardown_clearExpressions(group, depth) {
        depth = depth || 0;
        if (!group || depth > 24) return 0;
        var cleared = 0;
        var count = 0;
        try { count = group.numProperties; } catch (e) { return 0; }

        for (var i = 1; i <= count; i++) {
            var prop = null;
            try { prop = group.property(i); } catch (e2) { continue; }
            if (!prop) continue;

            // PropertyType.PROPERTY === 6212, INDEXED_GROUP/NAMED_GROUP are groups.
            var isGroup = false;
            try { isGroup = (prop.numProperties !== undefined && prop.numProperties > 0); } catch (e3) {}

            try {
                if (prop.canSetExpression && prop.expressionEnabled) {
                    // Via the helper: the controller and the guide layers are shy and
                    // locked, and a raw clear on those silently does nothing — leaving
                    // exactly the dangling expressions this function exists to remove.
                    ui_setExpressionVisible(prop, "", prop.value);
                    if (!prop.expressionEnabled) cleared++;
                }
            } catch (e4) {}

            if (isGroup) cleared += teardown_clearExpressions(prop, depth + 1);
        }
        return cleared;
    }

    /** Clear every expression on a layer, including effects and shape contents. */
    function teardown_clearLayerExpressions(layer) {
        if (!layer) return 0;
        var cleared = 0;
        // Walk the layer itself: Transform, Effects, Contents, Time Remap and Masks all
        // hang off it, so one recursive pass covers them.
        cleared += teardown_clearExpressions(layer, 0);
        return cleared;
    }

    /**
     * Everything HoverPanel adds to a project, for reporting before removal.
     *
     * Presented to the user before anything is deleted, because this is destructive and
     * "remove everything" deserves an accurate inventory rather than a vague warning.
     */
    function teardown_survey() {
        var report = {
            controller: null,
            buttons: 0,
            guides: 0,
            prefsLayers: 0,
            presetControls: 0,
            comps: 0
        };
        var mc = comp_findByName(uiState.mainCompName);
        if (!mc) return report;

        var ctrl = ctrl_find();
        report.controller = ctrl ? ctrl.name : null;

        var registered = ctrl_getRegisteredButtons();
        report.buttons = registered.length;
        for (var i = 0; i < registered.length; i++) {
            report.presetControls += preset_appliedTo(registered[i].layer).length;
        }

        for (var j = 1; j <= mc.numLayers; j++) {
            var L = null;
            try { L = mc.layer(j); } catch (e) { continue; }
            if (!L) continue;
            if (L.name === "EH_TIMEREMAP_PROJECT_PREFS") report.prefsLayers++;
            try {
                if (L.name && L.name.indexOf(CONFIG.guidePrefix) === 0) report.guides++;
            } catch (e2) {}
        }
        report.comps = 1;
        return report;
    }

    /**
     * Remove everything this script added from the main comp.
     *
     * Order matters. Expressions are cleared before layers are deleted: deleting the
     * controller first would leave every dependent expression referencing a missing
     * layer, and After Effects marks those as errors that survive the undo.
     *
     * Runs inside a single undo group via util_wrapOperation, so one Ctrl+Z restores
     * the whole setup.
     */
    function teardown_removeAll() {
        var mc = comp_findByName(uiState.mainCompName);
        if (!mc) throw new Error("Main Composition not found.");

        var result = { expressions: 0, layers: 0, effects: 0 };

        // 1. Expressions on every registered button and its source comp, while the
        //    controller still exists for them to reference.
        var registered = ctrl_getRegisteredButtons();
        for (var i = 0; i < registered.length; i++) {
            var L = registered[i].layer;
            if (!L) continue;
            result.expressions += teardown_clearLayerExpressions(L);

            // Preset controls and their blur effect.
            var applied = preset_appliedTo(L);
            for (var p = 0; p < applied.length; p++) {
                if (preset_remove(L, applied[p])) result.effects++;
            }

            // Inside the button's own comp: the animation layer may carry expressions
            // that reference the controller.
            try {
                if (L.source && L.source instanceof CompItem) {
                    for (var k = 1; k <= L.source.numLayers; k++) {
                        result.expressions += teardown_clearLayerExpressions(L.source.layer(k));
                    }
                }
            } catch (e) {}
        }

        // 2. The cursor comp's layers, whose opacity is driven by the controller.
        try {
            var cc = comp_findByName(uiState.cursorCompName);
            if (cc) {
                for (var c = 1; c <= cc.numLayers; c++) {
                    result.expressions += teardown_clearLayerExpressions(cc.layer(c));
                }
            }
        } catch (e2) {}

        // 3. The cursor layer in the main comp.
        try {
            if (uiState.cursorLayerName) {
                var cl = mc.layer(uiState.cursorLayerName);
                if (cl) result.expressions += teardown_clearLayerExpressions(cl);
            }
        } catch (e3) {}

        // 4. Only now remove the layers this script created. Iterating downward so
        //    deleting a layer cannot shift an index we have not visited yet.
        for (var n = mc.numLayers; n >= 1; n--) {
            var layer = null;
            try { layer = mc.layer(n); } catch (e4) { continue; }
            if (!layer) continue;

            var isOurs = false;
            try {
                if (layer.comment === CONFIG.controller.comment) isOurs = true;
                if (layer.name === "EH_TIMEREMAP_PROJECT_PREFS") isOurs = true;
                if (layer.name && layer.name.indexOf(CONFIG.guidePrefix) === 0) isOurs = true;
            } catch (e5) {}

            if (isOurs) {
                try {
                    layer.locked = false;
                    layer.remove();
                    result.layers++;
                } catch (e6) {}
            }
        }

        // Guide layers the legacy finder created without the prefix hang off the button
        // precomps rather than being named, so clear them through the known API.
        for (var g = 0; g < registered.length; g++) {
            try { guide_remove(registered[g].layer); } catch (e7) {}
        }

        uiState.controllerLayer = null;
        uiState.registeredButtons = [];
        return result;
    }

    /** Survey, confirm, then remove. Returns true if anything was removed. */
    function teardown_confirmAndRemove() {
        var survey = teardown_survey();
        if (!survey.controller && !survey.buttons && !survey.guides && !survey.prefsLayers) {
            ui_updateStatus("Nothing to remove in this composition.", 'info');
            return false;
        }

        var lines = [
            "Remove " + PKG_INFO.name + " from \"" + uiState.mainCompName + "\"?",
            "",
            "This clears every expression it created and deletes:",
            "  \u2022 " + survey.buttons + " registered button(s) unwired",
            "  \u2022 " + survey.guides + " guide layer(s)",
            "  \u2022 " + survey.presetControls + " hover preset control(s)",
            "  \u2022 " + (survey.controller ? "the controller layer" : "no controller"),
            "  \u2022 " + survey.prefsLayers + " settings layer(s)",
            "",
            "Your button layers, comps and their own animation are kept.",
            "One undo restores everything."
        ];

        var ok = false;
        try { ok = confirm(lines.join("\n"), false, PKG_INFO.name); } catch (e) { ok = false; }
        if (!ok) return false;

        var result = teardown_removeAll();
        ui_updateStatus(
            "Removed: " + result.expressions + " expression(s), " + result.layers +
            " layer(s), " + result.effects + " preset(s).",
            'success'
        );
        return true;
    }

// ==== packages/ae-hoverpanel/src/80-ui-panel.jsxinc ====
/**
 * ScriptUI panel construction.
 *
 * ScriptUI has no layout engine worth the name, so widths are measured from
 * rendered text and clamped per tab via the STYLES table.
 */

function buildUI(thisObj) {
        var panel = (thisObj instanceof Panel) ? thisObj : new Window("palette", pkg_title(), undefined, {resizeable:true});
        if (!panel) return null;

        var STYLES = {
            setup:    { GAP_ROW: 8,  GAP_COL: 12, LABEL_FIELD_GAP: 6, LABEL_MIN_W: 120, LABEL_MAX_W: 135, LABEL_PAD_W: 6, FIELD_H: 24, FIELD_W_NUM: 40, FIELD_W_SHORT: 90, FIELD_W_COLOR: 130, DD_W: 200, BTN_W: 140, BTN_H: 28, LABEL_H: 20 },
            buttons:  { GAP_ROW: 10, GAP_COL: 14, LABEL_FIELD_GAP: 6, LABEL_MIN_W: 40,  LABEL_MAX_W: 60,  LABEL_PAD_W: 6, FIELD_H: 22, FIELD_W_NUM: 40, FIELD_W_SHORT: 100, FIELD_W_COLOR: 120, DD_W: 200, BTN_W: 170, BTN_H: 28, LABEL_H: 18 },
            batch:    { GAP_ROW: 8,  GAP_COL: 16, LABEL_FIELD_GAP: 6, LABEL_MIN_W: 64,  LABEL_MAX_W: 92,  LABEL_PAD_W: 6, FIELD_H: 22, FIELD_W_NUM: 40, FIELD_W_SHORT: 90,  FIELD_W_COLOR: 90,  DD_W: 180, BTN_W: 170, BTN_H: 28, LABEL_H: 18 },
            maintenance:{GAP_ROW: 8, GAP_COL: 12, LABEL_FIELD_GAP: 6, LABEL_MIN_W: 50,  LABEL_MAX_W: 70,  LABEL_PAD_W: 6, FIELD_H: 22, FIELD_W_NUM: 40, FIELD_W_SHORT: 95,  FIELD_W_COLOR: 120, DD_W: 180, BTN_W: 170, BTN_H: 28, LABEL_H: 18 }
        };

        panel.orientation = "column";
        panel.margins = 12;
        panel.spacing = 10;
        panel.preferredSize = [600, 470];
        panel.minimumSize   = [500, 385];

        var statusBar = panel.add("statictext", undefined, "Ready.");
        statusBar.alignment = ["fill", "top"];
        ui.statusText = statusBar;

        function makeHelpers(style) {
            function measureLabelWidth(lbl, text) {
    try { var g = lbl.graphics; var m = g.measureString(text, g.font); return Math.round(m[0] + (style.LABEL_PAD_W||6)); }
    catch(e){ return (style.LABEL_W||120); }
}
            function mkRow(parent, colGap) {
                var r = parent.add("group");
                r.orientation = "row";
                r.alignChildren = ["left","center"];
                r.spacing = (typeof colGap==="number") ? colGap : style.GAP_COL;
                r.alignment = ["fill","top"];
                return r;
            }
            function mkSectionPanel(parent, title) {
                var p = parent.add("panel", undefined, title);
                p.alignment = ["fill","top"];
                p.margins = 12;
                p.spacing = style.GAP_ROW;
                return p;
            }
            function mkBtn(parent, label, width, tip) {
                var b = parent.add("button", undefined, label);
                b.preferredSize = [width || style.BTN_W, style.BTN_H];
                if (tip) b.helpTip = tip;
                return b;
            }
            function mkKVCol(row, labelText, fieldW, defaultText, tooltip) {
                var col = row.add("group");
                col.orientation = "row"; col.alignChildren = ["left","center"];
                col.spacing = style.LABEL_FIELD_GAP; col.margins = 0;

                var lbl = col.add("statictext", undefined, labelText);
                var w = measureLabelWidth(lbl, labelText);
                w = Math.max(style.LABEL_MIN_W, Math.min(style.LABEL_MAX_W, w));
                lbl.preferredSize = [w, style.LABEL_H];
                if (tooltip) lbl.helpTip = tooltip;

                var ed = col.add("edittext", undefined, (defaultText!=null?defaultText:""));
                ed.preferredSize = [fieldW, style.FIELD_H];
                if (tooltip) ed.helpTip = tooltip;

                return { group: col, label: lbl, field: ed };
            }
            function mkCheckCol(row, labelText, defaultVal, tooltip) {
                var col = row.add("group");
                col.orientation = "row"; col.alignChildren = ["left","center"];
                col.spacing = style.LABEL_FIELD_GAP;

                var lbl = col.add("statictext", undefined, labelText);
                var w = measureLabelWidth(lbl, labelText);
                w = Math.max(style.LABEL_MIN_W, Math.min(style.LABEL_MAX_W, w));
                lbl.preferredSize = [w, style.LABEL_H];
                if (tooltip) lbl.helpTip = tooltip;

                var cb = col.add("checkbox", undefined, "");
                cb.value = !!defaultVal;
                if (tooltip) cb.helpTip = tooltip;

                return { group: col, label: lbl, check: cb };
            }
            function mkDDCol(row, labelText, width, items, tooltip) {
                var col = row.add("group");
                col.orientation = "row"; col.alignChildren = ["left","center"];
                col.spacing = style.LABEL_FIELD_GAP;

                var lbl = col.add("statictext", undefined, labelText);
                var w = measureLabelWidth(lbl, labelText);
                w = Math.max(style.LABEL_MIN_W, Math.min(style.LABEL_MAX_W, w));
                lbl.preferredSize = [w, style.LABEL_H];
                if (tooltip) lbl.helpTip = tooltip;

                var dd = col.add("dropdownlist", undefined, items || []);
                dd.preferredSize = [width, style.FIELD_H];
                if (tooltip) dd.helpTip = tooltip;

                return { group: col, label: lbl, dd: dd };
            }
            return {
                mkRow: mkRow,
                mkSectionPanel: mkSectionPanel,
                mkBtn: mkBtn,
                mkKVCol: mkKVCol,
                mkCheckCol: mkCheckCol,
                mkDDCol: mkDDCol
            };
        }

        // Header
        var header = (function(){
            var h = panel.add("group");
            h.orientation="row"; h.spacing=10; h.alignment=["left","top"];
            var settings = h.add("button", undefined, "Settings")
        settings.onClick = function(){ ui_showSettingsDialog(); }; settings.preferredSize=[90,28];
            var refresh  = h.add("button", undefined, "Refresh");  refresh.preferredSize=[80,28];
            var verify   = h.add("button", undefined, "Verify");   verify.preferredSize=[80,28];
            // Version doubles as the update control: it is the thing a user looks at
            // when they wonder whether they are current, so it is also what they click.
            var update   = h.add("button", undefined, "v" + PKG_INFO.version); update.preferredSize=[80,28];
            settings.helpTip="Open script settings."; refresh.helpTip="Refresh lists."; verify.helpTip="Verify controller integrity.";
            update.helpTip="Check for a newer version of " + PKG_INFO.name + ".";
            ui.btnHdrSettings=settings; ui.btnHdrRefresh=refresh; ui.btnHdrVerify=verify; ui.btnHdrUpdate=update;
            return h;
        })();

        // Tabs
        var tabs = panel.add("tabbedpanel");
        tabs.alignment = ["fill","fill"];
        var tabSetup = tabs.add("tab", undefined, "Setup");
        var tabButtons = tabs.add("tab", undefined, "Buttons");
        var tabBatch = tabs.add("tab", undefined, "Batch");
        var tabMaint = tabs.add("tab", undefined, "Maintenance");
        tabSetup.margins=10; tabButtons.margins=10; tabBatch.margins=10; tabMaint.margins=10;

        var Hs = makeHelpers(STYLES.setup);
        var Hb = makeHelpers(STYLES.buttons);
        var Hba= makeHelpers(STYLES.batch);
        var Hm = makeHelpers(STYLES.maintenance);

        // Setup tab
        var pQuick = Hs.mkSectionPanel(tabSetup, "⚡ Quick Setup (New Users)");
        var rQ1 = Hs.mkRow(pQuick);
        ui.btnQuickSetup = Hs.mkBtn(rQ1, "🚀 One-Click Setup", STYLES.setup.DD_W, "Auto-create cursor comp, controller, and configure everything!");
        var rQ2 = Hs.mkRow(pQuick);
        var quickHint = pQuick.add("statictext", undefined, "Select Main Comp below, then click 'One-Click Setup' to get started instantly!", {multiline: true});
        quickHint.graphics.font = ScriptUI.newFont(quickHint.graphics.font.name, "ITALIC", 10);
        quickHint.graphics.foregroundColor = quickHint.graphics.newPen(quickHint.graphics.PenType.SOLID_COLOR, [0.5, 0.5, 0.5], 1);
        
        var pS1 = Hs.mkSectionPanel(tabSetup, "1. Core Setup");
        var rS1 = Hs.mkRow(pS1);
        var ddMain   = Hs.mkDDCol(rS1, "Main Composition:",   STYLES.setup.DD_W, [], "Your main comp.");
        var rS2 = Hs.mkRow(pS1);
        var ddCursor = Hs.mkDDCol(rS2, "Cursor Composition:", STYLES.setup.DD_W, [], "Comp that holds cursor states.");
        var rS3 = Hs.mkRow(pS1);
        var ddCurLayer = Hs.mkDDCol(rS3, "Cursor Layer (in Main):", STYLES.setup.DD_W, [], "Cursor precomp instance in your main comp.");

        var pS2 = Hs.mkSectionPanel(tabSetup, "2. Define Cursor States");
        var rS4 = Hs.mkRow(pS2);
        var ddDefault = Hs.mkDDCol(rS4, "Default Cursor Layer:", STYLES.setup.DD_W, [], "Default cursor layer inside the cursor comp.");
        var rS5 = Hs.mkRow(pS2);
        var ddHover   = Hs.mkDDCol(rS5, "Hover Cursor Layer:",   STYLES.setup.DD_W, [], "Hover cursor layer inside the cursor comp.");
        var rS6 = Hs.mkRow(pS2);
        var ddText   = Hs.mkDDCol(rS6, "Text Cursor Layer (optional):",   STYLES.setup.DD_W, [], "Optional: used when type = 2 (Text). Leave blank if not used.");
        var rS7 = Hs.mkRow(pS2);
        var ddLoading= Hs.mkDDCol(rS7, "Loading Cursor Layer (optional):", STYLES.setup.DD_W, [], "Optional: used when type = 3 (Loading). Leave blank if not used.");
        var rS8 = Hs.mkRow(pS2);
        var btnScan = Hs.mkBtn(rS8, "Scan Layers from Cursor Comp", 230, "Scan the cursor comp for layers.");

        ui.ddMainComp = ddMain.dd;
        ui.ddCursorComp = ddCursor.dd;
        ui.ddCursorLayer = ddCurLayer.dd;
        ui.ddCursorDefault = ddDefault.dd;
        ui.ddCursorHover   = ddHover.dd;
        ui.ddCursorText    = ddText.dd;
        ui.ddCursorLoading = ddLoading.dd;
        ui.btnScan = btnScan;

        // Ensure optional dropdowns show a selectable '(None)' immediately
        try{
            ui_populateDropdownWithNone(ui.ddCursorText,    [], uiState.cursorTextLayerName);
            ui_populateDropdownWithNone(ui.ddCursorLoading, [], uiState.cursorLoadingLayerName);
        }catch(__){}


        // Buttons tab
        var pAct = Hb.mkSectionPanel(tabButtons, "3. Actions");
        var rA1 = Hb.mkRow(pAct);
        ui.btnCreate   = Hb.mkBtn(rA1, "Create from Selection", STYLES.buttons.BTN_W, "Precomp selected layer(s) into hover buttons.");
        ui.btnTemplate = Hb.mkBtn(rA1, "Create Template",       STYLES.buttons.BTN_W, "Add a template button.");

        var rA2 = Hb.mkRow(pAct);
        ui.btnApplyCursor = Hb.mkBtn(rA2, "Apply Cursor State", STYLES.buttons.BTN_W, "Link default/hover cursor opacity.");
        ui.btnRemove      = Hb.mkBtn(rA2, "Remove Button(s)",   STYLES.buttons.BTN_W, "Revert selected hover button(s).");

        var rA3 = Hb.mkRow(pAct);
        ui.btnDebug       = Hb.mkBtn(rA3, "Debug Cursor Position", STYLES.buttons.BTN_W, "Quick debug dump.");

        var rA4 = Hb.mkRow(pAct);
        ui.btnToggleViz   = Hb.mkBtn(rA4, "Toggle Hover Area Viz", STYLES.buttons.BTN_W, "Show/Hide the hover-area guide for selected buttons.");
        
        var rA5 = Hb.mkRow(pAct);
        ui.btnAddHoverProgress = Hb.mkBtn(rA5, "✨ Add Hover Progress", STYLES.buttons.BTN_W * 2 + 10, "Add 0-100 slider to drive effects/properties. Use pick-whip to link blur, glow, position, etc.");

        // Cursor Types section
        var pCT = Hb.mkSectionPanel(tabButtons, "4. Cursor Type");
        var rCT1 = Hb.mkRow(pCT);
        var itemsCT = ["Default (0)", "Link (1)", "Text (2)", "Loading (3)"];
        ui.ddCursorType = Hb.mkDDCol(rCT1, "Type:", STYLES.buttons.DD_W, itemsCT, "Set cursor type for selected hover buttons").dd;
        ui.ddCursorType.selection = 1; // Default to Link (1)
        ui.btnApplyCursorType = Hb.mkBtn(rCT1, "Apply to Selected", STYLES.buttons.BTN_W, "Set 'EH Cursor Type' for selected hover buttons.");

        var pPre = Hb.mkSectionPanel(tabButtons, "5. Hover Presets");
        var rP1 = Hb.mkRow(pPre);
        // Built from preset_definitions() so the list can never offer a preset that
        // preset_apply() does not implement.
        var presetDefs = preset_definitions();
        var presetLabels = [];
        for (var pi = 0; pi < presetDefs.length; pi++) presetLabels.push(presetDefs[pi].label);
        ui.ddPreset = Hb.mkDDCol(rP1, "Preset:", STYLES.buttons.DD_W, presetLabels,
            "Drive a property from hover, on top of any animation the layer already has.").dd;
        ui.ddPreset.selection = 0;
        var rP2 = Hb.mkRow(pPre);
        ui.btnPresetApply  = Hb.mkBtn(rP2, "Apply to Selected", STYLES.buttons.BTN_W,
            "Add the preset's control and expression to the selected hover buttons.");
        ui.btnPresetRemove = Hb.mkBtn(rP2, "Remove from Selected", STYLES.buttons.BTN_W,
            "Clear the preset's expression and delete its control. The property keeps its current value.");
        var presetHint = pPre.add("statictext", undefined,
            "Presets read the layer's Hover Progress slider, so they share the same hover area as the cursor.",
            {multiline: true});
        presetHint.alignment = ["fill", "top"];
        try {
            presetHint.graphics.font = ScriptUI.newFont(presetHint.graphics.font.name, "ITALIC", 10);
            presetHint.graphics.foregroundColor = presetHint.graphics.newPen(presetHint.graphics.PenType.SOLID_COLOR, [0.55,0.55,0.55], 1);
        } catch (ePH) {}

        var pDup = Hb.mkSectionPanel(tabButtons, "6. Duplicate");
        var rD1 = Hb.mkRow(pDup);
        ui.edDupX = Hb.mkKVCol(rD1, "Offset X:", STYLES.buttons.FIELD_W_NUM, "30", "Duplicate offset X").field;
        ui.edDupY = Hb.mkKVCol(rD1, "Offset Y:", STYLES.buttons.FIELD_W_NUM, "30", "Duplicate offset Y").field;
        ui.edDupCount = Hb.mkKVCol(rD1, "Count:",   STYLES.buttons.FIELD_W_NUM, "1", "Number of copies").field;

        var rD2 = Hb.mkRow(pDup);
        ui.chkDupIndependent = Hb.mkCheckCol(rD2, "Independent (duplicate source comp)", false, "Make independent copies").check;
        ui.edDupSuf = Hb.mkKVCol(rD2, "Suffix:", STYLES.buttons.FIELD_W_SHORT, "Copy", "Name suffix").field;

        var rD3 = Hb.mkRow(pDup);
        ui.btnDupLinked = Hb.mkBtn(rD3, "Duplicate (Linked)", STYLES.buttons.BTN_W, "Duplicate using same source comp.");
        ui.btnDupIndep  = Hb.mkBtn(rD3, "Duplicate (Independent)", STYLES.buttons.BTN_W, "Duplicate with independent source comp.");

        // Batch tab
        var pBatch = Hba.mkSectionPanel(tabBatch, "5. Batch Edit");

        var rowB1 = Hba.mkRow(pBatch);
        ui.edPadX  = Hba.mkKVCol(rowB1, "Padding X:", STYLES.batch.FIELD_W_NUM, "10", "Extra hit area X").field;
        ui.edPadY  = Hba.mkKVCol(rowB1, "Padding Y:", STYLES.batch.FIELD_W_NUM, "10", "Extra hit area Y").field;
        ui.edInfl  = Hba.mkKVCol(rowB1, "Influence:", STYLES.batch.FIELD_W_NUM, "12", "Hover influence radius").field;

        var rowB2 = Hba.mkRow(pBatch);
        ui.edCorner = Hba.mkKVCol(rowB2, "Corner:", STYLES.batch.FIELD_W_NUM, String(CONFIG.viz.corner), "Guide corner radius").field;
        ui.edCoreW  = Hba.mkKVCol(rowB2, "Core W:", STYLES.batch.FIELD_W_NUM, String(CONFIG.viz.coreStroke), "Core stroke width").field;
        ui.edInflW  = Hba.mkKVCol(rowB2, "Infl W:", STYLES.batch.FIELD_W_NUM, String(CONFIG.viz.inflStroke), "Infl. stroke width").field;

        var rowB3 = Hba.mkRow(pBatch);
        ui.edDash = Hba.mkKVCol(rowB3, "Dash:", STYLES.batch.FIELD_W_NUM, String(CONFIG.viz.dash), "Dash length").field;
        ui.edGap  = Hba.mkKVCol(rowB3, "Gap:",  STYLES.batch.FIELD_W_NUM, String(CONFIG.viz.gap),  "Dash gap").field;
        ui.chkReverse = Hba.mkCheckCol(rowB3, "Reverse:", false, "Invert time mapping").check;

        var rowB4 = Hba.mkRow(pBatch);
        ui.edCoreColor = Hba.mkKVCol(rowB4, "Core Color (r,g,b):", STYLES.batch.FIELD_W_COLOR, "1,0.4,0", "Core stroke color").field;
        ui.edInflColor = Hba.mkKVCol(rowB4, "Infl Color:", STYLES.batch.FIELD_W_COLOR, "0,0.27,0.71", "Influence stroke color").field;

        // Batch: Cursor Type
        var rowB4a = Hba.mkRow(pBatch);
        ui.ddBatchCursorType = Hba.mkDDCol(rowB4a, "Cursor Type:", STYLES.batch.DD_W, ["No change", "Default (0)", "Link (1)", "Text (2)", "Loading (3)"], "Batch-assign cursor type").dd;

        var rowB5 = Hba.mkRow(pBatch, STYLES.batch.GAP_COL + 6);
        ui.chkVizInclude = Hba.mkCheckCol(rowB5, "Include viz styling", true, "Apply guide styling too").check;
        ui.edBatchGroupId = Hba.mkKVCol(rowB5, "Group ID:", STYLES.batch.FIELD_W_NUM, "1", "Group id to assign/apply").field;

        var rowB6 = Hba.mkRow(pBatch);
        ui.btnBatchSel   = Hba.mkBtn(rowB6, "Apply to Selected", 170, "Apply to selected hover buttons");
        ui.btnBatchAll   = Hba.mkBtn(rowB6, "Apply to All",      170, "Apply to all hover buttons in comp");

        var rowB7 = Hba.mkRow(pBatch);
        ui.btnBatchGroup = Hba.mkBtn(rowB7, "Apply to Group",    170, "Apply to group id");

        // Maintenance tab
        var pCtrl = Hm.mkSectionPanel(tabMaint, "Controller Management & Groups");
        ui.controllerStatus = pCtrl.add("statictext", undefined, "No controller detected"); ui.controllerStatus.alignment=["fill","top"];
        ui.buttonCount = pCtrl.add("statictext", undefined, "Max Button Id: 0"); ui.buttonCount.alignment=["fill","top"];
        ui.buttonCount.helpTip = "Highest registered button id on the controller (gaps possible).";

        var rM1 = Hm.mkRow(pCtrl);
        ui.btnCreateCtrl = Hm.mkBtn(rM1, "Create", STYLES.maintenance.BTN_W, "Create controller layer");
        ui.btnRemoveCtrl = Hm.mkBtn(rM1, "Remove", STYLES.maintenance.BTN_W, "Remove controller layer");

        var rM2 = Hm.mkRow(pCtrl);
        ui.btnFixRefs    = Hm.mkBtn(rM2, "Relink", STYLES.maintenance.BTN_W, "Relink broken controller refs");
        ui.btnSyncNames  = Hm.mkBtn(rM2, "Sync Names", STYLES.maintenance.BTN_W, "Sync name hints for relink");

        var rM3 = Hm.mkRow(pCtrl);
        ui.btnCleanup    = Hm.mkBtn(rM3, "Clean Orphan Refs", STYLES.maintenance.BTN_W, "Remove orphan controller refs");
        ui.btnSelectAll  = Hm.mkBtn(rM3, "Select All Buttons", STYLES.maintenance.BTN_W, "Select all hover buttons in comp");

        var rM4 = Hm.mkRow(pCtrl);
        var colGroupName = Hm.mkKVCol(rM4, "Group Name:", STYLES.maintenance.FIELD_W_SHORT, "default", "Freeform group label");
        var colGroupId   = Hm.mkKVCol(rM4, "Group ID:",   STYLES.maintenance.FIELD_W_NUM, "1", "Numeric group id");
        ui.edGroupName = colGroupName.field;
        ui.edGroupId   = colGroupId.field;

        var rM5 = Hm.mkRow(pCtrl);
        ui.btnAssignGroup = Hm.mkBtn(rM5, "Assign to Selected", STYLES.maintenance.BTN_W, "Set Group ID on selected");
        ui.btnSelectGroup = Hm.mkBtn(rM5, "Select Group",       STYLES.maintenance.BTN_W, "Select all with Group ID");

        var rM6 = Hm.mkRow(pCtrl);
        ui.btnClearGroup  = Hm.mkBtn(rM6, "Clear Group (Sel)",  STYLES.maintenance.BTN_W, "Clear Group ID on selection");

        var pWipe = Hm.mkSectionPanel(tabMaint, "Remove From Project");
        var rW1 = Hm.mkRow(pWipe);
        ui.btnRemoveAll = Hm.mkBtn(rW1, "Remove All " + PKG_INFO.name, STYLES.maintenance.BTN_W + 40,
            "Clear every expression and delete every layer this script added. Your buttons, comps and their own animation are kept.");
        var wipeHint = pWipe.add("statictext", undefined,
            "For handing a project to a client or rendering on a machine without scripts. Shows what it will remove first; one undo restores it.",
            {multiline: true});
        wipeHint.alignment = ["fill", "top"];
        try {
            wipeHint.graphics.font = ScriptUI.newFont(wipeHint.graphics.font.name, "ITALIC", 10);
            wipeHint.graphics.foregroundColor = wipeHint.graphics.newPen(wipeHint.graphics.PenType.SOLID_COLOR, [0.55,0.55,0.55], 1);
        } catch (eWH) {}

        var rM7 = Hm.mkRow(pCtrl);
        ui.btnRefreshExpr = Hm.mkBtn(rM7, "Refresh Expressions", STYLES.maintenance.BTN_W, "Reapply time remap + guide roundness expressions");

        var rM8 = Hm.mkRow(pCtrl);
        ui.btnPerformanceCheck = Hm.mkBtn(rM8, "⚡ Performance Check", STYLES.maintenance.BTN_W * 2 + 10, "Analyze setup for performance issues and overlaps");

        var rM9 = Hm.mkRow(pCtrl);
        ui.btnAnalytics = Hm.mkBtn(rM9, "📊 Analytics Dashboard", STYLES.maintenance.BTN_W * 2 + 10, "View detailed statistics and project insights");

        // Events
        ui.btnHdrSettings.onClick = function(){ ui_showSettingsDialog(); };
        ui.btnHdrRefresh.onClick  = function(){ ui_refreshAllLists(); ui_setEnabledStates(); };
        ui.btnHdrVerify.onClick   = function(){ if (ctrl_verifyIntegrity()) ui_updateStatus("Controller verified.", 'success'); };
        // Not wrapped in util_wrapOperation: this touches no project data, so it needs
        // no undo group, and opening one around a network call would leave an empty
        // entry in the History panel.
        ui.btnPresetApply.onClick = function(){
            util_wrapOperation(function(){
                var def = presetDefs[ui.ddPreset.selection.index];
                var r = preset_applyToSelection(def.id);
                ui_updateStatus(
                    "Applied " + def.label + " to " + r.applied + " button(s)" +
                    (r.skipped ? ", skipped " + r.skipped + " (not registered buttons)" : "") + ".",
                    r.applied ? 'success' : 'warning'
                );
            }, "Apply Hover Preset");
        };
        ui.btnPresetRemove.onClick = function(){
            util_wrapOperation(function(){
                var def = presetDefs[ui.ddPreset.selection.index];
                var mc = comp_findByName(uiState.mainCompName);
                if (!mc) throw new Error("Main Composition not found.");
                var sel = mc.selectedLayers;
                if (!sel || !sel.length) throw new Error("Select one or more button layers first.");
                var n = 0;
                for (var i = 0; i < sel.length; i++) if (preset_remove(sel[i], def.id)) n++;
                ui_updateStatus("Removed " + def.label + " from " + n + " layer(s).", 'success');
            }, "Remove Hover Preset");
        };
        ui.btnRemoveAll.onClick = function(){
            // Wrapped so the whole teardown is a single undo step: a partial rollback of
            // this would leave expressions pointing at a deleted controller.
            util_wrapOperation(function(){ teardown_confirmAndRemove(); }, "Remove All " + PKG_INFO.name);
        };
        ui.btnHdrUpdate.onClick   = function(){
            ui_updateStatus("Checking for updates\u2026", 'info');
            var updated = false;
            try { updated = updater_checkAndPrompt(false); } catch(e){ }
            ui_updateStatus(
                updated ? "Update installed \u2014 restart After Effects."
                        : "Up to date (" + PKG_INFO.version + ").",
                updated ? 'success' : 'info'
            );
        };

        function ui_setStateFromDropdown(dd, key){
            uiState[key] = (dd && dd.selection) ? dd.selection.text : "";
            // Persist these selections into settings so they preload on startup
            try{ if (!scriptSettings) scriptSettings = settings_getDefaults(); }catch(_){ }
            try{ scriptSettings[key] = uiState[key] || ""; }catch(_){ }
            ui_setEnabledStates();
            // Auto-save project-level settings for cursor selections
            try{
                if (key==="cursorDefaultLayerName" || key==="cursorHoverLayerName" || key==="cursorTextLayerName" || key==="cursorLoadingLayerName" || key==="mainCompName" || key==="cursorCompName" || key==="cursorLayerName"){
                    settings_save("project");
                }
            }catch(__){}
        }

        ui.ddMainComp.onChange    = function(){ ui_setStateFromDropdown(this,'mainCompName'); ui_scanForCursorLayer(); ctrl_updateStatus(); ui_setEnabledStates(); };
        ui.ddCursorComp.onChange  = function(){ ui_setStateFromDropdown(this,'cursorCompName'); ui_scanForCursorLayer(); ui_scanCursorCompLayers(); ui_setEnabledStates(); };
        ui.ddCursorLayer.onChange = function(){ ui_setStateFromDropdown(this,'cursorLayerName'); ctrl_updateReferences(); ui_setEnabledStates(); };
        ui.ddCursorDefault.onChange = function(){ ui_setStateFromDropdown(this,'cursorDefaultLayerName'); };
        ui.ddCursorHover.onChange   = function(){ ui_setStateFromDropdown(this,'cursorHoverLayerName'); };
        ui.ddCursorText.onChange    = function(){ ui_setStateFromDropdown(this,'cursorTextLayerName'); };
        ui.ddCursorLoading.onChange = function(){ ui_setStateFromDropdown(this,'cursorLoadingLayerName'); };
        ui.btnScan.onClick = function(){ ui_scanCursorCompLayers(); };
        
        ui.btnQuickSetup.onClick = function(){ 
            util_wrapOperation(function(){ 
                ctrl_quickSetup(); 
                ctrl_updateStatus();
            }, "Quick Setup"); 
        };

        ui.btnCreateCtrl.onClick = function(){ util_wrapOperation(function(){ ctrl_create(); }, "Create Controller"); };
        ui.btnRemoveCtrl.onClick = function(){ util_wrapOperation(function(){ ctrl_remove(); }, "Remove Controller"); };
        ui.btnFixRefs.onClick    = function(){
            util_wrapOperation(function(){
                var r = ctrl_fixBrokenReferences();
                var msg = "Relinked " + r.fixed + " ref(s).";
                // Ambiguity is reported rather than guessed away: the two-character name
                // hint cannot tell "Btn Home" from "Btn About", and a wrong relink is
                // worse than a broken one because nothing looks wrong.
                if (r.ambiguous) msg += " " + r.ambiguous + " ambiguous (rename the layers so their first two characters differ, then retry).";
                if (r.unresolved) msg += " " + r.unresolved + " could not be matched.";
                ui_updateStatus(msg, r.fixed ? 'success' : (r.ambiguous || r.unresolved ? 'warning' : 'info'));
            }, "Relink Button References");
        };
        ui.btnSyncNames.onClick  = function(){ var n=btn_resyncNames(); ui_updateStatus("Synced "+n+" name hint(s).", n?'success':'info'); };
        ui.btnCleanup.onClick    = function(){ util_wrapOperation(ctrl_cleanupInvalidRefs, "Clean Orphans"); };
        ui.btnSelectAll.onClick  = function(){
            var mc = comp_findByName(uiState.mainCompName); if (!mc) return;
            for (var i=1;i<=mc.numLayers;i++){ var L=mc.layer(i); try{ L.selected = (L.comment===CONFIG.buttonComment); }catch(_){L.selected=false;} }
        };
        ui.btnRefreshExpr.onClick = function(){ util_wrapOperation(btn_refreshExpressions, "Refresh Hover Expressions"); };
        ui.btnPerformanceCheck.onClick = function(){ validate_showReport(); };
        ui.btnAnalytics.onClick = function(){ analytics_showDashboard(); };

        ui.btnApplyCursorType.onClick = function(){
            util_wrapOperation(function(){
                var mc = comp_findByName(uiState.mainCompName); if (!mc) throw new Error("No main comp.");
                var sel = mc.selectedLayers; if (!sel || !sel.length) throw new Error("Select some hover buttons.");
                var dd = ui.ddCursorType; var t = -1;
                try{ if (dd && dd.selection && dd.selection.text){ var m = /\((\d+)\)/.exec(dd.selection.text); if (m) t = parseInt(m[1],10); } }catch(_){ }
                if (t < 0) throw new Error("Pick a cursor type first.");
                var applied=0;
                for (var i=0;i<sel.length;i++){
                    var L = sel[i]; if (L.comment !== CONFIG.buttonComment) continue;
                    try{ L.effect("EH Cursor Type")("Slider").setValue(t); }
                    catch(_){ try{ var e=L.Effects.addProperty(CONFIG.effects.slider); e.name="EH Cursor Type"; e.property("Slider").setValue(t);}catch(__){} }
                    applied++;
                }
                ui_updateStatus("Set Cursor Type="+t+" on "+applied+" button(s).", applied?'success':'info');
            }, "Set Cursor Type");
        };

        ui.btnAssignGroup.onClick = function(){
            var id = ui_validateIntegerInput(ui.edGroupId, 0, 999, 0, "Group ID");
            util_wrapOperation(function(){
                var mc = comp_findByName(uiState.mainCompName); if (!mc) throw new Error("Main Composition not set. Select one from Setup tab.");
                var sel = mc.selectedLayers; if (!sel.length) throw new Error("No layers selected. Select hover buttons to assign a group.");
                for (var i=0;i<sel.length;i++) {
                    var L=sel[i]; if (L.comment!==CONFIG.buttonComment) continue;
                    try { L.effect("Group ID")("Slider").setValue(id); } catch(_) {}
                    var bid = btn_findIdForLayer(L);
                    if (bid) {
                        var num = bid.replace(CONFIG.controller.effects.buttonPrefix,"");
                        var ctrl = ctrl_ensureExists();
                        if (ctrl) try { ctrl.Effects.property(CONFIG.controller.effects.buttonGroupIdPrefix+num).property("Slider").setValue(id);}catch(_){ }
                    }
                }
                ui_updateStatus("Assigned Group ID "+id+" to selected.", 'success');
            }, "Assign Group");
        };
        ui.btnSelectGroup.onClick = function(){
            var mc = comp_findByName(uiState.mainCompName); if (!mc) { ui_updateStatus("No main comp.", 'error'); return; }
            var gid = parseInt(ui.edGroupId.text,10)||0;
            for (var i=1;i<=mc.numLayers;i++){
                try { var c = mc.layer(i); if (!c) continue; if (c.comment!==CONFIG.buttonComment) continue;
                    // Issue #40: Include disabled layers in group selection
                    if (Math.round(c.effect("Group ID")("Slider").value||0)===gid) c.selected=true; else c.selected=false;
                } catch(_) { }
            }
        };
        ui.btnClearGroup.onClick = function(){
            util_wrapOperation(function(){
                var mc = comp_findByName(uiState.mainCompName); if (!mc) throw new Error("No main comp.");
                var sel = mc.selectedLayers; if (!sel.length) throw new Error("Nothing selected.");
                for (var i=0;i<sel.length;i++){
                    var L=sel[i]; if (L.comment!==CONFIG.buttonComment) continue;
                    try{ L.effect("Group ID")("Slider").setValue(0); }catch(_){}
                }
                ui_updateStatus("Cleared Group ID on selection.", 'success');
            }, "Clear Group");
        };

        ui.btnCreate.onClick      = function(){ util_wrapOperation(btn_createFromLayer, "Create Buttons"); };
        ui.btnTemplate.onClick    = function(){ util_wrapOperation(btn_createTemplate, "Create Template"); };
        ui.btnApplyCursor.onClick = function(){ util_wrapOperation(function(){ expr_applyCursorStateSwap(false); }, "Apply Cursor State"); };
        ui.btnRemove.onClick      = function(){ util_wrapOperation(btn_remove, "Remove Buttons"); };
        ui.btnDebug.onClick       = function(){
            alert("Quick debug:\nMain: "+uiState.mainCompName+"\nCursor comp: "+uiState.cursorCompName+"\nCursor layer: "+uiState.cursorLayerName+"\nDefault/Hover: "+uiState.cursorDefaultLayerName+" / "+uiState.cursorHoverLayerName);
        };
        ui.btnToggleViz.onClick   = function(){ util_wrapOperation(batch_toggleVizForSelection, "Toggle Hover Viz"); };
        ui.btnAddHoverProgress.onClick = function(){ util_wrapOperation(btn_addHoverProgress, "Add Hover Progress"); };

        function doDuplicate(independent) {
            var dx = ui_validateNumericInput(ui.edDupX, -10000, 10000, 0, "Offset X");
            var dy = ui_validateNumericInput(ui.edDupY, -10000, 10000, 0, "Offset Y");
            var count = ui_validateIntegerInput(ui.edDupCount, 1, 100, 1, "Duplicate Count");
            var suf = ui.edDupSuf.text || " Copy";
            
            util_wrapOperation(function(){
                var mc=comp_findByName(uiState.mainCompName); if(!mc) throw new Error("Main Composition not set. Select one from Setup tab.");
                if (!mc.selectedLayers.length) throw new Error("No hover buttons selected. Select at least one button to duplicate.");
                var made=0, skipped=[];
                for (var i=0;i<mc.selectedLayers.length;i++) {
                    var L=mc.selectedLayers[i];
                    if (!L || L.comment!==CONFIG.buttonComment) { try{ skipped.push(L && L.name ? L.name : "(non-button)"); }catch(_){ skipped.push("(non-button)"); } continue; }
                    var last=L;
                    for (var n=0;n<count;n++){
                        var nu = btn_duplicate(last, {independent:independent, offset:[dx,dy], suffix:suf});
                        if (!nu) break;
                        last = nu; made++;
                    }
                }
                try { expr_applyCursorStateSwap(true); }catch(_){}
                try { ctrl_updateStatus(); }catch(_){}
                if (skipped.length) ui_updateStatus("Duplicated "+made+" button(s). Skipped: "+skipped.join(", ")+".", made ? 'warning' : 'info');
                else ui_updateStatus("Duplicated "+made+" button(s).", 'success');
            }, "Duplicate Buttons");
        }
        ui.btnDupLinked.onClick = function(){ doDuplicate(false); };
        ui.btnDupIndep.onClick  = function(){ doDuplicate(true); };

        // --- Batch helpers and actions ---
        function util_parseColorFromText(txt){
            try{
                var parts = String(txt||"").split(/[, ]+/);
                var r = parseFloat(parts[0])||0;
                var g = parseFloat(parts[1])||0;
                var b = parseFloat(parts[2])||0;
                // Auto-detect 0-255 vs 0-1 range
                if (r > 1 || g > 1 || b > 1) {
                    r = Math.max(0, Math.min(255, r))/255;
                    g = Math.max(0, Math.min(255, g))/255;
                    b = Math.max(0, Math.min(255, b))/255;
                } else {
                    r = Math.max(0, Math.min(1, r));
                    g = Math.max(0, Math.min(1, g));
                    b = Math.max(0, Math.min(1, b));
                }
                return [r,g,b];
            }catch(_){ return [1,1,1]; }
        }
        function ui_buildBatchProps(){
            var px = ui_validateNumericInput(ui.edPadX, -1000, 1000, 0, "Padding X");
            var py = ui_validateNumericInput(ui.edPadY, -1000, 1000, 0, "Padding Y");
            var infl = ui_validateNumericInput(ui.edInfl, 0, 500, 0, "Influence Radius");
            var rev = ui.chkReverse.value ? 1 : 0;
            var gid = ui_validateIntegerInput(ui.edBatchGroupId, 0, 999, 0, "Batch Group ID");
            var props = { padding:[px,py], influence:infl, reverse:rev, groupId:gid };
            if (ui.chkVizInclude.value){
                props.viz = {
                    corner: parseFloat(ui.edCorner.text)||0,
                    coreW:  parseFloat(ui.edCoreW.text)||0,
                    inflW:  parseFloat(ui.edInflW.text)||0,
                    dash:   parseFloat(ui.edDash.text)||0,
                    gap:    parseFloat(ui.edGap.text)||0,
                    coreColor: util_parseColorFromText(ui.edCoreColor.text),
                    inflColor: util_parseColorFromText(ui.edInflColor.text)
                };
            }
            // Optional: Cursor Type
            try{
                var selTxt = (ui.ddBatchCursorType && ui.ddBatchCursorType.selection) ? ui.ddBatchCursorType.selection.text : "No change";
                var m = /\((\d+)\)/.exec(selTxt||"");
                if (m) { var ct = parseInt(m[1],10); if (isFinite(ct)) props.cursorType = ct; }
            }catch(_){ }
            return props;
        }

        ui.btnBatchSel.onClick = function(){
            util_wrapOperation(function(){
                var mc = comp_findByName(uiState.mainCompName); if (!mc) throw new Error("No main comp.");
                var sel = mc.selectedLayers; if (!sel || !sel.length) throw new Error("Select some hover buttons.");
                batch_applyToLayers(sel, ui_buildBatchProps());
                ui_updateStatus("Batch applied to selected.", 'success');
            }, "Batch Apply (Selected)");
        };
        ui.btnBatchAll.onClick = function(){
            util_wrapOperation(function(){
                var mc = comp_findByName(uiState.mainCompName); if (!mc) throw new Error("No main comp.");
                var all = [];
                for (var i=1;i<=mc.numLayers;i++){ var L = mc.layer(i); try{ if (L && L.comment===CONFIG.buttonComment) all.push(L); }catch(_){} }
                if (!all.length) throw new Error("No hover buttons found in comp.");
                batch_applyToLayers(all, ui_buildBatchProps());
                ui_updateStatus("Batch applied to all hover buttons.", 'success');
            }, "Batch Apply (All)");
        };
        ui.btnBatchGroup.onClick = function(){
            util_wrapOperation(function(){
                var mc = comp_findByName(uiState.mainCompName); if (!mc) throw new Error("No main comp.");
                var gid = parseInt(ui.edBatchGroupId.text,10)||0;
                var grp = [];
                for (var i=1;i<=mc.numLayers;i++){
                    try{
                        var L = mc.layer(i);
                        if (!L || L.comment!==CONFIG.buttonComment) continue;
                        var v = 0; try{ v = Math.round(L.effect("Group ID")("Slider").value||0); }catch(_){ v = 0; }
                        if (v === gid) grp.push(L);
                    }catch(__){}
                }
                if (!grp.length) throw new Error("No hover buttons found for Group ID "+gid+".");
                batch_applyToLayers(grp, ui_buildBatchProps());
                ui_updateStatus("Batch applied to group "+gid+".", 'success');
            }, "Batch Apply (Group)");
        };

        panel.onShow = function(){
            ui_refreshAllLists();
            ctrl_updateStatus();
            var comps = comp_getAll();
            if (comps.length===1) { ui.ddMainComp.selection=0; ui.ddMainComp.onChange(); }
            ui_setEnabledStates();
        };
        panel.onResizing = panel.onResize = function(){ panel.layout.layout(true); };

        return panel;
    }

// ==== packages/ae-hoverpanel/src/82-ui-actions.jsxinc ====
/**
 * Panel event handlers and list refreshing.
 */

    // -------------------------
    // Verify & UI state
    // -------------------------
    function ui_setEnabledStates() {
        var mc = comp_findByName(uiState.mainCompName);
        var hasCtrl = !!ctrl_find();
        var validCursor = uiState.cursorCompName && uiState.cursorLayerName && uiState.cursorDefaultLayerName && uiState.cursorHoverLayerName;

        if (ui.btnCreate)         ui.btnCreate.enabled = !!mc;
        if (ui.btnTemplate)       ui.btnTemplate.enabled = !!mc;
        if (ui.btnApplyCursor)    ui.btnApplyCursor.enabled = !!(mc && hasCtrl && validCursor);
        if (ui.btnRemove)         ui.btnRemove.enabled = !!mc;
        if (ui.btnCreateCtrl)     ui.btnCreateCtrl.enabled = !!mc && !hasCtrl;
        if (ui.btnRemoveCtrl)     ui.btnRemoveCtrl.enabled = !!mc && hasCtrl;
        if (ui.btnFixRefs)        ui.btnFixRefs.enabled = !!mc && hasCtrl;
        if (ui.btnSyncNames)      ui.btnSyncNames.enabled = !!mc && hasCtrl;
        if (ui.btnCleanup)        ui.btnCleanup.enabled = !!mc;
        if (ui.btnDupLinked)      ui.btnDupLinked.enabled = !!mc && hasCtrl;
        if (ui.btnDupIndep)       ui.btnDupIndep.enabled  = !!mc && hasCtrl;
        if (ui.btnBatchSel)       ui.btnBatchSel.enabled  = !!mc && hasCtrl;
        if (ui.btnBatchAll)       ui.btnBatchAll.enabled  = !!mc && hasCtrl;
        if (ui.btnBatchGroup)     ui.btnBatchGroup.enabled= !!mc && hasCtrl;
        if (ui.btnAssignGroup)    ui.btnAssignGroup.enabled= !!mc && hasCtrl;
        if (ui.btnSelectGroup)    ui.btnSelectGroup.enabled= !!mc && hasCtrl;
        if (ui.btnClearGroup)     ui.btnClearGroup.enabled = !!mc && hasCtrl;
        if (ui.btnSelectAll)      ui.btnSelectAll.enabled  = !!mc;
        if (ui.btnToggleViz)      ui.btnToggleViz.enabled  = !!mc && hasCtrl;
        if (ui.btnAddHoverProgress) ui.btnAddHoverProgress.enabled = !!mc && hasCtrl;
        if (ui.btnQuickSetup)     // Gated only on a main comp. It creates the controller and cursor comp, so
        // requiring their absence made it a one-shot button that then sat dead forever;
        // ctrl_quickSetup is idempotent, so re-running it repairs rather than duplicates.
        ui.btnQuickSetup.enabled = !!mc; // Only enabled if Main Comp selected and no controller yet
    }
    function ctrl_updateStatus() {
        var ctrl = ctrl_find();
        if (ctrl) {
            if (ui.controllerStatus) ui.controllerStatus.text = "Controller: " + ctrl.name;
            try{ ctrl_migrateCounterName(ctrl); }catch(_){ }
            var maxId = btn_recalcMaxId(ctrl);
            if (ui.buttonCount) ui.buttonCount.text = "Max Button Id: " + maxId;
        } else {
            if (ui.controllerStatus) ui.controllerStatus.text = "No controller detected";
            if (ui.buttonCount) ui.buttonCount.text = "Max Button Id: 0";
        }
        ui_setEnabledStates();
        // Keep all ButtonBounds expressions enforced
        expr_ensureLiveBounds();
    }
    function ui_refreshAllLists() {
        // Refresh exists to pick up project changes, so it must not trust memoised
        // lookups from before those changes. Cleared once here; every scan below then
        // shares one fresh set of results instead of re-walking the project each time.
        cache_reset();
        if (!app.project) { ui_updateStatus("No project open.", 'error'); return; }
        var comps = comp_getAll(); ui_populateDropdown(ui.ddMainComp, comps, uiState.mainCompName);
        ui_populateDropdown(ui.ddCursorComp, comps, uiState.cursorCompName);
        if (ui.ddMainComp.onChange) ui.ddMainComp.onChange();
        // Also populate cursor-comp driven dropdowns (Default/Hover/Text/Loading) immediately
        try{
            if (ui.ddCursorComp && ui.ddCursorComp.items && ui.ddCursorComp.items.length){
                if (ui.ddCursorComp.onChange) ui.ddCursorComp.onChange();
                else ui_scanCursorCompLayers();
            } else {
                // If no comp yet, at least show '(None)' in optional dropdowns
                if (ui.ddCursorText)    ui_populateDropdownWithNone(ui.ddCursorText, [], uiState.cursorTextLayerName);
                if (ui.ddCursorLoading) ui_populateDropdownWithNone(ui.ddCursorLoading, [], uiState.cursorLoadingLayerName);
            }
        }catch(__){}
        ui_updateStatus("Ready.", 'info');
    }
    function ui_scanForCursorLayer() {
        var mc = comp_findByName(uiState.mainCompName), layers = [];
        if (mc && uiState.cursorCompName) {
            for (var i=1;i<=mc.numLayers;i++) { 
                try {
                    var L = mc.layer(i); 
                    if (L && L.source && L.source.name===uiState.cursorCompName) layers.push(L.name); 
                } catch(_) {}
            }
        }
        ui_populateDropdown(ui.ddCursorLayer, layers, uiState.cursorLayerName);
        if (ui.ddCursorLayer.onChange) ui.ddCursorLayer.onChange();
    }
    function ui_scanCursorCompLayers() {
        var cc = comp_findByName(uiState.cursorCompName), layers=[];
        if (cc) for (var i=1;i<=cc.numLayers;i++) {
            try { var L = cc.layer(i); if (L && L.name) layers.push(L.name); } catch(_) {}
        }
        // Seed Default and Hover to DIFFERENT layers on a first run. Both previously
        // fell back to index 0, so a fresh setup always tripped the "Default and Hover
        // cannot be the same" check before anything could be created.
        ui_populateDropdown(ui.ddCursorDefault, layers, uiState.cursorDefaultLayerName, 0);
        ui_populateDropdown(ui.ddCursorHover, layers, uiState.cursorHoverLayerName, layers.length > 1 ? 1 : 0);
        if (ui.ddCursorText)    ui_populateDropdownWithNone(ui.ddCursorText, layers, uiState.cursorTextLayerName);
        if (ui.ddCursorLoading) ui_populateDropdownWithNone(ui.ddCursorLoading, layers, uiState.cursorLoadingLayerName);

        // Push the selections into uiState. Without this the panel could display one
        // layer while validation used a stale or empty value — the dropdown said one
        // thing and the error message another.
        if (ui.ddCursorDefault.onChange) ui.ddCursorDefault.onChange();
        if (ui.ddCursorHover.onChange)   ui.ddCursorHover.onChange();
        if (ui.ddCursorText && ui.ddCursorText.onChange)       ui.ddCursorText.onChange();
        if (ui.ddCursorLoading && ui.ddCursorLoading.onChange) ui.ddCursorLoading.onChange();
    }
    function ctrl_verifyIntegrity() {
        var mc=comp_findByName(uiState.mainCompName);
        if (!mc) { ui_updateStatus("Select a Main Comp first", 'info'); return false; }
        var ctrl=ctrl_find();
        if (!ctrl) { ui_updateStatus("No controller found.", 'info'); return false; }
        var issues=[];
        try{
            var c=ctrl.Effects.property(CONFIG.controller.effects.cursorLayer);
            if (c){ try{ if (c.property("Layer").value===0) issues.push("Cursor layer ref not set"); }catch(_){ issues.push("Cursor layer ref broken"); } }
        }catch(_){}
        var btns=ctrl_getRegisteredButtons();
        for (var i=0;i<btns.length;i++){
            var L=btns[i].layer;
            if (!L) issues.push(btns[i].buttonId+" -> missing layer");
            else if (L.comment!==CONFIG.buttonComment) issues.push(btns[i].buttonId+" -> not a button");
        }
        if (issues.length){ ui_updateStatus("Controller issues: "+issues.length, 'error'); return false; }
        return true;
    }

    // Refresh expressions on all registered hover buttons (time remap + guide roundness)
    function btn_refreshExpressions(){
        var mc = comp_findByName(uiState.mainCompName); if (!mc) throw new Error("No main comp.");
        var ctrl = ctrl_find(); if (!ctrl) throw new Error("No controller.");
        var btns = ctrl_getRegisteredButtons();
        var updated = 0, gUpdated = 0;

        function setGuideRoundnessFor(preLayer, buttonId){
            try{
                var viz = guide_find(mc, preLayer); if (!viz) return false;
                var root = viz.property("ADBE Root Vectors Group"); if (!root) return false;
                function setR(name, expr){
                    try{
                        var grp = root.property(name); if (!grp) return false;
                        var vg = grp.property("ADBE Vectors Group"); if (!vg) return false;
                        var rect = null;
                        for (var i=1;i<=vg.numProperties;i++){ var p = vg.property(i); if (p && p.matchName==="ADBE Vector Shape - Rect"){ rect=p; break; } }
                        if (!rect) return false;
                        var rr = rect.property("ADBE Vector Rect Roundness"); if (!rr) return false;
                        ui_setExpressionVisible(rr, expr, rr.value);
                        return true;
                    }catch(__){ return false; }
                }
                var ok1 = setR("Core Box", guide_generateRoundnessExpr_LEGACY(buttonId, false));
                var ok2 = setR("Influence Band", guide_generateRoundnessExpr_LEGACY(buttonId, true));
                return (ok1||ok2);
            }catch(__){ return false; }
        }

        for (var i=0;i<btns.length;i++){
            var b = btns[i]; var L = b.layer; if (!L) continue;
            try{ guide_ensureControls_LEGACY(L); }catch(_){ }
            try{ btn_applyDetectedCornerRadius(L); }catch(_){ }
            try{ ui_setExpressionVisible(L.property("Time Remap"), expr_generateTimeRemap(b.buttonId), 0); updated++; }catch(_){ }
            try{ if (setGuideRoundnessFor(L, b.buttonId)) gUpdated++; }catch(_){ }
        }
        expr_ensureLiveBounds();
        // Re-apply global cursor expression so detection uses latest rounding logic
        try{ expr_applyCursorStateSwap(true); }catch(_){ }
        ui_updateStatus("Refreshed expressions on "+updated+" button(s), guides updated: "+gUpdated+".", (updated||gUpdated)?'success':'info');
    }

    // -------------------------
    // Hover Progress System (v16.9.0)
    // -------------------------
    function btn_addHoverProgress() {
        var mc = comp_findByName(uiState.mainCompName);
        if (!mc || app.project.activeItem !== mc) throw new Error("Make '"+(mc?mc.name:uiState.mainCompName)+"' active.");
        if (mc.selectedLayers.length === 0) throw new Error("Select at least one Hover Button layer.");
        
        var ctrl = ctrl_find();
        if (!ctrl) throw new Error("No controller found.");
        
        var added = 0, skipped = [];
        
        for (var i = 0; i < mc.selectedLayers.length; i++) {
            var L = mc.selectedLayers[i];
            
            // Check if this is a hover button
            var buttonId = btn_findIdForLayer(L);
            if (!buttonId) {
                skipped.push(L.name + " (not a hover button)");
                continue;
            }
            
            try {
                // Check how many Hover Progress sliders already exist
                var existingCount = 0;
                for (var j = 1; j <= L.Effects.numProperties; j++) {
                    var eff = L.Effects.property(j);
                    if (eff && eff.name && eff.name.indexOf("Hover Progress") === 0) {
                        existingCount++;
                    }
                }
                
                // Create new slider with numbered suffix if needed
                var sliderName = existingCount === 0 ? "Hover Progress" : "Hover Progress " + (existingCount + 1);
                var slider = L.Effects.addProperty(CONFIG.effects.slider);
                slider.name = sliderName;
                
                // Set value range (0-100)
                try {
                    var sliderProp = slider.property("Slider");
                    sliderProp.setValue(0);
                } catch(_) {}
                
                // Apply expression
                var expr = expr_generateHoverProgress(buttonId);
                ui_setExpressionVisible(slider.property("Slider"), expr, 0);
                
                added++;
            } catch(e) {
                skipped.push(L.name + " (error: " + e.message + ")");
            }
        }
        
        if (added === 0) {
            throw new Error("No Hover Progress sliders added. " + (skipped.length ? "Issues: " + skipped.join(", ") : ""));
        }
        
        var msg = "Added Hover Progress slider to " + added + " button(s).";
        if (skipped.length) msg += " Skipped: " + skipped.join(", ");
        ui_updateStatus(msg, added > 0 ? 'success' : 'warning');
    }

// ==== packages/ae-hoverpanel/src/84-validate.jsxinc ====
/**
 * Project validation: overlap detection and performance warnings.
 */

    // -------------------------
    // Performance Validation
    // -------------------------
    function validate_getBounds(layer) {
        try {
            var ctrl = ctrl_find();
            if (!ctrl) return null;
            var bid = btn_findIdForLayer(layer);
            if (!bid) return null;
            var num = bid.replace(CONFIG.controller.effects.buttonPrefix, "");
            var bndProp = ctrl.effect(CONFIG.controller.effects.buttonBoundsPrefix + num);
            if (!bndProp) return null;
            var bnd = bndProp("Point").value;
            var pos = layer.property("Transform").property("Position").value;
            return {
                x: pos[0],
                y: pos[1],
                width: bnd[0],
                height: bnd[1],
                layer: layer
            };
        } catch(_) { return null; }
    }

    function validate_areOverlapping(bounds1, bounds2) {
        if (!bounds1 || !bounds2) return false;
        try {
            var pad1 = 0, pad2 = 0;
            try { var p1 = bounds1.layer.effect('Hover Padding')('Point').value; pad1 = Math.max(Math.abs(p1[0]), Math.abs(p1[1])); } catch(_) {}
            try { var p2 = bounds2.layer.effect('Hover Padding')('Point').value; pad2 = Math.max(Math.abs(p2[0]), Math.abs(p2[1])); } catch(_) {}
            
            var w1 = bounds1.width + pad1*2;
            var h1 = bounds1.height + pad1*2;
            var w2 = bounds2.width + pad2*2;
            var h2 = bounds2.height + pad2*2;
            
            var dx = Math.abs(bounds1.x - bounds2.x);
            var dy = Math.abs(bounds1.y - bounds2.y);
            var minDx = (w1 + w2) / 2;
            var minDy = (h1 + h2) / 2;
            
            return (dx < minDx && dy < minDy);
        } catch(_) { return false; }
    }

    function validate_performanceCheck() {
        var warnings = [];
        var mc = comp_findByName(uiState.mainCompName);
        if (!mc) {
            warnings.push({ type: 'error', message: 'No main composition selected', suggestion: 'Select a main comp from Setup tab' });
            return warnings;
        }

        var buttons = ctrl_getRegisteredButtons();
        if (!buttons || buttons.length === 0) {
            warnings.push({ type: 'info', message: 'No buttons found', suggestion: 'Create hover buttons to analyze' });
            return warnings;
        }

        // Check 1: Too many buttons
        if (buttons.length > 50) {
            warnings.push({
                type: 'performance',
                message: buttons.length + ' buttons detected. This may impact performance.',
                suggestion: 'Consider using Group IDs to organize buttons (recommended: 10-15 per group)'
            });
        }

        // Check 2: Large influence radius
        var largeInfluence = [];
        for (var i=0; i<buttons.length; i++) {
            try {
                var L = buttons[i].layer;
                if (!L) continue;
                var infl = L.effect('Hover Influence Radius')('Slider').value;
                if (infl > 200) {
                    largeInfluence.push({ name: L.name, value: Math.round(infl) });
                }
            } catch(_) {}
        }
        if (largeInfluence.length > 0) {
            var names = [];
            for (var j=0; j<Math.min(largeInfluence.length, 3); j++) {
                names.push(largeInfluence[j].name + " (" + largeInfluence[j].value + "px)");
            }
            var more = largeInfluence.length > 3 ? " and " + (largeInfluence.length - 3) + " more" : "";
            warnings.push({
                type: 'performance',
                message: largeInfluence.length + ' button(s) with large influence radius: ' + names.join(", ") + more,
                suggestion: 'Reduce to 150px or less for better performance'
            });
        }

        // Check 3: Overlapping buttons
        var overlaps = [];
        var bounds = [];
        for (var k=0; k<buttons.length; k++) {
            var b = validate_getBounds(buttons[k].layer);
            if (b) bounds.push(b);
        }
        
        for (var m=0; m<bounds.length; m++) {
            for (var n=m+1; n<bounds.length; n++) {
                if (validate_areOverlapping(bounds[m], bounds[n])) {
                    overlaps.push([bounds[m].layer.name, bounds[n].layer.name]);
                }
            }
        }

        if (overlaps.length > 0) {
            var pairs = [];
            for (var p=0; p<Math.min(overlaps.length, 3); p++) {
                pairs.push(overlaps[p][0] + " ↔ " + overlaps[p][1]);
            }
            var moreOverlaps = overlaps.length > 3 ? " and " + (overlaps.length - 3) + " more" : "";
            warnings.push({
                type: 'ux',
                message: overlaps.length + ' overlapping button pair(s) detected: ' + pairs.join(", ") + moreOverlaps,
                suggestion: 'Adjust padding or reposition buttons to prevent hover conflicts'
            });
        }

        // Check 4: Buttons without groups (if many buttons)
        if (buttons.length > 20) {
            var ungrouped = 0;
            for (var g=0; g<buttons.length; g++) {
                try {
                    var gid = buttons[g].layer.effect('Group ID')('Slider').value;
                    if (!gid || gid === 0) ungrouped++;
                } catch(_) { ungrouped++; }
            }
            if (ungrouped > 10) {
                warnings.push({
                    type: 'organization',
                    message: ungrouped + ' ungrouped buttons (out of ' + buttons.length + ')',
                    suggestion: 'Use Group IDs to organize buttons for easier batch operations'
                });
            }
        }

        return warnings;
    }

    function validate_showReport() {
        var warnings = validate_performanceCheck();
        
        if (warnings.length === 0) {
            alert("✅ Performance Check Complete\n\n" +
                  "No issues detected!\n" +
                  "Your setup looks great.");
            ui_updateStatus("Performance check: All good!", 'success');
            return;
        }

        var report = "⚠️ Performance & Setup Analysis\n";
        report += "═══════════════════════════════\n\n";
        
        var hasPerf = false, hasUX = false, hasOrg = false, hasError = false;
        
        for (var i=0; i<warnings.length; i++) {
            var w = warnings[i];
            var icon = "•";
            if (w.type === 'performance') { icon = "⚡"; hasPerf = true; }
            else if (w.type === 'ux') { icon = "👆"; hasUX = true; }
            else if (w.type === 'organization') { icon = "📁"; hasOrg = true; }
            else if (w.type === 'error') { icon = "❌"; hasError = true; }
            else if (w.type === 'info') { icon = "ℹ️"; }
            
            report += icon + " " + w.message + "\n";
            report += "   → " + w.suggestion + "\n\n";
        }

        report += "───────────────────────────────\n";
        report += "Legend:\n";
        if (hasPerf) report += "⚡ Performance - May affect render speed\n";
        if (hasUX) report += "👆 User Experience - May cause interaction issues\n";
        if (hasOrg) report += "📁 Organization - Affects maintainability\n";
        
        alert(report);
        
        var summary = warnings.length + " issue(s) found";
        if (hasPerf) summary += " (performance)";
        if (hasUX) summary += " (UX)";
        ui_updateStatus(summary, hasError ? 'error' : 'warning');
    }

// ==== packages/ae-hoverpanel/src/86-analytics.jsxinc ====
/**
 * Project statistics and the health dashboard.
 */

    // -------------------------
    // Analytics Dashboard
    // -------------------------
    function analytics_gatherStats() {
        var stats = {
            totalButtons: 0,
            cursorTypes: { 0: 0, 1: 0, 2: 0, 3: 0 },
            cursorTypeNames: { 0: 'Default', 1: 'Link', 2: 'Text', 3: 'Loading' },
            groups: {},
            influence: { min: Infinity, max: -Infinity, total: 0, values: [] },
            padding: { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity, avgX: 0, avgY: 0 },
            animDuration: { min: Infinity, max: -Infinity, avg: 0, values: [] },
            hasGuides: 0,
            noGuides: 0,
            bounds: { minW: Infinity, maxW: -Infinity, minH: Infinity, maxH: -Infinity }
        };

        var mc = comp_findByName(uiState.mainCompName);
        if (!mc) return null;

        var buttons = ctrl_getRegisteredButtons();
        if (!buttons || buttons.length === 0) return null;

        stats.totalButtons = buttons.length;

        for (var i = 0; i < buttons.length; i++) {
            try {
                var L = buttons[i].layer;
                if (!L) continue;

                // Cursor type
                var ct = 0;
                try { ct = Math.round(L.effect('EH Cursor Type')('Slider').value) || 0; } catch(_) {}
                if (ct < 0 || ct > 3) ct = 0;
                stats.cursorTypes[ct]++;

                // Influence radius
                try {
                    var infl = L.effect('Hover Influence Radius')('Slider').value;
                    stats.influence.values.push(infl);
                    stats.influence.total += infl;
                    if (infl < stats.influence.min) stats.influence.min = infl;
                    if (infl > stats.influence.max) stats.influence.max = infl;
                } catch(_) {}

                // Padding
                try {
                    var pad = L.effect('Hover Padding')('Point').value;
                    var px = pad[0], py = pad[1];
                    if (px < stats.padding.minX) stats.padding.minX = px;
                    if (px > stats.padding.maxX) stats.padding.maxX = px;
                    if (py < stats.padding.minY) stats.padding.minY = py;
                    if (py > stats.padding.maxY) stats.padding.maxY = py;
                    stats.padding.avgX += px;
                    stats.padding.avgY += py;
                } catch(_) {}

                // Group ID
                try {
                    var gid = Math.round(L.effect('Group ID')('Slider').value) || 0;
                    if (gid > 0) {
                        stats.groups[gid] = (stats.groups[gid] || 0) + 1;
                    }
                } catch(_) {}

                // Animation duration
                try {
                    var ctrl = ctrl_find();
                    if (ctrl) {
                        var bid = btn_findIdForLayer(L);
                        if (bid) {
                            var num = bid.replace(CONFIG.controller.effects.buttonPrefix, "");
                            var durEff = ctrl.effect(CONFIG.controller.effects.buttonAnimDuration + num);
                            if (durEff) {
                                var dur = durEff('Slider').value;
                                stats.animDuration.values.push(dur);
                                if (dur < stats.animDuration.min) stats.animDuration.min = dur;
                                if (dur > stats.animDuration.max) stats.animDuration.max = dur;
                            }
                        }
                    }
                } catch(_) {}

                // Guides
                var hasGuide = guide_find(mc, L) !== null;
                if (hasGuide) stats.hasGuides++; else stats.noGuides++;

                // Bounds
                try {
                    var bnd = validate_getBounds(L);
                    if (bnd) {
                        if (bnd.width < stats.bounds.minW) stats.bounds.minW = bnd.width;
                        if (bnd.width > stats.bounds.maxW) stats.bounds.maxW = bnd.width;
                        if (bnd.height < stats.bounds.minH) stats.bounds.minH = bnd.height;
                        if (bnd.height > stats.bounds.maxH) stats.bounds.maxH = bnd.height;
                    }
                } catch(_) {}

            } catch(_) {}
        }

        // Calculate averages
        if (stats.influence.values.length > 0) {
            stats.influence.avg = Math.round(stats.influence.total / stats.influence.values.length);
        }
        if (stats.totalButtons > 0) {
            stats.padding.avgX = Math.round(stats.padding.avgX / stats.totalButtons);
            stats.padding.avgY = Math.round(stats.padding.avgY / stats.totalButtons);
        }
        if (stats.animDuration.values.length > 0) {
            var totalDur = 0;
            for (var d = 0; d < stats.animDuration.values.length; d++) {
                totalDur += stats.animDuration.values[d];
            }
            stats.animDuration.avg = Math.round(totalDur / stats.animDuration.values.length * 100) / 100;
        }

        // Handle infinity edge cases
        if (stats.influence.min === Infinity) stats.influence.min = 0;
        if (stats.influence.max === -Infinity) stats.influence.max = 0;
        if (stats.animDuration.min === Infinity) stats.animDuration.min = 0;
        if (stats.animDuration.max === -Infinity) stats.animDuration.max = 0;
        if (stats.bounds.minW === Infinity) stats.bounds.minW = 0;
        if (stats.bounds.maxW === -Infinity) stats.bounds.maxW = 0;
        if (stats.bounds.minH === Infinity) stats.bounds.minH = 0;
        if (stats.bounds.maxH === -Infinity) stats.bounds.maxH = 0;
        if (stats.padding.minX === Infinity) stats.padding.minX = 0;
        if (stats.padding.maxX === -Infinity) stats.padding.maxX = 0;
        if (stats.padding.minY === Infinity) stats.padding.minY = 0;
        if (stats.padding.maxY === -Infinity) stats.padding.maxY = 0;

        return stats;
    }

    function analytics_showDashboard() {
        var stats = analytics_gatherStats();
        
        if (!stats) {
            alert("📊 Analytics Dashboard\n\n" +
                  "No data available.\n\n" +
                  "Create hover buttons first, then run this analysis.");
            ui_updateStatus("No buttons to analyze", 'info');
            return;
        }

        var report = "📊 Project Analytics Dashboard\n";
        report += "═══════════════════════════════\n\n";

        // Overview
        report += "📈 OVERVIEW\n";
        report += "   Total Buttons: " + stats.totalButtons + "\n";
        var groupCount = 0;
        for (var g in stats.groups) if (stats.groups.hasOwnProperty(g)) groupCount++;
        report += "   Active Groups: " + groupCount + "\n";
        report += "   With Guides: " + stats.hasGuides + " | Without: " + stats.noGuides + "\n\n";

        // Cursor Types
        report += "🖱️ CURSOR TYPE DISTRIBUTION\n";
        for (var ct = 0; ct <= 3; ct++) {
            var count = stats.cursorTypes[ct];
            var pct = stats.totalButtons > 0 ? Math.round((count / stats.totalButtons) * 100) : 0;
            var name = stats.cursorTypeNames[ct];
            var bar = "";
            for (var b = 0; b < Math.floor(pct / 5); b++) bar += "█";
            report += "   " + name + ": " + count + " (" + pct + "%) " + bar + "\n";
        }
        report += "\n";

        // Groups
        if (groupCount > 0) {
            report += "📁 GROUP BREAKDOWN\n";
            var sortedGroups = [];
            for (var gid in stats.groups) {
                if (stats.groups.hasOwnProperty(gid)) {
                    sortedGroups.push({ id: gid, count: stats.groups[gid] });
                }
            }
            sortedGroups.sort(function(a, b) { return b.count - a.count; });
            
            for (var sg = 0; sg < Math.min(sortedGroups.length, 5); sg++) {
                report += "   Group " + sortedGroups[sg].id + ": " + sortedGroups[sg].count + " button(s)\n";
            }
            if (sortedGroups.length > 5) {
                report += "   ... and " + (sortedGroups.length - 5) + " more group(s)\n";
            }
            report += "\n";
        }

        // Influence Radius
        report += "🎯 INFLUENCE RADIUS (px)\n";
        report += "   Average: " + stats.influence.avg + "px\n";
        report += "   Range: " + Math.round(stats.influence.min) + "px - " + Math.round(stats.influence.max) + "px\n";
        if (stats.influence.avg > 150) {
            report += "   ⚠️ Consider reducing for better performance\n";
        }
        report += "\n";

        // Padding
        report += "📐 HOVER PADDING (px)\n";
        report += "   Average: [" + stats.padding.avgX + ", " + stats.padding.avgY + "]\n";
        report += "   X Range: " + Math.round(stats.padding.minX) + " to " + Math.round(stats.padding.maxX) + "\n";
        report += "   Y Range: " + Math.round(stats.padding.minY) + " to " + Math.round(stats.padding.maxY) + "\n\n";

        // Animation Duration
        if (stats.animDuration.values.length > 0) {
            report += "⏱️ ANIMATION DURATION (sec)\n";
            report += "   Average: " + stats.animDuration.avg + "s\n";
            report += "   Range: " + stats.animDuration.min + "s - " + stats.animDuration.max + "s\n\n";
        }

        // Button Sizes
        report += "📏 BUTTON DIMENSIONS (px)\n";
        report += "   Width: " + Math.round(stats.bounds.minW) + " - " + Math.round(stats.bounds.maxW) + "px\n";
        report += "   Height: " + Math.round(stats.bounds.minH) + " - " + Math.round(stats.bounds.maxH) + "px\n\n";

        // Health Score
        var score = 100;
        if (stats.totalButtons > 50) score -= 10;
        if (stats.totalButtons > 100) score -= 15;
        if (stats.influence.avg > 150) score -= 15;
        if (stats.influence.avg > 200) score -= 10;
        if (groupCount === 0 && stats.totalButtons > 20) score -= 15;
        if (stats.noGuides > stats.hasGuides && stats.totalButtons > 10) score -= 10;
        
        var healthIcon = score >= 80 ? "💚" : score >= 60 ? "💛" : "🔴";
        var healthLabel = score >= 80 ? "Excellent" : score >= 60 ? "Good" : "Needs Attention";
        
        report += "───────────────────────────────\n";
        report += healthIcon + " PROJECT HEALTH SCORE: " + score + "/100 (" + healthLabel + ")\n";

        alert(report);
        ui_updateStatus("Analytics: " + stats.totalButtons + " buttons analyzed", 'success');
    }

// ==== packages/ae-hoverpanel/src/88-settings-dialog.jsxinc ====
/**
 * The settings dialog, plus defaults and file import/export.
 */

    // -------------------------
    // Settings dialog
    // -------------------------
	function util_deepClone(obj, _depth){
		_depth = _depth || 0;
		if (_depth > 20) return obj; // Guard against circular references (Issue #38)
		if (obj === null || typeof obj !== 'object') return obj;
		if (obj.constructor === Array){ var a=[]; for (var i=0;i<obj.length;i++) a[i]=util_deepClone(obj[i], _depth+1); return a; }
		var o={}; for (var k in obj) if (obj.hasOwnProperty(k)) o[k]=util_deepClone(obj[k], _depth+1); return o;
	}

	function settings_getDefaults(){
		// Prefer an external factory default if the host provides one
		try{
			if (typeof FACTORY_SETTINGS !== "undefined" && FACTORY_SETTINGS) return util_deepClone(FACTORY_SETTINGS);
			if (typeof getFactoryDefaults === "function"){
				var ext = getFactoryDefaults();
				if (ext) return util_deepClone(ext);
			}
		}catch(_){ }
		// Fallback: derive from CONFIG with safe defaults
		var cfg = {};
		try { for (var k in CONFIG) if (CONFIG.hasOwnProperty(k)) cfg[k] = util_deepClone(CONFIG[k]); }
		catch(__){ cfg = {}; }
		if (!cfg.behavior) cfg.behavior = {
			openComp:false, showConfirm:true, collapseTransformations:true,
			hideController:true, autoUpdateReferences:true,
			defaultAnimDuration:1, templateWidth:200, templateHeight:100,
			maxButtons:200, guideBelowButton:false
		};
		if (!cfg.naming) cfg.naming = { precomp:"[Button] ", animationLayer:"[ANIMATION] ", templateComp:"Button Template " };
		if (!cfg.viz) cfg.viz = {
			corner:10, coreStroke:1.0, inflStroke:2.0, dash:8, gap:6,
			coreColor:[1.0,0.4,0.0], inflColor:[0.0,0.27,0.71]
		};
		if (!cfg.detection) cfg.detection = { cornerFudge:1.0 }; // Ensure detection exists (Issue #39)
		if (!cfg.cursor) cfg.cursor = { magnetStrength:15, hoverScale:110 };
		return cfg;
	}


	function settings_exportToFile(){
		try{
			var f = File.saveDialog("Export Easy Hover settings as JSON","JSON:*.json");
			if (!f) return false;
			if (!/\.json$/i.test(f.name)) f = new File(f.fsName + ".json");
			if (f.exists) if (!confirm("Overwrite existing file?\n\n"+f.fsName)) return false;
			f.encoding = "UTF-8";
			f.open("w");
			f.write(JSON.stringify(scriptSettings));
			f.close();
			ui_updateStatus("Settings exported to: "+f.fsName, "success");
			return true;
		}catch(e){ ui_updateStatus("Export failed: "+e.message, "error"); return false; }
	}


	function settings_importFromFile(onApplied){
		try{
			var f = File.openDialog("Import Easy Hover settings (JSON)","JSON:*.json");
			if (!f) return false;
			f.encoding = "UTF-8";
			f.open("r");
			var txt = f.read();
			f.close();
			var loaded = {};
			try{ loaded = JSON.parse(txt); }
			catch (e){ alert("File is not valid JSON.\n"+e.message); return false; }
			// Merge with CONFIG to ensure new keys exist
			scriptSettings = settings_merge(loaded, CONFIG);
			if (typeof onApplied === 'function') onApplied(scriptSettings);
			ui_updateStatus("Imported settings (not saved yet).", 'info');
			return true;
		}catch(e){ alert("Import failed:\n"+e.message); return false; }
	}
	
function ui_openSettingsDialog(){
    // Use global helpers: deepClone, getDefaultSettings, exportSettingsToFile, importSettingsFromFile

    var d = new Window("dialog", "Time Remap Hover Settings");
    d.orientation="column"; d.alignChildren=["fill","top"]; d.spacing=10; d.margins=12; d.preferredSize=[560, null];

    // Header
    var hdr = d.add("group"); hdr.orientation="column"; hdr.alignChildren=["fill","top"]; hdr.spacing=4;
    var title = hdr.add("statictext", undefined, pkg_title() + " - Settings"); title.graphics.font = ScriptUI.newFont("dialog","BOLD",12);
    var sub = hdr.add("statictext", undefined, "Manage behavior, naming, template + viz defaults. Export/Import presets or reset to factory.");

    // Tabs for nicer organization
    var tabs = d.add("tabbedpanel"); tabs.alignment=["fill","fill"]; tabs.margins=0; tabs.preferredSize=[540, 360];
    var tabBehavior = tabs.add("tab", undefined, "Behavior"); tabBehavior.margins=10; tabBehavior.alignChildren=["left","top"]; tabBehavior.spacing=8;
    var tabNaming   = tabs.add("tab", undefined, "Naming");   tabNaming.margins=10;   tabNaming.alignChildren=["left","top"];   tabNaming.spacing=8;
    var tabTemplate = tabs.add("tab", undefined, "Template Size"); tabTemplate.margins=10; tabTemplate.alignChildren=["left","top"]; tabTemplate.spacing=8;
    var tabViz      = tabs.add("tab", undefined, "Viz Defaults"); tabViz.margins=10; tabViz.alignChildren=["left","top"]; tabViz.spacing=8;

    // === Behavior ===
    var pB = tabBehavior.add("panel", undefined, "Workflow"); pB.margins=10; pB.alignChildren=["left","top"]; pB.spacing=8;
    var openChk = pB.add("checkbox", undefined, "Open pre-comp after creation"); openChk.helpTip="Opens the button precomp once created.";
    var confChk = pB.add("checkbox", undefined, "Show confirmation before removing button");
    var collChk = pB.add("checkbox", undefined, "Enable Collapse Transformations");
    var hideChk = pB.add("checkbox", undefined, "Hide controller layer (shy + disabled)");
    var updChk  = pB.add("checkbox", undefined, "Auto-update references on rename");

    var rowB1 = pB.add("group"); rowB1.orientation="row"; rowB1.spacing=8; rowB1.alignChildren=["left","center"]; 
    rowB1.add("statictext", undefined, "Default Anim Duration:"); var edDur = rowB1.add("edittext", undefined, ""); edDur.preferredSize=[70,22]; edDur.helpTip="Seconds (>0)";
    var rowB2 = pB.add("group"); rowB2.orientation="row"; rowB2.spacing=8; rowB2.alignChildren=["left","center"]; 
    rowB2.add("statictext", undefined, "Max Buttons:"); var edMax = rowB2.add("edittext", undefined, ""); edMax.preferredSize=[70,22]; edMax.helpTip=">=1";
    var rowB3 = pB.add("group"); rowB3.orientation="row"; rowB3.spacing=8; rowB3.alignChildren=["left","center"]; 
    rowB3.add("statictext", undefined, "Corner Fudge:"); var edCF = rowB3.add("edittext", undefined, ""); edCF.preferredSize=[70,22]; edCF.helpTip="Extra local pixels to avoid corner misses (typical 0.5–2.0)";
    var rowB4 = pB.add("group"); rowB4.orientation="row"; rowB4.spacing=8; rowB4.alignChildren=["left","center"];
    rowB4.add("statictext", undefined, "Cursor Magnet %:"); var edMag = rowB4.add("edittext", undefined, ""); edMag.preferredSize=[70,22];
    edMag.helpTip="How strongly the cursor is pulled toward a hovered button. 0 disables magnetism.";
    rowB4.add("statictext", undefined, "Cursor Hover Scale %:"); var edCHS = rowB4.add("edittext", undefined, ""); edCHS.preferredSize=[70,22];
    edCHS.helpTip="Cursor size while hovering, as a percentage of its own scale. 100 disables it.";

    // === Naming ===
    var pN = tabNaming.add("panel", undefined, "Prefixes"); pN.margins=10; pN.alignChildren=["left","top"]; pN.spacing=8;
    var rN1 = pN.add("group"); rN1.orientation="row"; rN1.spacing=8; rN1.add("statictext", undefined, "Pre-comp prefix:"); var edPre = rN1.add("edittext", undefined, ""); edPre.preferredSize=[260,22];
    var rN2 = pN.add("group"); rN2.orientation="row"; rN2.spacing=8; rN2.add("statictext", undefined, "Animation layer prefix:"); var edAnim = rN2.add("edittext", undefined, ""); edAnim.preferredSize=[260,22];
    var rN3 = pN.add("group"); rN3.orientation="row"; rN3.spacing=8; rN3.add("statictext", undefined, "Template prefix:"); var edTmp = rN3.add("edittext", undefined, ""); edTmp.preferredSize=[260,22];
    var rN4 = pN.add("group"); rN4.orientation="row"; rN4.spacing=8; rN4.add("statictext", undefined, "Skip layer prefixes (CSV):"); var edSkip = rN4.add("edittext", undefined, ""); edSkip.preferredSize=[300,22]; edSkip.helpTip="Comma-separated list; layers starting with any listed prefix will not be auto-hidden.";
    var rN5 = pN.add("group"); rN5.orientation="row"; rN5.spacing=8; rN5.add("statictext", undefined, "Guide prefix:"); var edGuide = rN5.add("edittext", undefined, ""); edGuide.preferredSize=[300,22]; edGuide.helpTip="Name prefix for guide layers; also auto-whitelisted from hiding.";

    // === Template Size ===
    var pT = tabTemplate.add("panel", undefined, "Default Dimensions"); pT.margins=10; pT.alignChildren=["left","top"]; pT.spacing=8;
    var rT1 = pT.add("group"); rT1.orientation="row"; rT1.spacing=8; rT1.add("statictext", undefined, "Width:");  var edW = rT1.add("edittext", undefined, ""); edW.preferredSize=[70,22];
    var rT2 = pT.add("group"); rT2.orientation="row"; rT2.spacing=8; rT2.add("statictext", undefined, "Height:"); var edH = rT2.add("edittext", undefined, ""); edH.preferredSize=[70,22];

    // === Viz Defaults ===
    var pV = tabViz.add("panel", undefined, "Guides"); pV.margins=10; pV.alignChildren=["left","top"]; pV.spacing=8;
    var rV1 = pV.add("group"); rV1.orientation="row"; rV1.spacing=8;
    rV1.add("statictext", undefined, "Corner:"); var edCorner = rV1.add("edittext", undefined, ""); edCorner.preferredSize=[50,22];
    rV1.add("statictext", undefined, "Core W:"); var edCoreW = rV1.add("edittext", undefined, ""); edCoreW.preferredSize=[50,22];
    rV1.add("statictext", undefined, "Infl W:"); var edInflW = rV1.add("edittext", undefined, ""); edInflW.preferredSize=[50,22];

    var rV2 = pV.add("group"); rV2.orientation="row"; rV2.spacing=8;
    rV2.add("statictext", undefined, "Dash:"); var edDash = rV2.add("edittext", undefined, ""); edDash.preferredSize=[50,22];
    rV2.add("statictext", undefined, "Gap:");  var edGap  = rV2.add("edittext", undefined, ""); edGap.preferredSize=[50,22];

    var rV3 = pV.add("group"); rV3.orientation="row"; rV3.spacing=8; rV3.add("statictext", undefined, "Core Color (0-255):");
    var edCoreR = rV3.add("edittext", undefined, ""); edCoreR.preferredSize=[45,22];
    var edCoreG = rV3.add("edittext", undefined, ""); edCoreG.preferredSize=[45,22];
    var edCoreB = rV3.add("edittext", undefined, ""); edCoreB.preferredSize=[45,22];

    var rV4 = pV.add("group"); rV4.orientation="row"; rV4.spacing=8; rV4.add("statictext", undefined, "Infl Color (0-255):");
    var edInflR = rV4.add("edittext", undefined, ""); edInflR.preferredSize=[45,22];
    var edInflG = rV4.add("edittext", undefined, ""); edInflG.preferredSize=[45,22];
    var edInflB = rV4.add("edittext", undefined, ""); edInflB.preferredSize=[45,22];

    // Footer actions (3 buttons per row)
    var footer = d.add("group");
    footer.orientation = "column";
    footer.alignment = ["fill","bottom"];
    footer.spacing = 6;

    function makeRow(){ var r=footer.add("group"); r.orientation="row"; r.alignment=["fill","top"]; r.alignChildren=["fill","center"]; r.spacing=8; return r; }

	var row1 = makeRow();
	var btnExport = row1.add("button", undefined, "Export Settings…");
	var btnImport = row1.add("button", undefined, "Import Settings…");
	var btnReset  = row1.add("button", undefined, "Reset to Default");

	// --- Scope row (Both / Project / App) ---
	var rowScope = makeRow();
	rowScope.add("statictext", undefined, "Scope:");
	var ddScope = rowScope.add("dropdownlist", undefined, ["Both","Project","App"]); ddScope.selection = 0;
	var btnSaveScope  = rowScope.add("button", undefined, "Save");
	var btnResetScope = rowScope.add("button", undefined, "Reset");
	function batch_getScopedSelection(){ var t = ddScope.selection ? ddScope.selection.text.toLowerCase() : "both"; if (t==="project") return "project"; if (t==="app") return "app"; return "both"; }
	btnSaveScope.onClick  = function(){ try{ settings_save(batch_getScopedSelection()); ui_updateStatus("Settings saved ("+batch_getScopedSelection()+").","success"); }catch(e){ ui_updateStatus("Save failed: "+e.message, "error"); } };
	btnResetScope.onClick = function(){ try{ settings_reset(batch_getScopedSelection()); ui_updateStatus("Settings reset ("+batch_getScopedSelection()+").","warning"); }catch(e){ ui_updateStatus("Reset failed: "+e.message, "error"); } };


    var row2 = makeRow();
    var btnSave   = row2.add("button", undefined, "Save");
    var btnOK     = row2.add("button", undefined, "OK");
    var btnCancel = row2.add("button", undefined, "Cancel");

    (function equalize(){
        var all=[btnExport,btnImport,btnReset,btnSave,btnOK,btnCancel];
        var g=footer.graphics, maxW=0, pad=24;
        for (var i=0;i<all.length;i++){ var m=g.measureString(all[i].text,g.font); maxW=Math.max(maxW, Math.round(m[0])+pad); }
        for (var j=0;j<all.length;j++){ all[j].preferredSize.width=maxW; all[j].preferredSize.height=26; }
    })();

    // Populate UI from current settings
    function ui_populateFromSettings(){
        // ensure scriptSettings exists
        if (typeof scriptSettings === "undefined" || !scriptSettings) scriptSettings = settings_getDefaults();

        openChk.value = !!scriptSettings.behavior.openComp;
        confChk.value = !!scriptSettings.behavior.showConfirm;
        collChk.value = !!scriptSettings.behavior.collapseTransformations;
        hideChk.value = !!scriptSettings.behavior.hideController;
        updChk.value  = !!scriptSettings.behavior.autoUpdateReferences;
        edDur.text = String(scriptSettings.behavior.defaultAnimDuration);
        edMax.text = String(scriptSettings.behavior.maxButtons);
        if (!scriptSettings.detection) scriptSettings.detection = util_deepClone((CONFIG&&CONFIG.detection)||{cornerFudge:1.0});
        edCF.text = String(scriptSettings.detection.cornerFudge);
        if (!scriptSettings.cursor) scriptSettings.cursor = util_deepClone((CONFIG&&CONFIG.cursor)||{magnetStrength:15,hoverScale:110});
        edMag.text = String(scriptSettings.cursor.magnetStrength);
        edCHS.text = String(scriptSettings.cursor.hoverScale);
        edPre.text = scriptSettings.naming.precomp;
        edAnim.text= scriptSettings.naming.animationLayer;
        edTmp.text = scriptSettings.naming.templateComp;
        edW.text   = String(scriptSettings.behavior.templateWidth);
        edH.text   = String(scriptSettings.behavior.templateHeight);

        // viz
        if (!scriptSettings.viz){ scriptSettings.viz = settings_getDefaults().viz; }
        var v = scriptSettings.viz;
        edCorner.text = String(v.corner);
        edCoreW.text  = String(v.coreStroke);
        edInflW.text  = String(v.inflStroke);
        edDash.text   = String(v.dash);
        edGap.text    = String(v.gap);
        var cc = v.coreColor || [1,0.4,0];
        var ic = v.inflColor || [0,0.27,0.71];
        edCoreR.text = String(Math.round((cc[0]||0)*255));
        edCoreG.text = String(Math.round((cc[1]||0)*255));
        edCoreB.text = String(Math.round((cc[2]||0)*255));
        edInflR.text = String(Math.round((ic[0]||0)*255));
        edInflG.text = String(Math.round((ic[1]||0)*255));
        edInflB.text = String(Math.round((ic[2]||0)*255));

        // skipLayerPrefixes UI (CSV)
        try{
            var prefs = (scriptSettings && scriptSettings.skipLayerPrefixes);
            if (util_isArray(prefs)) edSkip.text = prefs.join(", ");
            else edSkip.text = "";
        }catch(__){ edSkip.text = ""; }
        // guidePrefix UI
        try{
            var gp = (scriptSettings && scriptSettings.guidePrefix);
            if (typeof gp === 'string') edGuide.text = gp; else edGuide.text = (CONFIG && CONFIG.guidePrefix) || "";
        }catch(__){ edGuide.text = (CONFIG && CONFIG.guidePrefix) || ""; }
    }

    function util_clampInteger(x, lo, hi){ x = parseInt(x,10); if (!isFinite(x)) x=lo; return Math.max(lo, Math.min(hi, x)); }
    function util_clampNumber(x, lo, hi){ x = parseFloat(x); if (!isFinite(x)) x=lo; return Math.max(lo, Math.min(hi, x)); }

    function ui_applyToSettings(){
        scriptSettings.behavior.openComp = !!openChk.value;
        scriptSettings.behavior.showConfirm = !!confChk.value;
        scriptSettings.behavior.collapseTransformations = !!collChk.value;
        scriptSettings.behavior.hideController = !!hideChk.value;
        scriptSettings.behavior.autoUpdateReferences = !!updChk.value;
        scriptSettings.behavior.defaultAnimDuration = Math.max(0.001, parseFloat(edDur.text)||scriptSettings.behavior.defaultAnimDuration);
        scriptSettings.behavior.maxButtons = Math.max(1, parseInt(edMax.text,10)||scriptSettings.behavior.maxButtons);
        scriptSettings.naming.precomp = edPre.text;
        scriptSettings.naming.animationLayer = edAnim.text;
        scriptSettings.naming.templateComp = edTmp.text;
        scriptSettings.behavior.templateWidth  = Math.max(1, parseInt(edW.text,10)||scriptSettings.behavior.templateWidth);
        scriptSettings.behavior.templateHeight = Math.max(1, parseInt(edH.text,10)||scriptSettings.behavior.templateHeight);
        if (!scriptSettings.detection) scriptSettings.detection = util_deepClone((CONFIG&&CONFIG.detection)||{cornerFudge:1.0});
        scriptSettings.detection.cornerFudge = util_clampNumber(edCF.text, 0, 10);
        if (!scriptSettings.cursor) scriptSettings.cursor = {};
        // Magnet is clamped to 100: beyond that the cursor would overshoot the button
        // and oscillate, since the pull is recomputed from the new position each frame.
        scriptSettings.cursor.magnetStrength = util_clampNumber(edMag.text, 0, 100);
        scriptSettings.cursor.hoverScale = util_clampNumber(edCHS.text, 10, 400);

        if (!scriptSettings.viz) scriptSettings.viz = util_deepClone((CONFIG&&CONFIG.viz)||settings_getDefaults().viz);
        scriptSettings.viz.corner     = util_clampNumber(edCorner.text, 0, 1000);
        scriptSettings.viz.coreStroke = util_clampNumber(edCoreW.text, 0, 1000);
        scriptSettings.viz.inflStroke = util_clampNumber(edInflW.text, 0, 1000);
        scriptSettings.viz.dash       = util_clampNumber(edDash.text,   0, 2000);
        scriptSettings.viz.gap        = util_clampNumber(edGap.text,    0, 2000);

        var cr = util_clampInteger(edCoreR.text, 0, 255), cg = util_clampInteger(edCoreG.text, 0, 255), cb = util_clampInteger(edCoreB.text, 0, 255);
        var ir = util_clampInteger(edInflR.text, 0, 255), ig = util_clampInteger(edInflG.text, 0, 255), ib = util_clampInteger(edInflB.text, 0, 255);
        scriptSettings.viz.coreColor = [cr/255, cg/255, cb/255];
        scriptSettings.viz.inflColor = [ir/255, ig/255, ib/255];

        // skipLayerPrefixes from CSV
        try{
            var raw = String(edSkip.text||"");
            var arr = [];
            if (raw.length){
                var parts = raw.split(",");
                for (var i=0;i<parts.length;i++){
                    var t = String(parts[i]);
                    // trim
                    try{ t = t.replace(/^\s+|\s+$/g, ""); }catch(_){ }
                    if (t.length) arr.push(t);
                }
            }
            scriptSettings.skipLayerPrefixes = arr;
        }catch(__){}
        // guidePrefix from field
        try{ scriptSettings.guidePrefix = String(edGuide.text||""); }catch(__){}
    }

    // Footer button actions
    btnExport.onClick = function(){ settings_exportToFile(); };
    btnImport.onClick = function(){
        settings_importFromFile(function(){
            ui_populateFromSettings(); // reflect immediately
        });
    };
    btnReset.onClick = function(){
        scriptSettings = settings_getDefaults();   // restore factory defaults
        ui_updateStatus("Settings reset (not saved yet).", "info");
        ui_populateFromSettings();                // update UI fields without closing the dialog
    };
    btnSave.onClick = function(){
        ui_applyToSettings();
        if (typeof settings_save === "function" && settings_save()) {
            ui_updateStatus("Settings saved.", "success");
            // Batch-apply Corner Fudge to all buttons from global setting
            try{
                var _btns = ctrl_getRegisteredButtons();
                for (var bi=0; bi<_btns.length; bi++){
                    var _L = _btns[bi].layer; if (!_L) continue;
                    try{
                        var _cf = _L.effect('Corner Fudge');
                        if (!_cf) { _cf = _L.Effects.addProperty(CONFIG.effects.slider); _cf.name = 'Corner Fudge'; }
                        _cf.property('Slider').setValue(scriptSettings.detection.cornerFudge);
                    }catch(__){}
                }
            }catch(__){}
            // Auto-reapply cursor expressions to reflect Corner Fudge immediately
            try { expr_applyCursorStateSwap(true); } catch(_) {}
        }
    };
    btnOK.onClick = function(){
        ui_applyToSettings();
        try { if (typeof settings_save === "function") settings_save(); } catch(_){}
        // Batch-apply Corner Fudge to all buttons from global setting on OK as well
        try{
            var _btns2 = ctrl_getRegisteredButtons();
            for (var bi2=0; bi2<_btns2.length; bi2++){
                var _L2 = _btns2[bi2].layer; if (!_L2) continue;
                try{
                    var _cf2 = _L2.effect('Corner Fudge');
                    if (!_cf2) { _cf2 = _L2.Effects.addProperty(CONFIG.effects.slider); _cf2.name = 'Corner Fudge'; }
                    _cf2.property('Slider').setValue(scriptSettings.detection.cornerFudge);
                }catch(__){}
            }
        }catch(__){}
        // Auto-reapply on OK as well
        try { expr_applyCursorStateSwap(true); } catch(_) {}
        d.close();
    };
    btnCancel.onClick = function(){ d.close(); };

    ui_populateFromSettings();
    d.show();
}

// ==== packages/ae-hoverpanel/src/99-init.jsxinc ====
/**
 * Entry point: load settings, build the panel, show it.
 */


    // -------------------------
    // INIT
    // -------------------------

    // When this copy was launched from the Scripts folder but a newer version has
    // already been downloaded, run that instead and stop here. Placed before any
    // other work so the old engine never touches the project.
    if (updater_handoffIfNewer(thisObj)) return;

    // A copy reached by handoff is evaluated with $.evalFile, where `this` is not the
    // Panel — so the docking host arrives via $.global instead. Without this the
    // panel would silently become a floating window after the first update.
    var __host = thisObj;
    try {
        if ($.global.__EH_HOST_OBJ) {
            __host = $.global.__EH_HOST_OBJ;
            $.global.__EH_HOST_OBJ = undefined;
        }
    } catch (e) {}

    settings_load();
    myPanel = buildUI(__host);

    // Populate the dropdowns and derive the enabled states straight away.
    //
    // Without this the panel opened with empty lists, so uiState.mainCompName was "" and
    // ui_setEnabledStates() disabled everything — including One-Click Setup, the one
    // control a new user needs. It only came alive after pressing Refresh, which is not
    // discoverable and looked like the script was broken.
    try { ui_refreshAllLists(); } catch (eRefresh) {}
    if (myPanel) { if (myPanel instanceof Window){ myPanel.center(); myPanel.show(); } else { myPanel.layout.layout(true); } }

})(this);
