Widget GTM Filter – filtrowanie i wyszukiwanie w listach GTM

Strona główna » Skrypty » Widget GTM Filter – filtrowanie i wyszukiwanie w listach GTM
Instrukcja użycia

→ Ściągnij wtyczkę Tampermonkey do przeglądarki
→ W opcjach wtyczki włącz opcję „Allow User Scripts” (dzięki temu będzie można korzystać z własnych skryptów)
→ Wklej w Tampermonkey poniższy skrypt GTM Toolbox
→ Wejdź do Google Tag Manager i korzystaj z filtrowania i wyszukiwania w listach tagów, reguł i zmiennych

Skrypt GTM Toolbox

// ==UserScript==
// @name         GTM Filter
// @namespace    https://pawelpiekarski.pl/
// @version      7.77
// @description  Filtrowanie po typie elementu, typie reguły uruchamiającej i wyszukiwanie po nazwie (z regex) lub po ID (np. 1|2, 100-120) w listach GTM, opcjonalne #ID, ID vendorów (G-, AW-, GT-, Meta…) i opcja uruchamiania przy nazwach + biblioteka standardowych parametrów tagów (GA4, Google tag, Ads, Consent, Meta)
// @author       Paweł Piekarski
// @match        https://tagmanager.google.com/*
// @icon         https://www.gstatic.com/analytics-suite/header/suite/v2/ic_tag_manager.svg
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    /* ╔══════════════════════════════════════════════════════════════════════════════╗
       ║  GTM Filter — MAPA PLIKU (szukaj po nagłówku „MODUŁ n:”)                     ║
       ╠══════════════════════════════════════════════════════════════════════════════╣
       ║  1. STAN I USTAWIENIA     — klucze localStorage, flagi opcji, kolory         ║
       ║  2. ID I WYSZUKIWANIE     — regex / ID w szukajce, ID elementu z wiersza     ║
       ║  3. API GTM               — pobieranie list (tags/triggers/variables/…),     ║
       ║                             auto-odświeżanie (podsłuch XHR/fetch), zapisy   ║
       ║  4. ADNOTACJE PRZY NAZWIE — #ID, ID vendorów (+walidacja), opcja            ║
       ║                             uruchamiania, event+parametry, consent, ESV     ║
       ║  5. ZMIENNE: KOLUMNA TAGI — użycia zmiennych (tagi/reguły/klienci/transf.)  ║
       ║  6. AKCJE NA LIŚCIE       — zmiana nazwy / duplikat (PUT/POST do API)        ║
       ║  7. UKŁAD ADNOTACJI       — sloty o równej szerokości, kolejność, kolumna    ║
       ║  8. GTM DOM               — wykrywanie tabel, kolumn, reguł z wiersza        ║
       ║  9. FILTRY                — typ, reguła, nazwa/ID, nieużywane, pauza,        ║
       ║                             duplikaty (+grupowanie), bez reguły, foldery    ║
       ║ 10. PARAMETRY (zakładka)  — biblioteka parametrów tagów + synonimy           ║
       ║ 11. WIDGET                — HTML/CSS panelu, obsługa klików, harmonijki      ║
       ║ 12. POZYCJA / DRAG        — przeciąganie, pilnowanie ekranu, zapis pozycji   ║
       ║ 13. PĘTLA / OBSERVER      — poll co 1,2 s, MutationObserver tabeli           ║
       ║ 14. DEBUG                 — gtmTbDebug() w konsoli                           ║
       ║ 15. INIT                                                                      ║
       ╚══════════════════════════════════════════════════════════════════════════════╝ */

    /* ═══════════════════ MODUŁ 1: STAN I USTAWIENIA ═══════════════════ */
    const WID = 'gtm-tb';
    const LS_COL = 'gtmTbCol';
    const LS_POS = 'gtmTbPos';        // zapamiętane położenie okna (po przeciągnięciu)
    const LS_H = 'gtmTbH';            // zapamiętana wysokość okna (uchwyt w stopce)
    const LS_TIDY = 'gtmTbTidy';      // '0' = wyłączone „Porządkuj” (zapauzowane na szaro i na dół); domyślnie wł.
    const LS_TRIG = 'gtmTbTrigTypes'; // + ':' + klucz kontenera
    const LS_TAB = 'gtmTbTab';        // ostatnia zakładka: filter | params
    const LS_IDS = 'gtmTbShowIds';    // '1' = pokazuj #ID przy nazwach na liście (domyślnie wył.)
    const LS_VIDS = 'gtmTbShowVendorIds'; // '1' = ID vendorów (G-…, AW-…) przy nazwach
    const LS_FIRE = 'gtmTbShowFiring';    // '1' = opcja uruchamiania tagu (raz na zdarzenie / stronę)
    const LS_EVT = 'gtmTbShowEvents';     // '1' = nazwa eventu + parametry tagu
    const LS_CONSENT = 'gtmTbShowConsent'; // '1' = ustawienia zgody przy tagu
    const LS_OPT_ACC = 'gtmTbOptAcc';     // '1' = harmonijka „Opcje wyświetlania” rozwinięta
    const LS_TRIG_ACC = 'gtmTbTrigAcc';   // '1' = harmonijka „Szukaj po nazwie reguły” rozwinięta
    const LS_VAR_USES = 'gtmTbVarUses';   // '0' = wyłączona kolumna „Tagi” na liście zmiennych (domyślnie wł.)
    const POLL_MS = 1200;

    let collapsed = localStorage.getItem(LS_COL) === '1';
    let activeTab = localStorage.getItem(LS_TAB) === 'params' ? 'params' : 'filter';
    let currentSection = null;
    let widget = null;
    let lastScanKey = '';

    let typeMap = {};
    let trigTypeMap = {};                   // { typReguły: liczbaTagów } — tylko sekcja tagów
    let activeTypeFilters = new Set();
    let activeTrigTypeFilters = new Set();  // wybrane TYPY reguł
    let trigNameSearch = '';                // szukajka po NAZWIE reguły (filtruje tagi)
    let trigNameRegex = null;
    let trigNameError = '';
    let trigNameIds = null;                 // predykaty ID reguł (szukajka reguł na Tagach)
    let nameSearch = '';
    let nameSearchRegex = null;
    let nameSearchError = '';
    let nameSearchIds = null;               // predykaty ID, gdy wpis w szukajce to same ID
    let showIds = false;
    try { showIds = localStorage.getItem(LS_IDS) === '1'; } catch (e) {}
    let showVendorIds = false, showFiring = false, trigAccOpen = false, showEvents = false, showConsent = false, optAccOpen = false;
    const annFirst = true; // etykiety zawsze przed nazwą
    const openParams = new Set(); // tagi z rozwiniętą listą parametrów
    try {
        showVendorIds = localStorage.getItem(LS_VIDS) === '1';
        showFiring = localStorage.getItem(LS_FIRE) === '1';
        showEvents = localStorage.getItem(LS_EVT) === '1';
        showConsent = localStorage.getItem(LS_CONSENT) === '1';
        optAccOpen = localStorage.getItem(LS_OPT_ACC) === '1';
        trigAccOpen = localStorage.getItem(LS_TRIG_ACC) !== '0'; // domyślnie rozwinięta
    } catch (e) {}
    let showVarUses = true;
    try { showVarUses = localStorage.getItem(LS_VAR_USES) !== '0'; } catch (e) {}

    // mapa nazwa reguły → typ reguły (budowana przy wizycie na stronie Reguł,
    // trzymana w localStorage per kontener)
    let trigTypeStore = {};
    let lastContainerKey = '';

    // cache układu kolumn per tabela
    const colCache = new WeakMap();

    const C = {
        bg: '#111827', hover: '#1e293b',
        accent: '#3b82f6', accentDk: '#1e3a5f',
        green: '#22c55e', red: '#ef4444', yellow: '#eab308',
        text: '#e5e7eb', dim: '#6b7280', border: '#374151',
    };
    const COLORS = ['#3b82f6','#ef4444','#22c55e','#eab308','#a855f7','#f97316','#06b6d4','#ec4899','#14b8a6','#f43f5e','#8b5cf6','#64748b','#84cc16','#0ea5e9','#d946ef'];
    const tcMap = {}; let ci = 0;
    function tc(t) { if (!tcMap[t]) tcMap[t] = COLORS[ci++ % COLORS.length]; return tcMap[t]; }
    function escHtml(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
    function escAttr(s) { return escHtml(s).replace(/"/g, '&quot;'); }

    /* ═══════════════════ MODUŁ 2: ID I WYSZUKIWANIE ═══════════════════ */
    /* ── regex w szukajce ── */
    const REGEX_SIGNAL = /[|\\^$*+?.()\[\]{}]/;

    /* ── ID elementu: z linku, atrybutów, danych AngularJS ── */
    // ID elementu siedzi tylko w linku: …/workspaces/64/tags/112
    // (luźny wzorzec — łapie też względne linki typu "triggers/5" i atrybuty ng-href / routerlink)
    const ENT_RE = /(?:^|[/#])(tags|triggers|variables)\/(\d+)(?=[/?&#]|$)/;
    const ID_ATTRS = ['href', 'ng-href', 'data-href', 'routerlink', 'ng-reflect-router-link'];

    // Wpis traktowany jako ID, gdy KAŻDY token to: 112 · #112 · 100-120 · >50 · <=50 · URL elementu.
    // Separatory: | , ; spacja. Zwraca tablicę predykatów albo null (= zwykłe szukanie po nazwie).
    function parseIdQuery(raw) {
        const toks = String(raw).trim().split(/[\s,;|]+/).filter(Boolean);
        if (!toks.length) return null;
        const preds = [];
        for (const t of toks) {
            let m;
            if ((m = ENT_RE.exec(t))) { const n = +m[2]; preds.push(id => id === n); }
            else if ((m = /^#?(\d+)$/.exec(t))) { const n = +m[1]; preds.push(id => id === n); }
            else if ((m = /^#?(\d+)-#?(\d+)$/.exec(t))) {
                const lo = Math.min(+m[1], +m[2]), hi = Math.max(+m[1], +m[2]);
                preds.push(id => id >= lo && id <= hi);
            }
            else if ((m = /^([<>]=?)#?(\d+)$/.exec(t))) {
                const op = m[1], n = +m[2];
                preds.push(id => op === '>' ? id > n : op === '>=' ? id >= n : op === '<' ? id < n : id <= n);
            }
            else return null;
        }
        return preds;
    }

    // ID wiersza: link w kolumnie Nazwa, a jeśli go nie ma — dowolny element wiersza
    // wskazujący na element TEJ sekcji (na Tagach nie weźmie ID z chipa reguły).
    function entFromEl(el) {
        for (const at of ID_ATTRS) {
            const v = el.getAttribute && el.getAttribute(at);
            if (!v) continue;
            const m = ENT_RE.exec(v);
            if (m) return { type: m[1], id: Number(m[2]) };
        }
        return null;
    }
    const ID_SEL = ID_ATTRS.map(a => '[' + a + ']').join(',');

    /* ── ID z danych AngularJS ──
       Na Regułach / Zmiennych GTM nie daje linku z ID — nazwa to <a href="" ng-click="ctrl.openTriggerSheet($event, trigger.key)">.
       ID siedzi w obiekcie trigger.key. Scope() bywa wyłączony (debugInfo off), ale kontrolery dyrektyw
       (np. gtm-table-row-checkbox data-item="trigger") zawsze są w angular.element(el).data() → szukamy tam
       pola tagId / triggerId / variableId (BFS, płytko, żeby nie wpaść w listę wszystkich elementów tabeli). */
    const NG_ID_KEYS = { tagId: 'tags', triggerId: 'triggers', variableId: 'variables' };
    const ngIdCache = new WeakMap();

    function findNgId(root, wantType) {
        const queue = [[root, 0]];
        const seen = new Set();
        while (queue.length) {
            const [o, d] = queue.shift();
            if (!o || typeof o !== 'object' || seen.has(o)) continue;
            if (o.nodeType || o === window) continue;
            seen.add(o);
            let keys;
            try { keys = Object.keys(o); } catch (e) { continue; }
            for (const k of keys) {
                const type = NG_ID_KEYS[k];
                if (!type || (wantType && type !== wantType)) continue;
                const v = o[k];
                if ((typeof v === 'string' && /^\d+$/.test(v)) || (typeof v === 'number' && v >= 0)) return { type, id: Number(v) };
            }
            if (d >= 4) continue;
            if (Array.isArray(o) && o.length > 1) continue; // listy (np. wszystkie wiersze tabeli) pomijamy
            for (const k of keys) {
                if (k.charAt(0) === '$' || /^(tableCtrl|parentCtrl|ctrl)$/.test(k)) continue;
                let v; try { v = o[k]; } catch (e) { continue; }
                if (v && typeof v === 'object') queue.push([v, d + 1]);
            }
        }
        return null;
    }

    function ngIdFromRow(row) {
        if (ngIdCache.has(row)) return ngIdCache.get(row);
        const ng = window.angular;
        if (!ng || !ng.element) return null;
        const want = currentSection || null;
        const els = [row, ...row.querySelectorAll('*')];
        for (const el of els) {
            let data;
            try { data = ng.element(el).data(); } catch (e) { continue; }
            if (!data) continue;
            for (const k of Object.keys(data)) {
                const r = findNgId(data[k], want);
                if (r) { ngIdCache.set(row, r); return r; }
            }
        }
        return null; // nie cache'ujemy porażki — Angular mógł jeszcze nie podpiąć danych
    }

    // Cały obiekt elementu (tag / reguła / zmienna) z danych Angulara — ten, który ma .key z ID.
    const ngItemCache = new WeakMap();
    // Szukamy pola tagId/triggerId/variableId w danych Angulara i bierzemy obiekt elementu:
    // jeśli ID leży w czymś w rodzaju .key (bez name/data/type) → elementem jest rodzic, inaczej sam obiekt.
    function ngItemFromRow(row) {
        if (ngItemCache.has(row)) return ngItemCache.get(row);
        const ng = window.angular;
        if (!ng || !ng.element) return null;
        const want = currentSection || null;
        const hasId = o => Object.keys(NG_ID_KEYS).some(f => o[f] != null && (!want || NG_ID_KEYS[f] === want));
        const looksLikeItem = o => ['name', 'data', 'type', 'parameter', 'tagFiringOption'].some(k => k in o);
        for (const el of [row, ...row.querySelectorAll('*')]) {
            let data;
            try { data = ng.element(el).data(); } catch (e) { continue; }
            if (!data) continue;
            for (const dk of Object.keys(data)) {
                const queue = [{ o: data[dk], d: 0, parent: null }], seen = new Set();
                while (queue.length) {
                    const cur = queue.shift();
                    const o = cur.o;
                    if (!o || typeof o !== 'object' || o.nodeType || o === window || seen.has(o)) continue;
                    seen.add(o);
                    let hit = false;
                    try { hit = hasId(o); } catch (e) {}
                    if (hit) {
                        const item = (!looksLikeItem(o) && cur.parent && cur.d > 1) ? cur.parent : o;
                        ngItemCache.set(row, item);
                        return item;
                    }
                    if (cur.d >= 4 || (Array.isArray(o) && o.length > 1)) continue;
                    let keys; try { keys = Object.keys(o); } catch (e) { continue; }
                    for (const k of keys) {
                        if (k.charAt(0) === '$' || /^(tableCtrl|parentCtrl|ctrl)$/.test(k)) continue;
                        let v; try { v = o[k]; } catch (e) { continue; }
                        if (v && typeof v === 'object') queue.push({ o: v, d: cur.d + 1, parent: o });
                    }
                }
            }
        }
        // Nowy Angular (lista Tagów): dane wiersza w __ngContext__ (LView), np. kontekst *ngFor → $implicit = tag
        const it2 = ivyItemFromRow(row, hasId, looksLikeItem);
        if (it2) { ngItemCache.set(row, it2); return it2; }
        return null;
    }

    /* ═══════════════════ MODUŁ 3: API GTM ═══════════════════ */
    /* ── Dane z API GTM (to samo, z czego korzysta panel): /api/accounts/A/containers/C/workspaces/W/tags ──
       Lista Tagów to nowy Angular — w DOM nie ma parametrów tagu. Pobieramy listę raz (cache 30 s,
       reset przy zmianie adresu) i mapujemy ID → obiekt (tagId / variableId / triggerId). */
    const API_ID_FIELD = { tags: 'tagId', variables: 'variableId', triggers: 'triggerId', clients: 'clientId', transformations: 'transformationId', folders: 'folderId' };
    // kontener serwerowy (SST) ma w menu Klientów i Transformacje — tylko wtedy pobieramy te listy
    function isServerContainer() {
        return !!document.querySelector('a[href*="/workspaces/"][href$="/clients"], a[href*="/workspaces/"][href$="/transformations"], .wd-open-client-list-button');
    }
    let apiStore = { at: 0, map: null, loading: false, status: '', sample: '' }; // ostatnio użyty (do debugu)

    function wsApiBase() {
        const m = location.hash.match(/accounts\/(\d+)\/containers\/(\d+)\/workspaces\/(\d+)/);
        return m ? `/api/accounts/${m[1]}/containers/${m[2]}/workspaces/${m[3]}` : null;
    }

    function parseApiText(txt) {
        return JSON.parse(String(txt).replace(/^\)\]\}'[^\n]*\n?/, '').trim());
    }

    // w API panelu zmienne to „makra” — ID bywa w key.macroId zamiast key.variableId
    const API_ID_ALT = { variableId: ['variableId', 'macroId'], tagId: ['tagId'], triggerId: ['triggerId', 'ruleId'], clientId: ['clientId'], transformationId: ['transformationId'], folderId: ['folderId'] };
    function collectApiItems(json, field) {
        const fields = API_ID_ALT[field] || [field];
        const pick = o => { for (const f of fields) if (o[f] != null && /^\d+$/.test(String(o[f]))) return Number(o[f]); return null; };
        const map = new Map();
        const stack = [json], seen = new Set();
        let budget = 200000;
        while (stack.length && budget-- > 0) {
            const o = stack.pop();
            if (!o || typeof o !== 'object' || seen.has(o)) continue;
            seen.add(o);
            // format API panelu: { key: { tagId: 197, … }, data: { … }, properties: { … } }
            if (!Array.isArray(o) && o.key && typeof o.key === 'object' && pick(o.key) !== null) {
                const id = pick(o.key);
                if (!map.has(id)) map.set(id, o);
                continue; // nie schodzimy niżej — to już element
            }
            if (!Array.isArray(o) && pick(o) !== null && ('name' in o || 'data' in o || 'parameter' in o)) {
                const id = pick(o);
                if (!map.has(id)) map.set(id, o);
                continue;
            }
            for (const k in o) { const v = o[k]; if (v && typeof v === 'object') stack.push(v); }
        }
        return map;
    }

    const apiStores = {}; // klucz: baza workspace + sekcja
    function apiMapFor(section) {
        const base = wsApiBase();
        const field = API_ID_FIELD[section];
        if (!base || !field) return null;
        const key = base + '/' + section;
        const st = apiStores[key] || (apiStores[key] = { map: null, at: 0, loading: false, status: '', sample: '' });
        apiStore = st;
        const fresh = st.map && Date.now() - st.at < 30000;
        if (!fresh && !st.loading) {
            st.loading = true;
            const url = base + '/' + section + '?hl=en';
            origFetch(url, { credentials: 'same-origin', headers: { 'Accept': 'application/json, text/plain, */*' } })
                .then(r => r.text().then(t => ({ ok: r.ok, status: r.status, t })))
                .then(({ ok, status, t }) => {
                    st.sample = t.slice(0, 1500);
                    if (!ok) throw new Error('HTTP ' + status);
                    st.map = collectApiItems(parseApiText(t), field);
                    st.at = Date.now();
                    st.status = 'ok · ' + st.map.size + ' elem.';
                    bumpData();
                    if (needVarParams()) hydrateStore(key, section);
                })
                .catch(e => { st.status = 'błąd: ' + (e && e.message); st.at = Date.now(); st.map = st.map || new Map(); })
                .finally(() => { st.loading = false; renderIdBadges(); if (hasActiveFilters()) applyAllFilters(); updateFooter(); });
        }
        return st.map;
    }

    /* ── Zmienne: lista z API ma puste vendorTemplate.param → dociągamy pełne dane pojedynczo (…/variables/ID),
       z cache po entityVersion, żeby odświeżenie listy nie wymuszało ponownych pobrań ── */
    const fullCache = new Map(); // 'variables:ID' → { ver, data, properties }
    const hydrating = new Set();
    // parametry zmiennych są potrzebne tylko do: wartości przy zmiennych, ID vendorów (stałe), duplikatów zmiennych
    function needVarParams() {
        return (currentSection === 'variables' && (showEvents || onlyDup || showVendorIds)) || (currentSection === 'tags' && showVendorIds);
    }
    function ensureVarParams() { const base = wsApiBase(); if (base && needVarParams()) hydrateStore(base + '/variables', 'variables'); }
    // cache pełnych zmiennych przeżywa przeładowanie strony (sessionStorage, per kontener)
    function fullCacheKey() { return 'gtmTbFull:' + (wsApiBase() || ''); }
    function loadFullCache() {
        try {
            const raw = sessionStorage.getItem(fullCacheKey());
            if (!raw) return;
            const obj = JSON.parse(raw);
            Object.keys(obj).forEach(k => fullCache.set(k, obj[k]));
        } catch (e) {}
    }
    function saveFullCache() {
        try {
            const obj = {}; fullCache.forEach((v, k) => { obj[k] = v; });
            const raw = JSON.stringify(obj);
            if (raw.length < 2500000) sessionStorage.setItem(fullCacheKey(), raw);
        } catch (e) {}
    }
    loadFullCache();
    window.addEventListener('hashchange', loadFullCache);
    function itemVer(it) { const sm = it && it.statMetadata; return sm ? String(sm.entityVersion || sm.modifiedTime || '') : ''; }
    function hasParams(vt) { return !!vt && Array.isArray(vt.param) && vt.param.length > 0; }
    function itemLacksParams(it) {
        if (!it || it.isBuiltIn) return false;
        const vtD = it.data && it.data.vendorTemplate, vtP = it.properties && it.properties.vendorTemplate;
        if (!vtD && !vtP) return false;
        return !hasParams(vtD) && !hasParams(vtP);
    }
    // „data” bez parametrów (są tylko w „properties”) → zapis z taką data skasowałby konfigurację
    function dataLacksParams(it) {
        if (!it || it.isBuiltIn) return false;
        const vtD = it.data && it.data.vendorTemplate;
        return !!vtD && !hasParams(vtD) && hasParams(it.properties && it.properties.vendorTemplate);
    }
    function mergeFull(it, full) {
        if (!full || !full.data || !it) return;
        it.data = full.data;
        if (full.properties) it.properties = full.properties; else delete it.properties;
    }
    async function fetchFullItem(sec, id) {
        const base = wsApiBase();
        if (!base) return null;
        const r = await origFetch(base + '/' + sec + '/' + id + '?hl=en', { credentials: 'same-origin', headers: { 'Accept': 'application/json, text/plain, */*' } });
        if (!r.ok) throw new Error('HTTP ' + r.status);
        const m = collectApiItems(parseApiText(await r.text()), API_ID_FIELD[sec]);
        return m.get(id) || m.values().next().value || null;
    }
    // pełne dane elementu do zapisu (lista zmiennych nie ma parametrów — PUT z taką „data” wyczyściłby konfigurację)
    async function ensureFullItem(sec, id) {
        const tm = apiMapFor(sec);
        const it = tm && tm.get(id);
        if (!it || !itemLacksParams(it)) return it;
        const full = await fetchFullItem(sec, id);
        if (full && full.data) { mergeFull(it, full); fullCache.set(sec + ':' + id, { ver: itemVer(full), data: full.data, properties: full.properties || null }); }
        return it;
    }
    function hydrateStore(key, sec) {
        const st = apiStores[key];
        if (!st || !st.map || sec !== 'variables' || hydrating.has(key)) return;
        const todo = [];
        st.map.forEach((it, id) => {
            if (!itemLacksParams(it)) return;
            const c = fullCache.get(sec + ':' + id);
            if (c && c.ver === itemVer(it)) { mergeFull(it, c); return; }
            todo.push(id);
        });
        if (!todo.length) return;
        hydrating.add(key);
        st.status = 'ok · ' + st.map.size + ' elem. · dociągam ' + todo.length + '…';
        let i = 0, done = 0;
        const map = st.map;
        const next = () => {
            if (i >= todo.length) return Promise.resolve();
            const id = todo[i++];
            return fetchFullItem(sec, id).then(full => {
                const it = map.get(id);
                if (full && full.data && it) { mergeFull(it, full); fullCache.set(sec + ':' + id, { ver: itemVer(full), data: full.data, properties: full.properties || null }); }
            }).catch(() => {}).then(() => { done++; return next(); });
        };
        Promise.all([next(), next(), next(), next(), next(), next()]).then(() => {
            hydrating.delete(key);
            if (st.map === map) { st.map = new Map(map); st.status = 'ok · ' + map.size + ' elem.'; } // nowa mapa → przeliczą się cache (ID vendorów, duplikaty)
            saveFullCache(); bumpData();
            try { renderIdBadges(); if (hasActiveFilters()) applyAllFilters(); updateFooter(); } catch (e) {}
        });
    }

    function apiItemName(it) {
        if (!it) return '';
        return (it.data && it.data.name) || it.name || (it.properties && it.properties.displayName) || '';
    }

    // {{Nazwa zmiennej}} → ID vendora (np. stała z G-…), z API zmiennych
    let varResolveCache = { src: null, map: null };
    function varResolveMap() {
        const vm = apiMapFor('variables');
        if (!vm) return null;
        ensureVarParams();
        if (varResolveCache.src === vm) return varResolveCache.map;
        const out = new Map();
        vm.forEach(it => {
            const n = apiItemName(it);
            if (!n) return;
            const ids = vendorIdsFromItem(it).filter(x => x.v !== 'ID' && x.v !== 'BAD');
            let raw = null;
            walkStrings(it.data || it, (str, key) => { if (raw === null && key === 'value' && /^[\w.-]{4,40}$/.test(str.trim())) raw = str.trim(); });
            out.set('{{' + n + '}}', { ids, raw });
        });
        varResolveCache = { src: vm, map: out };
        return out;
    }
    window.addEventListener('hashchange', () => { Object.values(apiStores).forEach(st => { st.at = 0; }); });

    /* ── Auto-odświeżanie: podsłuch zapytań panelu GTM do /api ──
       • GTM pobiera listę (…/workspaces/W/tags?…) → bierzemy tę samą odpowiedź (bez dodatkowego requestu),
       • GTM pobiera pojedynczy element (…/tags/43?…, np. po zapisie) → podmieniamy go w mapie,
       • zapis / usunięcie (POST/PUT/PATCH/DELETE) → unieważniamy cache i pobieramy dane ponownie. */
    const origFetch = window.fetch.bind(window);
    let invT = null;
    function invalidateApi(delay) {
        Object.values(apiStores).forEach(st => { st.at = 0; });
        clearTimeout(invT);
        invT = setTimeout(() => { try { renderIdBadges(); } catch (e) {} }, delay == null ? 400 : delay);
    }
    function apiPath(url) {
        try { return new URL(url, location.href).pathname; } catch (e) { return ''; }
    }
    function ingestApiResponse(method, url, text) {
        if (!xsrfSeen.value && text) {
            const i = text.search(/xsrf|csrf/i);
            if (i >= 0) { const mm = TOKEN_RE.exec(text.slice(Math.max(0, i - 300), i + 600)); if (mm) rememberXsrf(mm[1], 'odpowiedź ' + apiPath(url)); }
        }
        const path = apiPath(url);
        if (!/^\/api\/accounts\//.test(path)) return;
        if (method && method.toUpperCase() !== 'GET') { invalidateApi(500); return; }
        let m = path.match(/^(\/api\/accounts\/\d+\/containers\/\d+\/workspaces\/\d+)\/(tags|variables|triggers)$/);
        if (m) {
            try {
                const map = collectApiItems(parseApiText(text), API_ID_FIELD[m[2]]);
                const key = m[1] + '/' + m[2];
                const st = apiStores[key] || (apiStores[key] = { map: null, at: 0, loading: false, status: '', sample: '' });
                st.map = map; st.at = Date.now(); st.status = 'ok · ' + map.size + ' elem. (z panelu GTM)';
                bumpData();
                if (needVarParams()) hydrateStore(key, m[2]);
                clearTimeout(invT);
                invT = setTimeout(() => { try { renderIdBadges(); } catch (e) {} }, 150);
            } catch (e) {}
            return;
        }
        m = path.match(/^(\/api\/accounts\/\d+\/containers\/\d+\/workspaces\/\d+)\/(tags|variables|triggers)\/(\d+)$/);
        if (m) {
            const st = apiStores[m[1] + '/' + m[2]];
            if (!st || !st.map) return;
            try {
                const one = collectApiItems(parseApiText(text), API_ID_FIELD[m[2]]);
                if (!one.size) return;
                const next = new Map(st.map); // nowa mapa = przeliczą się też cache zmiennych / użyć
                one.forEach((v, k) => { next.set(k, v); if (v && v.data && !itemLacksParams(v)) fullCache.set(m[2] + ':' + k, { ver: itemVer(v), data: v.data, properties: v.properties || null }); });
                st.map = next; bumpData();
                clearTimeout(invT);
                invT = setTimeout(() => { try { renderIdBadges(); } catch (e) {} }, 150);
            } catch (e) {}
        }
    }
    (function hookNetwork() {
        const XO = XMLHttpRequest.prototype.open, XS = XMLHttpRequest.prototype.send;
        // rejestr zapisów (PUT/POST/DELETE) do API — do podejrzenia formatu w gtmTbDebug()
        window.gtmTbWrites = [];
        const noteWrite = (method, url, headers, body, kind) => {
            if (!method || method.toUpperCase() === 'GET' || !/\/api\/accounts\//.test(url)) return;
            let b = '';
            try { b = typeof body === 'string' ? body : body instanceof URLSearchParams ? body.toString() : body instanceof FormData ? '[FormData: ' + [...body.keys()].join(',') + ']' : body ? String(body) : ''; } catch (e) {}
            const rec = { time: new Date().toISOString(), via: kind, method: method.toUpperCase(), url: String(url), headers: headers || {}, body: b.slice(0, 6000), bodyLen: b.length };
            window.gtmTbWrites.push(rec);
            if (window.gtmTbWrites.length > 20) window.gtmTbWrites.shift();
        };
        const XSH = XMLHttpRequest.prototype.setRequestHeader;
        XMLHttpRequest.prototype.setRequestHeader = function (k, v) {
            (this.__tbHdr = this.__tbHdr || {})[k] = v;
            return XSH.apply(this, arguments);
        };
        XMLHttpRequest.prototype.open = function (method, url) {
            this.__tbReq = { method: method, url: String(url) };
            return XO.apply(this, arguments);
        };
        XMLHttpRequest.prototype.send = function (body) {
            const r = this.__tbReq;
            if (r) noteWrite(r.method, r.url, this.__tbHdr, body, 'xhr');
            if (r && /\/api\/accounts\//.test(r.url)) {
                this.addEventListener('loadend', () => {
                    if (this.status < 200 || this.status >= 300) return;
                    let t = '';
                    try { t = (this.responseType === '' || this.responseType === 'text') ? this.responseText : (typeof this.response === 'string' ? this.response : JSON.stringify(this.response)); } catch (e) {}
                    ingestApiResponse(r.method, r.url, t);
                });
            }
            return XS.apply(this, arguments);
        };
        window.fetch = function (input, init) {
            const url = typeof input === 'string' ? input : (input && input.url) || '';
            const method = (init && init.method) || (input && input.method) || 'GET';
            try {
                let h = {};
                const hs = (init && init.headers) || (input && input.headers);
                if (hs && typeof hs.forEach === 'function') hs.forEach((v, k) => { h[k] = v; }); else if (hs) h = Object.assign({}, hs);
                noteWrite(method, url, h, init && init.body, 'fetch');
            } catch (e) {}
            const p = origFetch(input, init);
            if (/\/api\/accounts\//.test(url)) {
                p.then(res => { if (res.ok) res.clone().text().then(t => ingestApiResponse(method, url, t)).catch(() => {}); }).catch(() => {});
            }
            return p;
        };
    })();

    function ivyItemFromRow(row, hasId, looksLikeItem) {
        // Angular debug API (window.ng) — kontekst wiersza *ngFor → $implicit = element listy
        const ngApi = window.ng;
        if (ngApi && typeof ngApi.getContext === 'function') {
            try {
                const c = ngApi.getContext(row);
                if (c && c.$implicit && typeof c.$implicit === 'object') return c.$implicit;
            } catch (e) {}
        }
        const els = [row, ...row.querySelectorAll('*')];
        for (const el of els) {
            const ctx = el.__ngContext__;
            if (!ctx || typeof ctx !== 'object') continue;
            const queue = [{ o: ctx, d: 0, parent: null }], seen = new Set();
            let budget = 3000;
            while (queue.length && budget-- > 0) {
                const cur = queue.shift();
                const o = cur.o;
                if (!o || typeof o !== 'object' || o.nodeType || o === window || seen.has(o)) continue;
                seen.add(o);
                let hit = false;
                try { hit = !Array.isArray(o) && hasId(o); } catch (e) {}
                if (hit) return (!looksLikeItem(o) && cur.parent && !Array.isArray(cur.parent)) ? cur.parent : o;
                if (cur.d >= 5) continue;
                let keys; try { keys = Object.keys(o); } catch (e) { continue; }
                if (Array.isArray(o) && o.length > 200) continue;
                for (const k of keys) {
                    if (k.charAt(0) === '$' && k !== '$implicit') continue;
                    let v; try { v = o[k]; } catch (e) { continue; }
                    if (v && typeof v === 'object') queue.push({ o: v, d: cur.d + 1, parent: o });
                }
            }
        }
        return null;
    }

    // Przejście po wszystkich polach obiektu (z limitem), cb(wartośćString, nazwaPola)
    function walkStrings(root, cb, skipRe) {
        const stack = [[root, '', 0]], seen = new Set();
        let budget = 4000;
        while (stack.length && budget-- > 0) {
            const [o, pk, d] = stack.pop();
            if (o == null) continue;
            if (typeof o === 'string') { cb(o, pk); continue; }
            if (typeof o === 'number' || typeof o === 'boolean') { cb(String(o), pk); continue; }
            if (typeof o !== 'object' || o.nodeType || seen.has(o) || d > 10) continue;
            seen.add(o);
            // para {key: 'tagId', value: 'G-…'} (format parametrów GTM)
            if (typeof o.key === 'string' && (typeof o.value === 'string' || typeof o.value === 'number')) cb(String(o.value), o.key);
            // format API panelu: { key: 'measurementIdOverride', value: { type: 1, string: 'G-…' } }
            if (typeof o.key === 'string' && o.value && typeof o.value === 'object' && typeof o.value.string === 'string') cb(o.value.string, o.key);
            // odwołanie do zmiennej w „data”: { key: 'pixelId', value: { type: 4, macroReference: 'Constant - FB Pixel ID' } }
            if (typeof o.key === 'string' && o.value && typeof o.value === 'object' && typeof o.value.macroReference === 'string') cb('{{' + o.value.macroReference + '}}', o.key);
            let keys; try { keys = Object.keys(o); } catch (e) { continue; }
            for (const k of keys) {
                if (k.charAt(0) === '$' || (skipRe && skipRe.test(k))) continue;
                let v; try { v = o[k]; } catch (e) { continue; }
                stack.push([v, Array.isArray(o) ? pk : k, d + 1]);
            }
        }
    }

    // ID vendorów: wzorce w dowolnym tekście (też w Custom HTML) + pola typowo trzymające ID
    const VENDOR_RES = [
        { v: 'GA4', re: /\bG-[A-Z0-9]{6,12}\b/g },
        { v: 'Ads', re: /\bAW-\d{6,12}\b/g },
        { v: 'Google tag', re: /\bGT-[A-Z0-9]{6,12}\b/g },
        { v: 'GTM', re: /\bGTM-[A-Z0-9]{4,10}\b/g },
        { v: 'UA', re: /\bUA-\d{4,10}-\d{1,4}\b/g },
        { v: 'Floodlight', re: /\bDC-\d{6,10}\b/g },
        { v: 'Meta', re: /fbq\(\s*['"]init['"]\s*,\s*['"](\d{10,20})['"]/g, grp: 1 },
        { v: 'TikTok', re: /ttq\.load\(\s*['"]([A-Z0-9]{15,25})['"]/g, grp: 1 },
        { v: 'Clarity', re: /clarity['"]?\s*,\s*['"]script['"]\s*,\s*['"]([a-z0-9]{8,12})['"]/g, grp: 1 },
        { v: 'Hotjar', re: /hjid\s*:\s*(\d{5,10})/g, grp: 1 },
        { v: 'LinkedIn', re: /_linkedin_partner_id\s*=\s*['"](\d{4,10})['"]/g, grp: 1 },
        { v: 'Bing', re: /\bti\s*:\s*['"](\d{6,10})['"]/g, grp: 1 },
    ];
    const VENDOR_KEY_RE = /^(tagId|measurementId|measurementIdOverride|conversionId|trackingId|pixelId|pixel_id|pixelIds|partnerId|uetTagId|advertiserId)$/i;
    const VENDOR_COL = { 'GA4': '#e37400', 'Ads': '#1a73e8', 'Google tag': '#188038', 'GTM': '#4285f4', 'UA': '#9aa0a6', 'Floodlight': '#a142f4', 'Meta': '#0866ff', 'TikTok': '#202124', 'Clarity': '#0078d4', 'Hotjar': '#fd3a5c', 'LinkedIn': '#0a66c2', 'Bing': '#008373', 'ID': '#5f6368' };

    // pola elementu, których nie przeszukujemy (klucz z ID konta/kontenera, nazwa, notatki, metadane)
    const VENDOR_SKIP = /^(key|name|notes|statMetadata|fingerprint|parentFolderId|path|tagManagerUrl|workspaceId|accountId|containerId|tagId|variableId|triggerId|firingTriggerId|blockingTriggerId|setupTag|teardownTag)$/;
    // Poprawność ID: format per vendor (czerwona etykieta, gdy coś nie gra)
    const ID_FORMAT = {
        'GA4': [/^G-[A-Z0-9]{10}$/, 'GA4: „G-” + 10 znaków (wielkie litery / cyfry)'],
        'Ads': [/^AW-\d{9,11}$/, 'Google Ads: „AW-” + 9–11 cyfr'],
        'Google tag': [/^GT-[A-Z0-9]{6,10}$/, 'Google tag: „GT-” + 6–10 znaków'],
        'GTM': [/^GTM-[A-Z0-9]{6,8}$/, 'GTM: „GTM-” + 6–8 znaków'],
        'Meta': [/^\d{15,16}$/, 'piksel Meta: 15–16 cyfr'],
        'UA': [null, 'Universal Analytics (UA-) nie zbiera danych od lipca 2024 — tag do usunięcia'],
    };
    function idProblem(v, id) {
        const f = ID_FORMAT[v];
        if (!f) return '';
        if (!f[0]) return f[1];
        return f[0].test(id) ? '' : 'Niepoprawny format — oczekiwane ' + f[1];
    }
    // czego oczekujemy w polu o danej nazwie (do opisu błędu)
    function expectedForKey(k) {
        if (/^measurementId(Override)?$/i.test(k)) return ID_FORMAT['GA4'][1];
        if (/^conversionId$/i.test(k)) return 'Google Ads: same cyfry (9–11) albo „AW-…”';
        if (/pixel/i.test(k)) return ID_FORMAT['Meta'][1];
        if (/^tagId$/i.test(k)) return '„G-…”, „AW-…” albo „GT-…”';
        return 'ID vendora';
    }

    function vendorIdsFromItem(item) {
        const out = new Map(); // id → vendor
        const keyOf = new Map(); // {{zmienna}} → nazwa pola (pixelId, conversionId…)
        const bad = new Map();   // błędna wartość w polu z ID → opis
        const spaced = new Set(); // ID ze spacjami — pokazujemy tylko czerwoną wersję
        const root = {};
        Object.keys(item).forEach(k => { if (!VENDOR_SKIP.test(k)) root[k] = item[k]; });
        walkStrings(root, (str, key) => {
            // macroReference = nazwa zmiennej bez {{ }} (już podana wyżej jako {{…}}) — pomijamy
            if (key && /^(name|displayName|notes|eventName|macroReference)$/.test(key)) return;
            // pole z ID wpisanym wprost: puste / spacje / nic nie pasuje do formatu → błąd
            if (key && VENDOR_KEY_RE.test(key) && typeof str === 'string' && str.indexOf('{{') < 0) {
                const t = str.trim();
                if (!t) { bad.set('(puste ' + key + ')', 'Puste pole „' + key + '” — oczekiwane ' + expectedForKey(key)); return; }
                if (t !== str) { bad.set('„' + str + '”', 'Spacja na początku / końcu ID w polu „' + key + '”'); spaced.add(t); }
                else {
                    const okWhole = VENDOR_RES.some(({ re, grp }) => { if (grp) return false; re.lastIndex = 0; const m = re.exec(t); return m && m[0] === t; });
                    const okDigits = /^\d{6,20}$/.test(t) && /^(tagId|conversionId|pixelId|pixel_id|pixelIds|partnerId|uetTagId|advertiserId)$/i.test(key);
                    if (!okWhole && !okDigits && !/^(true|false)$/i.test(t)) bad.set(t, 'Wartość „' + t + '” w polu „' + key + '” nie wygląda na ID — oczekiwane ' + expectedForKey(key));
                }
            }
            if (!str) return;
            // {{Zmienna}} w tekście: wzorców nie szukamy w nazwie zmiennej (np. {{GA4 ID G-XXX}}),
            // tylko dodajemy samą zmienną, jeśli siedzi w polu z ID albo jej nazwa wygląda na ID vendora
            const refs = str.match(/\{\{[^{}]+\}\}/g) || [];
            refs.forEach(ref => {
                const looksVendor = VENDOR_RES.some(({ re }) => { re.lastIndex = 0; return re.test(ref); });
                if ((key && VENDOR_KEY_RE.test(key)) || looksVendor) {
                    if (!out.has(ref)) { out.set(ref, 'ID'); keyOf.set(ref, key || ''); }
                }
            });
            const plain = refs.length ? str.replace(/\{\{[^{}]+\}\}/g, ' ') : str;
            if (plain.length < 4000 || /fbq|ttq|gtag|clarity|hjid|linkedin|uetq/.test(plain)) {
                VENDOR_RES.forEach(({ v, re, grp }) => {
                    re.lastIndex = 0;
                    let m;
                    while ((m = re.exec(plain))) {
                        const id = grp ? m[grp] : m[0];
                        if (!out.has(id)) out.set(id, v);
                    }
                });
            }
            // pole z ID: Meta pixel (same cyfry), Ads conversionId (bez AW-) albo {{Zmienna}}
            if (key && VENDOR_KEY_RE.test(key)) {
                const t = str.trim();
                if (/^\d{6,20}$/.test(t)) {
                    const id = /^conversionId$/i.test(key) ? 'AW-' + t : t;
                    if (!out.has(id)) out.set(id, /^conversionId$/i.test(key) ? 'Ads' : /pixel/i.test(key) ? 'Meta' : 'ID');
                    if (!keyOf.has(id)) keyOf.set(id, key);
                } else if (out.has(t) && !keyOf.has(t)) keyOf.set(t, key); // np. tagId = G-… wpisane wprost
            }
        }, /^valueCache$/);
        spaced.forEach(t => out.delete(t));
        const res = [...out.entries()].map(([id, v]) => keyOf.has(id) ? { id, v, k: keyOf.get(id) } : { id, v });
        bad.forEach((msg, id) => { if (!out.has(id)) res.push({ id, v: 'BAD', msg }); });
        return res;
    }

    // Opcja uruchamiania tagu: raz na zdarzenie / raz na stronę / bez limitu
    // API panelu zwraca liczbę: 1 = raz na zdarzenie (domyślne), 2 = raz na stronę, 3 = bez limitu
    const FIRING_NUM = { '1': 'Raz na zdarzenie', '2': 'Raz na stronę', '3': 'Bez limitu' };
    function firingFromItem(item) {
        let raw = null;
        walkStrings(item, (str, key) => { if (raw === null && key && /firing_?option/i.test(key)) raw = str; });
        // pole pominięte w API = wartość domyślna GTM („Once per event”)
        if (raw === null) return (item && item.data && item.key) ? { label: 'Raz na zdarzenie', raw: 'brak pola = domyślne' } : null;
        if (FIRING_NUM[String(raw)]) return { label: FIRING_NUM[String(raw)], raw };
        const r = String(raw).toLowerCase();
        if (/event/.test(r)) return { label: 'Raz na zdarzenie', raw };
        if (/load|page/.test(r)) return { label: 'Raz na stronę', raw };
        if (/unlimit|none/.test(r)) return { label: 'Bez limitu', raw };
        return { label: 'opcja: ' + raw, raw };
    }

    function idLinkFromRow(row) {
        const { nameIdx } = colsForRow(row);
        const cells = row.querySelectorAll('td');
        const nameCell = cells.length > nameIdx ? cells[nameIdx] : null;
        if (nameCell) {
            for (const el of nameCell.querySelectorAll(ID_SEL)) {
                const e = entFromEl(el);
                if (e) return { a: el, type: e.type, id: e.id };
            }
        }
        for (const el of row.querySelectorAll(ID_SEL)) {
            const e = entFromEl(el);
            if (e && (!currentSection || e.type === currentSection)) return { a: el, type: e.type, id: e.id };
        }
        const e = entFromEl(row);
        if (e && (!currentSection || e.type === currentSection)) return { a: nameCell && nameCell.querySelector('a') || row, type: e.type, id: e.id };
        const n = ngIdFromRow(row);
        if (n) return { a: nameCell && nameCell.querySelector('a') || row, type: n.type, id: n.id };
        return null;
    }

    function idFromRow(row) {
        const l = idLinkFromRow(row);
        return l ? l.id : null;
    }

    /* ═══════════════════ MODUŁ 4: ADNOTACJE PRZY NAZWIE ═══════════════════ */
    /* ── #ID przy nazwach (checkbox „Pokaż #ID na liście”) ── */
    const ID_BADGE = 'tb-id-badge';
    let idCssEl = null;
    function ensureIdCss() {
        if (idCssEl && idCssEl.isConnected) return;
        idCssEl = document.createElement('style');
        idCssEl.id = 'gtm-tb-id-css';
        idCssEl.textContent = `.${ID_BADGE}{display:inline-block;margin-left:6px;padding:0 5px;border:1px solid #dadce0;border-radius:4px;font:11px/16px Consolas,Monaco,monospace;color:#5f6368;background:#f1f3f4;cursor:copy;vertical-align:middle;white-space:nowrap;user-select:none;text-decoration:none}
.${ID_BADGE}:hover{border-color:#1a73e8;color:#1a73e8}
.tb-meta{display:inline;margin-left:2px}
.tb-vid{display:inline-block;margin-left:5px;padding:1px 6px;border-radius:4px;font:600 11px/15px Consolas,Monaco,monospace;color:#fff;cursor:copy;vertical-align:middle;white-space:nowrap;user-select:none;border:2px solid transparent;letter-spacing:.2px}
.tb-vid:hover{filter:brightness(1.12)}
.tb-vid .tb-via{font-weight:400;opacity:.8;margin-left:4px;font-size:10px}
.tb-vid.var{background:#fff!important;color:#5f6368;border:1px dashed #9aa0a6;font-weight:600}
.tb-vid.var.warn::before{color:#d93025}
.tb-vid.val{margin-left:3px}
.tb-vid.val::before{content:'= ';opacity:.85}
.tb-vid.bad{background:#d93025!important;color:#fff!important;border-color:#d93025!important}
.tb-vid.bad::before{content:'✖ '!important;color:#fff!important}
.tb-vid.var.bad{background:#fce8e6!important;color:#c5221f!important;border:1px dashed #d93025!important}
.tb-vid.var.bad::before{color:#c5221f!important}
.tb-vid.warn{border-color:#fff;box-shadow:0 0 0 2px #d93025}
.tb-vid.warn::before{content:'⚠ ';color:#fff}
.tb-fire:hover{filter:brightness(1.15);box-shadow:0 0 0 2px rgba(0,0,0,.08)}
.tb-fire{display:inline-block;margin-left:5px;padding:1px 8px;border-radius:10px;font:600 10px/15px 'Google Sans',Roboto,sans-serif;color:#fff;vertical-align:middle;white-space:nowrap;user-select:none;letter-spacing:.2px}
.tb-fire.f-event{background:#80868b}
.tb-fire.f-page{background:#202124}
.tb-fire.f-unl{background:#d93025}
.tb-fire.f-other{background:#5f6368}
.tb-vid.tb-ev{background:#7c3aed;color:#fff}
.tb-vid.tb-ev::before{content:'⚡ ';opacity:.9}
.tb-vid.tb-ev.dupe{box-shadow:0 0 0 2px #c4b5fd;background:#7c3aed}
.tb-vid.tb-ev.dupe.hot{box-shadow:0 0 0 2px #d93025;background:#d93025}
.tb-vid.tb-ev.pz{opacity:.55}
.tb-vid.tb-vv{background:#e8f0fe!important;color:#174ea6;border:1px solid #aecbfa;font-weight:500;max-width:240px;overflow:hidden;text-overflow:ellipsis}
.tb-vid.tb-vv::before{content:'= ';opacity:.7}
.tb-vid.tb-vv.ref{color:#1a73e8;border-style:dashed}
/* konflikt: ≥2 aktywne tagi z tym samym eventem — czerwona ramka wokół całej grupy */
tr.tb-grp-hot > td{background:#fdecea!important;border-top:none!important;border-bottom:none!important}
tr.tb-grp-hot.tb-grp-start > td{border-top:3px solid #d93025!important}
tr.tb-grp-hot.tb-grp-end > td{border-bottom:3px solid #d93025!important}
tr.tb-grp-hot > td:first-child{box-shadow:inset 3px 0 0 #d93025}
tr.tb-grp-hot > td:last-child{box-shadow:inset -3px 0 0 #d93025}
.tb-cons{display:inline-block;margin-left:5px;padding:1px 7px;border-radius:10px;font:600 10px/15px 'Google Sans',Roboto,sans-serif;vertical-align:middle;white-space:nowrap;user-select:none}
.tb-cons.c-req{color:#0d652d;background:#e6f4ea}
.tb-cons.c-none{color:#5f6368;background:#f1f3f4}
.tb-cons.c-unset{color:#b06000;background:#fef7e0}
.tb-noesv{margin-left:4px;font-size:10px}
/* zapauzowane: wyraźnie szary wiersz — szara nazwa, etykiety przygaszone (bez zmiany ich kolorów, żeby napisy nie znikały) */
tr.tb-paused > td{background:#e8eaed!important;color:#80868b!important}
tr.tb-paused > td > a,tr.tb-paused > td .fill-cell{color:#80868b!important}
tr.tb-paused > td:first-child{box-shadow:inset 4px 0 0 #9aa0a6}
tr.tb-paused .tb-ann,tr.tb-paused .tb-meta{opacity:.45}
tr.tb-paused .tb-meta *{text-decoration:none!important}
tr.tb-paused .tb-act{opacity:0}
tr.tb-paused:hover .tb-act{opacity:1}
tr.tb-paused.tb-grp-hot > td{background:#fdecea!important}
.tb-act{display:inline-flex;gap:3px;margin-left:8px;vertical-align:middle;opacity:0;transition:opacity .12s;white-space:nowrap}
tr:hover .tb-act{opacity:1}
.tb-act-btn{display:inline-block;border:1px solid #dadce0;background:#fff;border-radius:4px;font:12px/18px system-ui,sans-serif;cursor:pointer;padding:0 5px;color:#5f6368;text-decoration:none}
.tb-act-btn:hover{border-color:#1a73e8;color:#1a73e8;background:#e8f0fe}
tr.tb-newrow > td{background:#e6f4ea!important}
tr.tb-grp-hot > td:first-child::before{content:'⚠';color:#d93025;font-size:13px;font-weight:700;margin-right:4px;vertical-align:middle}
.tb-pc{display:inline-block;margin-left:5px;padding:0 6px;border-radius:4px;font:500 11px/16px 'Google Sans',Roboto,sans-serif;color:#5b21b6;background:#fff;border:1px solid #c4b5fd;cursor:pointer;vertical-align:middle;white-space:nowrap;user-select:none}
.tb-pc:hover,.tb-pc.open{background:#ede9fe}
.tb-plist{margin:4px 0 4px 0;padding:6px 10px;background:#f8f7ff;border-left:3px solid #7c3aed;border-radius:4px;font:11px/17px Consolas,Monaco,monospace;color:#3c4043;white-space:normal;word-break:break-word;cursor:text;user-select:text}
.tb-plist .k{color:#6d28d9;font-weight:600}
.tb-plist .eq{color:#9aa0a6}
.tb-plist .v.var{color:#1a73e8}
.tb-plist .v.code{display:block;white-space:pre-wrap;max-height:260px;overflow:auto;background:#fff;border:1px solid #e8e5ff;border-radius:4px;padding:4px 6px;margin-top:2px;font-size:10.5px;line-height:15px}
.tb-vid.tb-vv.code{background:#f1f3f4!important;color:#3c4043;border-color:#dadce0;font-weight:400}
.tb-vid.tb-vv.code::before{content:''}
.tb-plist .v.edit{cursor:text;border-bottom:1px dotted #c4b5fd}
.tb-inline{font:inherit;font-size:11px;padding:0 4px;border:1px solid #7c3aed;border-radius:3px;background:#fff;color:#202124;outline:none;vertical-align:middle;min-width:90px}
.tb-menu{position:fixed;z-index:2147483647;min-width:200px;max-width:420px;background:#fff;border:1px solid #dadce0;border-radius:8px;box-shadow:0 6px 24px rgba(0,0,0,.2);padding:4px 0;font:12px/18px 'Google Sans',Roboto,sans-serif;color:#202124}
.tb-menu-h{padding:4px 12px 6px;font-size:10px;color:#5f6368;border-bottom:1px solid #f1f3f4;margin-bottom:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tb-menu-i{display:flex;justify-content:space-between;gap:12px;padding:5px 12px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:Consolas,Monaco,monospace;font-size:11px}
.tb-menu-i:hover{background:#e8f0fe}
.tb-menu-i.act{font-family:'Google Sans',Roboto,sans-serif;color:#1a73e8;border-top:1px solid #f1f3f4}
.tb-menu-s{color:#80868b;font-size:10px}
.tb-menu-e{padding:4px 12px;font-size:11px;color:#5f6368}
.tb-menu-in{padding:6px 10px 6px;border-top:1px solid #f1f3f4;margin-top:3px}
.tb-menu-in input{width:100%;box-sizing:border-box;font:11px Consolas,Monaco,monospace;padding:4px 6px;border:1px solid #dadce0;border-radius:4px;outline:none;color:#202124;background:#fff}
.tb-menu-in input:focus{border-color:#1a73e8}
.tb-plist .v.edit:hover{background:#ede9fe}
.tb-slot{display:inline-block;vertical-align:middle;white-space:nowrap;box-sizing:border-box;margin-right:5px}
.tb-slot:empty{height:1px}
.tb-slot > *{box-sizing:border-box}
.tb-slot > :first-child{margin-left:0!important}
.tb-s-ev > .tb-vid,.tb-s-pc > .tb-pc,.tb-s-fire > .tb-fire{text-align:left}
.tb-id-badge{box-sizing:border-box;text-align:center}
table.tb-wide{table-layout:auto!important}
table.tb-wide .tb-namecell{white-space:nowrap!important;width:auto!important;max-width:none!important}
table.tb-wide .tb-namecell > a{white-space:nowrap!important;display:inline-block!important}
table.tb-wide .tb-namecell .tb-plist{white-space:normal;max-width:720px}
.tb-annfirst > .tb-meta{margin-right:1px}
.tb-annfirst > .tb-id-badge{margin-left:0;margin-right:5px}
.tb-annfirst > .tb-meta > :first-child{margin-left:0}
.tb-plist .tb-pg{font:600 9px/14px 'Google Sans',Roboto,sans-serif;text-transform:uppercase;letter-spacing:.4px;color:#80868b;margin-top:3px}
th.tb-uses-col,td.tb-uses-col{white-space:nowrap;padding-right:16px}
.tb-uses-n{font-variant-numeric:tabular-nums;cursor:help}
.tb-uses-n.zero{color:#d93025;font-weight:600}
.tb-uses-o{margin-left:6px;font-size:11px;color:#80868b;cursor:help}`;
        document.head.appendChild(idCssEl);
    }

    function stopAll(el) {
        ['mousedown', 'mouseup'].forEach(t => el.addEventListener(t, ev => ev.stopPropagation()));
    }

    function nameAnchor(row) {
        const { nameIdx } = colsForRow(row);
        const cell = row.querySelectorAll('td')[nameIdx];
        if (!cell) return null;
        return cell.querySelector('a') || cell;
    }

    /* ── Nazwa eventu + parametry tagu (z API panelu: data.vendorTemplate.param) ── */
    function valText(v) {
        if (v == null) return '';
        if (typeof v !== 'object') return String(v);
        if (typeof v.macroReference === 'string') return '{{' + v.macroReference + '}}';
        // szablon z wstawkami: "SCROLL_{{Scroll Depth Threshold}}" = lista tokenów (tekst / zmienna)
        if (Array.isArray(v.templateToken) && v.templateToken.length) return v.templateToken.map(valText).join('');
        if (typeof v.string === 'string') return v.string;
        if (typeof v.boolean === 'boolean') return String(v.boolean);
        if (v.integer != null) return String(v.integer);
        if (Array.isArray(v.listItem) && v.listItem.length) return '[' + v.listItem.map(valText).join(', ') + ']';
        if (Array.isArray(v.mapKey) && v.mapKey.length) return '{' + v.mapKey.map((k, i) => valText(k) + ': ' + valText((v.mapValue || [])[i])).join(', ') + '}';
        if (typeof v.value !== 'undefined') return valText(v.value);
        return '';
    }
    function mapOf(v) {
        const o = {};
        if (!v) return o;
        if (Array.isArray(v.mapKey)) v.mapKey.forEach((k, i) => { o[valText(k)] = valText((v.mapValue || [])[i]); });
        else if (Array.isArray(v.map)) v.map.forEach(e => { if (e && e.key) o[e.key] = valText(e.value != null ? e.value : e); });
        return o;
    }
    function tplParams(item) {
        const o = {};
        // „data” ma pełną strukturę; „properties” (wersja do wyświetlania) uzupełnia pola, których w data nie da się odczytać
        const vtD = item.data && item.data.vendorTemplate, vtP = item.properties && item.properties.vendorTemplate;
        if (vtD && Array.isArray(vtD.param)) vtD.param.forEach(p => { if (p && typeof p.key === 'string') o[p.key] = p.value; });
        if (vtP && Array.isArray(vtP.param)) vtP.param.forEach(p => {
            if (!p || typeof p.key !== 'string') return;
            if (!(p.key in o) || !valText(o[p.key]).trim()) o[p.key] = p.value;
        });
        if (!vtD && !vtP && Array.isArray(item.parameter)) item.parameter.forEach(p => { if (p && typeof p.key === 'string') o[p.key] = p; });
        return o;
    }
    // tabele parametrów: [pole szablonu, kolumna nazwy, kolumna wartości, grupa]
    const PARAM_TABLES = [
        ['eventSettingsTable', 'parameter', 'parameterValue', ''],
        ['eventParameters', 'name', 'value', ''],
        ['configSettingsTable', 'parameter', 'parameterValue', 'config'],
        ['fieldsToSet', 'fieldName', 'value', 'config'],
        ['userProperties', 'name', 'value', 'user'],
        ['userPropertiesTable', 'name', 'value', 'user'],
        ['objectPropertyList', 'name', 'value', ''],
        ['customParams', 'key', 'value', ''],
    ];
    const PARAM_SKIP = /^(eventName|standardEventName|customEventName|measurementIdOverride|measurementId|tagId|pixelId|conversionId|conversionLabel|html|supportDocumentWrite|sendEcommerceData|enableSendToServerContainer|enableConversionLinker|disablePushState|consent|dpoLDU|advancedMatching|useGA4Ecommerce|enhancedEcommerce|disableAutoConfig|optInMetaCAPI)$/;
    function eventInfoFromItem(item) {
        if (!item) return null;
        const o = tplParams(item);
        const t = k => (o[k] != null ? valText(o[k]).trim() : '');
        let ev = '', evKey = '';
        const en = t('eventName');
        if (en && !/^(standard|custom)$/i.test(en)) { ev = en; evKey = 'eventName'; }
        if (t('standardEventName') && (!en || /^standard$/i.test(en))) { ev = t('standardEventName'); evKey = 'standardEventName'; }
        if (t('customEventName') && (!ev || /^custom$/i.test(en))) { ev = t('customEventName'); evKey = 'customEventName'; }
        if (!ev && o.html) {
            const h = t('html');
            const m = /gtag\(\s*['"]event['"]\s*,\s*['"]([^'"]+)/.exec(h) || /fbq\(\s*['"]track(?:Custom)?['"]\s*,\s*['"]([^'"]+)/.exec(h) ||
                /['"]?event['"]?\s*:\s*['"]([^'"]+)['"]/.exec(h);
            if (m) ev = m[1];
        }
        if (!ev && t('conversionLabel')) ev = 'label: ' + t('conversionLabel');
        const params = [];
        if (t('eventSettingsVariable')) params.push({ k: 'Event Settings', v: t('eventSettingsVariable'), g: 'var', src: { key: 'eventSettingsVariable' } });
        PARAM_TABLES.forEach(([field, kn, vn, g]) => {
            const v = o[field];
            if (!v || !Array.isArray(v.listItem)) return;
            v.listItem.forEach((it, ri) => {
                const m = mapOf(it);
                const name = m[kn] || m.name || m.parameter || m.key;
                if (name) params.push({ k: name, v: m[vn] != null ? m[vn] : (m.value || ''), g, src: { field, row: ri, col: vn } });
            });
        });
        // pozostałe tagi (bez tabel): zwykłe parametry szablonu
        if (!params.length) Object.keys(o).forEach(k => {
            if (PARAM_SKIP.test(k)) return;
            if (PARAM_TABLES.some(pt => pt[0] === k)) return;
            const sv = valText(o[k]);
            if (!sv || sv === 'false' || sv === '[]' || sv === '{}') return;
            params.push({ k, v: sv.length > 140 ? sv.slice(0, 137) + '…' : sv, g: 'opt', src: sv.length > 140 ? null : { key: k } });
        });
        if (o.html) params.push({ k: 'html', v: t('html').length + ' znaków kodu', g: 'opt' });
        if (!ev && !params.length) return null;
        return { ev, evKey, params };
    }

    /* ── Reguły: nazwa eventu (Custom Event) + warunki + parametry (timer, scroll, widoczność…) ── */
    // typ warunku: w API może być tekstem (equals/contains…) albo liczbą (kolejność jak w publicznym API GTM)
    const COND_NUM = { 1: 'equals', 2: 'contains', 3: 'startsWith', 4: 'endsWith', 5: 'matchRegex', 6: 'greater', 7: 'greaterOrEquals', 8: 'less', 9: 'lessOrEquals', 10: 'cssSelector', 11: 'urlMatches', 12: 'matchRegexIgnoreCase' };
    const COND_PL = { equals: 'równa się', contains: 'zawiera', startsWith: 'zaczyna się od', endsWith: 'kończy się na', matchRegex: 'pasuje do regex', matchRegexIgnoreCase: 'pasuje do regex (i)', greater: '>', greaterOrEquals: '≥', less: '<', lessOrEquals: '≤', cssSelector: 'pasuje do selektora CSS', urlMatches: 'pasuje do URL' };
    const COND_NEG = { equals: 'nie równa się', contains: 'nie zawiera', startsWith: 'nie zaczyna się od', endsWith: 'nie kończy się na', matchRegex: 'nie pasuje do regex', matchRegexIgnoreCase: 'nie pasuje do regex (i)', greater: '≤', greaterOrEquals: '<', less: '≥', lessOrEquals: '>', cssSelector: 'nie pasuje do selektora CSS', urlMatches: 'nie pasuje do URL' };
    function condParts(c) {
        // warunek = { type, parameter:[{key:'arg0',value},{key:'arg1',value},{key:'negate',value}] } (różne warianty nazw)
        const ps = Array.isArray(c.parameter) ? c.parameter : (Array.isArray(c.param) ? c.param : []);
        const m = {};
        ps.forEach(p => { if (p && typeof p.key === 'string') m[p.key] = valText(p.value != null ? p.value : p); });
        // wewnętrzne API GTM: { type, negate, arg:[wartość0, wartość1], operator?:'CONTAINS' }
        if (Array.isArray(c.arg)) { if (m.arg0 == null) m.arg0 = valText(c.arg[0]); if (m.arg1 == null) m.arg1 = valText(c.arg[1]); }
        if (typeof c.operator === 'string') { const op = c.operator.toLowerCase().replace(/_([a-z])/g, (x, l) => l.toUpperCase()); if (COND_PL[op]) return { a: m.arg0 || '', op: (c.negate ? COND_NEG[op] : COND_PL[op]), b: m.arg1 || '', key: op, neg: !!c.negate }; }
        let t = c.type != null ? c.type : (c.conditionType != null ? c.conditionType : c.operator);
        if (typeof t === 'number' || /^\d+$/.test(String(t))) t = COND_NUM[Number(t)] || ('typ ' + t);
        t = String(t || '');
        const tk = Object.keys(COND_PL).find(k => k.toLowerCase() === t.toLowerCase().replace(/_/g, '')) || t;
        const neg = /^(true|1)$/i.test(m.negate || '') || c.negate === true;
        return { a: m.arg0 || '', op: (neg ? COND_NEG[tk] : COND_PL[tk]) || (neg ? 'NIE ' : '') + tk, b: m.arg1 || '', key: tk, neg };
    }
    function triggerInfoFromItem(item) {
        if (!item) return null;
        // Reguły: pokazujemy tylko nazwę eventu (warunki filtra lista GTM pokazuje sama).
        // Nazwa eventu = warunek na {{_event}} — szukamy go w całej strukturze (customEventFilter / filter / inne pola).
        let ev = '';
        const conds = [];
        const walk = (o, depth) => {
            if (!o || typeof o !== 'object' || depth > 7) return;
            if (Array.isArray(o)) { o.forEach(x => walk(x, depth + 1)); return; }
            const ps = Array.isArray(o.parameter) ? o.parameter : (Array.isArray(o.param) ? o.param : null);
            if ((ps && ps.some(x => x && /^arg[01]$/.test(String(x.key)))) || (Array.isArray(o.arg) && o.arg.length >= 2 && o.arg.every(x => x && typeof x === 'object'))) { conds.push(condParts(o)); return; }
            Object.keys(o).forEach(k => { if (k !== 'key' && k !== 'properties') walk(o[k], depth + 1); });
        };
        walk(item.data || item, 0);
        if (!conds.length && item.properties) walk(item.properties, 0);
        const evc = conds.find(c => /_event/.test(c.a)) || conds.find(c => /_event/.test(c.b));
        if (evc) {
            const name = /_event/.test(evc.a) ? evc.b : evc.a;
            // typ 1 = równa się; inny typ albo znaki specjalne → regex
            const rx = /regex/i.test(evc.key) || (evc.key !== 'equals' && !/^typ 1$/.test(evc.key) && /[|^$*+?()\[\]\\]/.test(name));
            ev = name + (rx ? ' (regex)' : '');
        }
        if (!ev) { // zapasowo: parametr reguły z nazwą eventu
            const o = tplParams(item);
            const d = item.data || item;
            const cand = ['customEventName', 'eventName', 'event'].map(k => (o[k] != null ? valText(o[k]) : (d[k] != null && typeof d[k] !== 'object' ? String(d[k]) : ''))).find(v => v && v.trim() && !/^gtm\./.test(v));
            if (cand) ev = cand.trim();
        }
        if (!ev) return null;
        return { ev, evKey: '', params: [], kind: 'trigger' };
    }
    // zmiana nazwy eventu reguły (Custom Event): podmieniamy drugi argument warunku {{_event}} i zapisujemy regułę
    function findEventCond(root) {
        let found = null;
        const walk = (o, depth) => {
            if (found || !o || typeof o !== 'object' || depth > 7) return;
            if (Array.isArray(o)) { o.forEach(x => walk(x, depth + 1)); return; }
            if (Array.isArray(o.arg) && o.arg.length >= 2 && /_event/.test(valText(o.arg[0]))) { found = { cond: o, kind: 'arg' }; return; }
            const ps = Array.isArray(o.parameter) ? o.parameter : null;
            if (ps) { const a0 = ps.find(x => x && x.key === 'arg0'), a1 = ps.find(x => x && x.key === 'arg1'); if (a0 && a1 && /_event/.test(valText(a0.value != null ? a0.value : a0))) { found = { cond: o, kind: 'param', a1 }; return; } }
            Object.keys(o).forEach(k => { if (k !== 'key') walk(o[k], depth + 1); });
        };
        walk(root, 0);
        return found;
    }
    async function editTriggerEvent(id, newName) {
        const tm = apiMapFor('triggers');
        const it = tm && tm.get(id);
        if (!it || !it.data) { alert('Nie mam danych tej reguły — kliknij ⟳ Odśwież i spróbuj ponownie.'); return; }
        const draft = clone(it);
        const f = findEventCond(draft.data);
        if (!f) { alert('Nie znalazłem w tej regule warunku na {{_event}} — zmień nazwę w edytorze GTM.'); return; }
        const nv = Object.assign({ type: 1, listItem: [], mapKey: [], mapValue: [], templateToken: [], escaping: [] }, { string: String(newName).trim() });
        if (f.kind === 'arg') { const old = f.cond.arg[1] || {}; f.cond.arg[1] = Object.assign({}, old, nv, { macroReference: undefined }); delete f.cond.arg[1].macroReference; }
        else f.a1.value = nv;
        const base = wsApiBase();
        try {
            await apiWrite('PUT', base + '/triggers/' + id + '?hl=en', { key: clone(draft.key), data: draft.data });
            it.data = draft.data;
            Object.values(apiStores).forEach(st => { st.at = 0; });
            renderIdBadges(); if (hasActiveFilters()) applyAllFilters(); updateFooter();
        } catch (e) { alert('Zmiana nazwy eventu nie powiodła się.\n' + (e && e.message)); }
    }
    const PARAM_SKIP_TRIG = /^(name|notes|type|fingerprint|parentFolderId|containerDraftId|triggerId|accountId|containerId|workspaceId|path|tagManagerUrl|customEventFilter|autoEventFilter|filter)$/;

    /* ── Zmienne: główna wartość (stała, klucz DLV, nazwa cookie…) + pozostałe parametry ── */
    // publicId szablonu → pole z „główną” wartością
    const VAR_MAIN = { c: 'value', v: 'name', k: 'name', j: 'name', u: 'component', aev: 'varType', d: 'elementId', smm: 'input', remm: 'input', e: null, jsm: null, f: 'queryKey', r: 'name', cid: null, ctv: 'name', gas: 'trackingId', gtes: null, gtcs: null, uv: 'name', vis: 'selector', dbg: null, ev: null, cn: 'name', awec: 'mode', gclid: null, gtlp: 'name', lcl: 'name', cvt: null };
    function variableInfoFromItem(item) {
        if (!item) return null;
        const vt = (item.data && item.data.vendorTemplate) || (item.properties && item.properties.vendorTemplate) || {};
        const pub = String((vt.key && vt.key.publicId) || (item.data && item.data.type) || '');
        const o = tplParams(item);
        const t = k => (o[k] != null ? valText(o[k]).trim() : '');
        const mainKey = VAR_MAIN[pub] !== undefined ? VAR_MAIN[pub] : (o.value != null ? 'value' : (o.name != null ? 'name' : null));
        let ev = mainKey ? t(mainKey) : '';
        let evKey = mainKey && ev ? mainKey : '';
        const params = [];
        // tabele (lookup/regex table, GA4 settings, Google tag config) → wiersze
        const TABLES = [['map', 'key', 'value', ''], ['eventSettingsTable', 'parameter', 'parameterValue', ''], ['configSettingsTable', 'parameter', 'parameterValue', 'config'], ['fieldsToSet', 'fieldName', 'value', 'config'], ['userProperties', 'name', 'value', 'user'], ['userPropertiesTable', 'name', 'value', 'user'], ['eventParameters', 'name', 'value', ''], ['customParams', 'key', 'value', '']];
        const tableKeys = new Set();
        TABLES.forEach(([field, kn, vn, g]) => {
            const v = o[field];
            if (!v || !Array.isArray(v.listItem)) return;
            tableKeys.add(field);
            v.listItem.forEach((it, ri) => {
                const m = mapOf(it);
                const name = m[kn] || m.name || m.parameter || m.key || m.fieldName;
                if (name != null) params.push({ k: name, v: m[vn] != null ? m[vn] : (m.value || ''), g: g || (field === 'map' ? 'map' : ''), src: { field, row: ri, col: vn } });
            });
        });
        Object.keys(o).forEach(k => {
            if (k === mainKey || tableKeys.has(k) || /^(name|notes|javascript)$/.test(k)) return;
            const sv = valText(o[k]).trim();
            if (!sv || sv === 'false' || sv === '[]' || sv === '{}') return;
            const long = sv.length > 140;
            params.push({ k, v: long ? sv.slice(0, 137) + '…' : sv, g: 'opt', src: long ? null : { key: k } });
        });
        if (pub === 'jsm' && o.javascript) {
            // Custom JS: bez etykiety wartości (kod to nie „wartość”) — kod pod nazwą po kliknięciu „kod ▾”
            const code = t('javascript');
            ev = ''; evKey = '';
            params.push({ k: 'javascript', v: code.length > 1500 ? code.slice(0, 1497) + '…' : code, g: 'code', src: null });
        }
        // formatowanie wartości (Format Value): tylko gdy coś ustawiono
        const fv = (item.data && item.data.formatValue) || (item.properties && item.properties.formatValue);
        if (fv && typeof fv === 'object') Object.keys(fv).forEach(k => { const sv = valText(fv[k]); if (sv && sv !== 'false') params.push({ k: 'format: ' + k, v: sv, g: 'opt', src: null }); });
        if (!ev && !params.length) return null;
        return { ev, evKey, params, kind: 'variable', pub, full: pub === 'jsm' ? t('javascript') : '' };
    }
    function rowInfoFromItem(item) {
        if (currentSection === 'triggers') return triggerInfoFromItem(item);
        if (currentSection === 'variables') return variableInfoFromItem(item);
        return eventInfoFromItem(item);
    }

    /* ── Edycja wartości parametru z poziomu listy — zapis przez API jak zmiana nazwy ── */
    // wartości danego pola szablonu użyte w innych tagach (do menu wyboru)
    // pola, które trzymają to samo ID w różnych szablonach (Google tag: tagId, GA4 Event: measurementIdOverride…)
    const KEY_FAMILY = [
        ['tagId', 'measurementIdOverride', 'measurementId', 'trackingId'],
        ['conversionId'],
        ['pixelId', 'pixel_id', 'pixelIds'],
    ];
    function keyFamily(key) { return KEY_FAMILY.find(f => f.includes(key)) || [key]; }
    function valuesUsedFor(key, exceptId) {
        const tm = apiMapFor('tags');
        const cnt = new Map();
        if (!tm) return [];
        const fam = keyFamily(key);
        tm.forEach((it, id) => {
            if (id === exceptId) return;
            const o = tplParams(it);
            fam.forEach(k => {
                if (!(k in o)) return;
                const v = valText(o[k]).trim();
                if (!v || /^\{\{[^}]+\}\}$/.test(v) === false && !/^[A-Z]{1,4}-[A-Z0-9-]{4,}$|^\d{6,20}$/i.test(v)) return; // tylko ID albo {{zmienna}}
                cnt.set(v, (cnt.get(v) || 0) + 1);
            });
        });
        return [...cnt.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
    }
    let openMenu = null;
    function closeMenu() { if (openMenu) { openMenu.remove(); openMenu = null; } }
    document.addEventListener('mousedown', e => { if (openMenu && !openMenu.contains(e.target)) closeMenu(); }, true);
    document.addEventListener('keydown', e => { if (e.key === 'Escape' && openMenu) closeMenu(); }, true);
    // menu pod etykietą: items = [{label, sub, onPick, cls}]
    function showMenu(anchor, items, title, opts) {
        closeMenu();
        ensureIdCss();
        opts = opts || {};
        const m = document.createElement('div');
        m.className = 'tb-menu';
        if (title) { const h = document.createElement('div'); h.className = 'tb-menu-h'; h.textContent = title; m.appendChild(h); }
        if (!items.length && opts.empty) { const e = document.createElement('div'); e.className = 'tb-menu-e'; e.textContent = opts.empty; m.appendChild(e); }
        items.forEach(it => {
            const d = document.createElement('div');
            d.className = 'tb-menu-i' + (it.cls ? ' ' + it.cls : '');
            const l = document.createElement('span'); l.textContent = it.label; d.appendChild(l);
            if (it.sub) { const sb = document.createElement('span'); sb.className = 'tb-menu-s'; sb.textContent = it.sub; d.appendChild(sb); }
            d.addEventListener('mousedown', ev => ev.stopPropagation());
            d.addEventListener('click', ev => { ev.preventDefault(); ev.stopPropagation(); closeMenu(); it.onPick(); });
            m.appendChild(d);
        });
        if (opts.onInput) {
            const w = document.createElement('div'); w.className = 'tb-menu-in';
            const inp = document.createElement('input'); inp.type = 'text'; inp.value = opts.input || ''; inp.placeholder = 'wpisz wartość albo {{Zmienna}} · Enter = zapis';
            ['keydown', 'keyup', 'keypress'].forEach(t => inp.addEventListener(t, e => e.stopPropagation()));
            inp.addEventListener('mousedown', e => e.stopPropagation());
            inp.addEventListener('keydown', e => {
                if (e.key === 'Enter') { e.preventDefault(); const v = inp.value; if (v.trim() && v !== (opts.input || '')) { closeMenu(); opts.onInput(v); } }
                else if (e.key === 'Escape') { e.preventDefault(); closeMenu(); }
            });
            w.appendChild(inp); m.appendChild(w);
            setTimeout(() => { inp.focus(); inp.select(); }, 0);
        }
        document.body.appendChild(m);
        const r = anchor.getBoundingClientRect();
        m.style.left = Math.min(r.left, window.innerWidth - m.offsetWidth - 8) + 'px';
        m.style.top = (r.bottom + 4 + (window.innerHeight - r.bottom < m.offsetHeight + 12 ? -(m.offsetHeight + r.height + 8) : 0)) + 'px';
        openMenu = m;
    }
    // pole tekstowe w miejscu etykiety: Enter = zapis, Esc = anuluj
    function inlineEdit(chip, cur, onSave) {
        if (chip.querySelector('input')) return;
        const old = chip.innerHTML;
        chip.innerHTML = '';
        const inp = document.createElement('input');
        inp.type = 'text'; inp.value = cur; inp.className = 'tb-inline';
        inp.style.width = Math.max(90, Math.min(360, cur.length * 7.5 + 24)) + 'px';
        ['keydown', 'keyup', 'keypress'].forEach(t => inp.addEventListener(t, e => e.stopPropagation()));
        ['mousedown', 'mouseup', 'click', 'dblclick'].forEach(t => inp.addEventListener(t, e => e.stopPropagation()));
        let done = false;
        const finish = (save) => {
            if (done) return; done = true;
            const v = inp.value;
            chip.innerHTML = old;
            if (save && v !== cur && v.trim()) onSave(v);
        };
        inp.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); finish(true); } else if (e.key === 'Escape') { e.preventDefault(); finish(false); } });
        inp.addEventListener('blur', () => setTimeout(() => finish(false), 0));
        chip.appendChild(inp);
        inp.focus(); inp.select();
    }
    function valueFromText(txt, old) {
        const t = String(txt);
        const base = { type: 1, listItem: [], mapKey: [], mapValue: [], templateToken: [], escaping: [] };
        const m = /^\{\{([^{}]+)\}\}$/.exec(t.trim());
        if (m) return Object.assign({}, base, { type: 4, macroReference: m[1].trim() });
        if (/\{\{[^{}]+\}\}/.test(t)) return null; // mieszane: tekst + zmienna — nie znamy formatu zapisu, edytuj w GTM
        if (/^(true|false)$/.test(t) && old && old.type === 8) return Object.assign({}, base, { type: 8, boolean: t === 'true' });
        return Object.assign({}, base, { type: 1, string: t });
    }
    function findParamRef(item, key) {
        const vt = item.data && item.data.vendorTemplate;
        if (!vt || !Array.isArray(vt.param)) return null;
        const pr = vt.param.find(p => p && p.key === key);
        return pr || null;
    }
    async function editParam(id, src, label, preset, sec) {
        if (!src) return;
        sec = sec || currentSection || 'tags';
        if (!/^(tags|variables)$/.test(sec)) return;
        const tm = apiMapFor(sec);
        let it = tm && tm.get(id);
        if (!it || !it.data) { alert('Nie mam danych tego elementu — kliknij ⟳ Odśwież i spróbuj ponownie.'); return; }
        try { it = (await ensureFullItem(sec, id)) || it; } catch (e) { alert('Nie udało się pobrać pełnych danych elementu — nie zapisuję.\n' + (e && e.message)); return; }
        if (dataLacksParams(it)) { alert('API GTM zwraca ten element bez parametrów w „data” — zapis z listy mógłby skasować konfigurację. Zmień wartość w edytorze GTM.'); return; }
        const draft = clone(it);
        let target = null, cur = '';
        if (src.key) {
            let pr = findParamRef(draft, src.key);
            if (!pr) { const vt = draft.data.vendorTemplate; if (!vt) return; vt.param = vt.param || []; pr = { key: src.key, value: null }; vt.param.push(pr); }
            target = pr; cur = pr.value ? valText(pr.value) : '';
        } else if (src.field) {
            const pr = findParamRef(draft, src.field);
            const rowV = pr && pr.value && Array.isArray(pr.value.listItem) ? pr.value.listItem[src.row] : null;
            if (!rowV || !Array.isArray(rowV.mapKey)) { alert('Tej wartości nie da się zmienić z listy — otwórz element w GTM.'); return; }
            const ci = rowV.mapKey.findIndex(k => valText(k) === src.col);
            if (ci < 0) { alert('Tej wartości nie da się zmienić z listy — otwórz element w GTM.'); return; }
            target = { holder: rowV.mapValue, idx: ci }; cur = valText(rowV.mapValue[ci]);
        } else return;
        const nv = (preset != null) ? preset : prompt('Nowa wartość: ' + label + '\n(zmienna: {{Nazwa zmiennej}})', cur);
        if (nv == null || nv === cur) return;
        const old = target.holder ? target.holder[target.idx] : target.value;
        const val = valueFromText(nv, old);
        if (!val) { alert('Wartość mieszana (tekst + {{zmienna}}) — tę zmień w edytorze GTM.'); return; }
        if (target.holder) target.holder[target.idx] = val; else target.value = val;
        if (draft.data.vendorTemplate && draft.data.vendorTemplate.valueCache) draft.data.vendorTemplate.valueCache = [];
        const base = wsApiBase();
        try {
            await apiWrite('PUT', base + '/' + sec + '/' + id + '?hl=en', { key: clone(draft.key), data: draft.data });
            it.data = draft.data;
            if (it.properties) delete it.properties.vendorTemplate; // nieaktualne — tplParams weźmie z data
            Object.values(apiStores).forEach(st => { st.at = 0; });
            renderIdBadges(); if (hasActiveFilters()) applyAllFilters(); updateFooter();
        } catch (e) { alert('Zmiana nie powiodła się.\n' + (e && e.message)); }
    }

    // rozwinięta lista parametrów pod nazwą tagu (poza linkiem, żeby klik nie otwierał tagu)
    function renderParamList(row, evi, isOpen) {
        const { nameIdx } = colsForRow(row);
        const cell = row.querySelectorAll('td')[nameIdx];
        if (!cell) return;
        let pl = cell.querySelector('.tb-plist');
        if (!isOpen) { if (pl) pl.remove(); return; }
        const sig = JSON.stringify(evi.params.map(p => [p.k, p.g, p.v.length, p.v.slice(0, 64)]));
        if (pl && pl.dataset.sig === sig) return;
        if (!pl) {
            pl = document.createElement('div');
            pl.className = 'tb-plist';
            stopAll(pl);
            pl.addEventListener('click', ev => ev.stopPropagation());
            cell.appendChild(pl);
        }
        pl.dataset.sig = sig;
        pl.innerHTML = '';
        const groupLbl = { '': '', config: 'konfiguracja', user: 'właściwości użytkownika', var: '', opt: '', cond: 'warunki', auto: 'filtr zdarzenia', map: 'tabela', code: 'kod' };
        let lastG = null;
        evi.params.forEach(p => {
            if (p.g !== lastG && groupLbl[p.g]) {
                const h = document.createElement('div');
                h.className = 'tb-pg';
                h.textContent = groupLbl[p.g];
                pl.appendChild(h);
            }
            lastG = p.g;
            const line = document.createElement('div');
            const k = document.createElement('span'); k.className = 'k'; k.textContent = p.k;
            const eq = document.createElement('span'); eq.className = 'eq'; eq.textContent = (p.g === 'cond' || p.g === 'auto') ? ' ' : ' = ';
            const v = document.createElement('span'); v.className = 'v' + (/\{\{/.test(p.v) ? ' var' : '') + (p.src ? ' edit' : '') + (p.g === 'code' ? ' code' : ''); v.textContent = p.v;
            if (p.src) {
                v.title = 'klik: wpisz nową wartość (Enter = zapis przez API GTM)';
                v.addEventListener('click', ev => { ev.preventDefault(); ev.stopPropagation(); const id = idFromRow(row); if (id !== null) inlineEdit(v, p.v, nv => editParam(id, p.src, p.k, nv)); });
            }
            line.append(k, eq, v);
            pl.appendChild(line);
        });
    }

    // Consent Settings tagu: manualConsentStatus 1 = „no additional consent required”, 2 = wymaga zgód z listy, 0/brak = nie ustawiono
    function consentFromItem(item) {
        const cs = (item.data && item.data.consentSettings) || (item.properties && item.properties.consentSettings);
        if (!cs) return { st: 0, list: [] };
        const st = Number(cs.manualConsentStatus || 0);
        const list = (cs.manualConsentSettings && Array.isArray(cs.manualConsentSettings.listItem)) ? cs.manualConsentSettings.listItem.map(valText).filter(Boolean) : [];
        return { st, list };
    }
    // GA4 Event bez Event Settings Variable — gdy większość tagów GA4 Event w kontenerze ją ma
    let esvCache = { src: null, ratio: 0 };
    function esvRatio() {
        const tm = apiMapFor('tags');
        if (!tm) return 0;
        if (esvCache.src === tm) return esvCache.ratio;
        let all = 0, withEsv = 0;
        tm.forEach(it => {
            const vt = it.data && it.data.vendorTemplate;
            if (!vt || !vt.key || vt.key.publicId !== 'gaawe') return;
            all++;
            const o = tplParams(it);
            if (o.eventSettingsVariable && valText(o.eventSettingsVariable).trim()) withEsv++;
        });
        esvCache = { src: tm, ratio: all ? withEsv / all : 0 };
        return esvCache.ratio;
    }
    function missingEsv(item) {
        const vt = item.data && item.data.vendorTemplate;
        if (!vt || !vt.key || vt.key.publicId !== 'gaawe') return false;
        const o = tplParams(item);
        if (o.eventSettingsVariable && valText(o.eventSettingsVariable).trim()) return false;
        return esvRatio() >= 0.5;
    }

    // ID vendorów + opcja uruchamiania (tylko gdy któraś opcja włączona)
    let metaStats = null;
    function updateMetaNote() {
        const n = document.getElementById('tb-meta-note');
        if (!n) return;
        if (!metaStats || !metaStats.rows) { n.style.display = 'none'; return; }
        let html = '';
        if (!metaStats.items) html = escHtml('Nie widzę danych elementów listy (0/' + metaStats.rows + '). Wpisz w konsoli gtmTbDebug() i wklej wynik.');
        else if (!metaStats.found) html = escHtml('Dane elementów są (' + metaStats.items + '/' + metaStats.rows + '), ale bez ID vendorów / opcji. Wpisz w konsoli gtmTbDebug() i wklej wynik.');
        else if ((metaStats.errors && metaStats.errors.length) || (metaStats.conflicts && metaStats.conflicts.length)) {
            const errs = metaStats.errors || [];
            if (errs.length) html += '<span style="color:#f87171">✖ <b>' + errs.length + ' ' + (errs.length === 1 ? 'problem' : ([2, 3, 4].includes(errs.length % 10) && ![12, 13, 14].includes(errs.length % 100) ? 'problemy' : 'problemów')) + ' z ID</b> (czerwone etykiety):</span><br>' +
                errs.slice(0, 4).map(e => '<span style="color:#fca5a5">· ' + escHtml(e.tag) + ': ' + escHtml(e.msg) + '</span>').join('<br>') +
                (errs.length > 4 ? '<br><span style="color:#fca5a5">· … i ' + (errs.length - 4) + ' więcej</span>' : '') +
                ((metaStats.conflicts || []).length ? '<br>' : '');
            html += (metaStats.conflicts || []).map(c => '⚠ <b>' + escHtml(c.v) + '</b>: ' + c.entries.length + ' różne ID — ' +
                c.entries.map(([id, k]) => '<code style="font-family:Consolas,monospace;color:#fde68a">' + escHtml(id) + '</code> ×' + k).join(', ')).join('<br>');
        }
        n.innerHTML = html;
        n.style.display = html ? '' : 'none';
    }

    function renderRowMeta() {
        const wantV = showVendorIds && (currentSection === 'tags' || currentSection === 'variables');
        const wantF = showFiring && currentSection === 'tags';
        const wantE = showEvents && (currentSection === 'tags' || currentSection === 'triggers' || currentSection === 'variables');
        const wantC = showConsent && currentSection === 'tags';
        const dupAware = currentSection !== 'triggers'; // duplikaty liczymy dla tagów i zmiennych
        if (!wantE) document.querySelectorAll('.tb-plist').forEach(b => b.remove());
        if (!wantV && !wantF && !wantE && !wantC) {
            document.querySelectorAll('.tb-meta').forEach(b => b.remove());
            metaStats = null;
            updateMetaNote();
            return;
        }
        ensureIdCss();
        const apiMap = apiMapFor(currentSection);
        if (!apiMap && apiStore.loading) return; // czekamy na odpowiedź API — potem render jeszcze raz
        if (apiMap) ensureVarParams();
        metaStats = { rows: 0, items: 0, found: 0, conflicts: [], errors: [] };
        const vres = wantV && currentSection === 'tags' ? varResolveMap() : null;

        // 1) dane wszystkich wierszy
        const list = getDataRows().map(row => {
            const id = idFromRow(row);
            const item = (apiMap && id !== null && apiMap.get(id)) || ngItemFromRow(row);
            metaStats.rows++;
            if (item) metaStats.items++;
            let vids = wantV && item ? vendorIdsFromItem(item) : [];
            // {{zmienna}} → rzeczywiste ID (stała z G-… itp.)
            // zmienna, której wartości nie znamy, ale nazwa zawiera ID (np. {{GA4 ID G-XXX}}) → kolor/porównanie wg nazwy
            const fromName = x => {
                for (const { v, re, grp } of VENDOR_RES) {
                    re.lastIndex = 0;
                    const m = re.exec(x.id);
                    if (m) return { id: x.id, v, rid: grp ? m[grp] : m[0], byName: true, k: x.k };
                }
                return x;
            };
            vids = vids.map(x => {
                if (x.v !== 'ID' || !/^\{\{/.test(x.id)) return x;
                if (!vres) return fromName(x);
                if (!vres.has(x.id)) return (x.k && vres.size > 0) ? Object.assign(fromName(x), { err: 'Zmienna ' + x.id + ' nie istnieje w tym kontenerze' }) : fromName(x);
                const r = vres.get(x.id);
                // etykietą zostaje zmienna; rozwinięte ID (rid) pokazujemy obok, służy też do porównań
                if (r.ids.length === 1) {
                    const o = { id: x.id, v: r.ids[0].v, rid: r.ids[0].id, k: x.k };
                    // nazwa zmiennej sugeruje inne ID niż jej wartość → błąd
                    const nm = fromName(x);
                    if (nm.byName && nm.rid !== o.rid) o.err = 'Nazwa zmiennej wskazuje ' + nm.rid + ', a jej wartość to ' + o.rid;
                    return o;
                }
                if (x.k && !r.ids.length && r.raw && !/^\d{6,20}$/.test(r.raw) && !/\{\{/.test(r.raw))
                    return Object.assign(fromName(x), { err: 'Zmienna ma wartość „' + r.raw + '”, która nie wygląda na ID — oczekiwane ' + expectedForKey(x.k) });
                // stała z samymi cyframi: piksel Meta / konwersja Ads — rodzaj z nazwy pola w tagu
                if (r.raw && /^\d{6,20}$/.test(r.raw) && x.k) {
                    if (/pixel/i.test(x.k)) return { id: x.id, v: 'Meta', rid: r.raw, k: x.k };
                    if (/^conversionId$/i.test(x.k)) return { id: x.id, v: 'Ads', rid: 'AW-' + r.raw, k: x.k };
                }
                return fromName(x);
            });
            // bez duplikatów; ID wpisane wprost, które jest wartością/nazwą użytej zmiennej → zostaje tylko zmienna
            const seenIds = new Set();
            vids = vids.filter(x => !seenIds.has(x.id) && seenIds.add(x.id));
            const varChips = vids.filter(x => /^\{\{/.test(x.id));
            if (varChips.length) vids = vids.filter(x => /^\{\{/.test(x.id) ||
                !varChips.some(vc => vc.rid === x.id || vc.id.indexOf(x.id) >= 0));
            // format ID (także rozwiniętego ze zmiennej)
            vids.forEach(x => {
                if (x.v === 'BAD') { x.err = x.msg; return; }
                if (x.err) return;
                const target = /^\{\{/.test(x.id) ? (x.byName ? null : x.rid) : x.id;
                if (target) { const pr = idProblem(x.v, target); if (pr) x.err = pr; }
            });
            vids.forEach(x => { if (x.err) metaStats.errors.push({ tag: apiItemName(item) || nameFromRow(row), id: x.id, msg: x.err }); });
            const fire = wantF && item ? firingFromItem(item) : null;
            const evi = wantE && item ? rowInfoFromItem(item) : null;
            const cons = wantC && item ? consentFromItem(item) : null;
            const noEsv = wantE && item && currentSection === 'tags' ? missingEsv(item) : false;
            if (vids.length || fire || evi || cons) metaStats.found++;
            return { row, vids, fire, evi, cons, noEsv, tid: id, item };
        });

        // 2) ostrzeżenia: w ramach vendora ID inne niż najczęstsze na liście
        const byVendor = {};
        list.forEach(({ vids }) => vids.forEach(x => {
            if (x.v === 'ID' || x.v === 'BAD') return;
            const m = byVendor[x.v] || (byVendor[x.v] = new Map());
            const rid = x.rid || x.id;
            m.set(rid, (m.get(rid) || 0) + 1);
        }));
        const odd = new Set(); // 'vendor|id'
        Object.entries(byVendor).forEach(([v, m]) => {
            if (m.size < 2) return;
            const max = Math.max(...m.values());
            const entries = [...m.entries()].sort((a, b) => b[1] - a[1]);
            entries.forEach(([id, n]) => { if (n < max) odd.add(v + '|' + id); });
            metaStats.conflicts.push({ v, entries });
        });

        // 3) render
        list.forEach(({ row, vids, fire, evi, cons, noEsv, tid, item }) => {
            vids.forEach(x => { x.warn = odd.has(x.v + '|' + (x.rid || x.id)); });
            const isOpen = !!(evi && evi.params.length && openParams.has(tid));
            renderParamList(row, evi, isOpen);
            const dsig = evi && dupAware ? rowDupInfo(row) : null;
            // sygnatura bez pełnych wartości (kod CJS bywa długi) — nazwa/długość/początek wystarczą
            const eviSig = evi ? [evi.ev, evi.evKey, evi.kind, evi.params.map(p => p.k + ':' + p.g + ':' + p.v.length + ':' + p.v.slice(0, 48))] : null;
            const sig = JSON.stringify([vids, fire && fire.label, fire && fire.raw, eviSig, isOpen, wantE, wantV, wantF, wantC, cons, noEsv, dsig && dsig.dup ? dsig.names.length : 0]);
            let box = row.querySelector('.tb-meta');
            if (box && box.dataset.sig === sig) return;
            // pusty wiersz też dostaje (puste) sloty — żeby nazwy były wyrównane w pionie
            if (!box) {
                box = document.createElement('span');
                box.className = 'tb-meta tb-ann';
                const host = nameAnchor(row);
                if (!host) return;
                host.appendChild(box);
            }
            box.dataset.sig = sig;
            box.innerHTML = '';
            // sloty o równej szerokości w całej tabeli (wyrównuje layoutAnnotations): event · param. · opcja · ID vendorów
            const mkSlot = cls => { const sl = document.createElement('span'); sl.className = 'tb-slot ' + cls; box.appendChild(sl); return sl; };
            const slEv = wantE ? mkSlot('tb-s-ev') : null;
            const slPc = wantE ? mkSlot('tb-s-pc') : null;
            const slFire = wantF ? mkSlot('tb-s-fire') : null;
            const slVid = wantV ? mkSlot('tb-s-vid') : null;
            const slCons = wantC ? mkSlot('tb-s-cons') : null;
            let target = box;
            const mkChip = (text, cls, bg, fg, title, copyVal) => {
                const c = document.createElement('span');
                c.className = 'tb-vid' + cls;
                if (bg) c.style.background = bg;
                if (fg) { c.style.color = fg; c.style.borderColor = fg; }
                const label = document.createElement('span');
                label.textContent = text;
                c.appendChild(label);
                const src = editSrc, lbl = editLabel, mode = editMode, fn = editFn, cur0 = editCur; // zamrożone dla tej etykiety
                const canEdit = !!(src && tid !== null);
                c.title = title + (canEdit
                    ? (mode === 'inline' ? '\nklik: wpisz nową wartość (Enter = zapis przez API GTM) · Ctrl+klik: kopiuj' : '\nklik: wybierz wartość z innych tagów albo wpisz · Ctrl+klik: kopiuj')
                    : '\nklik: kopiuj' + (copyVal !== text ? ' ' + copyVal : ''));
                stopAll(c);
                const doCopy = () => { copyText(copyVal); label.textContent = '✓ ' + text; setTimeout(() => { label.textContent = text; }, 800); };
                c.addEventListener('click', ev => {
                    ev.preventDefault(); ev.stopPropagation();
                    if (!canEdit || ev.ctrlKey || ev.metaKey) { doCopy(); return; }
                    const curVal = cur0 != null ? cur0 : curValueOf(src);
                    if (mode === 'inline') { inlineEdit(c, curVal, v => fn ? fn(v) : editParam(tid, src, lbl || text, v)); return; }
                    const used = valuesUsedFor(src.key, tid).filter(([v]) => v !== curVal);
                    const items = used.slice(0, 12).map(([v, n]) => ({ label: v, sub: 'w ' + n + ' ' + (n === 1 ? 'tagu' : 'tagach'), onPick: () => editParam(tid, src, lbl || text, v) }));
                    // zmienne-stałe z ID vendora, których żaden tag jeszcze nie używa w tym polu
                    if (VENDOR_KEY_RE.test(src.key)) {
                        const vres = varResolveMap();
                        if (vres) vres.forEach((r, vname) => {
                            if (vname === curVal || !r.ids.length || used.some(([v]) => v === vname)) return;
                            items.push({ label: vname, sub: r.ids[0].id, onPick: () => editParam(tid, src, lbl || text, vname) });
                        });
                    }
                    showMenu(c, items, (lbl || src.key) + ' · teraz: ' + (curVal || '—'), { input: curVal, onInput: v => editParam(tid, src, lbl || text, v), empty: 'Brak innych wartości na liście — wpisz nową:' });
                });
                target.appendChild(c);
                return c;
            };
            const curValueOf = src => { const o = item ? tplParams(item) : {}; return src && src.key && o[src.key] != null ? valText(o[src.key]).trim() : ''; };
            let editSrc = null, editLabel = '', editMode = 'menu', editFn = null, editCur = null;
            if (evi) {
                target = slEv || box;
                if (evi.ev && evi.kind === 'variable') {
                    // zmienna: główna wartość (stała / klucz DLV / nazwa cookie…) — klik = edycja
                    editSrc = evi.evKey ? { key: evi.evKey } : null; editLabel = evi.evKey || 'wartość'; editMode = 'inline';
                    const isVarRef = /\{\{/.test(evi.ev);
                    const e = mkChip(evi.ev.length > 60 ? evi.ev.slice(0, 57) + '…' : evi.ev, ' tb-vv' + (isVarRef ? ' ref' : '') + (evi.pub === 'jsm' ? ' code' : ''), null, null,
                        evi.pub === 'jsm' ? 'Custom JavaScript:\n' + (evi.full.length > 1200 ? evi.full.slice(0, 1197) + '…' : evi.full) : (evi.evKey ? evi.evKey + ': ' : '') + evi.ev, evi.pub === 'jsm' ? evi.full : evi.ev);
                    e.classList.add('tb-vv');
                } else if (evi.ev && evi.kind === 'trigger') {
                    const evName = evi.ev.replace(/ \(regex\)$/, '');
                    editSrc = { ev: true }; editLabel = 'nazwa eventu'; editMode = 'inline'; editCur = evName; editFn = v => editTriggerEvent(tid, v);
                    const e = mkChip(evi.ev, ' ev', null, null, 'Nazwa eventu (Custom Event): ' + evi.ev, evName);
                    e.classList.add('tb-ev');
                    editFn = null; editCur = null;
                } else if (evi.ev) {
                    const di = rowDupInfo(row);
                    const dupTxt = di && di.dup ? '\n' + (di.hot ? '⚠ KONFLIKT: ≥2 aktywne tagi wysyłają ten sam event' : 'ℹ duplikat, ale pozostałe tagi są zapauzowane') + '\n  ' + di.names.join('\n  ') : '';
                    const pz = di && di.dup && isRowPaused(row) === true;
                    editSrc = evi.evKey ? { key: evi.evKey } : null; editLabel = 'nazwa eventu'; editMode = 'inline';
                    const e = mkChip(evi.ev + (di && di.dup ? ' ×' + di.names.length : '') + (pz ? ' ⏸' : ''), ' ev' + (di && di.dup ? (di.hot ? ' dupe hot' : ' dupe') : '') + (pz ? ' pz' : ''), null, null, 'Nazwa eventu: ' + evi.ev + (pz ? '\n⏸ tag zapauzowany' : '') + dupTxt, evi.ev);
                    e.classList.add('tb-ev');
                }
                if (evi.params.length) {
                    const pc = document.createElement('span');
                    pc.className = 'tb-pc' + (isOpen ? ' open' : '');
                    const nC = evi.params.filter(p => p.g === 'cond' || p.g === 'auto').length, nP = evi.params.length - nC;
                    const onlyCode = evi.params.every(p => p.g === 'code');
                    pc.textContent = (onlyCode ? 'kod' : evi.kind === 'trigger' ? (nC ? nC + ' war.' : '') + (nC && nP ? ' + ' : '') + (nP ? nP + ' param.' : '') : evi.params.length + ' param.') + ' ' + (isOpen ? '▴' : '▾');
                    pc.title = evi.params.map(p => p.g === 'code' ? (p.v.length > 700 ? p.v.slice(0, 697) + '…' : p.v) : p.k + ' = ' + p.v).join('\n') + '\n\nkliknij, aby ' + (isOpen ? 'zwinąć' : 'rozwinąć ' + (evi.params.every(p => p.g === 'code') ? 'kod' : 'listę') + ' pod nazwą');
                    stopAll(pc);
                    pc.addEventListener('click', ev => {
                        ev.preventDefault(); ev.stopPropagation();
                        if (openParams.has(tid)) openParams.delete(tid); else openParams.add(tid);
                        renderIdBadges();
                    });
                    // zmienne: wartość i pigułka „kod / N param.” w jednej kolumnie (szybki podgląd, co siedzi w środku)
                    ((evi.kind === 'variable' ? slEv : slPc) || box).appendChild(pc);
                }
            }
            editSrc = null; editLabel = ''; editMode = 'menu';
            target = slVid || box;
            vids.forEach(x => {
                editSrc = x.k ? { key: x.k } : null; editLabel = x.k || 'ID'; editMode = 'menu';
                const isVar = /^\{\{/.test(x.id);
                const col = VENDOR_COL[x.v] && x.v !== 'ID' ? VENDOR_COL[x.v] : null;
                const cnt = byVendor[x.v] && byVendor[x.v].get(x.rid || x.id);
                const warnTxt = x.warn ? '\n⚠ inne ID niż większość tagów (' + cnt + '× na liście)' : '';
                const errTxt = x.err ? '\n✖ ' + x.err : '';
                if (x.v === 'BAD') {
                    mkChip(x.id, ' bad', null, null, '✖ ' + x.msg, x.id);
                    return;
                }
                if (isVar) {
                    const varErr = x.err && (!x.rid || x.byName || /Nazwa zmiennej|nie istnieje|nie wygląda/.test(x.err));
                    mkChip(x.id, ' var' + (varErr ? ' bad' : '') + (x.warn && x.byName ? ' warn' : ''), null, varErr ? null : col,
                        'Zmienna ' + x.id + (x.rid ? '\n→ ' + x.v + ' · ' + x.rid + (x.byName ? ' (z nazwy zmiennej — wartość nieznana)' : '') : '\n(wartość nieznana — to nie jest stała z ID)') + warnTxt + errTxt,
                        x.rid || x.id);
                    // obok: rzeczywista wartość zmiennej (gdy ją znamy)
                    if (x.rid && !x.byName) {
                        const valErr = x.err && !varErr;
                        mkChip(x.rid, ' val' + (valErr ? ' bad' : '') + (x.warn ? ' warn' : ''), valErr ? null : (col || '#5f6368'), null,
                            x.v + ' · ' + x.rid + '\nwartość zmiennej ' + x.id + warnTxt + (valErr ? errTxt : ''), x.rid);
                    }
                    return;
                }
                mkChip(x.id, (x.err ? ' bad' : '') + (x.warn ? ' warn' : ''), x.err ? null : (col || '#5f6368'), null,
                    x.v + ' · ' + x.id + warnTxt + errTxt, x.id);
            });
            if (fire) {
                const f = document.createElement('span');
                const cls = /zdarzenie/.test(fire.label) ? 'f-event' : /stronę/.test(fire.label) ? 'f-page' : /limitu/.test(fire.label) ? 'f-unl' : 'f-other';
                f.className = 'tb-fire ' + cls;
                f.textContent = fire.label;
                const cur = /zdarzenie/.test(fire.label) ? 1 : /stronę/.test(fire.label) ? 2 : /limitu/.test(fire.label) ? 3 : 0;
                const nxt = cur === 1 ? 2 : 1;
                f.title = 'Opcja uruchamiania tagu (' + fire.raw + ')\nklik: ' + (nxt === 2 ? 'zmień na „Raz na stronę”' : 'zmień na „Raz na zdarzenie”') + '\nShift+klik: „Bez limitu”' + (tid === null ? '' : '\n(zapis przez API GTM)');
                f.style.cursor = tid !== null ? 'pointer' : '';
                stopAll(f);
                f.addEventListener('click', ev => {
                    ev.preventDefault(); ev.stopPropagation();
                    if (tid === null) return;
                    setFiringOption(tid, ev.shiftKey ? 3 : nxt, f);
                });
                (slFire || box).appendChild(f);
            }
            if (noEsv && slEv) {
                const e = document.createElement('span');
                e.className = 'tb-vid bad tb-noesv';
                e.textContent = 'bez ESV';
                e.title = 'Ten tag GA4 Event nie ma Event Settings Variable, a większość tagów GA4 Event w kontenerze ją ma';
                stopAll(e); e.addEventListener('click', ev => { ev.preventDefault(); ev.stopPropagation(); });
                slEv.appendChild(e);
            }
            if (cons) {
                const c = document.createElement('span');
                const cls = cons.st === 2 ? 'c-req' : cons.st === 1 ? 'c-none' : 'c-unset';
                c.className = 'tb-cons ' + cls;
                c.textContent = cons.st === 2 ? '🛡 ' + (cons.list.length ? cons.list.map(x => x.replace(/_storage$/, '')).join(', ') : 'wymaga zgód') : cons.st === 1 ? '🛡 bez dodatkowych' : '🛡 nie ustawiono';
                c.title = 'Consent Settings: ' + (cons.st === 2 ? 'wymaga dodatkowych zgód: ' + cons.list.join(', ') : cons.st === 1 ? 'No additional consent required' : 'Not set (nie sprawdzono ustawień zgody)');
                stopAll(c); c.addEventListener('click', ev => { ev.preventDefault(); ev.stopPropagation(); });
                (slCons || box).appendChild(c);
            }
        });
        updateMetaNote();
    }

    /* ═══════════════════ MODUŁ 5: ZMIENNE — KOLUMNA „TAGI” ═══════════════════ */
    /* ── Zmienne: kolumna „Tagi” = w ilu tagach użyto {{zmiennej}} (jak kolumna Tags na liście reguł) ──
       Liczymy z API (tagi + reguły + zmienne) po nazwie, więc działa też dla zmiennych wbudowanych. */
    let usesCache = { key: null, map: null };
    function refsOf(item) {
        const out = new Set();
        walkStrings(item, (str, key) => {
            if (!str || str.indexOf('{{') < 0 || (key && /^(name|displayName|notes)$/.test(key))) return;
            const re = /\{\{([^{}]+)\}\}/g;
            let m;
            while ((m = re.exec(str))) out.add(m[1].trim());
        });
        return out;
    }
    function varUsesMap() {
        const tm = apiMapFor('tags'), rm = apiMapFor('triggers'), vm = apiMapFor('variables');
        const sst = isServerContainer();
        const cm = sst ? apiMapFor('clients') : new Map(), xm = sst ? apiMapFor('transformations') : new Map();
        if (!tm || !rm || !vm || !cm || !xm) return null;
        const key = [tm, rm, vm, cm, xm];
        if (usesCache.key && usesCache.key.every((k, i) => k === key[i])) return usesCache.map;
        const map = new Map(); // nazwa → { tags:[], triggers:[], variables:[], clients:[], transformations:[] }
        const add = (kind, src) => src.forEach(it => {
            const n = apiItemName(it);
            refsOf(it).forEach(ref => {
                if (kind === 'variables' && ref === n) return;
                const u = map.get(ref) || { tags: [], triggers: [], variables: [], clients: [], transformations: [] };
                u[kind].push(n);
                map.set(ref, u);
            });
        });
        add('tags', tm); add('triggers', rm); add('variables', vm); add('clients', cm); add('transformations', xm);
        usesCache = { key, map };
        return map;
    }
    function renderVarUses() {
        const on = showVarUses && currentSection === 'variables';
        if (!on) {
            document.querySelectorAll('.tb-uses-col').forEach(el => el.remove());
            return;
        }
        const uses = varUsesMap();
        if (!uses) return; // API jeszcze się ładuje — render po odpowiedzi
        ensureIdCss();
        findDataTables().forEach(table => {
            const { typeIdx } = resolveColumns(table);
            const headRow = headerRowOf(table);
            const cg = table.querySelector('colgroup');
            if (cg && !cg.querySelector('.tb-uses-col')) {
                const col = document.createElement('col');
                col.className = 'tb-uses-col';
                col.setAttribute('width', '90px');
                const cols = cg.querySelectorAll(':scope > col');
                cg.insertBefore(col, cols[typeIdx + 1] || null);
            }
            if (headRow && !headRow.querySelector('.tb-uses-col')) {
                const th = document.createElement('th');
                th.className = 'tb-uses-col';
                th.textContent = 'Tagi';
                th.title = 'W ilu tagach użyto zmiennej (GTM Filter) — szczegóły po najechaniu na liczbę';
                const hc = [...headRow.children].filter(e => e.tagName === 'TH' || e.tagName === 'TD');
                headRow.insertBefore(th, hc[typeIdx + 1] || null);
            }
            table.querySelectorAll('tbody tr').forEach(row => {
                if (row === headRow || !row.querySelector(':scope > td')) return;
                const name = nameFromRow(row);
                if (!name) return;
                const u = uses.get(name) || { tags: [], triggers: [], variables: [], clients: [], transformations: [] };
                const n = u.tags.length;
                const others = u.triggers.length + u.variables.length + u.clients.length + u.transformations.length;
                const sig = n + '|' + others;
                let td = row.querySelector('td.tb-uses-col');
                if (td && td.dataset.sig === sig) return;
                if (!td) {
                    td = document.createElement('td');
                    td.className = 'tb-uses-col';
                    const cells = row.querySelectorAll(':scope > td:not(.tb-uses-col)');
                    const ref = cells[typeIdx + 1] || null;
                    row.insertBefore(td, ref);
                }
                td.dataset.sig = sig;
                td.innerHTML = '';
                const num = document.createElement('span');
                num.className = 'tb-uses-n' + (n === 0 && others === 0 ? ' zero' : '');
                num.textContent = String(n);
                td.appendChild(num);
                if (others) {
                    const o = document.createElement('span');
                    o.className = 'tb-uses-o';
                    o.textContent = '+' + others;
                    td.appendChild(o);
                }
                const list = (lbl, arr) => arr.length ? lbl + ' (' + arr.length + '):\n  ' + arr.slice(0, 25).join('\n  ') + (arr.length > 25 ? '\n  …' : '') : '';
                td.title = [list('Tagi', u.tags), list('Reguły', u.triggers), list('Zmienne', u.variables), list('Klienci', u.clients), list('Transformacje', u.transformations)].filter(Boolean).join('\n') ||
                    'Nieużywana — żaden tag, reguła, zmienna' + (isServerContainer() ? ', klient ani transformacja' : '') + ' jej nie wywołuje';
            });
        });
    }

    // pomiar czasu (gtmTbDebug() → „czas”): ile kosztują poszczególne etapy
    const perf = {};
    function timed(name, fn) { const t0 = performance.now(); try { return fn(); } finally { const d = performance.now() - t0; const p = perf[name] || (perf[name] = { n: 0, ms: 0, max: 0 }); p.n++; p.ms += d; if (d > p.max) p.max = d; } }
    window.gtmTbPerf = perf;
    function renderIdBadges() {
        timed('renderIdBadgesCore', renderIdBadgesCore);
        timed('renderRowActions', renderRowActions);
        timed('renderPausedRows', renderPausedRows);
        timed('layoutAnnotations', () => layoutAnnotations());
    }
    window.addEventListener('resize', () => { try { layoutAnnotations(true); } catch (e) {} });

    // Zapauzowane tagi: cały wiersz przygaszony (ikona ⏸ z GTM jest za mało widoczna)
    function renderPausedRows() {
        if (!tidyActive()) { document.querySelectorAll('tr.tb-paused').forEach(r => r.classList.remove('tb-paused')); return; }
        ensureIdCss();
        getDataRows().forEach(row => row.classList.toggle('tb-paused', isRowPaused(row) === true));
    }

    /* ═══════════════════ MODUŁ 6: AKCJE NA LIŚCIE ═══════════════════ */
    /* ── zmiana nazwy / duplikat (przez to samo API, co panel GTM) ── */
    // Token XSRF: GTM nie trzyma go w czytelnym ciasteczku — szukamy w kilku miejscach
    const TOKEN_RE = /\b([A-Za-z0-9_-]{25,}:\d{12,14})\b/;
    let xsrfSeen = { value: null, from: '' };
    function rememberXsrf(v, from) { if (v && TOKEN_RE.test(v)) { xsrfSeen = { value: TOKEN_RE.exec(v)[1], from }; } }
    function xsrfToken() {
        // 1) nagłówek z zapisu wykonanego przez GTM w tej sesji (najpewniejsze)
        const w = (window.gtmTbWrites || []).slice().reverse().find(x => x.headers && (x.headers['X-XSRF-TOKEN'] || x.headers['x-xsrf-token']));
        if (w) return w.headers['X-XSRF-TOKEN'] || w.headers['x-xsrf-token'];
        // 2) ciasteczko — w GTM nazywa się GTM-XSRF-TOKEN
        const m = document.cookie.match(/(?:^|;\s*)([A-Za-z0-9_-]*XSRF[-_]?TOKEN[A-Za-z0-9_-]*)=([^;]+)/i);
        if (m) { let v = m[2]; try { v = decodeURIComponent(v); } catch (e) {} rememberXsrf(v, 'ciasteczko ' + m[1]); return v; }
        // 3) coś, co zauważyliśmy w odpowiedziach API / w stronie
        if (xsrfSeen.value) return xsrfSeen.value;
        // 4) skan: <meta>, inline <script>, globalne zmienne
        const meta = document.querySelector('meta[name*="xsrf" i], meta[name*="csrf" i]');
        if (meta && meta.content) { rememberXsrf(meta.content, 'meta'); if (xsrfSeen.value) return xsrfSeen.value; }
        for (const sc of document.querySelectorAll('script:not([src])')) {
            const t = sc.textContent || '';
            const i = t.search(/xsrf|csrf/i);
            if (i >= 0) { const mm = TOKEN_RE.exec(t.slice(Math.max(0, i - 200), i + 400)); if (mm) { rememberXsrf(mm[1], 'inline script'); return mm[1]; } }
        }
        try {
            for (const k of Object.keys(window)) {
                if (!/xsrf|csrf|token|config|__/i.test(k)) continue;
                let v; try { v = window[k]; } catch (e) { continue; }
                if (typeof v === 'string' && TOKEN_RE.test(v)) { rememberXsrf(v, 'window.' + k); return xsrfSeen.value; }
                if (v && typeof v === 'object') {
                    let js = ''; try { js = JSON.stringify(v).slice(0, 20000); } catch (e) {}
                    const i = js.search(/xsrf|csrf/i);
                    if (i >= 0) { const mm = TOKEN_RE.exec(js.slice(Math.max(0, i - 200), i + 400)); if (mm) { rememberXsrf(mm[1], 'window.' + k); return mm[1]; } }
                }
            }
        } catch (e) {}
        return null;
    }
    async function apiWrite(method, path, body) {
        const tok = xsrfToken();
        if (!tok) throw new Error('Brak tokenu zabezpieczającego (XSRF). Zapisz raz cokolwiek w GTM albo odśwież stronę i spróbuj ponownie.');
        const r = await window.fetch(path, {
            method, credentials: 'same-origin',
            headers: { 'Accept': 'application/json, text/plain, */*', 'Content-Type': 'application/json;charset=utf-8', 'X-XSRF-TOKEN': tok },
            body: JSON.stringify(body),
        });
        const t = await r.text();
        try { const w = (window.gtmTbWrites || []).slice(-1)[0]; if (w && w.url === path) { w.status = r.status; w.resp = t.slice(0, 1500); } } catch (e) {}
        if (!r.ok) throw new Error('GTM odpowiedział HTTP ' + r.status + (t ? ': ' + t.replace(/^\)\]\}'[^\n]*\n?/, '').slice(0, 300) : ''));
        return t;
    }
    // po zapisie: pobierz element z API i sprawdź, czy pole ma oczekiwaną wartość (GTM potrafi odpowiedzieć 200 i nic nie zmienić)
    async function verifyField(sec, id, getter, expected) {
        try {
            const t = await (await window.fetch(wsApiBase() + '/' + sec + '/' + id + '?hl=en', { credentials: 'same-origin' })).text();
            const m = collectApiItems(parseApiText(t), API_ID_FIELD[sec]);
            const it = m.get(id) || m.values().next().value;
            if (!it) return null;
            return { ok: getter(it) === expected, got: getter(it), item: it };
        } catch (e) { return null; }
    }
    function clone(o) { return JSON.parse(JSON.stringify(o)); }
    function reloadList() {
        // GTM trzyma listę w pamięci — wymuszamy ponowne wczytanie przez krótkie przejście na Przegląd i z powrotem
        const h = location.hash;
        const base = h.replace(/\/(tags|triggers|variables)(\/.*)?$/, '');
        Object.values(apiStores).forEach(st => { st.at = 0; });
        location.hash = base;
        setTimeout(() => { location.hash = h; }, 250);
    }
    const SEC_PL = { tags: 'tagu', triggers: 'reguły', variables: 'zmiennej' };
    async function renameTag(id) {
        const sec = currentSection;
        const tm = apiMapFor(sec);
        let it = tm && tm.get(id);
        if (!it || !it.data) { alert('Nie mam danych tego elementu — kliknij ⟳ Odśwież i spróbuj ponownie.'); return; }
        const cur = it.data.name || '';
        const name = prompt('Nowa nazwa ' + (SEC_PL[sec] || 'elementu') + ':', cur);
        if (name == null || !name.trim() || name.trim() === cur) return;
        try { it = (await ensureFullItem(sec, id)) || it; } catch (e) { alert('Nie udało się pobrać pełnych danych elementu — nie zapisuję.\n' + (e && e.message)); return; }
        if (dataLacksParams(it)) { alert('API GTM zwraca tę zmienną bez parametrów w „data” — zapis z listy mógłby skasować jej konfigurację. Zmień nazwę w edytorze GTM.\n(Wpisz w konsoli gtmTbDebug() i prześlij sekcję „pojedyncza zmienna” — poprawię format.)'); return; }
        const base = wsApiBase();
        const body = { key: clone(it.key), data: Object.assign(clone(it.data), { name: name.trim() }) };
        try {
            await apiWrite('PUT', base + '/' + sec + '/' + id + '?hl=en', body);
            // lista GTM trzyma stare dane w pamięci — podmieniamy nazwę w wierszu od razu, dane w cache też
            it.data.name = name.trim();
            if (it.properties) it.properties.displayName = name.trim();
            getDataRows().forEach(r => {
                if (idFromRow(r) !== id) return;
                const a = nameAnchor(r);
                if (!a) return;
                const tn = [...a.childNodes].find(n => n.nodeType === 3 && n.textContent.trim());
                if (tn) tn.textContent = ' ' + name.trim() + ' ';
            });
            Object.values(apiStores).forEach(st => { st.at = 0; });
            renderIdBadges(); if (hasActiveFilters()) applyAllFilters(); updateFooter();
        } catch (e) { alert('Zmiana nazwy nie powiodła się.\n' + (e && e.message)); }
    }
    // Klik w etykietę opcji uruchamiania: 1 = raz na zdarzenie, 2 = raz na stronę, 3 = bez limitu
    async function setFiringOption(id, opt, chip) {
        const tm = apiMapFor('tags');
        const it = tm && tm.get(id);
        if (!it || !it.data) { alert('Nie mam danych tego tagu — kliknij ⟳ Odśwież i spróbuj ponownie.'); return; }
        if (Number(it.data.tagFiringOption || 1) === opt) return;
        const base = wsApiBase();
        const body = { key: clone(it.key), data: Object.assign(clone(it.data), { tagFiringOption: opt }) };
        if (chip) { chip.style.opacity = '.5'; chip.textContent = '…'; }
        try {
            const resp = await apiWrite('PUT', base + '/tags/' + id + '?hl=en', body);
            const chk = await verifyField('tags', id, x => Number((x.data || {}).tagFiringOption || 1), opt);
            if (chk && !chk.ok) {
                renderIdBadges();
                alert('GTM przyjął zapis (HTTP 200), ale opcja uruchamiania się nie zmieniła (jest: ' + chk.got + ', miało być: ' + opt + ').\nWpisz w konsoli gtmTbDebug() i prześlij sekcję „zapisy do API” — poprawię format.');
                return;
            }
            it.data.tagFiringOption = opt;
            if (it.properties) it.properties.tagFiringOption = opt;
            Object.values(apiStores).forEach(st => { st.at = 0; });
            renderIdBadges(); updateFooter();
        } catch (e) {
            if (chip) { chip.style.opacity = ''; }
            renderIdBadges();
            alert('Zmiana opcji uruchamiania nie powiodła się.\n' + (e && e.message));
        }
    }

    async function duplicateTag(id) {
        const sec = currentSection;
        const field = API_ID_FIELD[sec];
        const tm = apiMapFor(sec);
        let it = tm && tm.get(id);
        if (!it || !it.data) { alert('Nie mam danych tego elementu — kliknij ⟳ Odśwież i spróbuj ponownie.'); return; }
        const cur = it.data.name || '';
        const name = prompt('Nazwa kopii ' + (SEC_PL[sec] || 'elementu') + ':', 'Copy of ' + cur);
        if (name == null || !name.trim()) return;
        try { it = (await ensureFullItem(sec, id)) || it; } catch (e) { alert('Nie udało się pobrać pełnych danych elementu — nie kopiuję.\n' + (e && e.message)); return; }
        if (dataLacksParams(it)) { alert('API GTM zwraca tę zmienną bez parametrów w „data” — kopia z listy byłaby pusta. Skopiuj w edytorze GTM.\n(Wpisz w konsoli gtmTbDebug() i prześlij sekcję „pojedyncza zmienna” — poprawię format.)'); return; }
        const base = wsApiBase();
        const body = { key: null, data: Object.assign(clone(it.data), { name: name.trim() }) };
        try {
            const resp = await apiWrite('POST', base + '/' + sec + '?hl=en', body);
            // nowy element z odpowiedzi (albo z ponownie pobranej listy) → dokładamy wiersz pod oryginałem, bez przeładowania
            let created = null;
            try { const m = collectApiItems(parseApiText(resp), field); created = m.values().next().value || null; } catch (e) {}
            if (!created) {
                try {
                    const t = await (await window.fetch(base + '/' + sec + '?hl=en', { credentials: 'same-origin' })).text();
                    const m = collectApiItems(parseApiText(t), field);
                    m.forEach(it => { if (!created && apiItemName(it) === name.trim()) created = it; });
                } catch (e) {}
            }
            if (!created) { location.reload(); return; }
            const newId = Number((API_ID_ALT[field] || [field]).map(f => created.key[f]).find(v => v != null));
            const tm2 = apiMapFor(sec);
            if (tm2) { const nm = new Map(tm2); nm.set(newId, created); const st = apiStores[base + '/' + sec]; if (st) { st.map = nm; st.at = Date.now(); } }
            const src = getDataRows().find(r => idFromRow(r) === id);
            if (!src) { location.reload(); return; }
            invalidateRows(); bumpUi();
            const nr = src.cloneNode(true);
            nr.classList.add('tb-newrow');
            nr.classList.remove('tb-grp', 'tb-grp-start', 'tb-grp-end', 'tb-grp-hot', 'tb-grp-odd');
            nr.removeAttribute('data-tb-hidden'); nr.removeAttribute('data-tb-oi');
            nr.querySelectorAll('.tb-ann, .tb-act, .tb-plist, .tb-meta, .' + ID_BADGE).forEach(x => x.remove());
            const a = nameAnchor(nr);
            if (a && a.tagName === 'A') {
                const href = a.getAttribute('href') || '';
                if (/\/(tags|triggers|variables)\/\d+/.test(href)) a.setAttribute('href', href.replace(/\/(tags|triggers|variables)\/\d+/, '/' + sec + '/' + newId));
                else { a.removeAttribute('data-ng-click'); a.removeAttribute('ng-click'); a.setAttribute('href', location.hash.replace(/\/(tags|triggers|variables)(\/.*)?$/, '') + '/' + sec + '/' + newId); }
                a.textContent = ' ' + name.trim() + ' ';
            }
            nr.querySelectorAll('[aria-checked]').forEach(x => x.setAttribute('aria-checked', 'false'));
            nr.title = 'Nowy element (dodany przez GTM Filter) — pełne dane po odświeżeniu listy przez GTM';
            src.insertAdjacentElement('afterend', nr);
            renderIdBadges(); if (hasActiveFilters()) applyAllFilters(); updateFooter();
        } catch (e) { alert('Duplikowanie nie powiodło się.\n' + (e && e.message)); }
    }
    function renderRowActions() {
        const on = currentSection === 'tags' || currentSection === 'triggers' || currentSection === 'variables';
        if (!on) { document.querySelectorAll('.tb-act').forEach(a => a.remove()); return; }
        if (currentSection === 'variables') { /* wbudowane zmienne nie mają ID → bez akcji */ }
        ensureIdCss();
        getDataRows().forEach(row => {
            const { nameIdx } = colsForRow(row);
            const cell = row.querySelectorAll('td')[nameIdx];
            if (!cell || cell.querySelector('.tb-act')) return;
            if (cell.querySelector('.tb-plist')) { /* lista parametrów pod nazwą nie przeszkadza */ }
            const id = idFromRow(row);
            if (id === null) return;
            const box = document.createElement('span');
            box.className = 'tb-act';
            stopAll(box);
            box.addEventListener('click', ev => { ev.preventDefault(); ev.stopPropagation(); });
            const mk = (txt, title, fn) => {
                const b = document.createElement('span');
                b.className = 'tb-act-btn'; b.textContent = txt; b.title = title; b.setAttribute('role', 'button');
                stopAll(b);
                b.addEventListener('click', ev => { ev.preventDefault(); ev.stopPropagation(); fn(id); });
                box.appendChild(b);
            };
            mk('✎', 'Zmień nazwę (zapis przez API GTM, trafia do zmian workspace)', renameTag);
            mk('⧉', 'Duplikuj (jak ⋮ → Copy w edytorze)', duplicateTag);
            // w środku linku nazwy — zostaje w tej samej linii, tuż za nazwą
            const host = cell.querySelector('a') || cell;
            host.appendChild(box);
        });
    }

    /* ═══════════════════ MODUŁ 7: UKŁAD ADNOTACJI ═══════════════════ */
    /* ── sloty o równej szerokości, kolejność, poszerzenie kolumny Nazwa ── */
    let layoutSig = '';
    function layoutAnnotations(force) {
        // pomiar szerokości (reflow) tylko gdy coś się zmieniło: zestaw etykiet, liczba wierszy, szerokość okna
        const tables = findDataTables();
        let sig = currentSection + '|' + window.innerWidth + '|' + annFirst;
        tables.forEach(t => { sig += '|' + t.querySelectorAll('tbody tr').length + ':' + t.querySelectorAll('.' + ID_BADGE).length + ':' + t.querySelectorAll('.tb-uses-col').length; t.querySelectorAll('.tb-meta').forEach(m => { sig += m.dataset.sig; }); });
        if (!force && sig === layoutSig) return;
        layoutSig = sig;
        tables.forEach(table => {
            const { nameIdx } = resolveColumns(table);
            const has = !!table.querySelector('.tb-ann');
            // kolejność etykiet względem nazwy (przesuwamy tylko, gdy trzeba — inaczej observer kręciłby się w kółko)
            table.querySelectorAll('tbody tr').forEach(row => {
                const host = row.querySelector('.tb-ann') && row.querySelector('.tb-ann').parentElement;
                if (!host) return;
                const anns = [...host.querySelectorAll(':scope > .' + ID_BADGE), ...host.querySelectorAll(':scope > .tb-meta')];
                if (!anns.length) return;
                const kids = [...host.childNodes].filter(n => !(n.nodeType === 3 && !n.textContent.trim()) && n.nodeType !== 8);
                const slice = annFirst ? kids.slice(0, anns.length) : kids.slice(-anns.length);
                const ok = slice.length === anns.length && slice.every((n, i) => n === anns[i]);
                if (!ok) {
                    if (annFirst) host.prepend(...anns); else host.append(...anns);
                }
                host.classList.toggle('tb-annfirst', annFirst);
            });
            // równe szerokości: #ID, event, param., opcja, ID vendorów (slot i — dla pojedynczych etykiet — sama etykieta)
            if (has) [
                ['.' + ID_BADGE, false],
                ['.tb-s-ev', true],
                ['.tb-s-pc', true],
                ['.tb-s-fire', true],
                ['.tb-s-vid', false],
                ['.tb-s-cons', true],
            ].forEach(([sel, chipToo]) => {
                const els = [...table.querySelectorAll(sel)];
                if (!els.length) return;
                // samą etykietę rozciągamy tylko, gdy jest jedyna w slocie (zmienne: wartość + pigułka „kod” dzielą jeden slot)
                const stretch = el => chipToo && el.children.length === 1 && !!el.firstElementChild;
                els.forEach(el => { el.style.minWidth = ''; [...el.children].forEach(c => { c.style.minWidth = ''; }); });
                let max = 0;
                els.forEach(el => { if (el.offsetParent !== null) max = Math.max(max, el.offsetWidth); });
                if (!max) return;
                els.forEach(el => {
                    el.style.minWidth = max + 'px';
                    if (stretch(el)) el.firstElementChild.style.minWidth = max + 'px';
                });
            });
            // poszerzenie kolumny Nazwa (nowrap) — tylko gdy są etykiety
            table.classList.toggle('tb-wide', has);
            const cg = table.querySelector('colgroup');
            if (cg) {
                const cols = [...cg.querySelectorAll(':scope > col')].filter(c => !c.classList.contains('tb-uses-col'));
                const col = cols[nameIdx];
                if (col) {
                    if (has && !col.hasAttribute('data-tb-w')) { col.setAttribute('data-tb-w', col.getAttribute('width') || ''); col.removeAttribute('width'); }
                    if (!has && col.hasAttribute('data-tb-w')) { const w = col.getAttribute('data-tb-w'); if (w) col.setAttribute('width', w); col.removeAttribute('data-tb-w'); }
                }
            }
            table.querySelectorAll('tr').forEach(row => {
                const cells = [...row.children].filter(e => (e.tagName === 'TD' || e.tagName === 'TH') && !e.classList.contains('tb-uses-col'));
                const c = cells[nameIdx];
                if (c) c.classList.toggle('tb-namecell', has);
            });
        });
    }

    function renderIdBadgesCore() {
        renderVarUses();
        renderRowMeta();
        if (!showIds) {
            document.querySelectorAll('.' + ID_BADGE).forEach(b => b.remove());
            return;
        }
        if (!currentSection) return;
        ensureIdCss();
        getDataRows().forEach(row => {
            const l = idLinkFromRow(row);
            if (!l) return;
            let b = row.querySelector('.' + ID_BADGE);
            if (b) {
                if (b.dataset.id !== String(l.id)) { b.dataset.id = l.id; b.textContent = '#' + l.id; }
                return;
            }
            b = document.createElement('span');
            b.className = ID_BADGE + ' tb-ann';
            b.dataset.id = l.id;
            b.textContent = '#' + l.id;
            b.title = 'ID ' + l.id + ' — kliknij, aby skopiować';
            b.addEventListener('mousedown', ev => ev.stopPropagation());
            b.addEventListener('mouseup', ev => ev.stopPropagation());
            b.addEventListener('click', ev => {
                ev.preventDefault();
                ev.stopPropagation(); // nie otwieraj elementu
                copyText(b.dataset.id);
                b.textContent = '✓ ' + b.dataset.id;
                setTimeout(() => { b.textContent = '#' + b.dataset.id; }, 800);
            });
            if (l.a === row) {
                const { nameIdx } = colsForRow(row);
                const cell = row.querySelectorAll('td')[nameIdx];
                if (cell) cell.appendChild(b);
            } else {
                // w środku linku — zostaje w tej samej linii także przy a.fill-cell; przed chipami vendorów
                const meta = l.a.querySelector('.tb-meta');
                if (meta) l.a.insertBefore(b, meta); else l.a.appendChild(b);
            }
        });
    }

    function compileSearch(raw) {
        nameSearch = raw;
        nameSearchError = '';
        nameSearchRegex = null;
        nameSearchIds = null;
        if (!raw) return;
        // same ID (np. 1|2, 100-120, >50) → filtr po ID, nie po nazwie
        const ids = parseIdQuery(raw);
        if (ids) { nameSearchIds = ids; return; }
        if (REGEX_SIGNAL.test(raw)) {
            try {
                nameSearchRegex = new RegExp(raw, 'i');
            } catch (e) {
                nameSearchError = e.message.replace(/^Invalid regular expression: /, '');
                nameSearchRegex = null;
            }
        }
    }

    function nameMatchesSearch(name) {
        if (!nameSearch) return true;
        if (nameSearchRegex) return nameSearchRegex.test(name);
        return name.toLowerCase().includes(nameSearch.toLowerCase());
    }

    function compileTrigSearch(raw) {
        trigNameSearch = raw;
        trigNameError = '';
        trigNameRegex = null;
        trigNameIds = null;
        if (!raw) return;
        const ids = parseIdQuery(raw);
        if (ids) { trigNameIds = ids; return; }
        if (REGEX_SIGNAL.test(raw)) {
            try {
                trigNameRegex = new RegExp(raw, 'i');
            } catch (e) {
                trigNameError = e.message.replace(/^Invalid regular expression: /, '');
                trigNameRegex = null;
            }
        }
    }

    function trigNameMatches(name) {
        if (!trigNameSearch) return true;
        if (trigNameRegex) return trigNameRegex.test(name);
        return name.toLowerCase().includes(trigNameSearch.toLowerCase());
    }

    /* ═══════════════════ MAPA TYPÓW REGUŁ ═══════════════════ */
    function containerKey() {
        const m = location.href.match(/accounts\/(\d+)\/containers\/(\d+)/);
        return m ? m[1] + ':' + m[2] : 'default';
    }

    function loadTrigTypeStore() {
        try {
            trigTypeStore = JSON.parse(localStorage.getItem(LS_TRIG + ':' + containerKey()) || '{}');
        } catch (e) { trigTypeStore = {}; }
        lastContainerKey = containerKey();
    }

    function saveTrigTypeStore() {
        try {
            localStorage.setItem(LS_TRIG + ':' + containerKey(), JSON.stringify(trigTypeStore));
        } catch (e) { /* quota — trudno */ }
    }

    // Reguły wbudowane nie pojawiają się na liście Reguł — mapujemy ręcznie
    const BUILTIN_TRIG_TYPES = {
        'All Pages': 'Page View',
        'Wszystkie strony': 'Page View',
        'Initialization - All Pages': 'Initialization',
        'Inicjowanie – wszystkie strony': 'Initialization',
        'Inicjowanie - wszystkie strony': 'Initialization',
        'Consent Initialization - All Pages': 'Consent Initialization',
        'Inicjowanie zgody – wszystkie strony': 'Consent Initialization',
        'Inicjowanie zgody - wszystkie strony': 'Consent Initialization',
    };
    const UNKNOWN_TYPE = '❓ Nieznany typ';

    // Typ reguły odczytywany WPROST z ikony chipa w tabeli tagów:
    // <i class="gtm-trigger-custom-event-icon-small ..."> → 'custom-event'
    const ICON_TYPE_MAP = {
        'pageview': 'Page View',
        'dom-ready': 'DOM Ready',
        'window-loaded': 'Window Loaded',
        'custom-event': 'Custom Event',
        'click': 'Click',
        'link-click': 'Just Links',
        'element-visibility': 'Element Visibility',
        'form-submit': 'Form Submission',
        'form-submission': 'Form Submission',
        'history-change': 'History Change',
        'js-error': 'JavaScript Error',
        'scroll-depth': 'Scroll Depth',
        'timer': 'Timer',
        'youtube-video': 'YouTube Video',
        'trigger-group': 'Trigger Group',
        'init': 'Initialization',
        'consent-init': 'Consent Initialization',
    };

    function iconTypeFromChip(chipEl) {
        const icon = chipEl.querySelector('i[class*="gtm-trigger-"]');
        if (!icon) return null;
        const m = icon.className.match(/gtm-trigger-([a-z-]+?)-icon/);
        if (!m) return null;
        const slug = m[1];
        if (ICON_TYPE_MAP[slug]) return ICON_TYPE_MAP[slug];
        // nieznany slug → ładnie sformatuj (np. 'ampl-timer' → 'Ampl Timer')
        return slug.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
    }

    function typeForTrigger(name) {
        return trigTypeStore[name] || BUILTIN_TRIG_TYPES[name] || UNKNOWN_TYPE;
    }

    // Na stronie Reguł: zbierz pary nazwa→typ i zapisz
    function harvestTriggerTypes() {
        if (currentSection !== 'triggers') return;
        let changed = false;
        getDataRows().forEach(row => {
            const n = nameFromRow(row), t = typeFromRow(row);
            if (n && t && trigTypeStore[n] !== t) { trigTypeStore[n] = t; changed = true; }
        });
        if (changed) saveTrigTypeStore();
    }

    /* ═══════════════════ MODUŁ 8: GTM DOM (odporne wykrywanie) ═══════════════════ */
    function detectSection() {
        const u = location.href;
        if (/\/tags\b/.test(u)) return 'tags';
        if (/\/triggers\b/.test(u)) return 'triggers';
        if (/\/variables\b/.test(u)) return 'variables';
        return null;
    }
    function sectionLabel(s) { return { tags: 'Tagi', triggers: 'Reguły', variables: 'Zmienne' }[s] || '—'; }

    // Znajdź WSZYSTKIE tabele z danymi (strona zmiennych ma dwie:
    // zmienne wbudowane + zdefiniowane przez użytkownika)
    function findDataTables() {
        const legacy = [...document.querySelectorAll('table.gtm-table--hide-empty')]
            .filter(t => t.offsetParent !== null && t.querySelector('tbody tr'));
        if (legacy.length) return legacy;

        const out = [];
        document.querySelectorAll('table').forEach(tab => {
            if (tab.offsetParent === null) return;
            if (tab.closest('#' + WID)) return;
            const rows = tab.querySelectorAll('tbody tr');
            if (!rows.length) return;
            const hasLink = [...rows].some(r => r.querySelector('a'));
            const headTxt = headerCellsOf(tab)
                .map(th => th.textContent.trim().toLowerCase());
            const hasNameHeader = headTxt.some(t => /^(nazwa|name)\b/.test(t));
            if (hasLink || hasNameHeader) out.push(tab);
        });
        return out;
    }

    // Wiersz nagłówka: <thead><tr> albo (stary GTM, np. lista zmiennych) pierwszy <tr> z <th> w <tbody>
    function headerRowOf(table) {
        const t = table.querySelector('thead tr');
        if (t) return t;
        return [...table.querySelectorAll('tr')].find(r => r.querySelector(':scope > th')) || null;
    }
    function headerCellsOf(table) {
        const r = headerRowOf(table);
        return r ? [...r.children].filter(e => e.tagName === 'TH' || e.tagName === 'TD') : [];
    }

    // Ustal indeksy kolumn: Nazwa, Typ, Reguły uruchamiające (cache per tabela)
    function resolveColumns(table) {
        const cached = colCache.get(table);
        if (cached) return cached;

        let nameIdx = -1, typeIdx = -1, trigIdx = -1;
        const headCells = headerCellsOf(table).filter(c => !c.classList.contains('tb-uses-col'));
        [...headCells].forEach((th, i) => {
            const txt = th.textContent.trim().toLowerCase();
            if (nameIdx < 0 && /^(nazwa|name)\b/.test(txt)) nameIdx = i;
            if (typeIdx < 0 && /^(typ|type)\b/.test(txt)) typeIdx = i;
            if (trigIdx < 0 && /(regu|trigger)/.test(txt)) trigIdx = i;
        });

        if (nameIdx < 0) {
            const firstRow = [...table.querySelectorAll('tbody tr')].find(r => r.querySelector(':scope > td'));
            if (firstRow) {
                const cells = firstRow.querySelectorAll('td');
                [...cells].forEach((c, i) => {
                    if (nameIdx < 0 && c.querySelector('a')) nameIdx = i;
                });
            }
        }
        if (nameIdx < 0) nameIdx = 1;
        if (typeIdx < 0) typeIdx = nameIdx + 1;
        if (trigIdx < 0) trigIdx = typeIdx + 1; // w tagach: Nazwa | Typ | Reguły

        const cols = { nameIdx, typeIdx, trigIdx };
        colCache.set(table, cols);
        return cols;
    }

    let rowsMemo = { t: 0, rows: null, sec: null };
    function invalidateRows() { rowsMemo.t = 0; }
    function getDataRows() {
        const now = Date.now();
        if (rowsMemo.rows && rowsMemo.sec === currentSection && now - rowsMemo.t < 120) return rowsMemo.rows;
        const out = getDataRowsRaw();
        rowsMemo = { t: now, rows: out, sec: currentSection };
        return out;
    }
    function getDataRowsRaw() {
        const tables = findDataTables();
        const out = [];
        tables.forEach(table => {
            const { nameIdx } = resolveColumns(table);
            [...table.querySelectorAll('tbody tr')].forEach(r => {
                const cells = r.querySelectorAll('td');
                if (cells.length > nameIdx && cells[nameIdx].textContent.trim().length > 0) out.push(r);
            });
        });
        return out;
    }

    function colsForRow(row) {
        const table = row.closest('table');
        return table ? resolveColumns(table) : { nameIdx: 1, typeIdx: 2, trigIdx: 3 };
    }

    function nameFromRow(row) {
        const { nameIdx } = colsForRow(row);
        const cells = row.querySelectorAll('td');
        if (cells.length > nameIdx) {
            const a = cells[nameIdx].querySelector('a');
            const el = a || cells[nameIdx];
            let txt = el.textContent;
            el.querySelectorAll('.tb-ann, .tb-act').forEach(b => { txt = txt.replace(b.textContent, ''); });
            return txt.trim();
        }
        return '';
    }

    function typeFromRow(row) {
        const { typeIdx } = colsForRow(row);
        const cells = row.querySelectorAll('td');
        if (cells.length > typeIdx) return cells[typeIdx].textContent.trim();
        return null;
    }

    // Reguły uruchamiające z wiersza tagu: [{ name, type }]
    // Typ czytamy z ikony chipa (gtm-trigger-*-icon), z fallbackiem na mapę
    // nazwa→typ z localStorage i mapę reguł wbudowanych.
    function triggersFromRow(row) {
        const { trigIdx } = colsForRow(row);
        const cells = row.querySelectorAll('td');
        if (cells.length <= trigIdx) return [];
        const cell = cells[trigIdx];

        // 1) chipy reguł (nowy i stary UI)
        const chips = [...cell.querySelectorAll('gtm-trigger-chip a, a.small-trigger-chip')];
        if (chips.length) {
            const seen = new Set(), out = [];
            chips.forEach(a => {
                const name = a.textContent.trim();
                if (!name || seen.has(name)) return;
                seen.add(name);
                const type = iconTypeFromChip(a) || typeForTrigger(name);
                const e = entFromEl(a) || entFromEl(a.closest('gtm-trigger-chip') || a);
                out.push({ name, type, id: e && e.type === 'triggers' ? e.id : null });
            });
            return out;
        }

        // 2) fallback: dowolne linki / dzieci / cały tekst — typ z mapy nazw
        const linkIds = {};
        cell.querySelectorAll('a').forEach(a => { const e = entFromEl(a); if (e && e.type === 'triggers') linkIds[a.textContent.trim()] = e.id; });
        let items = [...cell.querySelectorAll('a')].map(a => a.textContent.trim());
        if (!items.length) items = [...cell.children].map(el => el.textContent.trim());
        if (!items.length) {
            const whole = cell.textContent.trim();
            items = whole ? [whole] : [];
        }
        return [...new Set(items.filter(t => t && t.length > 1))]
            .map(name => ({ name, type: typeForTrigger(name), id: name in linkIds ? linkIds[name] : null }));
    }

    // TYPY reguł uruchamiających dla wiersza tagu
    function triggerTypesFromRow(row) {
        return [...new Set(triggersFromRow(row).map(t => t.type))];
    }

    function scanTypes() {
        const map = {};
        getDataRows().forEach(row => { const t = typeFromRow(row); if (t) map[t] = (map[t] || 0) + 1; });
        return map;
    }

    function scanTriggerTypes() {
        const map = {};
        if (currentSection !== 'tags') return map;
        getDataRows().forEach(row => {
            triggerTypesFromRow(row).forEach(t => { map[t] = (map[t] || 0) + 1; });
        });
        return map;
    }

    /* ═══════════════════ PAGINACJA: wymuś "ALL" ═══════════════════ */
    // GTM domyślnie pokazuje 50 wierszy na stronę — filtr działałby tylko na
    // widocznej stronie. Przy aktywnym filtrze przełączamy "Show rows" na ALL.
    function ensureShowAll() {
        document.querySelectorAll('gtm-pagination select, .gtm-pagination select').forEach(sel => {
            if (sel.offsetParent === null) return;
            const allOpt = [...sel.options].find(o =>
                /all/i.test(o.value) || /all/i.test(o.label || '') || /all/i.test(o.textContent || ''));
            if (!allOpt || sel.value === allOpt.value) return;
            sel.value = allOpt.value;
            sel.dispatchEvent(new Event('change', { bubbles: true }));
        });
    }

    /* ═══════════════════ MODUŁ 9: FILTRY ═══════════════════ */
    let filterStyleEl = null;
    function getFilterStyleEl() {
        if (!filterStyleEl || !filterStyleEl.isConnected) {
            filterStyleEl = document.createElement('style');
            filterStyleEl.id = 'gtm-tb-filter-css';
            document.head.appendChild(filterStyleEl);
        }
        return filterStyleEl;
    }

    /* ── Reguły: ile tagów ich używa (uruchamiające + wyjątki) — z API tagów, po ID ── */
    let trigUsesCache = { src: null, map: null };
    function trigUsesMap() {
        const tm = apiMapFor('tags');
        if (!tm) return null;
        if (trigUsesCache.src === tm) return trigUsesCache.map;
        const map = new Map(); // triggerId → liczba tagów
        tm.forEach(it => {
            const d = it.data || it;
            ['positiveTriggerId', 'negativeTriggerId', 'firingTriggerId', 'blockingTriggerId'].forEach(k => {
                const arr = d[k] || (it.properties && it.properties[k]);
                if (Array.isArray(arr)) arr.forEach(id => { const n = Number(id); if (!isNaN(n)) map.set(n, (map.get(n) || 0) + 1); });
            });
        });
        trigUsesCache = { src: tm, map };
        return map;
    }

    // Filtr „tylko nieużywane”: zmienne bez żadnego użycia (tagi, reguły, zmienne, klienci, transformacje), reguły bez tagów
    let onlyUnused = false;
    function isRowUnused(row) {
        if (currentSection === 'variables') {
            const uses = varUsesMap();
            if (!uses) return null; // dane jeszcze się ładują
            const u = uses.get(nameFromRow(row));
            return !u || !(u.tags.length + u.triggers.length + u.variables.length + u.clients.length + u.transformations.length);
        }
        if (currentSection === 'triggers') {
            const tu = trigUsesMap();
            if (!tu) return null;
            const id = idFromRow(row);
            if (id === null) return null;
            return !tu.get(id);
        }
        return null;
    }

    // Filtr „tylko niezapauzowane” (Tagi): data.paused z API, z fallbackiem na ikonę pauzy w wierszu
    let pausedMode = 0; // 0 = wszystkie, 1 = tylko niezapauzowane, 2 = tylko zapauzowane
    // Pauza: najpierw to, co pokazuje lista GTM (klasa wiersza / ikona ⏸ — zawsze aktualne), potem dane z API
    function rowPausedDom(row) {
        if (row.classList.contains('gtm-table-row--paused')) return true;
        return !!row.querySelector('.pause-circle-filled-icon, [class*="paused" i], [title*="has been paused" i], [title*="wstrzyman" i]');
    }
    function isRowPaused(row) {
        if (rowPausedDom(row)) return true;
        const tm = apiMapFor('tags');
        const id = idFromRow(row);
        if (tm && id !== null && tm.has(id)) {
            const it = tm.get(id);
            const d = it.data || it;
            if (typeof d.paused === 'boolean') return d.paused;
            if (it.properties && typeof it.properties.paused === 'boolean') return it.properties.paused;
            return false;
        }
        return tm ? false : null; // null = dane jeszcze się ładują
    }

    /* ── Duplikaty eventów (Tagi): ten sam typ tagu + ta sama nazwa eventu w >1 tagu ── */
    let onlyDup = false;
    let dupCache = { src: null, map: null };
    const dupKeyCache = new WeakMap(); // item.data → klucz (kod CJS bywa długi — nie licz za każdym razem)
    function dupKeyOf(item) {
        const d = item && item.data;
        if (d && typeof d === 'object') {
            const c = dupKeyCache.get(d);
            if (c && c.sec === currentSection) return c.key;
            const key = dupKeyOfRaw(item);
            dupKeyCache.set(d, { sec: currentSection, key });
            return key;
        }
        return dupKeyOfRaw(item);
    }
    function dupKeyOfRaw(item) {
        if (currentSection === 'variables') {
            // zmienna: ten sam typ + ta sama konfiguracja (stała: wartość; DLV: ścieżka; inne: wszystkie parametry)
            const vt = (item.data && item.data.vendorTemplate) || {};
            const pub = (vt.key && vt.key.publicId) || '';
            if (!pub || item.isBuiltIn || itemLacksParams(item)) return null; // bez parametrów (jeszcze nie dociągnięte) = nie porównujemy
            const o = tplParams(item);
            // uwaga: „name” w parametrach to np. klucz dataLayer / nazwa cookie — to jest część konfiguracji
            const keys = Object.keys(o).filter(k => !/^(notes)$/.test(k)).sort();
            const sig = keys.map(k => k + '=' + valText(o[k]).trim()).join('\u0001');
            if (!sig) return null;
            return String(pub) + '|' + sig;
        }
        const evi = eventInfoFromItem(item);
        if (!evi || !evi.ev || /^label: /.test(evi.ev)) return null;
        const vt = (item.data && item.data.vendorTemplate) || (item.properties && item.properties.vendorTemplate) || {};
        const pub = (vt.key && vt.key.publicId) || (item.data && item.data.type) || '';
        return String(pub) + '|' + evi.ev.trim().toLowerCase();
    }
    function dupSection() { return currentSection === 'variables' ? 'variables' : 'tags'; }
    function dupEventMap() {
        const tm = apiMapFor(dupSection());
        if (!tm) return null;
        if (dupSection() === 'variables') { ensureVarParams(); if (hydrating.has((wsApiBase() || '') + '/variables')) return null; } // trwa dociąganie parametrów
        if (dupCache.src === tm) return dupCache.map;
        const map = new Map(); // klucz → [nazwy tagów]
        tm.forEach(it => {
            const d = it.data || {};
            const k = dupKeyOf(it);
            // zapauzowane też liczymy (często to właśnie para „nowy / stary”); w etykiecie oznaczamy ⏸
            if (k) {
                const a = map.get(k) || [];
                a.push(apiItemName(it) + (d.paused === true ? ' ⏸' : ''));
                a.active = (a.active || 0) + (d.paused === true ? 0 : 1); // ile AKTYWNYCH tagów wysyła ten event
                map.set(k, a);
            }
        });
        dupCache = { src: tm, map };
        return map;
    }
    function rowDupKey(row) {
        const tm = apiMapFor(dupSection());
        const id = idFromRow(row);
        const it = tm && id !== null ? tm.get(id) : null;
        if (!it) return null;
        return dupKeyOf(it);
    }

    // Grupowanie duplikatów: wiersze z tym samym eventem jeden pod drugim (oryginalna kolejność zapamiętana w data-tb-oi)
    let grouped = false;
    let tidy = true;
    try { tidy = localStorage.getItem(LS_TIDY) !== '0'; } catch (e) {}
    function tidyActive() { return tidy && currentSection === 'tags'; }
    function groupDuplicates(enable) {
        ensureIdCss(); // style grup są w tym samym arkuszu co etykiety — bez włączonych etykiet nie byłby wstrzyknięty
        findDataTables().forEach(table => {
            const tbody = table.querySelector('tbody') || table;
            const rows = [...tbody.querySelectorAll(':scope > tr')].filter(r => r.querySelector(':scope > td'));
            if (!rows.length) return;
            rows.forEach((r, i) => { if (!r.hasAttribute('data-tb-oi')) r.setAttribute('data-tb-oi', String(i)); });
            let order;
            if (enable) {
                const dm = dupEventMap();
                if (!dm) return;
                const groups = new Map(); // klucz → [rows]
                const rest = [];
                rows.forEach(r => {
                    const k = r.hasAttribute('data-tb-hidden') ? null : rowDupKey(r);
                    const arr = k && dm.get(k);
                    if (arr && arr.length > 1) { const g = groups.get(k) || []; g.push(r); groups.set(k, g); }
                    else rest.push(r);
                });
                order = [];
                let gi = 0;
                groups.forEach((g, k) => {
                    const hot = (dm.get(k).active || 0) >= 2;
                    g.forEach((r, i) => {
                        r.classList.toggle('tb-grp-start', i === 0);
                        r.classList.toggle('tb-grp-end', i === g.length - 1);
                        r.classList.toggle('tb-grp-odd', gi % 2 === 1);
                        r.classList.toggle('tb-grp-hot', hot);
                        r.classList.add('tb-grp');
                    });
                    order.push(...g);
                    gi++;
                });
                rest.forEach(r => r.classList.remove('tb-grp', 'tb-grp-start', 'tb-grp-end', 'tb-grp-odd', 'tb-grp-hot'));
                order.push(...rest);
            } else {
                order = rows.slice().sort((a, b) => Number(a.getAttribute('data-tb-oi')) - Number(b.getAttribute('data-tb-oi')));
                rows.forEach(r => r.classList.remove('tb-grp', 'tb-grp-start', 'tb-grp-end', 'tb-grp-odd', 'tb-grp-hot'));
            }
            // „Porządkuj”: zapauzowane na sam dół (stabilnie — kolejność w obrębie grup bez zmian)
            if (tidyActive()) {
                const act = [], pz = [];
                order.forEach(r => { (isRowPaused(r) === true ? pz : act).push(r); });
                order = act.concat(pz);
            }
            // przestaw tylko, gdy kolejność faktycznie się różni (inaczej observer kręciłby się w kółko)
            const same = order.every((r, i) => r === rows[i]);
            if (!same) order.forEach(r => tbody.appendChild(r));
        });
        grouped = enable || tidyActive();
    }
    function syncOrder() {
        const dupOn = onlyDup && (currentSection === 'tags' || currentSection === 'variables');
        if (dupOn || tidyActive() || grouped) groupDuplicates(dupOn);
    }

    function rowDupInfo(row) {
        const tm = apiMapFor(dupSection()), dm = dupEventMap();
        if (!tm || !dm) return null;
        const id = idFromRow(row);
        const it = id !== null ? tm.get(id) : null;
        if (!it) return { dup: false };
        const k = dupKeyOf(it);
        const arr = k ? dm.get(k) : null;
        return arr && arr.length > 1 ? { dup: true, names: arr, hot: (arr.active || 0) >= 2 } : { dup: false };
    }

    // Tagi bez reguły uruchamiającej (z API: positiveTriggerId puste)
    let onlyNoTrig = false;
    function isRowNoTrigger(row) {
        const tm = apiMapFor('tags');
        if (!tm) return null;
        const id = idFromRow(row);
        const it = id !== null ? tm.get(id) : null;
        if (!it) return null;
        const d = it.data || {};
        const pos = d.positiveTriggerId || d.firingTriggerId || (it.properties && it.properties.positiveTriggerId) || [];
        return !pos.length;
    }

    // Foldery: nazwa folderu elementu (parentFolderId → /folders)
    let activeFolderFilters = new Set();
    function folderNameOf(item) {
        const fm = apiMapFor('folders');
        const d = item.data || item;
        const fid = d.parentFolderId != null ? d.parentFolderId : (item.properties && item.properties.parentFolderId);
        if (fid == null || fid === '' || Number(fid) === 0) return 'Unfiled items';
        const f = fm && fm.get(Number(fid));
        return f ? (apiItemName(f) || ('folder #' + fid)) : ('folder #' + fid);
    }
    function rowFolder(row) {
        const sec = currentSection;
        if (!API_ID_FIELD[sec]) return null;
        const m = apiMapFor(sec);
        if (!m) return null;
        const id = idFromRow(row);
        const it = id !== null ? m.get(id) : null;
        return it ? folderNameOf(it) : null;
    }

    function hasActiveFilters() {
        return activeTypeFilters.size > 0 || activeTrigTypeFilters.size > 0 || nameSearch || trigNameSearch || onlyUnused || pausedMode || onlyDup || onlyNoTrig || activeFolderFilters.size > 0;
    }

    function applyAllFilters() {
        if (hasActiveFilters()) ensureShowAll();
        const rows = getDataRows();
        if (!hasActiveFilters()) {
            rows.forEach(r => r.removeAttribute('data-tb-hidden'));
            getFilterStyleEl().textContent = '';
            syncOrder();
            return;
        }
        rows.forEach(row => {
            let show = true;
            if (activeTypeFilters.size > 0) {
                const t = typeFromRow(row);
                if (!t || !activeTypeFilters.has(t)) show = false;
            }
            if (show && activeTrigTypeFilters.size > 0) {
                const types = triggerTypesFromRow(row);
                if (!types.some(t => activeTrigTypeFilters.has(t))) show = false;
            }
            if (show && trigNameSearch && !trigNameError) {
                const trigs = triggersFromRow(row);
                if (trigNameIds) {
                    if (!trigs.some(t => t.id !== null && trigNameIds.some(p => p(t.id)))) show = false;
                } else if (!trigs.some(t => trigNameMatches(t.name))) show = false;
            }
            if (show && onlyUnused) {
                const un = isRowUnused(row);
                if (un === false) show = false;
            }
            if (show && onlyNoTrig && currentSection === 'tags') {
                if (isRowNoTrigger(row) === false) show = false;
            }
            if (show && activeFolderFilters.size > 0) {
                const f = rowFolder(row);
                if (f !== null && !activeFolderFilters.has(f)) show = false;
            }
            if (show && onlyDup && (currentSection === 'tags' || currentSection === 'variables')) {
                const di = rowDupInfo(row);
                if (di && !di.dup) show = false;
            }
            if (show && pausedMode && currentSection === 'tags') {
                const pz = isRowPaused(row);
                if (pausedMode === 1 && pz === true) show = false;
                if (pausedMode === 2 && pz === false) show = false;
            }
            if (show && nameSearch) {
                if (nameSearchError) { /* nie filtruj zepsutym regexem */ }
                else if (nameSearchIds) {
                    const id = idFromRow(row);
                    if (id === null || !nameSearchIds.some(p => p(id))) show = false;
                }
                else if (!nameMatchesSearch(nameFromRow(row))) show = false;
            }
            if (show) row.removeAttribute('data-tb-hidden');
            else row.setAttribute('data-tb-hidden', '1');
        });
        getFilterStyleEl().textContent = 'tr[data-tb-hidden="1"] { display: none !important; }';
        syncOrder();
    }

    function clearAllFilters() {
        activeTypeFilters.clear();
        activeTrigTypeFilters.clear();
        trigNameSearch = '';
        trigNameRegex = null;
        trigNameError = '';
        trigNameIds = null;
        nameSearch = '';
        nameSearchRegex = null;
        nameSearchError = '';
        nameSearchIds = null;
        onlyUnused = false;
        pausedMode = 0;
        onlyDup = false;
        onlyNoTrig = false;
        activeFolderFilters.clear();
        folderListKey = '';
        const nt = document.getElementById('tb-notrig-toggle');
        if (nt) nt.classList.remove('on');
        if (grouped) groupDuplicates(false);
        const dt = document.getElementById('tb-dup-toggle');
        if (dt) dt.classList.remove('on');
        const ut = document.getElementById('tb-unused-toggle');
        if (ut) ut.classList.remove('on');
        renderPausedToggle();
        getDataRows().forEach(r => r.removeAttribute('data-tb-hidden'));
        getFilterStyleEl().textContent = '';
    }


    /* ═══════════════════ MODUŁ 10: PARAMETRY (zakładka) ═══════════════════ */
    /* ── synonimy (wyszukiwarka łapie różne warianty nazw) ── */
    // Grupy pojęć przypinane do sekcji — wpisanie DOWOLNEGO wariantu
    // ("Google Analytics", "GA4", "ga 4", "analityka") pokazuje te same sekcje.
    const SYN = {
        GA: ['google analytics', 'google analytics 4', 'googleanalytics', 'ga', 'ga4', 'ga 4', 'g4', 'analytics', 'analityka', 'analitics', 'gtag', 'g-'],
        GTAG: ['google tag', 'tag google', 'gtag', 'gtag.js', 'gt', 'config', 'konfiguracja', 'configuration', 'configuration settings', 'ustawienia konfiguracji', 'ustawienia tagu'],
        EVENTS: ['event', 'eventy', 'zdarzenie', 'zdarzenia', 'event parameters', 'parametry eventu', 'parametry zdarzenia', 'shared event settings', 'event settings', 'lead', 'leady', 'generate_lead', 'login', 'logowanie', 'rejestracja', 'sign up', 'search', 'wyszukiwanie'],
        ECOM: ['ecommerce', 'e-commerce', 'e commerce', 'ecom', 'sklep', 'shop', 'zakup', 'purchase', 'koszyk', 'cart', 'checkout', 'transakcja', 'zamowienie', 'order', 'sprzedaz', 'promocja', 'promotion', 'woocommerce', 'shopify'],
        ITEMS: ['items', 'item', 'produkt', 'produkty', 'product', 'sku', 'kategoria', 'category', 'marka', 'brand', 'wariant'],
        EM: ['enhanced measurement', 'pomiar zaawansowany', 'ulepszony pomiar', 'automatyczne', 'auto events', 'click', 'klik', 'klikniecia', 'outbound', 'linki wychodzace', 'download', 'pobieranie', 'plik', 'pliki', 'scroll', 'przewijanie', 'video', 'wideo', 'youtube', 'form', 'formularz', 'formularze'],
        CONSENT: ['consent', 'consent mode', 'consent mode v2', 'cm', 'cmv2', 'gcm', 'zgody', 'zgoda', 'zgod', 'cmp', 'rodo', 'gdpr', 'dma', 'baner cookies', 'cookie banner', 'usercentrics', 'cookiebot', 'onetrust', 'cookieyes', 'prywatnosc', 'privacy'],
        EC: ['enhanced conversions', 'ec', 'ulepszone konwersje', 'rozszerzone konwersje', 'user data', 'user_data', 'user provided data', 'upd', 'dane uzytkownika', 'first party data', 'hash', 'sha256', 'email', 'telefon', 'phone'],
        ADS_SHORT: ['google ads', 'ads', 'adwords'],
        ADS: ['google ads', 'googleads', 'ads', 'adwords', 'aw', 'aw-', 'gads', 'ppc', 'sem', 'konwersja', 'konwersje', 'conversion', 'conversions', 'remarketing', 'dynamic remarketing', 'cart data', 'cwcd', 'merchant center', 'gmc', 'new customer', 'nowy klient', 'pmax', 'performance max', 'shopping', 'doubleclick'],
        ROUTING: ['cross domain', 'cross-domain', 'crossdomain', 'miedzy domenami', 'linker', '_gl', 'domeny', 'domains', 'routing', 'send_to', 'groups', 'callback', 'redirect', 'platnosci', 'bramka platnosci'],
        SGTM: ['sgtm', 'server side', 'server-side', 'serverside', 'ssgtm', 'serwer', 'server', 'stape', 'cloud run', 'override', 'nadpisanie', 'measurement protocol', 'mp'],
        META: ['meta', 'meta pixel', 'facebook', 'facebook pixel', 'fb', 'fbq', 'pixel', 'piksel', 'instagram', 'ig', 'capi', 'conversions api'],
        AM: ['advanced matching', 'am', 'zaawansowane dopasowanie', 'dopasowanie', 'emq', 'email', 'telefon'],
    };
    function syn() { return [].concat(...[...arguments].map(k => SYN[k] || [])); }

    // Dodatkowe słowa kluczowe per parametr (szukanie po znaczeniu, nie tylko po nazwie)
    const PRM_KEYWORDS = {
        server_container_url: 'sgtm server side serwer stape endpoint first party',
        transport_url: 'sgtm server side serwer stape endpoint',
        send_page_view: 'pageview page view odslona odslony',
        ignore_referrer: 'referral referrer unwanted referrals niechciane odeslania samoodeslania self referral bramka platnosci',
        allow_google_signals: 'signals sygnaly remarketing',
        allow_ad_personalization_signals: 'personalizacja reklam remarketing',
        user_id: 'uid userid identyfikator logowanie crm',
        client_id: 'cid clientid identyfikator',
        debug_mode: 'debugview debug testy',
        page_location: 'url adres strony',
        page_referrer: 'referrer odeslanie',
        page_title: 'tytul strony',
        event_id: 'dedup deduplikacja capi eventid',
        eventID: 'dedup deduplikacja capi event_id',
        transaction_id: 'order id zamowienie numer dedup deduplikacja',
        new_customer: 'nowy klient nca acquisition pozyskiwanie',
        aw_merchant_id: 'cart data merchant gmc', aw_feed_country: 'cart data gmc feed', aw_feed_language: 'cart data gmc feed',
        wait_for_update: 'cmp opoznienie timeout',
        url_passthrough: 'gclid consent', ads_data_redaction: 'gclid consent redakcja',
        ip_override: 'ip geo geolokalizacja',
        em: 'email mail', ph: 'telefon phone', fn: 'imie first name', ln: 'nazwisko last name', ct: 'miasto city',
        st: 'region wojewodztwo state', zp: 'kod pocztowy zip', ge: 'plec gender', db: 'data urodzenia birth',
        email: 'mail', phone_number: 'telefon', 'address.first_name': 'imie', 'address.last_name': 'nazwisko',
        'address.street': 'ulica adres', 'address.city': 'miasto', 'address.region': 'wojewodztwo',
        'address.postal_code': 'kod pocztowy zip', 'address.country': 'kraj', country: 'kraj',
        sha256_email_address: 'mail', sha256_phone_number: 'telefon',
        'address.sha256_first_name': 'imie', 'address.sha256_last_name': 'nazwisko',
        external_id: 'crm user id', value: 'wartosc przychod revenue', currency: 'waluta pln',
        linker: 'cross domain _gl', domains: 'cross domain _gl', accept_incoming: 'cross domain _gl',
        decorate_forms: 'cross domain _gl formularze', url_position: 'cross domain _gl',
    };
    const PRM_PREFIX_KEYWORDS = [
        [/^cookie_/, 'cookies ciasteczka ciastka _ga'],
        [/^client_id$/, 'cookie _ga'],
        [/^campaign/, 'utm kampania kampanie zrodlo ruchu source medium'],
        [/^(sha256_|address\.sha256_)/, 'hash hashowany'],
    ];

    /* ═══════════════════ PARAMETRY TAGÓW GTM ═══════════════════ */
    // Nazwy parametrów do wpisania w polach tagów/zmiennych GTM (Configuration
    // settings, Event parameters, User-Provided Data, szablony CMP/Meta…).
    // p = nazwa (kopiowana kliknięciem), v = przykładowa wartość, s = zakres, d = opis.
    // Źródła: developers.google.com (GA4 config reference, gtag parameter reference,
    // cross-domain, consent mode, sGTM send-data), Google Ads Help (enhanced conversions,
    // new customer, cart data), Simo Ahava (settings variables, sGTM field overrides).
    const TAG_PARAMS = [
        { group: 'Google tag — Configuration settings', where: 'Google tag → Configuration settings / zmienna „Google Tag: Configuration Settings” · gtag(\'config\')',
          aliases: syn('GTAG','GA'), items: [
            { p: 'server_container_url', v: 'https://sgtm.domena.pl', s: 'config', d: 'Wysyłka hitów do sGTM — sam origin, bez końcowego /' },
            { p: 'send_page_view', v: 'false', s: 'config', d: 'Domyślnie true — false wyłącza automatyczny page_view przy starcie Google tagu' },
            { p: 'cookie_domain', v: 'auto', s: 'config', d: 'Domena cookie _ga: auto = eTLD+1, none = pełny host bieżącej strony' },
            { p: 'cookie_expires', v: '63072000', s: 'config', d: 'Ważność cookie w sekundach (domyślnie 2 lata; 0 = cookie sesyjne)' },
            { p: 'cookie_flags', v: 'SameSite=None;Secure', s: 'config', d: 'Dodatkowe flagi cookie, rozdzielone średnikiem (np. GA w iframe)' },
            { p: 'cookie_path', v: '/', s: 'config', d: 'Ścieżka, pod którą zapisywane jest cookie' },
            { p: 'cookie_prefix', v: 'pk', s: 'config', d: 'Prefiks nazw cookies — np. dwie niezależne konfiguracje na jednej domenie' },
            { p: 'cookie_update', v: 'false', s: 'config', d: 'false = wygaśnięcie liczone od 1. wizyty (bez odświeżania przy każdej odsłonie)' },
            { p: 'ignore_referrer', v: 'true', s: 'config', d: 'Referrer nie staje się źródłem ruchu (np. powrót z bramki płatności). Też jako event param' },
            { p: 'allow_google_signals', v: 'false', s: 'config', d: 'Wyłącza Google Signals (cechy reklamowe z identyfikatorów Google)' },
            { p: 'allow_ad_personalization_signals', v: 'false', s: 'config', d: 'Wyłącza personalizację reklam dla danych z tego tagu' },
            { p: 'user_id', v: '{{DLV - user_id}}', s: 'config', d: 'Stały ID zalogowanego użytkownika — nie PII, max 256 znaków' },
            { p: 'client_id', v: '{{Cookie - _ga}}', s: 'config', d: 'Nadpisanie Client ID — zwykle tylko w nietypowych integracjach' },
            { p: 'page_location', v: '{{Page URL}}', s: 'config', d: 'Nadpisanie URL (max 1000 zn.) — np. usunięcie PII/tokenów z query. Też jako event param' },
            { p: 'page_referrer', v: '{{Referrer}}', s: 'config', d: 'Nadpisanie referrera (max 420 zn.) — przydatne w SPA. Też jako event param' },
            { p: 'page_title', v: '{{Page Title}}', s: 'config', d: 'Nadpisanie tytułu strony (max 300 zn.). Też jako event param' },
            { p: 'content_group', v: '/blog/', s: 'config', d: 'Grupa treści — zasila wymiar „Grupa treści” w raportach GA4' },
            { p: 'language', v: 'pl-pl', s: 'config', d: 'Nadpisanie języka (domyślnie navigator.language)' },
            { p: 'screen_resolution', v: '1920x1080', s: 'config', d: 'Nadpisanie rozdzielczości ekranu (format SZERxWYS)' },
            { p: 'campaign_source', v: 'newsletter', s: 'config', d: 'Nadpisuje utm_source z URL' },
            { p: 'campaign_medium', v: 'email', s: 'config', d: 'Nadpisuje utm_medium z URL' },
            { p: 'campaign_name', v: 'jesien_2026', s: 'config', d: 'Nadpisuje utm_campaign z URL' },
            { p: 'campaign_term', v: 'wiertarka', s: 'config', d: 'Nadpisuje utm_term z URL' },
            { p: 'campaign_content', v: 'baner_top', s: 'config', d: 'Nadpisuje utm_content z URL' },
            { p: 'campaign_id', v: 'abc.123', s: 'config', d: 'Nadpisuje utm_id z URL' },
            { p: 'user_data', v: '{{User-Provided Data}}', s: 'config', d: 'Dane do enhanced conversions / user-provided data — zmienna typu User-Provided Data' },
            { p: 'user_properties', v: '{favorite_store: \'Bydgoszcz\'}', s: 'config', d: 'Obiekt właściwości użytkownika — w GTM lepiej wpisać je w sekcji „User Properties” tagu' },
            { p: 'debug_mode', v: 'true', s: 'config', d: 'Hity w DebugView. Działa KAŻDA wartość — żeby wyłączyć, usuń parametr (nie publikuj na prod!)' },
            { p: 'update', v: 'true', s: 'config', d: 'Tylko gtag.js: scala nowe ustawienia z configiem bez wysyłania page_view (SPA)' },
            { p: 'transport_url', v: 'https://sgtm.domena.pl', s: 'config', d: 'Legacy — starsza nazwa endpointu sGTM; dziś server_container_url' },
        ]},
        { group: 'GA4 — parametry eventów (ogólne)', where: 'GA4 Event → Event parameters / zmienna „Google Tag: Event Settings” / Google tag → Shared event settings',
          aliases: syn('GA','EVENTS'), items: [
            { p: 'event_id', v: '{{DLV - event_id}}', s: 'event', d: 'Własny ID eventu — deduplikacja web ↔ server (sGTM, Meta CAPI). Parametr niestandardowy' },
            { p: 'engagement_time_msec', v: '100', s: 'event', d: 'Czas zaangażowania w ms — potrzebny m.in. w Measurement Protocol / sGTM' },
            { p: 'currency', v: 'PLN', s: 'event', d: 'Waluta ISO 4217 — wymagana, gdy wysyłasz value' },
            { p: 'value', v: '{{DLV - value}}', s: 'event', d: 'Wartość eventu (liczba, nie string) — np. generate_lead, purchase' },
            { p: 'lead_source', v: 'formularz_kontakt', s: 'event', d: 'Źródło leada — rekomendowany dla eventów lead gen (generate_lead itd.)' },
            { p: 'method', v: 'email', s: 'event', d: 'Metoda — dla login / sign_up / share' },
            { p: 'search_term', v: '{{DLV - search_term}}', s: 'event', d: 'Fraza wyszukiwania — search / view_search_results' },
            { p: 'content_type', v: 'product', s: 'event', d: 'Typ treści — select_content / share' },
            { p: 'content_id', v: 'C_12345', s: 'event', d: 'ID wybranej treści — select_content' },
        ]},
        { group: 'GA4 — e-commerce (poziom eventu)', where: 'GA4 Event → Event parameters · albo „More settings → Ecommerce → Send Ecommerce data (Data Layer)”',
          aliases: syn('GA','ECOM'), items: [
            { p: 'transaction_id', v: '{{DLV - ecommerce.transaction_id}}', s: 'event', d: 'Unikalny ID zamówienia — GA4 deduplikuje purchase po tym polu' },
            { p: 'value', v: '{{DLV - ecommerce.value}}', s: 'event', d: 'Wartość zamówienia/eventu (bez shipping i tax, jeśli tak raportujesz)' },
            { p: 'currency', v: 'PLN', s: 'event', d: 'Waluta — obowiązkowa przy value' },
            { p: 'tax', v: '59.09', s: 'event', d: 'Podatek transakcji' },
            { p: 'shipping', v: '15.99', s: 'event', d: 'Koszt dostawy' },
            { p: 'coupon', v: 'LATO10', s: 'event', d: 'Kupon na poziomie zamówienia' },
            { p: 'payment_type', v: 'BLIK', s: 'event', d: 'Metoda płatności — add_payment_info' },
            { p: 'shipping_tier', v: 'Kurier DPD', s: 'event', d: 'Metoda dostawy — add_shipping_info' },
            { p: 'item_list_id', v: 'bestsellery', s: 'event', d: 'ID listy — view_item_list / select_item' },
            { p: 'item_list_name', v: 'Bestsellery', s: 'event', d: 'Nazwa listy — view_item_list / select_item' },
            { p: 'promotion_id', v: 'P_LATO', s: 'event', d: 'ID promocji — view_promotion / select_promotion' },
            { p: 'promotion_name', v: 'Lato -10%', s: 'event', d: 'Nazwa promocji' },
            { p: 'creative_name', v: 'baner_lato', s: 'event', d: 'Nazwa kreacji promocji' },
            { p: 'creative_slot', v: 'hero_1', s: 'event', d: 'Slot kreacji promocji' },
            { p: 'items', v: '{{DLV - ecommerce.items}}', s: 'event', d: 'Tablica produktów — pola w grupie „items[]” poniżej' },
        ]},
        { group: 'GA4 — parametry items[]', where: 'wewnątrz tablicy ecommerce.items (dataLayer / gtag) — nie jako osobne parametry tagu',
          aliases: syn('GA','ECOM','ITEMS'), items: [
            { p: 'item_id', v: 'SKU-750W', s: 'item', d: 'ID produktu (item_id lub item_name wymagane)' },
            { p: 'item_name', v: 'Wiertarka udarowa 750W', s: 'item', d: 'Nazwa produktu' },
            { p: 'price', v: '299.99', s: 'item', d: 'Cena jednostkowa (liczba)' },
            { p: 'quantity', v: '1', s: 'item', d: 'Ilość' },
            { p: 'item_brand', v: 'Domitech', s: 'item', d: 'Marka' },
            { p: 'item_category', v: 'Elektronarzędzia', s: 'item', d: 'Kategoria (poziom 1)' },
            { p: 'item_category2', v: 'Wiertarki', s: 'item', d: 'Kategoria poziom 2' },
            { p: 'item_category3', v: 'Udarowe', s: 'item', d: 'Kategoria poziom 3' },
            { p: 'item_category4', v: '', s: 'item', d: 'Kategoria poziom 4' },
            { p: 'item_category5', v: '', s: 'item', d: 'Kategoria poziom 5' },
            { p: 'item_variant', v: 'czarny', s: 'item', d: 'Wariant (kolor, rozmiar…)' },
            { p: 'discount', v: '30.00', s: 'item', d: 'Rabat jednostkowy na produkcie' },
            { p: 'coupon', v: 'LATO10', s: 'item', d: 'Kupon na poziomie produktu' },
            { p: 'index', v: '0', s: 'item', d: 'Pozycja produktu na liście' },
            { p: 'item_list_id', v: 'bestsellery', s: 'item', d: 'Lista, z której pochodzi produkt' },
            { p: 'item_list_name', v: 'Bestsellery', s: 'item', d: 'Nazwa listy produktu' },
            { p: 'affiliation', v: 'Sklep online', s: 'item', d: 'Sklep/dostawca (np. marketplace, sklep stacjonarny)' },
            { p: 'location_id', v: 'ChIJ…', s: 'item', d: 'Lokalizacja (np. Google Place ID sklepu)' },
            { p: 'promotion_id', v: 'P_LATO', s: 'item', d: 'Promocja przypisana do produktu' },
            { p: 'promotion_name', v: 'Lato -10%', s: 'item', d: 'Nazwa promocji na produkcie' },
            { p: 'creative_name', v: 'baner_lato', s: 'item', d: 'Kreacja promocji na produkcie' },
            { p: 'creative_slot', v: 'hero_1', s: 'item', d: 'Slot kreacji na produkcie' },
        ]},
        { group: 'GA4 — Enhanced Measurement (auto)', where: 'nazwy zbierane automatycznie przez GA4 — warto je reużyć w custom eventach (spójne wymiary)',
          aliases: syn('GA','EM'), items: [
            { p: 'link_url', v: '{{Click URL}}', s: 'event', d: 'URL klikniętego linku (click, file_download)' },
            { p: 'link_domain', v: 'allegro.pl', s: 'event', d: 'Domena linku' },
            { p: 'link_text', v: '{{Click Text}}', s: 'event', d: 'Tekst linku' },
            { p: 'link_id', v: '{{Click ID}}', s: 'event', d: 'Atrybut id linku' },
            { p: 'link_classes', v: '{{Click Classes}}', s: 'event', d: 'Klasy CSS linku' },
            { p: 'outbound', v: 'true', s: 'event', d: 'Czy link wychodzący' },
            { p: 'file_name', v: '/cennik.pdf', s: 'event', d: 'Ścieżka/nazwa pobranego pliku' },
            { p: 'file_extension', v: 'pdf', s: 'event', d: 'Rozszerzenie pliku' },
            { p: 'percent_scrolled', v: '90', s: 'event', d: 'Głębokość scrolla (%)' },
            { p: 'video_title', v: '{{Video Title}}', s: 'event', d: 'Tytuł wideo' },
            { p: 'video_url', v: '{{Video URL}}', s: 'event', d: 'URL wideo' },
            { p: 'video_provider', v: 'youtube', s: 'event', d: 'Dostawca wideo' },
            { p: 'video_percent', v: '{{Video Percent}}', s: 'event', d: 'Procent obejrzenia' },
            { p: 'video_current_time', v: '{{Video Current Time}}', s: 'event', d: 'Bieżący czas wideo (s)' },
            { p: 'video_duration', v: '{{Video Duration}}', s: 'event', d: 'Długość wideo (s)' },
            { p: 'visible', v: '{{Video Visible}}', s: 'event', d: 'Czy wideo było widoczne' },
            { p: 'form_id', v: '{{Form ID}}', s: 'event', d: 'Atrybut id formularza (form_start / form_submit)' },
            { p: 'form_name', v: 'kontakt', s: 'event', d: 'Atrybut name formularza' },
            { p: 'form_destination', v: '{{Form URL}}', s: 'event', d: 'URL, na który wysyłany jest formularz' },
            { p: 'form_submit_text', v: 'Wyślij', s: 'event', d: 'Tekst przycisku wysyłki' },
        ]},
        { group: 'Consent Mode v2', where: 'szablon CMP / Consent Initialization · gtag(\'consent\', \'default\'|\'update\') · gtag(\'set\')',
          aliases: syn('CONSENT'), items: [
            { p: 'ad_storage', v: 'denied', s: 'consent', d: 'Zapis cookies reklamowych (_gcl_*, IDE…)' },
            { p: 'analytics_storage', v: 'denied', s: 'consent', d: 'Zapis cookies analitycznych (_ga, _ga_*)' },
            { p: 'ad_user_data', v: 'denied', s: 'consent', d: 'v2: zgoda na wysyłkę danych usera do Google w celach reklamowych' },
            { p: 'ad_personalization', v: 'denied', s: 'consent', d: 'v2: zgoda na personalizację reklam / remarketing' },
            { p: 'functionality_storage', v: 'denied', s: 'consent', d: 'Storage funkcjonalny (np. język)' },
            { p: 'personalization_storage', v: 'denied', s: 'consent', d: 'Storage personalizacji (np. rekomendacje)' },
            { p: 'security_storage', v: 'granted', s: 'consent', d: 'Storage bezpieczeństwa (uwierzytelnianie, anty-fraud)' },
            { p: 'wait_for_update', v: '500', s: 'consent', d: 'Ile ms tagi czekają na consent update z CMP (tylko w default)' },
            { p: 'region', v: "['PL','DE']", s: 'consent', d: 'Default tylko dla regionów (ISO 3166-2) — np. inne ustawienia dla EOG' },
            { p: 'url_passthrough', v: 'true', s: 'set', d: 'Przy braku zgody przenosi gclid/dclid w URL między stronami' },
            { p: 'ads_data_redaction', v: 'true', s: 'set', d: 'Przy ad_storage=denied usuwa identyfikatory kliknięć z hitów reklamowych' },
        ]},
        { group: 'Enhanced conversions / user_data', where: 'zmienna „User-Provided Data” (Manual → Code/pola) → user_data · gtag(\'set\', \'user_data\', {…})',
          aliases: syn('EC','ADS_SHORT'), items: [
            { p: 'email', v: '{{DLV - email}}', s: 'user_data', d: 'E-mail (niehashowany — Google sam normalizuje i hashuje)' },
            { p: 'phone_number', v: '+48600100200', s: 'user_data', d: 'Telefon w formacie E.164 (z +48)' },
            { p: 'address.first_name', v: 'Jan', s: 'user_data', d: 'Imię (w obiekcie address)' },
            { p: 'address.last_name', v: 'Kowalski', s: 'user_data', d: 'Nazwisko' },
            { p: 'address.street', v: 'Gdańska 1', s: 'user_data', d: 'Ulica i numer' },
            { p: 'address.city', v: 'Bydgoszcz', s: 'user_data', d: 'Miasto' },
            { p: 'address.region', v: 'kujawsko-pomorskie', s: 'user_data', d: 'Województwo / region' },
            { p: 'address.postal_code', v: '85-001', s: 'user_data', d: 'Kod pocztowy' },
            { p: 'address.country', v: 'PL', s: 'user_data', d: 'Kod kraju ISO' },
            { p: 'sha256_email_address', v: '{{JS - email sha256}}', s: 'user_data', d: 'E-mail już zahashowany (hex SHA-256, po trim + lowercase)' },
            { p: 'sha256_phone_number', v: '{{JS - phone sha256}}', s: 'user_data', d: 'Telefon zahashowany (E.164 przed hashowaniem)' },
            { p: 'address.sha256_first_name', v: '{{JS - fname sha256}}', s: 'user_data', d: 'Imię zahashowane' },
            { p: 'address.sha256_last_name', v: '{{JS - lname sha256}}', s: 'user_data', d: 'Nazwisko zahashowane' },
            { p: 'allow_enhanced_conversions', v: 'true', s: 'config', d: 'gtag.js: włącza enhanced conversions w configu tagu' },
        ]},
        { group: 'Google Ads — konwersje i cart data', where: 'Google Ads Conversion Tracking (pola tagu, „Provide product-level sales data”) · gtag(\'event\', \'conversion\'|\'purchase\')',
          aliases: syn('ADS'), items: [
            { p: 'send_to', v: 'AW-123456789/AbCdEfGh', s: 'ads', d: 'ID konwersji + etykieta; bez etykiety leci tylko remarketing' },
            { p: 'value', v: '{{DLV - ecommerce.value}}', s: 'ads', d: 'Wartość konwersji' },
            { p: 'currency', v: 'PLN', s: 'ads', d: 'Waluta konwersji' },
            { p: 'transaction_id', v: '{{DLV - ecommerce.transaction_id}}', s: 'ads', d: 'ID zamówienia — deduplikacja konwersji w Ads' },
            { p: 'new_customer', v: 'true', s: 'ads', d: 'Pozyskiwanie nowych klientów — true/false liczone dynamicznie' },
            { p: 'aw_merchant_id', v: '123456789', s: 'ads', d: 'Cart data: ID konta Merchant Center' },
            { p: 'aw_feed_country', v: 'PL', s: 'ads', d: 'Cart data: kraj / etykieta feedu w GMC' },
            { p: 'aw_feed_language', v: 'pl', s: 'ads', d: 'Cart data: język feedu' },
            { p: 'discount', v: '10.00', s: 'ads', d: 'Cart data: rabat na poziomie ZAMÓWIENIA (w GA4 discount jest per item)' },
            { p: 'id', v: 'SKU-750W', s: 'ads', d: 'Cart data / remarketing: w items[] ID produktu zgodne z feedem GMC' },
        ]},
        { group: 'gtag — routing i cross-domain (linker)', where: 'gtag.js config/event/set — w GTM cross-domain: Google tag → Configure tag settings → Configure your domains',
          aliases: syn('ROUTING','GTAG'), items: [
            { p: 'send_to', v: 'G-XXXXXXX', s: 'gtag', d: 'Docelowe ID / grupa, do której idzie event (nadpisuje routing domyślny)' },
            { p: 'groups', v: 'default,vendors', s: 'gtag', d: 'Przypisanie targetu w configu do grupy (routing przez send_to)' },
            { p: 'event_callback', v: 'function(){ form.submit(); }', s: 'gtag', d: 'Funkcja wywołana po przetworzeniu eventu (np. submit/redirect)' },
            { p: 'event_timeout', v: '2000', s: 'gtag', d: 'Timeout (ms) dla event_callback — odpali nawet bez odpowiedzi' },
            { p: 'linker', v: "{domains:['sklep.pl']}", s: 'gtag', d: 'Obiekt linkera — ustaw PRZED komendą config' },
            { p: 'domains', v: "['sklep.pl','platnosci.pl']", s: 'gtag', d: 'linker: domeny, do których linki dostają _gl' },
            { p: 'accept_incoming', v: 'true', s: 'gtag', d: 'linker: przyjmuj _gl na domenie docelowej (domyślnie true, gdy są domains)' },
            { p: 'decorate_forms', v: 'true', s: 'gtag', d: 'linker: dekoruj też formularze (brak odpowiednika w GTM)' },
            { p: 'url_position', v: 'fragment', s: 'gtag', d: 'linker: _gl w query (domyślnie) albo po #' },
        ]},
        { group: 'Server-side GTM — nadpisania w tagu GA4', where: 'sGTM → tag Google Analytics: GA4 → Event Parameters → Parameters to Add / Edit',
          aliases: syn('SGTM','GA'), items: [
            { p: 'ip_override', v: '{{X-Forwarded-For}}', s: 'sgtm', d: 'Nadpisanie IP użytkownika (geo) w hicie wysyłanym do GA4' },
            { p: 'client_id', v: '{{ED - client_id}}', s: 'sgtm', d: 'Nadpisanie Client ID' },
            { p: 'user_id', v: '{{ED - user_id}}', s: 'sgtm', d: 'Nadpisanie / dołożenie User ID po stronie serwera' },
            { p: 'ga_session_id', v: '1727700000', s: 'sgtm', d: 'ID sesji GA4' },
            { p: 'ga_session_number', v: '4', s: 'sgtm', d: 'Numer sesji użytkownika' },
            { p: 'engagement_time_msec', v: '1000', s: 'sgtm', d: 'Czas zaangażowania w ms' },
            { p: 'page_referrer', v: 'https://google.com', s: 'sgtm', d: 'Nadpisanie referrera' },
        ]},
        { group: 'Meta Pixel — parametry eventów', where: 'szablon Meta/Facebook Pixel → Object properties · fbq(\'track\', \'Purchase\', {…}, {eventID})',
          aliases: syn('META'), items: [
            { p: 'value', v: '{{DLV - ecommerce.value}}', s: 'meta', d: 'Wartość — wymagana dla Purchase' },
            { p: 'currency', v: 'PLN', s: 'meta', d: 'Waluta — wymagana dla Purchase' },
            { p: 'content_ids', v: "['SKU-750W']", s: 'meta', d: 'Tablica ID produktów — zgodna z katalogiem (DPA)' },
            { p: 'content_type', v: 'product', s: 'meta', d: 'product albo product_group (gdy ID to grupa wariantów)' },
            { p: 'contents', v: "[{id:'SKU-750W',quantity:1}]", s: 'meta', d: 'Tablica obiektów id + quantity (+ item_price)' },
            { p: 'content_name', v: 'Wiertarka udarowa 750W', s: 'meta', d: 'Nazwa produktu / strony' },
            { p: 'content_category', v: 'Elektronarzędzia', s: 'meta', d: 'Kategoria' },
            { p: 'num_items', v: '1', s: 'meta', d: 'Liczba sztuk — InitiateCheckout' },
            { p: 'search_string', v: 'wiertarka', s: 'meta', d: 'Fraza — Search' },
            { p: 'predicted_ltv', v: '1200', s: 'meta', d: 'Przewidywana wartość klienta — Subscribe/StartTrial' },
            { p: 'status', v: 'true', s: 'meta', d: 'Status — CompleteRegistration' },
            { p: 'eventID', v: '{{DLV - event_id}}', s: 'meta', d: 'Deduplikacja z CAPI — ten sam ID w pixelu i w evencie serwerowym' },
        ]},
        { group: 'Meta Pixel — Advanced Matching', where: 'szablon Meta Pixel → Advanced Matching · fbq(\'init\', ID, {em, ph, …})',
          aliases: syn('META','AM'), items: [
            { p: 'em', v: '{{DLV - email}}', s: 'meta', d: 'E-mail (lowercase; pixel hashuje sam)' },
            { p: 'ph', v: '48600100200', s: 'meta', d: 'Telefon — same cyfry z kierunkowym, bez + i spacji' },
            { p: 'fn', v: 'jan', s: 'meta', d: 'Imię (lowercase)' },
            { p: 'ln', v: 'kowalski', s: 'meta', d: 'Nazwisko (lowercase)' },
            { p: 'ct', v: 'bydgoszcz', s: 'meta', d: 'Miasto (lowercase, bez spacji)' },
            { p: 'st', v: 'kp', s: 'meta', d: 'Region/stan — kod' },
            { p: 'zp', v: '85001', s: 'meta', d: 'Kod pocztowy' },
            { p: 'country', v: 'pl', s: 'meta', d: 'Kraj — 2-literowy kod ISO, lowercase' },
            { p: 'external_id', v: '{{DLV - user_id}}', s: 'meta', d: 'Twój ID użytkownika (CRM/konto)' },
            { p: 'ge', v: 'm', s: 'meta', d: 'Płeć: f / m' },
            { p: 'db', v: '19900131', s: 'meta', d: 'Data urodzenia YYYYMMDD' },
        ]},
    ];


    let prmQuery = '';

    function copyText(txt) {
        if (navigator.clipboard && navigator.clipboard.writeText) {
            return navigator.clipboard.writeText(txt).catch(() => fallbackCopy(txt));
        }
        return Promise.resolve(fallbackCopy(txt));
    }
    function fallbackCopy(txt) {
        const ta = document.createElement('textarea');
        ta.value = txt;
        ta.style.cssText = 'position:fixed;opacity:0';
        document.body.appendChild(ta);
        ta.select();
        try { document.execCommand('copy'); } catch (e) {}
        ta.remove();
    }

    /* ── wyszukiwarka: słowa + synonimy + odmiana, bez polskich znaków ── */
    function normTxt(str) {
        return String(str).toLowerCase().replace(/ł/g, 'l').normalize('NFD').replace(/[̀-ͯ]/g, '');
    }
    function wordsOf(str) { return normTxt(str).split(/[^a-z0-9]+/).filter(Boolean); }
    function phraseWords(str) {
        // "google analytics" → google, analytics, googleanalytics (wariant bez spacji)
        const w = wordsOf(str);
        return w.length > 1 ? w.concat(w.join('')) : w;
    }
    function uniq(a) { return [...new Set(a)]; }

    // indeks budowany raz
    /* ── chipy kategorii pod wyszukiwarką ── */
    const PRM_CHIPS = {
        plat: { label: 'Platforma', items: [
            { id: 'ga4', label: 'GA4' }, { id: 'gtag', label: 'Google tag' }, { id: 'ads', label: 'Google Ads' },
            { id: 'consent', label: 'Consent' }, { id: 'sgtm', label: 'sGTM' }, { id: 'meta', label: 'Meta' },
        ]},
        topic: { label: 'Temat', items: [
            { id: 'cfg', label: 'Konfiguracja' }, { id: 'evt', label: 'Eventy' }, { id: 'ecom', label: 'E-commerce' },
            { id: 'ud', label: 'Dane usera' }, { id: 'ck', label: 'Cookies' }, { id: 'utm', label: 'Kampanie/UTM' },
            { id: 'xd', label: 'Cross-domain' },
        ]},
    };
    const LS_PRM_CHIPS = 'gtmTbPrmChips';
    let prmSel = { plat: new Set(), topic: new Set() };
    try {
        const saved = JSON.parse(localStorage.getItem(LS_PRM_CHIPS) || '{}');
        ['plat', 'topic'].forEach(r => { if (Array.isArray(saved[r])) prmSel[r] = new Set(saved[r]); });
    } catch (e) {}
    function savePrmSel() {
        try { localStorage.setItem(LS_PRM_CHIPS, JSON.stringify({ plat: [...prmSel.plat], topic: [...prmSel.topic] })); } catch (e) {}
    }

    // przypisanie parametru do platform i tematów
    function prmTags(g, it) {
        const G = g.group, p = it.p;
        const plat = new Set(
            /^Google tag/.test(G) ? ['gtag', 'ga4'] :
            /^GA4/.test(G) ? ['ga4'] :
            /^Consent/.test(G) ? ['consent'] :
            /^Enhanced conversions/.test(G) ? ['ads', 'ga4'] :
            /^Google Ads/.test(G) ? ['ads'] :
            /^gtag/.test(G) ? ['gtag'] :
            /^Server-side/.test(G) ? ['sgtm', 'ga4'] :
            /^Meta/.test(G) ? ['meta'] : []);
        if (/^(server_container_url|transport_url)$/.test(p)) plat.add('sgtm');

        const t = new Set();
        if (it.s === 'config' || it.s === 'set') t.add('cfg');
        if (it.s === 'event' || /^(GA4 — parametry eventów|GA4 — e-commerce|GA4 — Enhanced|Google Ads|Meta Pixel — parametry)/.test(G)) t.add('evt');
        if (/^(GA4 — e-commerce|GA4 — parametry items)/.test(G)) t.add('ecom');
        if (/^GA4 — parametry eventów/.test(G) && /^(currency|value)$/.test(p)) t.add('ecom');
        if (/^Google Ads/.test(G) && p !== 'send_to') t.add('ecom');
        if (/^Meta Pixel — parametry/.test(G) && /^(value|currency|content_ids|content_type|contents|content_name|content_category|num_items)$/.test(p)) t.add('ecom');
        if (/^(Enhanced conversions|Meta Pixel — Advanced)/.test(G) || /^(user_id|client_id|user_data|user_properties|external_id|ip_override|ga_session_id|ga_session_number)$/.test(p)) t.add('ud');
        if (/^cookie_/.test(p) || /^(client_id|ad_storage|analytics_storage|functionality_storage|personalization_storage|security_storage)$/.test(p)) t.add('ck');
        if (/^campaign/.test(p) || /^(ignore_referrer|page_referrer|url_passthrough)$/.test(p)) t.add('utm');
        if ((/^gtag/.test(G) && /^(linker|domains|accept_incoming|decorate_forms|url_position)$/.test(p)) || /^(cookie_domain|cookie_flags)$/.test(p)) t.add('xd');
        return { plat, topic: t };
    }

    const PRM_INDEX = TAG_PARAMS.map(g => {
        let gw = phraseWords(g.group).concat(wordsOf(g.where));
        (g.aliases || []).forEach(a => { gw = gw.concat(phraseWords(a)); });
        return {
            g,
            words: uniq(gw),
            items: g.items.map(it => {
                let kw = PRM_KEYWORDS[it.p] || '';
                PRM_PREFIX_KEYWORDS.forEach(([re, k]) => { if (re.test(it.p)) kw += ' ' + k; });
                return {
                    it,
                    words: uniq(phraseWords(it.p).concat(wordsOf(it.s), phraseWords(kw))),
                    vWords: uniq(wordsOf(it.v || '')),
                    dWords: uniq(wordsOf(it.d)),
                    tags: prmTags(g, it),
                };
            }),
        };
    });

    function tokenHit(tok, words) {
        return words.some(w => {
            if (tok.length <= 2) return w === tok;                           // ga, fb, ec, am — tylko całe słowo
            if (w.startsWith(tok)) return true;                               // prefiks: "anal" → analytics
            if (tok.length >= 5 && w.startsWith(tok.slice(0, -1))) return true; // odmiana: zgoda → zgody, produkty → produkt
            return tok.length >= 4 && w.includes(tok);                        // w środku słowa: "storage"
        });
    }

    // jedno zapytanie (bez |): każde słowo musi trafić w sekcję LUB w parametr;
    // wariant sklejony ("ga 4" → ga4, "google analytics" → googleanalytics) też się liczy
    function makeMatcher(q, useDesc) {
        const toks = wordsOf(q);
        if (!toks.length) return () => true;
        const compact = toks.join('');
        return (gw, iw) => {
            const all = useDesc ? gw.concat(iw.words, iw.vWords, iw.dWords) : gw.concat(iw.words, iw.vWords);
            if (toks.every(t => tokenHit(t, all))) return true;
            return toks.length > 1 && tokenHit(compact, all);
        };
    }

    function searchParams(useDesc) {
        const raw = prmQuery;
        let test;
        if (/[\\^$*+?()[\]{}]/.test(raw)) {
            // pełny regex (np. ^item_, _id$) — na surowych nazwach/opisach
            let re = null;
            try { re = new RegExp(raw, 'i'); } catch (e) {}
            if (!re) return [];
            test = (e, ix) => [e.g.group, e.g.where, ...(e.g.aliases || []), ix.it.p, ix.it.s, ix.it.v || '', useDesc ? ix.it.d : ''].some(x => re.test(x));
        } else {
            // "ga4|meta" → OR kilku zapytań, każde z synonimami
            const alts = raw.split('|').map(x => x.trim()).filter(Boolean);
            const ms = alts.map(a => makeMatcher(a, useDesc));
            test = (e, ix) => !ms.length || ms.some(m => m(e.words, ix));
        }
        const out = [];
        PRM_INDEX.forEach(e => e.items.forEach(ix => { if (test(e, ix)) out.push({ e, ix }); }));
        return out;
    }

    // chipy: OR w obrębie rzędu, AND między rzędami (skipRow — do liczników)
    function passChips(hit, skipRow) {
        return ['plat', 'topic'].every(r =>
            r === skipRow || !prmSel[r].size || [...prmSel[r]].some(id => hit.ix.tags[r].has(id)));
    }

    let prmUsedDesc = false;
    let prmLastBase = [];
    function countParams() {
        prmUsedDesc = false;
        let base = searchParams(false);
        if (!base.length && prmQuery) { base = searchParams(true); prmUsedDesc = base.length > 0; } // 2. próba: po opisach
        prmLastBase = base;
        const out = [];
        base.filter(h => passChips(h)).forEach(h => {
            const last = out[out.length - 1];
            if (last && last.g === h.e.g) last.items.push(h.ix.it);
            else out.push({ g: h.e.g, items: [h.ix.it] });
        });
        return out;
    }

    // rozwijane listy z checkboxami (Platforma ▾ / Temat ▾)
    let prmOpenDd = null; // 'plat' | 'topic' | null

    function renderPrmChips() {
        const box = document.getElementById('tb-prm-chips');
        if (!box) return;
        box.innerHTML = '';
        const wrap = document.createElement('div');
        wrap.className = 'tb-dds';
        ['plat', 'topic'].forEach(r => {
            const def = PRM_CHIPS[r];
            const sel = def.items.filter(c => prmSel[r].has(c.id)).map(c => c.label);
            const dd = document.createElement('div');
            dd.className = 'tb-dd tb-dd-' + r + (prmOpenDd === r ? ' open' : '');
            dd.innerHTML =
                `<button class="tb-dd-btn${sel.length ? ' active' : ''}" title="${escAttr(sel.join(', ') || 'wszystkie')}">` +
                    `<span class="tb-dd-lab">${escHtml(def.label)}</span>` +
                    `<span class="tb-dd-val">${sel.length ? escHtml(sel.length > 1 ? sel[0] + ' +' + (sel.length - 1) : sel[0]) : 'wszystkie'}</span>` +
                    `<span class="tb-dd-arr">${prmOpenDd === r ? '▴' : '▾'}</span>` +
                `</button>` +
                `<div class="tb-dd-menu"></div>`;
            dd.querySelector('.tb-dd-btn').addEventListener('click', () => {
                prmOpenDd = prmOpenDd === r ? null : r;
                renderPrmChips();
            });
            const menu = dd.querySelector('.tb-dd-menu');
            def.items.forEach(c => {
                const cnt = prmLastBase.filter(h => passChips(h, r) && h.ix.tags[r].has(c.id)).length;
                const on = prmSel[r].has(c.id);
                const row = document.createElement('div');
                row.className = 'tb-frow' + (on ? ' on' : '') + (!cnt && !on ? ' zero' : '');
                row.innerHTML = `<div class="tb-chk">✓</div><span class="tb-fname">${escHtml(c.label)}</span><span class="tb-fcnt">${cnt}</span>`;
                row.addEventListener('click', () => {
                    if (on) prmSel[r].delete(c.id); else prmSel[r].add(c.id);
                    savePrmSel();
                    renderParams(); // lista zostaje rozwinięta (prmOpenDd)
                });
                menu.appendChild(row);
            });
            if (prmSel[r].size) {
                const clr = document.createElement('div');
                clr.className = 'tb-dd-foot';
                clr.textContent = '✕ odznacz wszystko';
                clr.addEventListener('click', () => { prmSel[r].clear(); savePrmSel(); renderParams(); });
                menu.appendChild(clr);
            }
            wrap.appendChild(dd);
        });
        if (prmSel.plat.size || prmSel.topic.size) {
            const x = document.createElement('button');
            x.className = 'tb-dd-x';
            x.textContent = '✕';
            x.title = 'Wyczyść kategorie';
            x.addEventListener('click', () => {
                prmSel.plat.clear(); prmSel.topic.clear(); prmOpenDd = null;
                savePrmSel();
                renderParams();
            });
            wrap.appendChild(x);
        }
        box.appendChild(wrap);
    }

    // klik poza listą / Esc → zwiń
    document.addEventListener('mousedown', e => {
        if (prmOpenDd && !(e.target.closest && e.target.closest('.tb-dd'))) { prmOpenDd = null; renderPrmChips(); }
    }, true);
    document.addEventListener('keydown', e => {
        if (e.key === 'Escape' && prmOpenDd) { prmOpenDd = null; renderPrmChips(); }
    });

    function renderParams() {
        const list = document.getElementById('tb-prm-list');
        if (!list) return;
        list.innerHTML = '';
        const groups = countParams();
        let n = 0;
        groups.forEach(({ g, items }) => {
            const lab = document.createElement('div');
            lab.className = 'tb-label tb-prm-group';
            lab.textContent = g.group;
            list.appendChild(lab);
            const wh = document.createElement('div');
            wh.className = 'tb-prm-where';
            wh.textContent = '↳ ' + g.where;
            list.appendChild(wh);
            items.forEach(it => {
                n++;
                const row = document.createElement('div');
                row.className = 'tb-prm';
                row.innerHTML =
                    `<div class="tb-prm-hd">` +
                        `<span class="tb-prm-name">${escHtml(it.p)}</span>` +
                        `<span class="tb-prm-sc sc-${escAttr(it.s)}">${escHtml(it.s)}</span>` +
                        `<span class="tb-copied" style="display:none">✓</span>` +
                        (it.v ? `<button class="tb-prm-val" title="Kopiuj przykładową wartość: ${escAttr(it.v)}">= ${escHtml(it.v)}</button>` : '') +
                    `</div>` +
                    `<div class="tb-prm-desc">${escHtml(it.d)}</div>`;
                const flash = txt => {
                    const c = row.querySelector('.tb-copied');
                    c.textContent = txt;
                    c.style.display = '';
                    setTimeout(() => { c.style.display = 'none'; }, 900);
                };
                row.addEventListener('click', () => { copyText(it.p); flash('✓ nazwa'); });
                const vb = row.querySelector('.tb-prm-val');
                if (vb) vb.addEventListener('click', ev => {
                    ev.stopPropagation();
                    copyText(it.v);
                    flash('✓ wartość');
                });
                list.appendChild(row);
            });
        });
        if (!n) list.innerHTML = '<div class="tb-prm-empty">Nic nie pasuje' + ((prmSel.plat.size || prmSel.topic.size) ? ' — sprawdź zaznaczone kategorie.' : '.') + '</div>';
        renderPrmChips();
        updateFooter();
    }

    function paramsCount() {
        return countParams().reduce((a, x) => a + x.items.length, 0);
    }

    function switchTab(tab) {
        activeTab = tab;
        try { localStorage.setItem(LS_TAB, tab); } catch (e) {}
        document.querySelectorAll('#' + WID + ' .tb-tab').forEach(b => b.classList.toggle('on', b.dataset.tab === tab));
        document.getElementById('tb-panel-filter').style.display = tab === 'filter' ? '' : 'none';
        document.getElementById('tb-panel-params').style.display = tab === 'params' ? '' : 'none';
        document.getElementById('tb-clear').style.display = tab === 'filter' ? '' : 'none';
        if (tab === 'params') renderParams();
        updateFooter();
    }

    /* ═══════════════════ MODUŁ 11: WIDGET ═══════════════════ */
    function buildWidget() {
        if (widget) widget.remove();
        widget = document.createElement('div');
        widget.id = WID;
        widget.innerHTML = `
<style>
#${WID}{position:fixed;bottom:16px;right:16px;z-index:2147483647;font-family:'Google Sans',system-ui,sans-serif;width:340px;max-height:min(calc(100vh - 24px),var(--tb-h,640px));display:flex;flex-direction:column;border-radius:14px;overflow:hidden;box-shadow:0 10px 36px rgba(0,0,0,.55);border:1px solid ${C.border};background:${C.bg};user-select:none}
#${WID} *{box-sizing:border-box}
.tb-hdr{background:linear-gradient(135deg,${C.accentDk},${C.bg});padding:10px 14px;display:flex;align-items:center;gap:8px;cursor:move;border-bottom:1px solid ${C.border}}
.tb-logo{width:22px;height:22px;flex-shrink:0}
.tb-logo svg{display:block}
.tb-title{flex:1;font-size:13px;font-weight:600;color:#fff;letter-spacing:.3px}
.tb-badge{font-size:10px;background:${C.border};color:${C.dim};padding:2px 8px;border-radius:10px}
.tb-hdr-btn{background:none;border:none;color:#fff;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;opacity:.6;transition:opacity .15s}
.tb-hdr-btn:hover{opacity:1}
.tb-hdr{flex:none}
.tb-wrap{overflow:hidden;transition:max-height .25s ease;flex:1 1 auto;min-height:0;display:flex;flex-direction:column}
.tb-wrap.open{max-height:none}.tb-wrap.closed{max-height:0!important;flex:0 0 0}
/* zminimalizowany do paska: zostaje sam nagłówek, znika stopka i uchwyt wysokości */
#${WID}.tb-min{height:auto!important;max-height:none!important}
#${WID}.tb-min .tb-wrap,#${WID}.tb-min .tb-foot,#${WID}.tb-min .tb-resize{display:none!important}
#${WID}.tb-min .tb-hdr{border-bottom:none}
.tb-tabs{flex:none}
.tb-panel{padding:10px 14px;flex:1 1 auto;min-height:0;overflow-y:auto}
.tb-foot{flex:none}
.tb-panel::-webkit-scrollbar{width:5px}.tb-panel::-webkit-scrollbar-thumb{background:${C.border};border-radius:3px}
.tb-label{font-size:10px;color:${C.dim};font-weight:700;text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}
.tb-input-wrap{position:relative;margin-bottom:8px}
.tb-input{width:100%;background:${C.border};border:1px solid #4b5563;color:${C.text};padding:7px 10px;border-radius:8px;font-size:12px;outline:none;font-family:inherit}
.tb-input:focus{border-color:${C.accent}}
.tb-input::placeholder{color:#6b7280}
.tb-input.tb-input--regex{font-family:'Consolas','Monaco','Courier New',monospace;border-color:#7c3aed}
.tb-input.tb-input--regex:focus{border-color:#a78bfa}
.tb-input.tb-input--error{border-color:${C.red}!important}
.tb-search-mode{position:absolute;right:6px;top:50%;transform:translateY(-50%);font-size:9px;padding:2px 6px;border-radius:4px;font-weight:700;letter-spacing:.3px;border:none;background:none;pointer-events:none}
.tb-search-mode--text{background:${C.border};color:${C.dim}}
.tb-search-mode--clear{background:${C.border};color:${C.text};pointer-events:auto;cursor:pointer}
.tb-search-mode--clear:hover{background:#4b5563}
.tb-search-mode--regex{background:#7c3aed;color:#fff;pointer-events:auto;cursor:pointer}
.tb-search-mode--regex:hover{background:#6d28d9}
.tb-search-mode--id{background:#0e7490;color:#fff;pointer-events:auto;cursor:pointer}
.tb-search-mode--id:hover{background:#155e75}
.tb-input.tb-input--id{font-family:'Consolas','Monaco','Courier New',monospace;border-color:#0e7490}
.tb-idtoggle{display:flex;align-items:center;gap:9px;padding:3px 4px;cursor:pointer;border-radius:6px;margin:0 0 1px}
.tb-idtoggle:hover{background:${C.hover}}
.tb-idtoggle.on .tb-chk{background:${C.accent};border-color:${C.accent};color:#fff}
.tb-idtoggle.tb-filt.on .tb-chk{background:${C.red};border-color:${C.red}}
#tb-paused-toggle .tb-chk{font-size:9px}
#tb-paused-toggle.on .tb-chk{background:${C.yellow};border-color:${C.yellow};color:#111}
.tb-idtoggle span{font-size:11px;color:${C.text}}
.tb-idtoggle + .tb-idtoggle{margin-top:0}
.tb-acc-hd{display:flex;align-items:center;gap:6px;margin:10px 0 6px;padding:4px 4px;border-radius:6px;cursor:pointer}
.tb-acc-hd:hover{background:${C.hover}}
.tb-acc-arr{display:inline-block;font-size:10px;color:${C.dim};width:10px;text-align:center;transition:transform .15s}
.tb-acc-hd.open .tb-acc-arr{transform:rotate(90deg)}
.tb-acc-on{font-size:9px;font-weight:700;color:#fff;background:${C.accent};padding:1px 6px;border-radius:7px;text-transform:uppercase;letter-spacing:.3px}
.tb-search-mode--error{background:${C.red};color:#fff;pointer-events:auto;cursor:pointer}
.tb-search-mode--error:hover{background:#dc2626}
.tb-input-sm{width:100%;background:${C.border};border:1px solid #4b5563;color:${C.text};padding:5px 8px;border-radius:6px;font-size:11px;outline:none;font-family:inherit;margin-bottom:6px}
.tb-input-sm:focus{border-color:${C.accent}}
.tb-input-sm::placeholder{color:#6b7280}
.tb-search-error{font-size:10px;color:${C.red};margin:-4px 0 6px 2px;line-height:1.3}
.tb-search-hint{font-size:10px;color:${C.dim};margin:-4px 0 6px 2px;line-height:1.3}
.tb-frow{display:flex;align-items:center;gap:9px;padding:4px 4px;cursor:pointer;transition:background .08s;border-radius:6px}
.tb-frow:hover{background:${C.hover}}
.tb-chk{width:16px;height:16px;border-radius:4px;border:2px solid ${C.border};display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:all .12s;font-size:10px;color:transparent}
.tb-frow.on .tb-chk{background:${C.accent};border-color:${C.accent};color:#fff}
.tb-dot{width:7px;height:7px;border-radius:2px;flex-shrink:0}
.tb-fname{flex:1;font-size:12px;color:${C.text};white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tb-fcnt{font-size:10px;color:${C.dim};background:${C.border};padding:1px 7px;border-radius:7px;min-width:20px;text-align:center}
.tb-note{font-size:10px;color:${C.yellow};margin:4px 0 0 2px;line-height:1.4}
.tb-foot{border-top:1px solid ${C.border};padding:7px 14px 9px;display:flex;justify-content:space-between;align-items:center;position:relative}
/* dolna krawędź okna = uchwyt zmiany wysokości */
.tb-resize{position:absolute;left:0;right:0;bottom:0;height:7px;cursor:ns-resize;z-index:2;border-radius:0 0 14px 14px}
.tb-resize::before{content:'';position:absolute;left:50%;bottom:2px;transform:translateX(-50%);width:36px;height:3px;border-radius:2px;background:${C.border};transition:background .12s}
.tb-resize:hover::before{background:${C.accent}}
.tb-foot-txt{font-size:10px;color:${C.dim}}
.tb-foot-btn{font-size:10px;color:${C.accent};background:none;border:none;cursor:pointer;font-weight:600;padding:2px 4px;border-radius:4px}
.tb-foot-btn:hover{background:rgba(59,130,246,.12)}
.tb-foot-btn.on{background:${C.accent};color:#fff}
.tb-tabs{display:flex;border-bottom:1px solid ${C.border}}
.tb-tab{flex:1;background:none;border:none;color:${C.dim};font-size:11px;font-weight:600;padding:8px 4px;cursor:pointer;font-family:inherit;border-bottom:2px solid transparent;transition:all .15s}
.tb-tab:hover{color:${C.text};background:${C.hover}}
.tb-tab.on{color:${C.accent};border-bottom-color:${C.accent}}
.tb-prm-group{margin-top:10px}
.tb-prm-group:first-child{margin-top:0}
.tb-prm-where{font-size:9px;color:#94a3b8;margin:-2px 0 4px 2px;line-height:1.35}
.tb-prm{padding:5px 7px;border-radius:7px;cursor:pointer;transition:background .1s;margin-bottom:1px}
.tb-prm:hover{background:${C.hover}}
.tb-prm-hd{display:flex;align-items:center;gap:6px;min-width:0}
.tb-prm-name{font-family:'Consolas','Monaco',monospace;font-size:11px;font-weight:700;color:#bfdbfe;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
.tb-prm-desc{font-size:10px;color:${C.dim};line-height:1.35}
.tb-copied{color:${C.green};font-size:10px;font-weight:700;flex-shrink:0}
.tb-prm-sc{font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;padding:1px 5px;border-radius:5px;flex-shrink:0;background:rgba(107,114,128,.2);color:${C.dim}}
.tb-prm-sc.sc-config{background:rgba(59,130,246,.15);color:#93c5fd}
.tb-prm-sc.sc-event{background:rgba(34,197,94,.13);color:${C.green}}
.tb-prm-sc.sc-item{background:rgba(234,179,8,.13);color:${C.yellow}}
.tb-prm-sc.sc-consent{background:rgba(192,132,252,.15);color:#c084fc}
.tb-prm-sc.sc-set{background:rgba(192,132,252,.1);color:#d8b4fe}
.tb-prm-sc.sc-user_data{background:rgba(244,114,182,.13);color:#f472b6}
.tb-prm-sc.sc-ads{background:rgba(251,146,60,.13);color:#fb923c}
.tb-prm-sc.sc-gtag{background:rgba(45,212,191,.13);color:#2dd4bf}
.tb-prm-sc.sc-sgtm{background:rgba(248,113,113,.13);color:#f87171}
.tb-prm-sc.sc-meta{background:rgba(96,165,250,.13);color:#60a5fa}
.tb-prm-val{margin-left:auto;background:${C.border};border:none;color:${C.dim};font-family:'Consolas','Monaco',monospace;font-size:9px;padding:1px 6px;border-radius:5px;cursor:pointer;max-width:130px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex-shrink:1;transition:.1s}
.tb-prm-val:hover{color:${C.text};background:#4b5563}
.tb-dds{display:flex;gap:6px;align-items:stretch;margin-bottom:4px}
.tb-dd{position:relative;flex:1;min-width:0}
.tb-dd-btn{width:100%;display:flex;align-items:center;gap:5px;background:${C.border};border:1px solid #4b5563;color:${C.text};padding:5px 8px;border-radius:7px;font-size:11px;cursor:pointer;font-family:inherit;text-align:left;line-height:1.3}
.tb-dd-btn:hover,.tb-dd.open .tb-dd-btn{border-color:${C.accent}}
.tb-dd-btn.active{border-color:${C.accent};background:rgba(59,130,246,.18)}
.tb-dd-lab{color:${C.dim};font-size:10px;font-weight:600;flex-shrink:0}
.tb-dd-val{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:600}
.tb-dd-btn:not(.active) .tb-dd-val{color:#9ca3af;font-weight:400}
.tb-dd-arr{color:${C.dim};font-size:9px;flex-shrink:0}
.tb-dd-menu{display:none;position:absolute;top:calc(100% + 4px);left:0;min-width:175px;background:${C.bg};border:1px solid ${C.border};border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.5);padding:4px;z-index:5}
.tb-dd-topic .tb-dd-menu{left:auto;right:0}
.tb-dd.open .tb-dd-menu{display:block}
.tb-dd-menu .tb-frow{padding:5px 6px}
.tb-frow.zero{opacity:.4}
.tb-dd-foot{font-size:10px;color:${C.dim};padding:6px 6px 3px;margin-top:2px;border-top:1px solid ${C.border};cursor:pointer}
.tb-dd-foot:hover{color:${C.red}}
.tb-dd-x{flex-shrink:0;background:${C.border};border:1px solid #4b5563;color:${C.dim};border-radius:7px;padding:0 8px;cursor:pointer;font-size:11px;font-family:inherit}
.tb-dd-x:hover{color:${C.red};border-color:${C.red}}
.tb-prm-empty{font-size:12px;color:${C.dim};text-align:center;padding:14px 8px}
</style>

<div class="tb-hdr" id="tb-hdr">
  <div class="tb-logo"><svg width="22" height="22" viewBox="0 0 192 192" xmlns="http://www.w3.org/2000/svg"><rect width="192" height="192" rx="24" fill="#4285F4"/><path d="M96 36L140 96l-44 60-44-60L96 36z" fill="#fff" opacity=".9"/><circle cx="96" cy="96" r="20" fill="#fff"/></svg></div>
  <span class="tb-title">GTM Filter</span>
  <span class="tb-badge" id="tb-badge">—</span>
  <button class="tb-hdr-btn" id="tb-col">▾</button>
</div>

<div class="tb-wrap ${collapsed ? 'closed' : 'open'}" id="tb-wrap">
  <div class="tb-tabs">
    <button class="tb-tab" data-tab="filter">🔎 Filtr</button>
    <button class="tb-tab" data-tab="params" title="Standardowe parametry tagów: Google tag, GA4, Consent, Ads, Meta…">🏷️ Parametry</button>
  </div>
  <div class="tb-panel" id="tb-panel-filter">
    <div class="tb-label">Szukaj po nazwie</div>
    <div style="font-size:10px;color:#9ca3af;margin:-2px 0 5px 2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="Regex: GA4|FB|cHTML · ID: 1|2, 100-120, >50 albo URL elementu">regex, np. GA4|FB|cHTML · ID, np. 1|2 · 100-120 · &gt;50</div>
    <div class="tb-input-wrap">
      <input class="tb-input" id="tb-name-search" style="padding-right:38px">
      <span class="tb-search-mode tb-search-mode--text" id="tb-search-mode">TEXT</span>
    </div>
    <div id="tb-search-hint" class="tb-search-hint" style="display:none"></div>
    <div class="tb-acc-hd" id="tb-opt-acc-hd" style="margin-top:4px">
      <span class="tb-acc-arr">▸</span>
      <span class="tb-label" style="margin:0;flex:1">Opcje wyświetlania</span>
      <span class="tb-acc-on" id="tb-opt-acc-on" style="display:none"></span>
    </div>
    <div class="tb-acc-body" id="tb-opt-acc-body" style="display:none">
    <div class="tb-idtoggle${showIds ? ' on' : ''}" id="tb-ids-toggle" title="Dokleja #ID przy nazwach tagów / reguł / zmiennych (klik w #ID = kopiuj)"><div class="tb-chk">✓</div><span>Pokaż #ID na liście</span></div>
    <div class="tb-idtoggle${showVendorIds ? ' on' : ''}" id="tb-vids-toggle" title="G-…, AW-…, GT-…, GTM-…, piksel Meta, {{zmienna}} z ID — z ustawień tagu i z Custom HTML (klik = kopiuj). Tagi i zmienne."><div class="tb-chk">✓</div><span>Pokaż ID vendorów</span></div>
    <div class="tb-idtoggle${showFiring ? ' on' : ''}" id="tb-fire-toggle" title="Opcja uruchamiania tagu: raz na zdarzenie / raz na stronę / bez limitu. Tylko tagi."><div class="tb-chk">✓</div><span>Pokaż opcję uruchamiania</span></div>
    <div class="tb-idtoggle${showEvents ? ' on' : ''}" id="tb-evt-toggle" title="Nazwa eventu (GA4, Meta, Custom HTML…) i liczba parametrów; klik w „N param.” rozwija listę pod nazwą tagu."><div class="tb-chk">✓</div><span>Pokaż event i parametry</span></div>
    <div class="tb-idtoggle${showConsent ? ' on' : ''}" id="tb-consent-toggle" title="Ustawienia zgody tagu (Consent Settings): wymagane dodatkowe zgody, „bez dodatkowych” albo „nie ustawiono”. Tylko tagi."><div class="tb-chk">✓</div><span>Pokaż consent</span></div>
    <div class="tb-idtoggle${showVarUses ? ' on' : ''}" id="tb-uses-toggle" title="Na liście Zmiennych: kolumna z liczbą tagów, w których użyto zmiennej (+N = reguły / inne zmienne). 0 na czerwono = zmienna nieużywana."><div class="tb-chk">✓</div><span>Kolumna „Tagi” przy zmiennych</span></div>
    </div>
    <div class="tb-note" id="tb-meta-note" style="display:none;margin:-2px 0 6px 4px"></div>
    <div class="tb-label" style="margin-top:8px" id="tb-filt-label">Filtry</div>
    <div class="tb-idtoggle tb-filt" id="tb-notrig-toggle" title="Tagi bez żadnej reguły uruchamiającej (nigdy się nie odpalą)."><div class="tb-chk">✓</div><span>Tylko bez reguły uruchamiającej</span><span class="tb-fcnt" id="tb-notrig-cnt" style="margin-left:auto"></span></div>
    <div class="tb-idtoggle tb-filt" id="tb-active-toggle" title="Tagi: ukryj zapauzowane."><div class="tb-chk">✓</div><span>Tylko niezapauzowane</span></div>
    <div class="tb-idtoggle tb-filt" id="tb-paused-toggle" title="Tagi: pokaż tylko zapauzowane (Pause w GTM). Licznik po prawej = ile tagów jest zapauzowanych."><div class="tb-chk">⏸</div><span>Tylko zapauzowane</span><span class="tb-fcnt" id="tb-active-cnt" style="margin-left:auto"></span></div>
    <div class="tb-idtoggle tb-filt" id="tb-dup-toggle" title="Tagi: tylko te, które wysyłają ten sam event co inny tag tego samego typu (np. dwa tagi GA4 Event z eventem purchase). Zapauzowane też się liczą; grupy z ≥2 aktywnymi tagami są na czerwono. Porównanie po nazwie eventu, nie po nazwie tagu; duplikaty są grupowane jeden pod drugim. Licznik = ile tagów ma duplikat."><div class="tb-chk">✓</div><span>Tylko zduplikowane</span><span class="tb-fcnt" id="tb-dup-cnt" style="margin-left:auto"></span></div>
    <div class="tb-idtoggle tb-filt" id="tb-unused-toggle" title="Zmienne: tylko te, których nie używa żaden tag, reguła, zmienna (w SST także klient / transformacja). Reguły: tylko te, których nie używa żaden tag (ani jako uruchamiająca, ani jako wyjątek)."><div class="tb-chk">✓</div><span>Tylko nieużywane (0 użyć)</span><span class="tb-fcnt" id="tb-unused-cnt" style="margin-left:auto"></span></div>

    <div class="tb-label" style="margin-top:6px">Filtruj po typie</div>
    <div id="tb-type-list"></div>
    <div id="tb-folder-section" style="display:none">
      <div class="tb-label" style="margin-top:10px">Filtruj po folderze</div>
      <div id="tb-folder-list"></div>
    </div>

    <div id="tb-trig-section" style="display:none">
      <div class="tb-acc-hd" id="tb-trig-acc-hd">
        <span class="tb-acc-arr" id="tb-trig-acc-arr">▸</span>
        <span class="tb-label" style="margin:0;flex:1">Reguły uruchamiające</span>
        <span class="tb-acc-on" id="tb-trig-acc-on" style="display:none">aktywny</span>
      </div>
      <div class="tb-acc-body" id="tb-trig-acc-body" style="display:none">
        <div class="tb-label">Szukaj po nazwie reguły uruchamiającej</div>
        <div style="font-size:10px;color:#9ca3af;margin:-2px 0 5px 2px">obsługuje regex, np. CE - |Click · ID reguły, np. 9|12</div>
        <div class="tb-input-wrap">
          <input class="tb-input" id="tb-trig-search" style="padding-right:38px">
          <span class="tb-search-mode tb-search-mode--text" id="tb-trig-mode">TEXT</span>
        </div>
        <div id="tb-trig-hint" class="tb-search-hint" style="display:none"></div>
        <div class="tb-label" style="margin-top:6px">Filtruj po typie reguły uruchamiającej</div>
        <div id="tb-trig-list"></div>
        <div class="tb-note" id="tb-trig-note" style="display:none"></div>
      </div>
    </div>
  </div>

  <div class="tb-panel" id="tb-panel-params" style="display:none">
    <div class="tb-input-wrap" style="margin-bottom:5px">
      <input class="tb-input" id="tb-prm-q" placeholder="np. Google Analytics, GA4, zgody, FB, ecommerce, meta|ads…" style="padding-right:30px">
      <span class="tb-search-mode tb-search-mode--clear" id="tb-prm-clear" style="display:none">✕</span>
    </div>
    <div id="tb-prm-chips"></div>
    <div class="tb-search-hint" style="margin:2px 0 8px 2px">Klik w wiersz = kopiuje <b style="color:#9ca3af">nazwę</b> · klik w <b style="color:#9ca3af">= …</b> = przykładowa wartość</div>
    <div id="tb-prm-list"></div>
  </div>

  <div class="tb-foot">
    <span class="tb-foot-txt" id="tb-foot-txt"></span>
    <span style="display:flex;gap:2px;align-items:center">
      <button class="tb-foot-btn${tidy ? ' on' : ''}" id="tb-tidy" title="Porządkuj (Tagi): zapauzowane tagi na szaro i na samym dole listy. Klik = włącz / wyłącz.">⇅ Porządkuj</button>
      <button class="tb-foot-btn" id="tb-refresh" title="Pobierz ponownie dane tagów / zmiennych / reguł z GTM (ID vendorów, opcja uruchamiania, kolumna Tagi). Odświeża się też samo po zapisie zmian.">⟳ Odśwież</button>
      <button class="tb-foot-btn" id="tb-clear" title="Wyczyść wszystko: filtry, wyszukiwarki i opcje wyświetlania (#ID, ID vendorów, event, consent…)">Wyczyść</button>
    </span>
  </div>
</div>
<div class="tb-resize" id="tb-resize" title="Przeciągnij dolną krawędź, aby zmienić wysokość okna (dwuklik = domyślna)"></div>
        `;
        document.body.appendChild(widget);
        widget.addEventListener('click', () => bumpUi(), true);
        widget.addEventListener('input', () => bumpUi(), true);

        const colBtn = document.getElementById('tb-col');
        // strzałka: zwija cały widget do samego paska nagłówka (stan zapamiętany)
        function applyCollapsed() {
            widget.classList.toggle('tb-min', collapsed);
            document.getElementById('tb-wrap').classList.toggle('open', !collapsed);
            document.getElementById('tb-wrap').classList.toggle('closed', collapsed);
            colBtn.textContent = collapsed ? '▸' : '▾';
            colBtn.title = collapsed ? 'Rozwiń widget' : 'Zwiń do paska';
        }
        applyCollapsed();
        colBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            collapsed = !collapsed; try { localStorage.setItem(LS_COL, collapsed ? '1' : '0'); } catch (x) {}
            updateExpandDirection();
            applyCollapsed();
            // po rozwinięciu okno może nie mieścić się na ekranie — dosuwamy
            keepOnScreen();
        });

        document.getElementById('tb-name-search').addEventListener('input', (e) => {
            compileSearch(e.target.value);
            updateSearchIndicator();
            applyAllFilters();
            updateFooter();
        });

        document.getElementById('tb-search-mode').addEventListener('click', () => {
            if (!nameSearch) return;
            const input = document.getElementById('tb-name-search');
            input.value = '';
            compileSearch('');
            updateSearchIndicator();
            applyAllFilters();
            updateFooter();
            input.focus();
        });

        const idsToggle = document.getElementById('tb-ids-toggle');
        idsToggle.addEventListener('click', () => {
            showIds = !showIds;
            try { localStorage.setItem(LS_IDS, showIds ? '1' : '0'); } catch (e) {}
            idsToggle.classList.toggle('on', showIds);
            renderIdBadges();
        });
        const vidsToggle = document.getElementById('tb-vids-toggle');
        vidsToggle.addEventListener('click', () => {
            showVendorIds = !showVendorIds;
            try { localStorage.setItem(LS_VIDS, showVendorIds ? '1' : '0'); } catch (e) {}
            vidsToggle.classList.toggle('on', showVendorIds);
            renderIdBadges();
        });
        const unusedToggle = document.getElementById('tb-unused-toggle');
        unusedToggle.addEventListener('click', () => {
            onlyUnused = !onlyUnused;
            unusedToggle.classList.toggle('on', onlyUnused);
            applyAllFilters();
            updateFooter();
        });
        // dwa checkboxy wykluczające się: włączenie jednego wyłącza drugi
        document.getElementById('tb-active-toggle').addEventListener('click', () => {
            pausedMode = pausedMode === 1 ? 0 : 1;
            setDup(false);
            renderPausedToggle(); applyAllFilters(); renderIdBadges(); updateFooter();
        });
        // trzy filtry wykluczające się (jak radio): niezapauzowane / zapauzowane / zduplikowane
        const setDup = v => { onlyDup = v; document.getElementById('tb-dup-toggle').classList.toggle('on', v); };
        document.getElementById('tb-dup-toggle').addEventListener('click', () => {
            const v = !onlyDup;
            if (currentSection === 'tags') { pausedMode = 0; renderPausedToggle(); }
            setDup(v);
            applyAllFilters(); renderIdBadges(); updateFooter();
        });
        document.getElementById('tb-paused-toggle').addEventListener('click', () => {
            pausedMode = pausedMode === 2 ? 0 : 2;
            setDup(false);
            renderPausedToggle(); applyAllFilters(); renderIdBadges(); updateFooter();
        });
        const usesToggle = document.getElementById('tb-uses-toggle');
        usesToggle.addEventListener('click', () => {
            showVarUses = !showVarUses;
            try { localStorage.setItem(LS_VAR_USES, showVarUses ? '1' : '0'); } catch (e) {}
            usesToggle.classList.toggle('on', showVarUses);
            renderIdBadges();
        });
        const consentToggle = document.getElementById('tb-consent-toggle');
        consentToggle.addEventListener('click', () => {
            showConsent = !showConsent;
            try { localStorage.setItem(LS_CONSENT, showConsent ? '1' : '0'); } catch (e) {}
            consentToggle.classList.toggle('on', showConsent);
            renderIdBadges();
        });
        document.getElementById('tb-opt-acc-hd').addEventListener('click', () => {
            optAccOpen = !optAccOpen;
            try { localStorage.setItem(LS_OPT_ACC, optAccOpen ? '1' : '0'); } catch (e) {}
            updateOptAcc();
        });
        updateOptAcc();
        document.getElementById('tb-notrig-toggle').addEventListener('click', () => {
            onlyNoTrig = !onlyNoTrig;
            document.getElementById('tb-notrig-toggle').classList.toggle('on', onlyNoTrig);
            applyAllFilters(); updateFooter();
        });
        const evtToggle = document.getElementById('tb-evt-toggle');
        evtToggle.addEventListener('click', () => {
            showEvents = !showEvents;
            try { localStorage.setItem(LS_EVT, showEvents ? '1' : '0'); } catch (e) {}
            evtToggle.classList.toggle('on', showEvents);
            renderIdBadges();
        });
        const fireToggle = document.getElementById('tb-fire-toggle');
        fireToggle.addEventListener('click', () => {
            showFiring = !showFiring;
            try { localStorage.setItem(LS_FIRE, showFiring ? '1' : '0'); } catch (e) {}
            fireToggle.classList.toggle('on', showFiring);
            renderIdBadges();
        });
        document.getElementById('tb-trig-acc-hd').addEventListener('click', () => {
            trigAccOpen = !trigAccOpen;
            try { localStorage.setItem(LS_TRIG_ACC, trigAccOpen ? '1' : '0'); } catch (e) {}
            updateTrigAcc();
        });
        updateTrigAcc();
        updateSectionToggles();

        document.getElementById('tb-trig-search').addEventListener('input', (e) => {
            compileTrigSearch(e.target.value);
            updateTrigSearchIndicator();
            applyAllFilters();
            updateFooter();
        });

        document.getElementById('tb-trig-mode').addEventListener('click', () => {
            if (!trigNameSearch) return;
            const input = document.getElementById('tb-trig-search');
            input.value = '';
            compileTrigSearch('');
            updateTrigSearchIndicator();
            applyAllFilters();
            updateFooter();
            input.focus();
        });

        const tidyBtn = document.getElementById('tb-tidy');
        tidyBtn.addEventListener('click', () => {
            tidy = !tidy;
            try { localStorage.setItem(LS_TIDY, tidy ? '1' : '0'); } catch (e) {}
            tidyBtn.classList.toggle('on', tidy);
            renderIdBadges(); syncOrder(); updateFooter();
        });
        const refBtn = document.getElementById('tb-refresh');
        refBtn.addEventListener('click', () => {
            invalidateApi(0);
            refBtn.textContent = '⟳ …';
            setTimeout(() => { refBtn.textContent = '⟳ Odśwież'; }, 1200);
        });
        document.getElementById('tb-clear').addEventListener('click', () => {
            clearAllFilters();
            document.getElementById('tb-name-search').value = '';
            document.getElementById('tb-trig-search').value = '';
            // odznacz też wszystkie opcje wyświetlania (stan zapamiętany → wyłączone)
            const opts = [
                ['tb-ids-toggle', () => { showIds = false; }, LS_IDS],
                ['tb-vids-toggle', () => { showVendorIds = false; }, LS_VIDS],
                ['tb-fire-toggle', () => { showFiring = false; }, LS_FIRE],
                ['tb-evt-toggle', () => { showEvents = false; }, LS_EVT],
                ['tb-consent-toggle', () => { showConsent = false; }, LS_CONSENT],
                ['tb-uses-toggle', () => { showVarUses = false; }, LS_VAR_USES],
            ];
            opts.forEach(([id, off, key]) => { off(); const el = document.getElementById(id); if (el) el.classList.remove('on'); try { localStorage.setItem(key, '0'); } catch (e) {} });
            openParams.clear();
            updateSearchIndicator();
            updateTrigSearchIndicator();
            renderFilterTab();
            renderTrigTypeList();
            renderFolderList();
            renderIdBadges();
            updateFooter();
        });

        widget.querySelectorAll('.tb-tab').forEach(b => b.addEventListener('click', () => switchTab(b.dataset.tab)));

        const prmQ = document.getElementById('tb-prm-q');
        const prmClear = document.getElementById('tb-prm-clear');
        prmQ.addEventListener('input', () => {
            prmQuery = prmQ.value.trim();
            prmClear.style.display = prmQuery ? '' : 'none';
            renderParams();
        });
        prmClear.addEventListener('click', () => {
            prmQ.value = ''; prmQuery = '';
            prmClear.style.display = 'none';
            renderParams();
            prmQ.focus();
        });

        makeDraggable(widget, document.getElementById('tb-hdr'));
        // uchwyt wysokości w stopce: ciągnięcie w dół = wyżej okno (zakotwiczone górą podczas zmiany)
        (function () {
            const grip = document.getElementById('tb-resize');
            grip.addEventListener('dblclick', e => { e.preventDefault(); e.stopPropagation(); widget.style.removeProperty('--tb-h'); widget.style.height = ''; try { localStorage.removeItem(LS_H); } catch (x) {} keepOnScreen(); });
            grip.addEventListener('mousedown', e => {
                e.preventDefault(); e.stopPropagation();
                const r = widget.getBoundingClientRect();
                widget.style.top = r.top + 'px'; widget.style.bottom = 'auto'; widget.style.left = r.left + 'px'; widget.style.right = 'auto';
                const sy = e.clientY, sh = r.height;
                const mv = ev => {
                    const h = Math.max(240, Math.min(window.innerHeight - 16, sh + (ev.clientY - sy)));
                    widget.style.setProperty('--tb-h', h + 'px');
                    widget.style.height = h + 'px';
                    // gdy dół wyszedłby poza ekran — przesuwamy okno w górę (rośnie „do góry”)
                    const top = Math.max(8, Math.min(r.top, window.innerHeight - 8 - h));
                    widget.style.top = top + 'px';
                };
                const up = () => {
                    document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up);
                    try { const r2 = widget.getBoundingClientRect(); localStorage.setItem(LS_H, String(Math.round(r2.height))); localStorage.setItem(LS_POS, JSON.stringify({ l: Math.round(r2.left), t: Math.round(r2.top) })); } catch (x) {}
                };
                document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up);
            });
        })();
        try {
            const h = parseInt(localStorage.getItem(LS_H) || '', 10);
            if (h >= 240) { widget.style.setProperty('--tb-h', h + 'px'); widget.style.height = 'min(calc(100vh - 24px), ' + h + 'px)'; }
            const pos = JSON.parse(localStorage.getItem(LS_POS) || 'null');
            if (pos && typeof pos.l === 'number' && typeof pos.t === 'number') {
                widget.style.left = pos.l + 'px'; widget.style.top = pos.t + 'px'; widget.style.right = 'auto'; widget.style.bottom = 'auto';
                keepOnScreen();
            }
        } catch (e) {}
        switchTab(activeTab);
    }

    function updateSearchIndicator() {
        const input = document.getElementById('tb-name-search');
        const badge = document.getElementById('tb-search-mode');
        const hint = document.getElementById('tb-search-hint');

        input.classList.remove('tb-input--regex', 'tb-input--error', 'tb-input--id');
        badge.className = 'tb-search-mode';
        hint.style.display = 'none';

        if (!nameSearch) {
            badge.className = 'tb-search-mode tb-search-mode--text';
            badge.textContent = 'TEXT';
            return;
        }
        if (nameSearchIds) {
            input.classList.add('tb-input--id');
            badge.className = 'tb-search-mode tb-search-mode--id';
            badge.textContent = 'ID';
            badge.title = 'Wyczyść';
            hint.style.display = 'block';
            hint.className = 'tb-search-hint';
            hint.textContent = 'Tryb ID · liczbę w nazwie znajdziesz regexem, np. (404)';
        } else if (nameSearchError) {
            input.classList.add('tb-input--regex', 'tb-input--error');
            badge.className = 'tb-search-mode tb-search-mode--error';
            badge.textContent = '✕';
            hint.style.display = 'block';
            hint.className = 'tb-search-error';
            hint.textContent = nameSearchError;
        } else if (nameSearchRegex) {
            input.classList.add('tb-input--regex');
            badge.className = 'tb-search-mode tb-search-mode--regex';
            badge.textContent = '✕';
            hint.style.display = 'block';
            hint.className = 'tb-search-hint';
            hint.textContent = 'Tryb regex · użyj | jako OR, np. GA4|FB|cHTML';
        } else {
            badge.className = 'tb-search-mode tb-search-mode--clear';
            badge.textContent = '✕';
        }
    }

    // Opcje widoczne tylko tam, gdzie mają sens: ID vendorów / opcja uruchamiania → Tagi, kolumna „Tagi” → Zmienne
    function renderPausedToggle() {
        const a = document.getElementById('tb-active-toggle'), z = document.getElementById('tb-paused-toggle');
        if (a) a.classList.toggle('on', pausedMode === 1);
        if (z) z.classList.toggle('on', pausedMode === 2);
    }

    function updateSectionToggles() {
        const show = (id, on) => { const el = document.getElementById(id); if (el) el.style.display = on ? '' : 'none'; };
        show('tb-vids-toggle', currentSection === 'tags');
        show('tb-fire-toggle', currentSection === 'tags');
        show('tb-evt-toggle', currentSection === 'tags' || currentSection === 'triggers' || currentSection === 'variables');
        {
            const el = document.getElementById('tb-evt-toggle');
            const L = {
                tags: ['Pokaż event i parametry', 'Nazwa eventu (GA4, Meta, Custom HTML…) i liczba parametrów; klik w „N param.” rozwija listę pod nazwą tagu. Klik w nazwę eventu = edycja.'],
                triggers: ['Pokaż nazwę eventu', 'Nazwa eventu z warunku Custom Event ({{_event}} równa się / pasuje do regex) przy nazwie reguły. Klik = kopiuj.'],
                variables: ['Pokaż wartość i parametry', 'Wartość zmiennej (stała, klucz dataLayer, nazwa cookie, tabela…) i pozostałe parametry; klik w wartość = edycja, „N param.” rozwija listę.'],
            }[currentSection];
            if (el && L) { el.querySelector('span').textContent = L[0]; el.title = L[1]; }
        }
        show('tb-consent-toggle', currentSection === 'tags');
        show('tb-notrig-toggle', currentSection === 'tags');
        show('tb-tidy', currentSection === 'tags');
        updateOptAcc();
        renderFolderList();
        show('tb-uses-toggle', currentSection === 'variables');
        show('tb-unused-toggle', currentSection === 'variables' || currentSection === 'triggers');
        show('tb-active-toggle', currentSection === 'tags');
        show('tb-paused-toggle', currentSection === 'tags');
        show('tb-dup-toggle', currentSection === 'tags' || currentSection === 'variables');
        const dl = document.querySelector('#tb-dup-toggle > span:not(.tb-fcnt)');
        if (dl) dl.textContent = currentSection === 'variables' ? 'Tylko zduplikowane zmienne' : 'Tylko zduplikowane eventy';
        if (currentSection !== 'tags') show('tb-meta-note', false);
        // liczniki (pętle po wszystkich wierszach) — tylko gdy zmieniły się dane, wiersze albo ustawienia
        const rowsC = getDataRows();
        const ck = [currentSection, rowsC.length, dataGen, uiGen, hydrating.size, Object.values(apiStores).map(st => st.at).join(',')].join('|');
        if (ck === lastCountKey) return;
        lastCountKey = ck;
        const nc = document.getElementById('tb-notrig-cnt');
        if (nc) {
            if (currentSection === 'tags') {
                let n = 0, pending = false;
                getDataRows().forEach(r => { const v = isRowNoTrigger(r); if (v === null) pending = true; else if (v) n++; });
                nc.textContent = pending ? '…' : String(n);
                nc.style.color = n ? C.red : '';
            } else nc.textContent = '';
        }
        const dc = document.getElementById('tb-dup-cnt');
        if (dc) {
            if (currentSection === 'tags' || currentSection === 'variables') {
                let n = 0, hot = 0, pending = false;
                getDataRows().forEach(r => { const di = rowDupInfo(r); if (!di) pending = true; else if (di.dup) { n++; if (di.hot) hot++; } });
                dc.textContent = pending ? '…' : (hot ? n + ' · ⚠' + hot : String(n));
                dc.style.color = hot ? C.red : (n ? C.yellow : '');
                dc.title = hot ? hot + ' tagów wysyła ten sam event co inny AKTYWNY tag (podwójne zdarzenia!) · ' + n + ' duplikatów łącznie' : (n ? n + ' duplikatów, ale w każdej parze co najwyżej jeden tag jest aktywny' : '');
            } else dc.textContent = '';
        }
        const ac = document.getElementById('tb-active-cnt');
        if (ac) {
            if (currentSection === 'tags') {
                let n = 0, pending = false;
                getDataRows().forEach(r => { const pz = isRowPaused(r); if (pz === null) pending = true; else if (pz) n++; });
                ac.textContent = pending ? '…' : (n ? n + ' ⏸' : '0');
                ac.style.color = n ? C.yellow : '';
            } else ac.textContent = '';
        }
        const uc = document.getElementById('tb-unused-cnt');
        if (uc) {
            if (currentSection === 'variables' || currentSection === 'triggers') {
                const rows = getDataRows();
                let n = 0, pending = false;
                rows.forEach(r => { const u = isRowUnused(r); if (u === null) pending = true; else if (u) n++; });
                uc.textContent = pending ? '…' : String(n);
                uc.style.color = n ? C.red : '';
            } else uc.textContent = '';
        }
    }
    let lastCountKey = '';

    function updateOptAcc() {
        const hd = document.getElementById('tb-opt-acc-hd'), body = document.getElementById('tb-opt-acc-body'), on = document.getElementById('tb-opt-acc-on');
        if (!hd || !body) return;
        hd.classList.toggle('open', optAccOpen);
        body.style.display = optAccOpen ? '' : 'none';
        // ile opcji włączonych (widocznych w tej sekcji) — plakietka na zwiniętej harmonijce
        const ids = ['tb-ids-toggle', 'tb-vids-toggle', 'tb-fire-toggle', 'tb-evt-toggle', 'tb-consent-toggle', 'tb-uses-toggle'];
        const n = ids.filter(id => { const el = document.getElementById(id); return el && el.style.display !== 'none' && el.classList.contains('on'); }).length;
        on.textContent = n ? 'włączone: ' + n : '';
        on.style.display = (!optAccOpen && n) ? '' : 'none';
    }

    let folderListKey = '';
    function renderFolderList() {
        const sec = document.getElementById('tb-folder-section'), list = document.getElementById('tb-folder-list');
        if (!sec || !list) return;
        if (!currentSection || !API_ID_FIELD[currentSection] || currentSection === 'folders') { sec.style.display = 'none'; return; }
        const fm = apiMapFor('folders'), m = apiMapFor(currentSection);
        if (!fm || !m) { sec.style.display = 'none'; return; }
        const counts = {};
        m.forEach(it => { const f = folderNameOf(it); counts[f] = (counts[f] || 0) + 1; });
        const names = Object.keys(counts);
        if (names.length < 2) { sec.style.display = 'none'; activeFolderFilters.clear(); return; }
        sec.style.display = '';
        const key = currentSection + '|' + JSON.stringify(counts) + '|' + [...activeFolderFilters].join(',');
        if (key === folderListKey) return;
        folderListKey = key;
        list.innerHTML = '';
        names.sort((a, b) => counts[b] - counts[a] || a.localeCompare(b)).forEach(name => {
            const row = document.createElement('div');
            row.className = 'tb-frow' + (activeFolderFilters.has(name) ? ' on' : '');
            row.innerHTML = `<div class="tb-chk">✓</div><div class="tb-dot" style="background:${tc('folder:' + name)}"></div><span class="tb-fname" title="${escHtml(name)}">${escHtml(name)}</span><span class="tb-fcnt">${counts[name]}</span>`;
            row.addEventListener('click', () => {
                if (activeFolderFilters.has(name)) { activeFolderFilters.delete(name); row.classList.remove('on'); }
                else { activeFolderFilters.add(name); row.classList.add('on'); }
                folderListKey = '';
                applyAllFilters(); updateFooter();
            });
            list.appendChild(row);
        });
    }

    function updateTrigAcc() {
        const hd = document.getElementById('tb-trig-acc-hd');
        const body = document.getElementById('tb-trig-acc-body');
        const on = document.getElementById('tb-trig-acc-on');
        if (!hd || !body) return;
        hd.classList.toggle('open', trigAccOpen);
        body.style.display = trigAccOpen ? '' : 'none';
        // zwinięta harmonijka z aktywnym filtrem → plakietka, żeby nie zapomnieć o ukrytym filtrze
        const n = (trigNameSearch ? 1 : 0) + activeTrigTypeFilters.size;
        on.textContent = n > 1 ? 'aktywne: ' + n : 'aktywny';
        on.style.display = (!trigAccOpen && n) ? '' : 'none';
    }

    function updateTrigSearchIndicator() {
        updateTrigAcc();
        const input = document.getElementById('tb-trig-search');
        const badge = document.getElementById('tb-trig-mode');
        const hint = document.getElementById('tb-trig-hint');
        if (!input || !badge) return;

        input.classList.remove('tb-input--regex', 'tb-input--error', 'tb-input--id');
        badge.className = 'tb-search-mode';
        hint.style.display = 'none';

        if (!trigNameSearch) {
            badge.className = 'tb-search-mode tb-search-mode--text';
            badge.textContent = 'TEXT';
            return;
        }
        if (trigNameIds) {
            input.classList.add('tb-input--id');
            badge.className = 'tb-search-mode tb-search-mode--id';
            badge.textContent = 'ID';
            hint.style.display = 'block';
            hint.className = 'tb-search-hint';
            hint.textContent = 'Tryb ID reguły · pokazuje tagi uruchamiane przez te reguły';
        } else if (trigNameError) {
            input.classList.add('tb-input--regex', 'tb-input--error');
            badge.className = 'tb-search-mode tb-search-mode--error';
            badge.textContent = '✕';
            hint.style.display = 'block';
            hint.className = 'tb-search-error';
            hint.textContent = trigNameError;
        } else if (trigNameRegex) {
            input.classList.add('tb-input--regex');
            badge.className = 'tb-search-mode tb-search-mode--regex';
            badge.textContent = '✕';
            hint.style.display = 'block';
            hint.className = 'tb-search-hint';
            hint.textContent = 'Tryb regex · użyj | jako OR, np. CE - |Click';
        } else {
            badge.className = 'tb-search-mode tb-search-mode--clear';
            badge.textContent = '✕';
        }
    }

    function renderFilterTab() {
        const list = document.getElementById('tb-type-list'); list.innerHTML = '';
        Object.entries(typeMap).sort((a, b) => b[1] - a[1]).forEach(([name, count]) => {
            const row = document.createElement('div');
            row.className = 'tb-frow' + (activeTypeFilters.has(name) ? ' on' : '');
            row.innerHTML = `<div class="tb-chk">✓</div><div class="tb-dot" style="background:${tc(name)}"></div><span class="tb-fname" title="${escHtml(name)}">${escHtml(name)}</span><span class="tb-fcnt">${count}</span>`;
            row.addEventListener('click', () => {
                if (activeTypeFilters.has(name)) { activeTypeFilters.delete(name); row.classList.remove('on'); }
                else { activeTypeFilters.add(name); row.classList.add('on'); }
                applyAllFilters(); updateFooter();
            });
            list.appendChild(row);
        });
        updateFooter();
    }

    function renderTrigTypeList() {
        const section = document.getElementById('tb-trig-section');
        const list = document.getElementById('tb-trig-list');
        const note = document.getElementById('tb-trig-note');
        if (!section || !list) return;

        if (currentSection !== 'tags' || Object.keys(trigTypeMap).length === 0) {
            section.style.display = 'none';
            return;
        }
        section.style.display = '';
        list.innerHTML = '';

        Object.entries(trigTypeMap)
            .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
            .forEach(([name, count]) => {
                const row = document.createElement('div');
                row.className = 'tb-frow' + (activeTrigTypeFilters.has(name) ? ' on' : '');
                row.innerHTML = `<div class="tb-chk">✓</div><div class="tb-dot" style="background:${tc('trig:' + name)}"></div><span class="tb-fname" title="${escHtml(name)}">${escHtml(name)}</span><span class="tb-fcnt">${count}</span>`;
                row.addEventListener('click', () => {
                    if (activeTrigTypeFilters.has(name)) { activeTrigTypeFilters.delete(name); row.classList.remove('on'); }
                    else { activeTrigTypeFilters.add(name); row.classList.add('on'); }
                    applyAllFilters(); updateFooter();
                });
                list.appendChild(row);
            });

        // podpowiedź, gdy część reguł nie ma jeszcze znanego typu
        if (trigTypeMap[UNKNOWN_TYPE]) {
            note.style.display = '';
            note.textContent = 'Część reguł ma nieznany typ — wejdź raz na kartę Reguły, żeby je zeskanować.';
        } else {
            note.style.display = 'none';
        }
    }

    function updateFooter() {
        updateTrigAcc();
        updateSectionToggles();
        if (activeTab === 'params') {
            const ftp = document.getElementById('tb-foot-txt');
            if (ftp) ftp.textContent = `${paramsCount()} parametrów tagów` + (prmUsedDesc ? ' · dopasowane po opisie' : '') + ((prmSel.plat.size + prmSel.topic.size) ? ` · kategorie: ${prmSel.plat.size + prmSel.topic.size}` : '') + (hasActiveFilters() ? ' · filtr listy aktywny' : '');
            return;
        }
        const rows = getDataRows(), total = rows.length, visible = rows.filter(r => !r.hasAttribute('data-tb-hidden')).length;
        const ft = document.getElementById('tb-foot-txt');
        ft.textContent = !hasActiveFilters()
            ? `${Object.keys(typeMap).length} typów · ${total} elementów`
            : `${visible} z ${total} widocznych`;
        // stan API GTM (etykiety, duplikaty, kolumna Tagi, akcje ✎⧉ zależą od niego)
        const bad = Object.entries(apiStores).filter(([k, st]) => /błąd/.test(st.status || '')).map(([k, st]) => k.split('/').pop() + ': ' + st.status);
        let ap = document.getElementById('tb-foot-api');
        if (!ap) { ap = document.createElement('span'); ap.id = 'tb-foot-api'; ap.style.cssText = 'color:' + C.red + ';font-weight:700;margin-left:6px;cursor:help'; ft.after(ap); }
        ap.textContent = bad.length ? '⚠ API' : '';
        ap.title = bad.length ? 'GTM nie odpowiedział na część zapytań — etykiety i filtry mogą być niepełne. Kliknij ⟳ Odśwież.\n' + bad.join('\n') : '';
    }

    /* ═══════════════════ MODUŁ 12: POZYCJA / DRAG ═══════════════════ */
    function updateExpandDirection() {
        if (!widget) return;
        const rect = widget.getBoundingClientRect();
        const hdrH = document.getElementById('tb-hdr').offsetHeight;
        const hdrMid = rect.top + hdrH / 2;
        const viewMid = window.innerHeight / 2;
        if (hdrMid > viewMid) {
            widget.style.bottom = Math.max(8, window.innerHeight - rect.bottom) + 'px';
            widget.style.top = 'auto';
        } else {
            widget.style.top = Math.max(8, rect.top) + 'px';
            widget.style.bottom = 'auto';
        }
        keepOnScreen();
    }

    // Nagłówek widgetu musi być zawsze w oknie (inaczej nie da się go złapać i przesunąć)
    function keepOnScreen() {
        if (!widget) return;
        const r = widget.getBoundingClientRect();
        const hdrH = (document.getElementById('tb-hdr') || {}).offsetHeight || 44;
        const vw = window.innerWidth, vh = window.innerHeight;
        let fix = false;
        if (r.top < 0 || r.top > vh - hdrH) { widget.style.top = Math.max(8, Math.min(r.top, vh - hdrH - 8)) + 'px'; widget.style.bottom = 'auto'; fix = true; }
        if (r.left < 0 || r.left > vw - 60) { widget.style.left = Math.max(0, Math.min(r.left, vw - r.width)) + 'px'; widget.style.right = 'auto'; fix = true; }
        if (fix && r.top < 0) { widget.style.top = '8px'; widget.style.bottom = 'auto'; }
    }
    window.addEventListener('resize', () => { try { keepOnScreen(); } catch (e) {} });

    /* ═══════════════════ DRAG ═══════════════════ */
    function makeDraggable(el, handle) {
        let sx, sy, sl, st;
        handle.addEventListener('mousedown', e => {
            if (e.target.closest('button')) return; e.preventDefault();
            sx = e.clientX; sy = e.clientY; const r = el.getBoundingClientRect(); sl = r.left; st = r.top;
            const mv = ev => { el.style.left = (sl+ev.clientX-sx)+'px'; el.style.top = (st+ev.clientY-sy)+'px'; el.style.right = 'auto'; el.style.bottom = 'auto'; };
            const up = () => {
                document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up);
                updateExpandDirection();
                try { const r2 = el.getBoundingClientRect(); localStorage.setItem(LS_POS, JSON.stringify({ l: Math.round(r2.left), t: Math.round(r2.top) })); } catch (e) {}
            };
            document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up);
        });
    }

    /* ═══════════════════ MODUŁ 13: PĘTLA / OBSERVER ═══════════════════ */
    function poll() { return timed('poll', pollCore); }
    function pollCore() {
        try { keepOnScreen(); } catch (e) {}
        // zmiana kontenera → przeładuj mapę typów reguł
        if (containerKey() !== lastContainerKey) loadTrigTypeStore();

        const section = detectSection();
        if (section !== currentSection) {
            currentSection = section;
            clearAllFilters();
            const si = document.getElementById('tb-name-search');
            if (si) si.value = '';
            const ts = document.getElementById('tb-trig-search');
            if (ts) ts.value = '';
            updateSearchIndicator();
            updateTrigSearchIndicator();
        }
        document.getElementById('tb-badge').textContent = sectionLabel(currentSection);
        // ciężkie rzeczy (liczniki, skan typów, filtry, etykiety, kolejność) tylko gdy coś się zmieniło od ostatniego cyklu
        const rows0 = currentSection ? getDataRows() : [];
        const fp = [currentSection, rows0.length, rows0[0], rows0[rows0.length - 1], dataGen, uiGen].map(x => (x && x.nodeType) ? (x.__tbId || (x.__tbId = ++domIdSeq)) : String(x)).join('|');
        if (fp === lastPollFp) { updateFooter(); return; }
        lastPollFp = fp;
        timed('updateSectionToggles', updateSectionToggles);
        if (!currentSection) {
            typeMap = {}; trigTypeMap = {}; lastScanKey = '';
            renderFilterTab(); renderTrigTypeList();
            return;
        }

        // na stronie Reguł: buduj mapę nazwa→typ do wykorzystania na Tagach
        harvestTriggerTypes();

        const newTypes = scanTypes();
        const newTrigTypes = scanTriggerTypes();
        const key = JSON.stringify([newTypes, newTrigTypes]);
        if (key !== lastScanKey) {
            lastScanKey = key;
            typeMap = newTypes;
            trigTypeMap = newTrigTypes;
            renderFilterTab();
            renderTrigTypeList();
        }
        if (hasActiveFilters()) applyAllFilters();
        renderIdBadges();
        syncOrder();
        updateFooter();
    }
    let lastPollFp = '', domIdSeq = 0, dataGen = 0, uiGen = 0;
    function bumpData() { dataGen++; lastPollFp = ''; }
    function bumpUi() { uiGen++; lastPollFp = ''; invalidateRows(); }

    /* ═══════════════════ TABLE OBSERVER ═══════════════════ */
    let tableObserver = null, obsT = null;
    const observedTables = new WeakSet();

    function watchTable() {
        if (!tableObserver) {
            tableObserver = new MutationObserver(muts => {
                // własne zmiany (etykiety, ikonki, listy parametrów) nie wymagają ponownego renderu
                const ours = n => n.nodeType === 1 ? /(^|\s)(tb-|gtm-tb)/.test(n.className || '') : (n.parentElement && !!n.parentElement.closest('.tb-meta,.tb-plist,.tb-act,.tb-uses-col,.' + ID_BADGE));
                const own = muts.every(m => (m.target.nodeType === 1 && m.target.closest && m.target.closest('.tb-meta,.tb-plist,.tb-act,.tb-uses-col')) || ([...m.addedNodes, ...m.removedNodes].length > 0 && [...m.addedNodes, ...m.removedNodes].every(ours)));
                if (own) return;
                invalidateRows();
                clearTimeout(obsT);
                obsT = setTimeout(() => timed('observer', () => { if (hasActiveFilters()) applyAllFilters(); renderIdBadges(); }), 120);
            });
        }
        findDataTables().forEach(table => {
            if (observedTables.has(table)) return;
            observedTables.add(table);
            tableObserver.observe(table, { childList: true, subtree: true });
        });
    }

    /* ═══════════════════ MODUŁ 14: DEBUG ═══════════════════ */
    // W konsoli: gtmTbDebug() — pokaże co skrypt widzi
    window.gtmTbDebug = function () {
        const tables = findDataTables();
        console.log('[GTM Filter] sekcja:', currentSection, '· kontener:', containerKey());
        console.log('[GTM Filter] tabel:', tables.length, tables);
        tables.forEach((table, ti) => {
            const cols = resolveColumns(table);
            console.log(`[GTM Filter] tabela #${ti} — name:`, cols.nameIdx, 'type:', cols.typeIdx, 'trig:', cols.trigIdx);
        });
        const rows = getDataRows();
        console.log('[GTM Filter] wierszy łącznie:', rows.length);
        rows.slice(0, 8).forEach(r => console.log('  →', '#' + idFromRow(r), nameFromRow(r), '|', typeFromRow(r), '|', triggersFromRow(r).map(t => `${t.name} [${t.type}]`).join(', ')));
        const noId = rows.filter(r => idFromRow(r) === null);
        console.log('[GTM Filter] wierszy bez ID:', noId.length);
        if (noId.length) {
            console.log('[GTM Filter] pierwszy wiersz bez ID (HTML):', noId[0].outerHTML.slice(0, 1500));
            const ng = window.angular;
            console.log('[GTM Filter] angular dostępny:', !!(ng && ng.element));
            if (ng && ng.element) [noId[0], ...noId[0].querySelectorAll('*')].forEach(el => {
                let d; try { d = ng.element(el).data(); } catch (e) { return; }
                if (!d || !Object.keys(d).length) return;
                Object.keys(d).forEach(k => {
                    const v = d[k];
                    let ks = ''; try { ks = v && typeof v === 'object' ? Object.keys(v).join(', ') : String(v); } catch (e) {}
                    console.log('   <' + el.tagName.toLowerCase() + '> data.' + k + ' → {' + ks.slice(0, 300) + '}');
                });
            });
        }
        if (rows[0]) {
            const ng = window.angular;
            console.log('[GTM Filter] angular dostępny:', !!(ng && ng.element), '· dane Angulara 1. wiersza:');
            if (ng && ng.element) [rows[0], ...rows[0].querySelectorAll('*')].forEach(el => {
                let d; try { d = ng.element(el).data(); } catch (e) { return; }
                if (!d || !Object.keys(d).length) return;
                Object.keys(d).forEach(k => {
                    const v = d[k];
                    let ks = '';
                    try {
                        ks = v && typeof v === 'object' ? Object.keys(v).map(kk => {
                            let vv; try { vv = v[kk]; } catch (e) { return kk; }
                            if (vv && typeof vv === 'object' && !vv.nodeType) { try { return kk + ':{' + Object.keys(vv).slice(0, 15).join(',') + '}'; } catch (e) {} }
                            return kk;
                        }).join(', ') : String(v);
                    } catch (e) {}
                    console.log('   <' + el.tagName.toLowerCase() + '> data.' + k + ' → {' + ks.slice(0, 600) + '}');
                });
            });
            const r0 = rows[0];
            console.log('[GTM Filter] HTML 1. wiersza:', r0.outerHTML.slice(0, 2500));
            const desc = c => c === undefined ? '—' : Array.isArray(c) ? 'tablica[' + c.length + ']' : typeof c === 'object' ? 'obiekt{' + Object.keys(c).slice(0, 10).join(',') + '}' : typeof c + ' ' + String(c).slice(0, 40);
            [r0, ...r0.querySelectorAll('*')].forEach(el => {
                let own = [];
                try { own = Object.getOwnPropertyNames(el).filter(k => !/^on/.test(k)); } catch (e) {}
                if (own.length) console.log('   <' + el.tagName.toLowerCase() + '> własne pola: ' + own.join(', ') + ' · __ngContext__: ' + desc(el.__ngContext__));
            });
            console.log('[GTM Filter] window.ng:', typeof window.ng, '· getAllAngularRootElements:', typeof window.getAllAngularRootElements);
            try {
                const urls = performance.getEntriesByType('resource').map(e => e.name)
                    .filter(u => /tagmanager\.google\.com\/(?!.*\.(js|css|png|svg|woff2?)(\?|$))/.test(u));
                console.log('[GTM Filter] ostatnie requesty do tagmanager.google.com:\n' + urls.slice(-25).map(u => '   ' + u.slice(0, 220)).join('\n'));
            } catch (e) {}
            try {
                const c = window.ng && window.ng.getContext ? window.ng.getContext(r0) : null;
                console.log('[GTM Filter] ng.getContext(wiersz):', c ? desc(c) : '—', '· $implicit:', c && c.$implicit ? desc(c.$implicit) : '—');
            } catch (e) { console.log('[GTM Filter] ng.getContext błąd:', e && e.message); }
            const base = wsApiBase();
            if (base && API_ID_FIELD[currentSection]) {
                const url = base + '/' + currentSection + '?hl=en';
                fetch(url, { credentials: 'same-origin' }).then(r => r.text().then(t => {
                    console.log('[GTM Filter] API ' + url + ' → HTTP ' + r.status + ', ' + t.length + ' zn.; początek:\n' + t.slice(0, 1500));
                    try {
                        const map = collectApiItems(parseApiText(t), API_ID_FIELD[currentSection]);
                        console.log('[GTM Filter] API: elementów z ' + API_ID_FIELD[currentSection] + ':', map.size);
                        const first = map.get(idFromRow(r0)) || map.values().next().value;
                        const fo = {};
                        map.forEach(it => {
                            const d = it.data || {};
                            const k = 'tagFiringOption' in d ? String(d.tagFiringOption) : '(brak)';
                            (fo[k] = fo[k] || []).push(apiItemName(it));
                        });
                        if (currentSection === 'tags') console.log('[GTM Filter] tagFiringOption w API → tagi:\n' + Object.entries(fo).map(([k, arr]) => '   ' + k + ' (' + arr.length + '): ' + arr.slice(0, 4).join(' · ')).join('\n'));
                        if (first) {
                            console.log('[GTM Filter] API 1. element (JSON, 3000 zn.):', JSON.stringify(first).slice(0, 3000));
                            console.log('[GTM Filter] API → ID vendorów:', vendorIdsFromItem(first), '· opcja uruchamiania:', firingFromItem(first));
                        }
                    } catch (e) { console.log('[GTM Filter] API parse błąd:', e && e.message); }
                })).catch(e => console.log('[GTM Filter] API fetch błąd:', e && e.message));
            }
            try {
                const vst = apiStores[(wsApiBase() || '') + '/variables'];
                console.log('[GTM Filter] API zmiennych:', vst ? (vst.status || '—') : 'nie pobrane', vst && vst.map ? '· elementów: ' + vst.map.size : '');
                if (vst && vst.sample) console.log('[GTM Filter] API zmiennych — początek odpowiedzi:\n' + vst.sample.slice(0, 900));
                const vr = varResolveMap();
                if (vr) console.log('[GTM Filter] zmienne → ID (pierwsze 15):\n' + [...vr.entries()].filter(([, r]) => r.ids.length || r.raw).slice(0, 15)
                    .map(([n, r]) => '   ' + n + ' → ' + (r.ids.map(x => x.v + ' ' + x.id).join(', ') || '—') + (r.raw ? ' · wartość: ' + r.raw : '')).join('\n'));
                else console.log('[GTM Filter] zmienne: API zmiennych jeszcze się ładuje');
            } catch (e) {}
            console.log('[GTM Filter] stan API w skrypcie:', apiStore.status || '—', apiStore.loading ? '(ładowanie)' : '');
            const it = ngItemFromRow(rows[0]);
            console.log('[GTM Filter] obiekt elementu (1. wiersz) znaleziony:', !!it);
            if (it) {
                console.log('[GTM Filter]   ID vendorów:', vendorIdsFromItem(it), '· opcja uruchamiania:', firingFromItem(it));
                const seen = new WeakSet();
                let js = '';
                try { js = JSON.stringify(it, (k, v) => { if (k.charAt(0) === '$') return undefined; if (v && typeof v === 'object') { if (seen.has(v)) return '[cykl]'; seen.add(v); } return v; }); } catch (e) { js = String(e); }
                console.log('[GTM Filter]   obiekt (JSON, 3000 zn.):', js.slice(0, 3000));
            }
        }
        {
            const tok = xsrfToken();
            console.log('[GTM Filter] token XSRF:', tok ? (tok.slice(0, 12) + '… (źródło: ' + (xsrfSeen.from || 'nagłówek/ciasteczko') + ')') : 'NIE ZNALEZIONY');
            console.log('[GTM Filter] ciasteczka (nazwy):', document.cookie.split(';').map(c => c.trim().split('=')[0]).filter(Boolean).join(', ') || '(brak czytelnych)');
            const cands = [];
            document.querySelectorAll('script:not([src])').forEach((sc, i) => { const t = sc.textContent || ''; let m; const re = /.{0,60}(xsrf|csrf).{0,120}/ig; while ((m = re.exec(t)) && cands.length < 8) cands.push('script#' + i + ': ' + m[0].replace(/\s+/g, ' ')); });
            const html = document.documentElement.outerHTML; let m; const re2 = /.{0,60}(xsrf|csrf).{0,120}/ig; let n = 0;
            while ((m = re2.exec(html)) && n++ < 6) cands.push('html: ' + m[0].replace(/\s+/g, ' '));
            console.log('[GTM Filter] wystąpienia „xsrf/csrf” na stronie:\n' + (cands.join('\n') || '   (brak)'));
            try { const ks = Object.keys(window).filter(k => /xsrf|csrf|token/i.test(k)); console.log('[GTM Filter] globalne zmienne z xsrf/csrf/token:', ks.join(', ') || '(brak)'); } catch (e) {}
        }
        if (currentSection === 'tags') {
            const rs = getDataRows();
            const tmx = apiMapFor('tags');
            const line = r => { const id = idFromRow(r); const it = tmx && id !== null ? tmx.get(id) : null; return '#' + id + ' ' + nameFromRow(r).slice(0, 40) + ' · DOM:' + rowPausedDom(r) + ' · API:' + (it ? String((it.data || {}).paused) : '—') + ' · wynik:' + isRowPaused(r); };
            console.log('[GTM Filter] pauza (pierwsze 10 wierszy):\n' + rs.slice(0, 10).map(line).join('\n'));
            console.log('[GTM Filter] pauza: tryb filtra =', pausedMode, '(0 wszystkie, 1 tylko niezapauzowane, 2 tylko zapauzowane) · ukrytych wierszy:', rs.filter(r => r.hasAttribute('data-tb-hidden')).length);
        }
        if (currentSection === 'variables') {
            const vm = apiMapFor('variables');
            if (vm) {
                let lack = 0, user = 0;
                vm.forEach(it => { if (!it.isBuiltIn) { user++; if (itemLacksParams(it)) lack++; } });
                console.log('[GTM Filter] zmienne: użytkownika ' + user + ', bez parametrów (nie dociągnięte) ' + lack + ' · dociąganie trwa: ' + hydrating.has((wsApiBase() || '') + '/variables') + ' · cache pełnych: ' + fullCache.size);
                const first = [...vm.values()].find(it => !it.isBuiltIn && !itemLacksParams(it));
                if (first) console.log('[GTM Filter] zmienna z parametrami (JSON, 2500 zn.):', JSON.stringify(first).slice(0, 2500), '\n   → info:', JSON.stringify(variableInfoFromItem(first)).slice(0, 600));
                const uv = [...vm.entries()].find(([, it]) => !it.isBuiltIn);
                if (uv) fetchFullItem('variables', uv[0]).then(full => {
                    console.log('[GTM Filter] pojedyncza zmienna GET …/variables/' + uv[0] + ' (JSON, 3000 zn.):', JSON.stringify(full).slice(0, 3000));
                    console.log('[GTM Filter]   data.vendorTemplate.param:', full && full.data && full.data.vendorTemplate ? (full.data.vendorTemplate.param || []).length : '—', '· properties.vendorTemplate.param:', full && full.properties && full.properties.vendorTemplate ? (full.properties.vendorTemplate.param || []).length : '—');
                }).catch(e => console.log('[GTM Filter] pojedyncza zmienna: błąd', e && e.message));
                const dm = dupEventMap();
                if (dm) { const grp = [...dm.entries()].filter(([, a]) => a.length > 1).slice(0, 6); console.log('[GTM Filter] duplikaty zmiennych (klucz → nazwy):\n' + (grp.map(([k, a]) => '   ' + k.slice(0, 160) + ' → ' + a.join(' · ')).join('\n') || '   (brak)')); }
                else console.log('[GTM Filter] duplikaty zmiennych: jeszcze nie policzone (dociąganie)');
            }
        }
        console.log('[GTM Filter] czas (ms): ' + Object.entries(perf).map(([k, p]) => k + ' ×' + p.n + ' = ' + p.ms.toFixed(0) + ' (śr. ' + (p.ms / p.n).toFixed(1) + ', max ' + p.max.toFixed(0) + ')').join(' · '));
        const w = window.gtmTbWrites || [];
        console.log('[GTM Filter] zapisy do API w tej sesji (' + w.length + '):');
        w.forEach(x => console.log('   ' + x.time.slice(11, 19) + ' ' + x.via + ' ' + x.method + ' ' + x.url + (x.status ? ' → HTTP ' + x.status : '') + '\n      nagłówki: ' + JSON.stringify(x.headers) + '\n      body (' + x.bodyLen + ' zn.): ' + x.body.slice(0, 2500) + (x.resp ? '\n      odpowiedź: ' + x.resp.slice(0, 800) : '')));
        console.log('[GTM Filter] typeMap:', typeMap);
        console.log('[GTM Filter] trigTypeMap:', trigTypeMap);
        console.log('[GTM Filter] trigTypeStore (nazwa→typ):', trigTypeStore);
    };

    /* ═══════════════════ MODUŁ 15: INIT ═══════════════════ */
    function init() {
        loadTrigTypeStore();
        buildWidget(); updateExpandDirection(); poll(); watchTable();
        setInterval(() => { poll(); watchTable(); }, POLL_MS);
        let lastUrl = location.href;
        new MutationObserver(() => {
            if (location.href !== lastUrl) {
                lastUrl = location.href;
                setTimeout(() => { poll(); watchTable(); }, 600);
            }
        }).observe(document.body, { childList: true, subtree: true });
    }
    if (document.readyState === 'complete') setTimeout(init, 1500);
    else window.addEventListener('load', () => setTimeout(init, 1500));
})();

Dodaj komentarz

Paweł Piekarski

Paweł Piekarski

Doktorat z nauk technicznych, ponad 10 lat analizy danych na uczelni i ponad 3 lata w analityce marketingowej.

Z nauki wyniosłem jedną zasadę: wnioski są tyle warte, co dane, na których się opierają. Dlatego najpierw porządkuję dane, a potem pomagam przełożyć je na decyzje, które przynoszą zyski.

Pracuję jako freelancer. Specjalizuję się w GTM, GA4 i BigQuery. Przeprowadzam audyty, wdrożenia od zera (albo po kimś) i opiekę analityczną.

Prowadzę kanał YouTube z ponad 50 filmami o analityce internetowej i biznesowej. Dzielę się darmowymi treściami, żeby ogarnąć analitykę samodzielnie.

Napisz do mnie

Paweł Piekarski
Paweł Piekarski Digital Analyst Marketing oparty na danych

Dane kontaktowe

+48 725 473 745

poczta@pawelpiekarski.pl

linkedin.com/in/pawelpiekarskipl

    Wysyłając wiadomość, zgadzasz się na kontakt i przetwarzanie danych zgodnie z polityką prywatności.