FetLife Enhancer

FetLife browsing: masonry home feed, story filter, mute, ASL/gender filter, auto-follow, place kinksters nav, infinite scroll, privacy presets. Community build — no analytics.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         FetLife Enhancer
// @namespace    https://sleazyfork.org/users/1618643-ianmxaof
// @homepageURL  https://telegram.me/aofsubscriptions_bot
// @version      1.0.0
// @description  FetLife browsing: masonry home feed, story filter, mute, ASL/gender filter, auto-follow, place kinksters nav, infinite scroll, privacy presets. Community build — no analytics.
// @author       AOF community fork
// @match        https://fetlife.com/*
// @match        https://www.fetlife.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_addStyle
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

/* AOF community build 2026-08-22T04:11:01.524Z - v1.0.0 - see tbcc/userscripts/NOTICE.md */

(function (g) { g.__TBCC_EDITION__ = 'community'; })(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);

/* ---- packages/shared/ns.js ---- */
/* TBCC userscripts shared bootstrap — concatenated first */
(function (global) {
  'use strict';
  const root = (global.__TBCC_US__ = global.__TBCC_US__ || {});
  root.shared = root.shared || {};
  root.suites = root.suites || {};
  root.version = root.version || '0.1.0';
})(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);

/* ---- packages/shared/storage.js ---- */
/* GM storage helpers; Chrome extension uses localStorage; mem last-resort */
(function (global) {
  'use strict';
  const S = global.__TBCC_US__.shared;
  const mem = Object.create(null);
  const LS_PREFIX = 'tbcc_us_';

  function hasGM() {
    return typeof GM_getValue === 'function' && typeof GM_setValue === 'function';
  }

  function lsGet(key, fallback) {
    try {
      if (typeof localStorage === 'undefined') return fallback;
      const raw = localStorage.getItem(LS_PREFIX + key);
      if (raw == null) return fallback;
      return JSON.parse(raw);
    } catch (_) {
      return fallback;
    }
  }

  function lsSet(key, value) {
    try {
      if (typeof localStorage === 'undefined') return false;
      localStorage.setItem(LS_PREFIX + key, JSON.stringify(value));
      return true;
    } catch (_) {
      return false;
    }
  }

  S.storage = {
    get(key, fallback) {
      try {
        if (hasGM()) {
          const v = GM_getValue(key, fallback);
          return v === undefined ? fallback : v;
        }
      } catch (_) { /* ignore */ }
      if (typeof localStorage !== 'undefined') {
        return lsGet(key, key in mem ? mem[key] : fallback);
      }
      return key in mem ? mem[key] : fallback;
    },
    set(key, value) {
      try {
        if (hasGM()) {
          GM_setValue(key, value);
          return;
        }
      } catch (_) { /* ignore */ }
      if (lsSet(key, value)) return;
      mem[key] = value;
    },
  };
})(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);

/* ---- packages/shared/flags.js ---- */
/* Feature flags: defaults + persisted overrides */
(function (global) {
  'use strict';
  const S = global.__TBCC_US__.shared;

  S.createFlags = function createFlags(storageKey, defaults) {
    const saved = S.storage.get(storageKey, null) || {};
    const state = { ...defaults, ...(typeof saved === 'object' ? saved : {}) };

    return {
      get(name) {
        return state[name] !== false;
      },
      raw(name) {
        return state[name];
      },
      set(name, on) {
        state[name] = !!on;
        S.storage.set(storageKey, { ...state });
      },
      all() {
        return { ...state };
      },
      defaults: { ...defaults },
    };
  };
})(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);

/* ---- packages/shared/observer.js ---- */
/* Debounced document MutationObserver bus */
(function (global) {
  'use strict';
  const S = global.__TBCC_US__.shared;
  const listeners = new Set();
  let mo = null;
  let timer = null;

  function flush() {
    timer = null;
    for (const fn of listeners) {
      try {
        fn();
      } catch (err) {
        console.warn('[TBCC_US] observer listener error', err);
      }
    }
  }

  S.observer = {
    subscribe(fn) {
      listeners.add(fn);
      if (!mo && typeof MutationObserver !== 'undefined' && document.documentElement) {
        mo = new MutationObserver(() => {
          if (timer) return;
          timer = setTimeout(flush, 120);
        });
        mo.observe(document.documentElement, { childList: true, subtree: true });
      }
      return () => listeners.delete(fn);
    },
    ping() {
      flush();
    },
  };
})(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);

/* ---- packages/shared/spa.js ---- */
/* SPA navigation hooks (pushState / replaceState / popstate) */
(function (global) {
  'use strict';
  const S = global.__TBCC_US__.shared;
  const listeners = new Set();
  let hooked = false;

  function emit() {
    for (const fn of listeners) {
      try {
        fn(location.href);
      } catch (err) {
        console.warn('[TBCC_US] spa listener error', err);
      }
    }
  }

  function hookHistory(method) {
    const orig = history[method];
    history[method] = function () {
      const ret = orig.apply(this, arguments);
      setTimeout(emit, 50);
      return ret;
    };
  }

  S.spa = {
    onChange(fn) {
      listeners.add(fn);
      if (!hooked) {
        hooked = true;
        hookHistory('pushState');
        hookHistory('replaceState');
        window.addEventListener('popstate', () => setTimeout(emit, 50));
      }
      return () => listeners.delete(fn);
    },
    path() {
      return location.pathname || '';
    },
  };
})(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);

/* ---- packages/shared/ui-panel.js ---- */
/* Minimal floating settings shell */
(function (global) {
  'use strict';
  const S = global.__TBCC_US__.shared;

  S.ensureStyle = function ensureStyle(id, css) {
    if (document.getElementById(id)) return;
    const el = document.createElement('style');
    el.id = id;
    el.textContent = css;
    document.documentElement.appendChild(el);
  };

  S.mountFlagPanel = function mountFlagPanel(opts) {
    const { id, title, flags, labels, onChange } = opts;
    const panelId = id + '-panel';
    const fabId = id + '-fab';

    S.ensureStyle(
      id + '-style',
      `
      #${panelId} {
        position: fixed; z-index: 999999; right: 16px; bottom: 64px;
        width: min(360px, calc(100vw - 24px)); max-height: min(70vh, 560px);
        overflow: auto; background: #1a1a1a; color: #d4d4d4;
        border: 1px solid #333; border-radius: 8px; box-shadow: 0 8px 28px rgba(0,0,0,.55);
        font: 13px/1.35 system-ui, sans-serif; display: none;
      }
      #${panelId}.open { display: block; }
      #${panelId} header {
        position: sticky; top: 0; background: #222; padding: 10px 12px;
        border-bottom: 1px solid #333; display: flex; gap: 8px; align-items: center;
      }
      #${panelId} header strong { flex: 1; }
      #${panelId} header button, #${fabId} {
        background: #333; color: #eee; border: 1px solid #555; border-radius: 6px;
        padding: 6px 10px; cursor: pointer;
      }
      #${fabId} { position: fixed; z-index: 999998; right: 16px; bottom: 16px; }
      #${panelId} label { display: flex; gap: 8px; padding: 6px 12px; cursor: pointer; }
      #${panelId} .note { padding: 8px 12px 12px; color: #888; font-size: 12px; }
    `
    );

    if (!document.getElementById(panelId)) {
      const panel = document.createElement('div');
      panel.id = panelId;
      panel.innerHTML = `<header><strong>${title}</strong><button type="button" data-act="close">Close</button></header>
        <div class="note">Flags persist in extension localStorage (or TM if present). Reload if a feature looks stuck.</div>`;
      const all = flags.all();
      Object.keys(all).forEach((key) => {
        const lab = document.createElement('label');
        const cb = document.createElement('input');
        cb.type = 'checkbox';
        cb.checked = all[key] !== false;
        cb.dataset.flag = key;
        cb.addEventListener('change', () => {
          flags.set(key, cb.checked);
          if (onChange) onChange(key, cb.checked);
        });
        lab.appendChild(cb);
        lab.appendChild(document.createTextNode(labels[key] || key));
        panel.appendChild(lab);
      });
      panel.querySelector('[data-act="close"]').addEventListener('click', () => panel.classList.remove('open'));
      document.documentElement.appendChild(panel);

      const fab = document.createElement('button');
      fab.id = fabId;
      fab.type = 'button';
      fab.textContent = opts.fabLabel || 'Suite';
      fab.addEventListener('click', () => panel.classList.toggle('open'));
      document.documentElement.appendChild(fab);
    }

    function open() {
      document.getElementById(panelId)?.classList.add('open');
    }

    if (typeof GM_registerMenuCommand === 'function') {
      try {
        GM_registerMenuCommand(opts.menuLabel || `${title}: settings`, open);
      } catch (_) { /* ignore */ }
    }

    return { open };
  };
})(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);

/* ---- packages/fetlife-suite/features/story-filter-core.js ---- */
/**
 * Pure story-type catalog + classifier (no DOM).
 * Used by FetLife suite story-filter feature and unit tests.
 */
(function (root, factory) {
  'use strict';
  const api = factory();
  if (typeof module !== 'undefined' && module.exports) {
    module.exports = api;
  }
  const g = root.__TBCC_US__ || (root.__TBCC_US__ = { shared: {}, suites: {} });
  g.fetlife = g.fetlife || {};
  g.fetlife.storyFilterCore = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
  'use strict';

  const CATALOG = [
    {
      category: 'General Profile',
      items: [
        { id: 'friendship_request_accepted', label: 'Accepted a Friendship Request', defaultOn: true },
        { id: 'sign_ups', label: 'Sign ups & Invitations', defaultOn: true },
        { id: 'following', label: 'Following a Member', defaultOn: true },
      ],
    },
    {
      category: 'Profile Updates',
      items: [
        { id: 'profile_updates', label: 'Posted a Profile Update', defaultOn: true },
        { id: 'commented_on_profile_update', label: 'Commented on a Profile Update', defaultOn: true },
        { id: 'loved_profile_update', label: 'Loved a Profile Update', defaultOn: true },
        { id: 'superloved_profile_update', label: 'Superloved a Profile Update', defaultOn: true },
        { id: 'fetish_added', label: 'Added/Updated a Fetish', defaultOn: false },
      ],
    },
    {
      category: 'Relationship Updates',
      items: [
        { id: 'relationship_changes', label: 'Posted a Relationship Update', defaultOn: true },
        { id: 'commented_on_relationship', label: 'Commented on a Relationship Update', defaultOn: true },
        { id: 'loved_relationship', label: 'Loved a Relationship Update', defaultOn: true },
        { id: 'superloved_relationship_update', label: 'Superloved a Relationship Update', defaultOn: true },
      ],
    },
    {
      category: 'Status Updates',
      items: [
        { id: 'status_created', label: 'Posted a Status Update', defaultOn: true },
        { id: 'status_comment_created', label: 'Commented on a Status Update', defaultOn: true },
        { id: 'loved_status_update', label: 'Loved a Status Update', defaultOn: true },
        { id: 'superloved_status_update', label: 'Superloved a Status Update', defaultOn: true },
      ],
    },
    {
      category: 'Pictures',
      items: [
        { id: 'picture_created', label: 'Uploaded a New Picture/Album', defaultOn: true },
        { id: 'comment_created', label: 'Commented on a Picture/Album', defaultOn: true },
        { id: 'loved_picture', label: 'Loved a Picture/Album', defaultOn: true },
        { id: 'superloved_picture', label: 'Superloved a Picture/Album', defaultOn: true },
        { id: 'user_tagged_in_picture_approved', label: 'Tagged in a Picture', defaultOn: true },
        { id: 'tagged_picture_trending_created', label: 'Trending Picture', defaultOn: true },
      ],
    },
    {
      category: 'Videos',
      items: [
        { id: 'video_created', label: 'Uploaded a New Video', defaultOn: true },
        { id: 'video_comment_created', label: 'Commented on a Video', defaultOn: true },
        { id: 'loved_video', label: 'Loved a Video', defaultOn: true },
        { id: 'superloved_video', label: 'Superloved a Video', defaultOn: true },
        { id: 'user_tagged_in_video_approved', label: 'Tagged in a Video', defaultOn: true },
        { id: 'tagged_video_trending_created', label: 'Trending Video', defaultOn: true },
      ],
    },
    {
      category: 'Writings',
      items: [
        { id: 'post_created', label: 'Posted a New Writing/Collection', defaultOn: true },
        { id: 'post_comment_created', label: 'Commented on a Writing/Collection', defaultOn: true },
        { id: 'loved_writing', label: 'Loved a Writing/Collection', defaultOn: true },
        { id: 'superloved_writing', label: 'Superloved a Writing/Collection', defaultOn: true },
        { id: 'tagged_writing_trending_created', label: 'Trending Writing', defaultOn: true },
      ],
    },
    {
      category: 'Wall Posts',
      items: [
        { id: 'wall_posts', label: 'Posted on a Wall', defaultOn: true },
        { id: 'commented_on_wall_post', label: 'Commented on a Wall Post', defaultOn: true },
        { id: 'loved_wall_post', label: 'Loved a Wall Post', defaultOn: true },
        { id: 'superloved_wall_post', label: 'Superloved a Wall Post', defaultOn: true },
      ],
    },
    {
      category: 'Community Lists',
      items: [
        { id: 'community_list_created', label: 'Posted a Community List', defaultOn: true },
        { id: 'community_list_comment_created', label: 'Commented on a Community List', defaultOn: true },
        { id: 'loved_community_list', label: 'Loved a Community List', defaultOn: true },
        { id: 'superloved_community_list', label: 'Superloved a Community List', defaultOn: true },
      ],
    },
    {
      category: 'Ask Me Anything',
      items: [
        { id: 'ask_me_anything_story_created', label: 'Posted an AMA story', defaultOn: true },
        { id: 'ask_me_anything_story_comment_created', label: 'Commented on an AMA story', defaultOn: true },
        { id: 'loved_ask_me_anything_story', label: 'Loved an AMA story', defaultOn: true },
        { id: 'superloved_ama', label: 'Superloved an AMA story', defaultOn: true },
      ],
    },
    {
      category: 'Events',
      items: [
        { id: 'event_created', label: 'Created a New Event', defaultOn: true },
        { id: 'rsvp_created', label: 'RSVPed to an Event', defaultOn: true },
        { id: 'rsvp_updated', label: 'RSVP updated', defaultOn: true },
        { id: 'event_discussion_created', label: 'Posted Event Discussion', defaultOn: true },
        { id: 'loved_event_discussion', label: 'Loved Event Discussion', defaultOn: true },
        { id: 'superloved_event_discussion', label: 'Superloved an Event Discussion', defaultOn: true },
        { id: 'commented_on_event_discussion', label: 'Commented on Event Discussion', defaultOn: true },
      ],
    },
    {
      category: 'Groups - General',
      items: [
        { id: 'became_group_leader', label: 'Became Leader of a Group', defaultOn: true },
        { id: 'group_membership_created', label: 'Joined a Group', defaultOn: true },
      ],
    },
    {
      category: 'Groups - Member Of',
      items: [
        { id: 'group_post_being_member_by_friend', label: 'New Discussion by someone you follow', defaultOn: true },
        { id: 'group_post_being_member', label: "New Discussion by someone you don't follow", defaultOn: false },
        { id: 'group_comment_created_being_member_by_friend', label: 'Comment on a Discussion by someone you follow', defaultOn: true },
        { id: 'loved_group_discussion_being_member', label: 'Loved a Group Discussion', defaultOn: true },
        { id: 'superloved_group_discussion_being_member', label: 'Superloved a Group Discussion', defaultOn: true },
      ],
    },
    {
      category: 'Groups - Not Member Of',
      items: [
        { id: 'group_post_not_being_member', label: 'New Discussion', defaultOn: false },
        { id: 'group_comment_not_being_member', label: 'Comment on a Discussion', defaultOn: false },
        { id: 'loved_group_discussion_not_being_member', label: 'Loved a Group Discussion', defaultOn: true },
        { id: 'superloved_group_discussion_not_being_member', label: 'Superloved a Group Discussion', defaultOn: true },
      ],
    },
  ];

  const MATCHERS = [
    { id: 'superloved_picture', re: /superloved .{0,80}(picture|album|pic)\b/i },
    { id: 'loved_picture', re: /\bloved .{0,80}(picture|album|pic)\b/i },
    { id: 'comment_created', re: /commented on .{0,80}(picture|album|pic)\b/i },
    { id: 'user_tagged_in_picture_approved', re: /tagged .{0,40}in .{0,40}(picture|album|pic)\b/i },
    { id: 'tagged_picture_trending_created', re: /trending .{0,40}(picture|album|pic)\b/i },
    { id: 'picture_created', re: /(uploaded|posted|added) .{0,40}(new )?(picture|album|pic)\b/i },
    { id: 'superloved_video', re: /superloved .{0,80}video\b/i },
    { id: 'loved_video', re: /\bloved .{0,80}video\b/i },
    { id: 'video_comment_created', re: /commented on .{0,80}video\b/i },
    { id: 'user_tagged_in_video_approved', re: /tagged .{0,40}in .{0,40}video\b/i },
    { id: 'tagged_video_trending_created', re: /trending .{0,40}video\b/i },
    { id: 'video_created', re: /(uploaded|posted|added) .{0,40}(new )?video\b/i },
    { id: 'superloved_writing', re: /superloved .{0,80}(writing|post|collection)\b/i },
    { id: 'loved_writing', re: /\bloved .{0,80}(writing|collection)\b/i },
    { id: 'post_comment_created', re: /commented on .{0,80}(writing|post|collection)\b/i },
    { id: 'tagged_writing_trending_created', re: /trending .{0,40}(writing|post)\b/i },
    { id: 'post_created', re: /(posted|published|wrote) .{0,40}(new )?(writing|collection)\b/i },
    { id: 'superloved_wall_post', re: /superloved .{0,80}wall\b/i },
    { id: 'loved_wall_post', re: /\bloved .{0,80}wall\b/i },
    { id: 'commented_on_wall_post', re: /commented on .{0,80}wall\b/i },
    { id: 'wall_posts', re: /(posted|wrote) .{0,40}on .{0,40}wall\b/i },
    { id: 'superloved_community_list', re: /superloved .{0,80}(community )?list\b/i },
    { id: 'loved_community_list', re: /\bloved .{0,80}(community )?list\b/i },
    { id: 'community_list_comment_created', re: /commented on .{0,80}(community )?list\b/i },
    { id: 'community_list_created', re: /(posted|created|updated) .{0,40}(community )?list\b/i },
    { id: 'superloved_ama', re: /superloved .{0,80}(ama|ask me anything)\b/i },
    { id: 'loved_ask_me_anything_story', re: /\bloved .{0,80}(ama|ask me anything)\b/i },
    { id: 'ask_me_anything_story_comment_created', re: /commented on .{0,80}(ama|ask me anything)\b/i },
    { id: 'ask_me_anything_story_created', re: /(posted|asked|answered).{0,40}(ama|ask me anything)\b/i },
    { id: 'superloved_event_discussion', re: /superloved .{0,80}event .{0,20}discussion\b/i },
    { id: 'loved_event_discussion', re: /\bloved .{0,80}event .{0,20}discussion\b/i },
    { id: 'commented_on_event_discussion', re: /commented on .{0,80}event .{0,20}discussion\b/i },
    { id: 'event_discussion_created', re: /(posted|started).{0,40}event .{0,20}discussion\b/i },
    { id: 'rsvp_updated', re: /rsvp(ed)? .{0,40}updated|updated .{0,40}rsvp/i },
    { id: 'rsvp_created', re: /rsvp(ed)? (to|for) .{0,60}event\b|is (going|interested)/i },
    { id: 'event_created', re: /(created|posted) .{0,40}(new )?event\b/i },
    { id: 'superloved_group_discussion_being_member', re: /superloved .{0,80}(group )?discussion\b/i },
    { id: 'loved_group_discussion_being_member', re: /\bloved .{0,80}(group )?discussion\b/i },
    { id: 'group_comment_created_being_member_by_friend', re: /commented on .{0,80}(group )?discussion\b/i },
    { id: 'group_post_being_member_by_friend', re: /(posted|started|created) .{0,40}(new )?(group )?discussion\b/i },
    { id: 'became_group_leader', re: /became .{0,40}(leader|owner).{0,40}group\b/i },
    { id: 'group_membership_created', re: /(joined|became a member of) .{0,60}group\b/i },
    { id: 'superloved_status_update', re: /superloved .{0,80}status\b/i },
    { id: 'loved_status_update', re: /\bloved .{0,80}status\b/i },
    { id: 'status_comment_created', re: /commented on .{0,80}status\b/i },
    { id: 'status_created', re: /(posted|updated) .{0,40}status\b/i },
    { id: 'superloved_relationship_update', re: /superloved .{0,80}relationship\b/i },
    { id: 'loved_relationship', re: /\bloved .{0,80}relationship\b/i },
    { id: 'commented_on_relationship', re: /commented on .{0,80}relationship\b/i },
    { id: 'following', re: /(is now following|started following|followed)\b/i },
    { id: 'relationship_changes', re: /\b(relationship|partner|in a relationship)\b/i },
    { id: 'superloved_profile_update', re: /superloved .{0,80}profile\b/i },
    { id: 'loved_profile_update', re: /\bloved .{0,80}profile\b/i },
    { id: 'commented_on_profile_update', re: /commented on .{0,80}profile\b/i },
    { id: 'fetish_added', re: /(added|updated).{0,40}fetish/i },
    { id: 'profile_updates', re: /(updated|changed).{0,40}profile\b/i },
    { id: 'friendship_request_accepted', re: /(accepted|are now friends|friendship)/i },
    { id: 'sign_ups', re: /(signed up|joined fetlife|invited)/i },
  ];

  function defaultEnabledMap() {
    const map = {};
    for (const cat of CATALOG) {
      for (const item of cat.items) map[item.id] = item.defaultOn;
    }
    return map;
  }

  function classifyStoryText(text) {
    const t = String(text || '')
      .replace(/\s+/g, ' ')
      .trim();
    if (!t) return null;
    for (const m of MATCHERS) {
      if (m.re.test(t)) return m.id;
    }
    return null;
  }

  return { CATALOG, MATCHERS, defaultEnabledMap, classifyStoryText };
});

/* ---- packages/fetlife-suite/features/story-filter.js ---- */
/* FetLife story filter — DOM layer (client-side only) */
(function (global) {
  'use strict';
  const US = global.__TBCC_US__;
  const core = US.fetlife.storyFilterCore;
  const S = US.shared;
  const FL = (US.fetlife = US.fetlife || {});

  const STORAGE_KEY = 'tbcc_fl_story_enabled_v1';
  const HIDDEN_ATTR = 'data-fl-fsf-hidden';
  const TYPE_ATTR = 'data-fl-fsf-type';
  const PANEL_ID = 'tbcc-fl-story-panel';

  function loadEnabled() {
    const saved = S.storage.get(STORAGE_KEY, null);
    const base = core.defaultEnabledMap();
    if (!saved || typeof saved !== 'object') return base;
    return { ...base, ...saved };
  }

  let enabled = loadEnabled();

  function storyNodes() {
    const selectors = ['#stories-list > *', '#fl-masonry-wrap .fl-masonry-col > *', '#stories > *'];
    const found = new Set();
    for (const sel of selectors) {
      document.querySelectorAll(sel).forEach((el) => {
        if (el.classList?.contains('infinite-loading-container')) return;
        if ((el.textContent || '').trim().length < 20) return;
        if (el.closest(`#${PANEL_ID}`)) return;
        found.add(el);
      });
    }
    return [...found].filter((el) => ![...found].some((o) => o !== el && o.contains(el)));
  }

  function applyFeedFilter() {
    const nodes = storyNodes();
    let hidden = 0;
    for (const el of nodes) {
      const text = el.innerText || el.textContent || '';
      const type =
        el.getAttribute('data-feed-event') ||
        el.getAttribute('data-story-type') ||
        el.getAttribute('data-type') ||
        core.classifyStoryText(text);
      if (type) el.setAttribute(TYPE_ATTR, type);
      const show = !type || enabled[type] !== false;
      if (show) el.removeAttribute(HIDDEN_ATTR);
      else {
        el.setAttribute(HIDDEN_ATTR, '1');
        hidden += 1;
      }
    }
    if (hidden) console.debug(`[FL suite] storyFilter hidden=${hidden}/${nodes.length}`);
  }

  function unlockSettingsPage() {
    const form = document.getElementById('update_feed_settings_form');
    if (!form) return;
    if (!document.getElementById('tbcc-fl-fsf-banner')) {
      const h2 = [...form.querySelectorAll('h2')].find((h) => /Hide\/Show Feed Stories/i.test(h.textContent || ''));
      const banner = document.createElement('div');
      banner.id = 'tbcc-fl-fsf-banner';
      banner.style.cssText =
        'margin:8px 0 12px;padding:8px 10px;background:#2a1f14;border:1px solid #664;border-radius:6px;color:#e8c48a;font-size:13px;';
      banner.textContent =
        'TBCC FetLife Suite: toggles save locally and filter your browser feed. Not sent to FetLife Supporter APIs.';
      (h2?.parentElement || form).insertBefore(banner, h2?.nextSibling || form.firstChild);
    }
    form.querySelectorAll('input[name="feed[story_types][]"]').forEach((input) => {
      if (!input.value) return;
      input.disabled = false;
      input.checked = enabled[input.value] !== false;
      const label = input.closest('label');
      if (label) {
        label.style.opacity = '1';
        label.style.cursor = 'pointer';
        label.style.pointerEvents = 'auto';
      }
      if (input.dataset.tbccBound) return;
      input.dataset.tbccBound = '1';
      input.addEventListener('change', (ev) => {
        ev.stopPropagation();
        enabled = { ...enabled, [input.value]: input.checked };
        S.storage.set(STORAGE_KEY, enabled);
        applyFeedFilter();
      });
    });
    if (!form.dataset.tbccSubmitBlocked) {
      form.dataset.tbccSubmitBlocked = '1';
      form.addEventListener(
        'submit',
        (ev) => {
          ev.preventDefault();
          ev.stopPropagation();
          S.storage.set(STORAGE_KEY, enabled);
        },
        true
      );
    }
  }

  function buildTypePanel() {
    if (document.getElementById(PANEL_ID)) return;
    S.ensureStyle(
      'tbcc-fl-fsf-style',
      `[${HIDDEN_ATTR}="1"]{display:none!important}
       #${PANEL_ID}{position:fixed;z-index:999999;right:16px;bottom:110px;width:min(420px,calc(100vw - 24px));max-height:min(65vh,560px);overflow:auto;background:#1a1a1a;color:#d4d4d4;border:1px solid #333;border-radius:8px;display:none;font:13px/1.35 system-ui,sans-serif}
       #${PANEL_ID}.open{display:block}
       #${PANEL_ID} header{position:sticky;top:0;background:#222;padding:10px 12px;display:flex;gap:8px;align-items:center;border-bottom:1px solid #333}
       #${PANEL_ID} header strong{flex:1}
       #${PANEL_ID} button{background:#333;color:#eee;border:1px solid #555;border-radius:6px;padding:6px 10px;cursor:pointer}
       #${PANEL_ID} .cat{padding:8px 12px 4px;font-weight:700;color:#bbb}
       #${PANEL_ID} label{display:flex;gap:8px;padding:4px 12px 4px 16px;cursor:pointer}
       #tbcc-fl-story-fab{position:fixed;z-index:999998;right:16px;bottom:60px;background:#333;color:#eee;border:1px solid #555;border-radius:6px;padding:6px 10px;cursor:pointer}`
    );
    const panel = document.createElement('div');
    panel.id = PANEL_ID;
    panel.innerHTML = `<header><strong>Story types</strong>
      <button type="button" data-act="all-on">All on</button>
      <button type="button" data-act="all-off">All off</button>
      <button type="button" data-act="close">Close</button></header>
      <div style="padding:8px 12px;color:#888;font-size:12px">Unchecked = hidden in browser only.</div>`;
    for (const cat of core.CATALOG) {
      const h = document.createElement('div');
      h.className = 'cat';
      h.textContent = cat.category;
      panel.appendChild(h);
      for (const item of cat.items) {
        const lab = document.createElement('label');
        const cb = document.createElement('input');
        cb.type = 'checkbox';
        cb.checked = enabled[item.id] !== false;
        cb.addEventListener('change', () => {
          enabled = { ...enabled, [item.id]: cb.checked };
          S.storage.set(STORAGE_KEY, enabled);
          unlockSettingsPage();
          applyFeedFilter();
        });
        lab.appendChild(cb);
        lab.appendChild(document.createTextNode(item.label));
        panel.appendChild(lab);
      }
    }
    panel.querySelector('[data-act="close"]').onclick = () => panel.classList.remove('open');
    panel.querySelector('[data-act="all-on"]').onclick = () => {
      Object.keys(enabled).forEach((k) => (enabled[k] = true));
      S.storage.set(STORAGE_KEY, enabled);
      panel.querySelectorAll('input[type=checkbox]').forEach((cb) => (cb.checked = true));
      applyFeedFilter();
    };
    panel.querySelector('[data-act="all-off"]').onclick = () => {
      Object.keys(enabled).forEach((k) => (enabled[k] = false));
      S.storage.set(STORAGE_KEY, enabled);
      panel.querySelectorAll('input[type=checkbox]').forEach((cb) => (cb.checked = false));
      applyFeedFilter();
    };
    document.documentElement.appendChild(panel);
    const fab = document.createElement('button');
    fab.id = 'tbcc-fl-story-fab';
    fab.type = 'button';
    fab.textContent = 'Story types';
    fab.onclick = () => panel.classList.toggle('open');
    document.documentElement.appendChild(fab);
  }

  FL.features = FL.features || {};
  FL.features.storyFilter = {
    start() {
      unlockSettingsPage();
      buildTypePanel();
      applyFeedFilter();
      this._unsubObs = S.observer.subscribe(() => {
        unlockSettingsPage();
        applyFeedFilter();
      });
      this._unsubSpa = S.spa.onChange(() => setTimeout(applyFeedFilter, 300));
    },
    stop() {
      this._unsubObs?.();
      this._unsubSpa?.();
    },
  };
})(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);

/* ---- packages/fetlife-suite/features/mute.js ---- */
/* Comment mute — adapted from brighid fetlife-mute-button */
(function (global) {
  'use strict';
  const US = global.__TBCC_US__;
  const S = US.shared;
  const FL = (US.fetlife = US.fetlife || {});
  const STORAGE_KEY = 'tbcc_fl_muted_users_v1';
  const muteBtnClass = 'tbcc-fl-mute-btn';
  const replacerClass = 'tbcc-fl-muted-comment';

  function loadMuted() {
    const raw = S.storage.get(STORAGE_KEY, '');
    if (!raw) return [];
    if (Array.isArray(raw)) return raw.map(String);
    if (/^[\d,]+$/.test(String(raw))) return String(raw).split(',').filter(Boolean);
    return [];
  }

  let mutedUsers = loadMuted();

  function save() {
    S.storage.set(STORAGE_KEY, mutedUsers.join(','));
  }

  function muteUser(userId) {
    if (!/^\d+$/.test(userId) || mutedUsers.includes(userId)) return;
    mutedUsers.push(userId);
    save();
  }

  function unmuteUser(userId) {
    mutedUsers = mutedUsers.filter((id) => id !== userId);
    save();
  }

  function findComments() {
    return [...document.querySelectorAll('section#comments article.fl-comment, article.fl-comment')].map((root) => {
      const nickEl =
        root.querySelector('a.fl-comment__user') ||
        root.querySelector('a[href*="/users/"]');
      if (!nickEl) return null;
      const href = nickEl.getAttribute('href') || '';
      const m = href.match(/\/users\/(\d+)/);
      if (!m) return null;
      return { root, nickEl, nick: (nickEl.textContent || '').trim(), userId: m[1] };
    }).filter(Boolean);
  }

  function hideComment(root, userId, nick) {
    [...root.children].forEach((ch) => {
      if (!ch.classList.contains(replacerClass)) ch.style.display = 'none';
    });
    if (root.querySelector(`.${replacerClass}`)) return;
    const div = document.createElement('div');
    div.className = replacerClass;
    div.style.cssText = 'padding:8px;font-size:12px;opacity:.75';
    const a = document.createElement('a');
    a.href = '#';
    a.textContent = `Muted comment from ${nick} (click to unmute)`;
    a.addEventListener('click', (ev) => {
      ev.preventDefault();
      unmuteUser(userId);
      apply();
    });
    div.appendChild(a);
    root.insertBefore(div, root.firstChild);
  }

  function unhideComment(root) {
    [...root.children].forEach((ch) => {
      ch.style.display = '';
    });
    root.querySelector(`.${replacerClass}`)?.remove();
  }

  function addMuteButton(nickEl, userId, nick) {
    if (nickEl.parentElement?.querySelector(`.${muteBtnClass}`)) return;
    const span = document.createElement('span');
    span.className = muteBtnClass;
    span.style.cssText = 'margin-left:6px;font-size:11px;opacity:.7';
    const a = document.createElement('a');
    a.href = '#';
    a.textContent = '(mute)';
    a.addEventListener('click', (ev) => {
      ev.preventDefault();
      muteUser(userId);
      apply();
    });
    span.appendChild(a);
    nickEl.insertAdjacentElement('afterend', span);
  }

  function apply() {
    mutedUsers = loadMuted();
    for (const c of findComments()) {
      if (mutedUsers.includes(c.userId)) {
        hideComment(c.root, c.userId, c.nick);
      } else {
        unhideComment(c.root);
        addMuteButton(c.nickEl, c.userId, c.nick);
      }
    }
  }

  FL.features = FL.features || {};
  FL.features.mute = {
    start() {
      apply();
      this._unsub = S.observer.subscribe(apply);
    },
    stop() {
      this._unsub?.();
    },
  };
})(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);

/* ---- packages/fetlife-suite/features/newest-discussions.js ---- */
/* Newest discussions redirect — adapted from GreasyFork script 29395 */
(function (global) {
  'use strict';
  const US = global.__TBCC_US__;
  const FL = (US.fetlife = US.fetlife || {});

  function maybeRedirect() {
    const path = location.pathname || '';
    if (!/^\/groups\/\d+$/.test(path)) return;
    if (location.search.includes('order=')) return;
    location.replace(path + '?order=discussions');
  }

  FL.features = FL.features || {};
  FL.features.newestDiscussions = {
    start() {
      maybeRedirect();
      this._unsub = US.shared.spa.onChange(() => setTimeout(maybeRedirect, 50));
    },
    stop() {
      this._unsub?.();
    },
  };
})(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);

/* ---- packages/fetlife-suite/features/home-feed.js ---- */
/* Home feed masonry — adapted from FetLife Suite - Home Feed (RYSTA, MIT) */
(function (global) {
  'use strict';
  const US = global.__TBCC_US__;
  const S = US.shared;
  const FL = (US.fetlife = US.fetlife || {});

  const FILTERS = [
    { label: 'All', path: '/home' },
    { label: 'Pics', path: '/home/pictures' },
    { label: 'Vids', path: '/home/videos' },
    { label: 'Text', path: '/home/statuses' },
    { label: 'Writings', path: '/home/writings' },
    { label: 'AMA', path: '/home/ama' },
    { label: 'Audio', path: '/home/audio' },
    { label: 'For You', path: '/home/for-you' },
  ];

  let masonryWrap = null;
  let columns = [];
  let placedItems = new WeakSet();
  let currentColCount = 0;
  let postObserver = null;
  let started = false;

  function getColumnCount() {
    const w = window.innerWidth;
    if (w >= 2400) return 4;
    if (w >= 1200) return 3;
    if (w >= 720) return 2;
    return 1;
  }

  function getShortestColumn() {
    let shortest = 0;
    let minHeight = Infinity;
    columns.forEach((col, i) => {
      const h = col.offsetHeight;
      if (h < minHeight) {
        minHeight = h;
        shortest = i;
      }
    });
    return columns[shortest];
  }

  function getThemeVars() {
    const rgb = window.getComputedStyle(document.body).backgroundColor.match(/\d+/g);
    const isDark =
      !rgb || parseInt(rgb[0], 10) * 0.299 + parseInt(rgb[1], 10) * 0.587 + parseInt(rgb[2], 10) * 0.114 < 128;
    return isDark
      ? '--card-bg:#121212; --card-border:none; --card-shadow:none; --avatar-border:2px solid #333;'
      : '--card-bg:#f0f0f0; --card-border:1px solid #e2e8f0; --card-shadow:none; --avatar-border:2px solid #e2e8f0;';
  }

  function injectStyles() {
    S.ensureStyle(
      'tbcc-fl-home-feed-style',
      `
      #tbcc-fl-back-to-top {
        position: fixed; bottom: 28px; right: 100px; z-index: 9999;
        width: 44px; height: 44px; border-radius: 50%;
        background: rgba(30,30,30,.85); border: 1px solid #444; color: #ccc;
        cursor: pointer; display: flex; align-items: center; justify-content: center;
        opacity: 0; visibility: hidden; transition: opacity .25s, visibility .25s;
      }
      #tbcc-fl-back-to-top.fl-visible { opacity: 1; visibility: visible; }
      #tbcc-fl-back-to-top:hover { background: #e11d48; border-color: #e11d48; color: #fff; }
      #fl-feed-filters {
        display: flex; gap: 6px; padding: 8px 0 10px; flex-wrap: wrap; width: 100%; box-sizing: border-box;
      }
      #fl-feed-filters a {
        display: inline-block; padding: 5px 14px; border-radius: 20px; font-size: 12px; font-weight: 600;
        text-decoration: none; color: #aaa; background: #1e1e1e; border: 1px solid #333;
      }
      #fl-feed-filters a.fl-filter-active { color: #fff; background: #e11d48; border-color: #e11d48; }
      body.fl-home-active { ${getThemeVars()} }
      body.fl-home-active .max-w-screen-xl { max-width: 100vw !important; padding-left: 1.5rem !important; padding-right: 1.5rem !important; }
      body.fl-home-active .mx-auto.max-w-3xl { max-width: 100% !important; margin: 0 !important; }
      body.fl-home-active .flex.flex-col.lg\\:flex-row > aside { display: none !important; }
      #fl-masonry-wrap { display: flex !important; gap: 1.5rem; width: 100%; align-items: flex-start; }
      .fl-masonry-col { flex: 1; display: flex; flex-direction: column; gap: 1.5rem; min-width: 0; }
      .fl-masonry-col > div {
        border-radius: 12px; padding: 0 1.5rem !important;
        background-color: var(--card-bg) !important; border: var(--card-border) !important;
      }
      .fl-masonry-col > div header a.flex-none { width: 72px !important; height: 72px !important; margin-right: 1.25rem !important; }
      .fl-masonry-col > div header a.flex-none img {
        width: 100% !important; height: 100% !important; border-radius: 50% !important; object-fit: cover;
        border: var(--avatar-border) !important;
      }
      body.fl-home-active header.flex.h-14 { position: sticky !important; top: 0; z-index: 50; }
    `
    );
  }

  function mountBackToTop() {
    if (document.getElementById('tbcc-fl-back-to-top')) return;
    const btn = document.createElement('button');
    btn.id = 'tbcc-fl-back-to-top';
    btn.type = 'button';
    btn.title = 'Back to top';
    btn.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 4l-8 8h5v8h6v-8h5z"/></svg>';
    btn.addEventListener('click', () => window.scrollTo({ top: 0, behavior: 'smooth' }));
    document.body.appendChild(btn);
    window.addEventListener('scroll', () => {
      btn.classList.toggle('fl-visible', window.scrollY > 600);
    });
  }

  function getCurrentFilter() {
    const path = window.location.pathname;
    if (path === '/home') return '/home';
    for (const f of FILTERS) if (f.path !== '/home' && path.startsWith(f.path)) return f.path;
    return '/home';
  }

  function injectFilterBar() {
    if (document.getElementById('fl-feed-filters')) return;
    if (!/^\/home(\/|$)/.test(location.pathname)) return;
    const bar = document.createElement('div');
    bar.id = 'fl-feed-filters';
    const current = getCurrentFilter();
    FILTERS.forEach((f) => {
      const a = document.createElement('a');
      a.href = f.path;
      a.textContent = f.label;
      if (f.path === current) a.classList.add('fl-filter-active');
      bar.appendChild(a);
    });
    const main = document.getElementById('responsive-feed') || document.querySelector('main');
    if (main) main.insertBefore(bar, main.firstChild);
  }

  function createMasonryContainer(storiesList) {
    if (masonryWrap) {
      masonryWrap.remove();
      masonryWrap = null;
      columns = [];
    }
    currentColCount = getColumnCount();
    masonryWrap = document.createElement('div');
    masonryWrap.id = 'fl-masonry-wrap';
    for (let i = 0; i < currentColCount; i++) {
      const col = document.createElement('div');
      col.className = 'fl-masonry-col';
      masonryWrap.appendChild(col);
      columns.push(col);
    }
    storiesList.parentNode.insertBefore(masonryWrap, storiesList);
  }

  function distributeNewItems(storiesList) {
    const items = [];
    for (const child of Array.from(storiesList.children)) {
      if (child.nodeType !== 1) continue;
      if (child.classList.contains('infinite-loading-container')) continue;
      if (placedItems.has(child)) continue;
      items.push(child);
    }
    items.forEach((item) => {
      placedItems.add(item);
      getShortestColumn().appendChild(item);
    });
  }

  function fixInfiniteLoader() {
    let attempts = 0;
    const interval = setInterval(() => {
      attempts += 1;
      const masonry = document.getElementById('fl-masonry-wrap');
      const storiesList = document.getElementById('stories-list');
      const loader = document.querySelector('.infinite-loading-container');
      if (masonry && storiesList && loader && storiesList.contains(loader)) {
        masonry.parentElement.appendChild(loader);
        clearInterval(interval);
      }
      if (attempts > 30) clearInterval(interval);
    }, 500);
  }

  function initMasonry() {
    const storiesList = document.getElementById('stories-list');
    if (!storiesList) return;
    if (!masonryWrap) createMasonryContainer(storiesList);
    distributeNewItems(storiesList);
    if (postObserver) postObserver.disconnect();
    postObserver = new MutationObserver(() => {
      setTimeout(() => distributeNewItems(storiesList), 150);
    });
    postObserver.observe(storiesList, { childList: true });
    fixInfiniteLoader();
  }

  function teardownMasonry() {
    if (postObserver) postObserver.disconnect();
    postObserver = null;
    if (masonryWrap) {
      const storiesList = document.getElementById('stories-list');
      if (storiesList) {
        columns.forEach((col) => {
          while (col.firstChild) storiesList.appendChild(col.firstChild);
        });
      }
      masonryWrap.remove();
      masonryWrap = null;
      columns = [];
      placedItems = new WeakSet();
      currentColCount = 0;
    }
  }

  function checkPage() {
    const isHome = location.pathname.startsWith('/home');
    document.body.classList.toggle('fl-home-active', isHome);
    injectFilterBar();
    if (isHome) setTimeout(initMasonry, 400);
    else teardownMasonry();
  }

  FL.features = FL.features || {};
  FL.features.homeFeed = {
    start() {
      if (started) return;
      started = true;
      injectStyles();
      mountBackToTop();
      checkPage();
      this._unsubSpa = S.spa.onChange(() => setTimeout(checkPage, 100));
      this._onResize = () => {
        if (!location.pathname.startsWith('/home')) return;
        const n = getColumnCount();
        if (n === currentColCount) return;
        const storiesList = document.getElementById('stories-list');
        if (!storiesList) return;
        columns.forEach((col) => {
          while (col.firstChild) {
            placedItems.delete(col.firstChild);
            storiesList.insertBefore(col.firstChild, storiesList.querySelector('.infinite-loading-container'));
          }
        });
        createMasonryContainer(storiesList);
        distributeNewItems(storiesList);
      };
      window.addEventListener('resize', this._onResize);
    },
    stop() {
      started = false;
      this._unsubSpa?.();
      if (this._onResize) window.removeEventListener('resize', this._onResize);
      teardownMasonry();
    },
  };
})(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);

/* ---- packages/fetlife-suite/features/place-nav.js ---- */
/* Place → kinksters navigation (no hardcoded city) */
(function (global) {
  'use strict';
  const US = global.__TBCC_US__;
  const S = US.shared;
  const FL = (US.fetlife = US.fetlife || {});

  const CFG_KEY = 'tbcc_fl_place_nav_v1';
  const BOOKMARK_KEY = 'tbcc_fl_kinksters_bookmark_v1';
  const DEFAULT_CFG = {
    country: 'united-states',
    region: 'california',
    lastQuery: '',
    lastPath: '',
  };

  function loadCfg() {
    const saved = S.storage.get(CFG_KEY, null);
    return { ...DEFAULT_CFG, ...(saved && typeof saved === 'object' ? saved : {}) };
  }

  function saveCfg(partial) {
    const next = { ...loadCfg(), ...partial };
    S.storage.set(CFG_KEY, next);
    return next;
  }

  function slugifyPlace(name) {
    return String(name || '')
      .trim()
      .toLowerCase()
      .normalize('NFKD')
      .replace(/[\u0300-\u036f]/g, '')
      .replace(/&/g, ' and ')
      .replace(/[^a-z0-9]+/g, '-')
      .replace(/^-+|-+$/g, '');
  }

  function kinkstersPath(placeName, cfg) {
    const c = cfg || loadCfg();
    const slug = slugifyPlace(placeName);
    if (!slug) return null;
    const country = slugifyPlace(c.country) || 'united-states';
    const region = slugifyPlace(c.region) || 'california';
    return `/p/${country}/${region}/${slug}/kinksters`;
  }

  function kinkstersUrl(placeName, cfg) {
    const path = kinkstersPath(placeName, cfg);
    if (!path) return null;
    return `https://fetlife.com${path}`;
  }

  /** Places search fallback when slug path is unknown / 404. */
  function placesSearchUrl(placeName) {
    const q = String(placeName || '').trim();
    if (!q) return null;
    // FetLife global search; user lands on results and can open Places → kinksters.
    return `https://fetlife.com/search?q=${encodeURIComponent(q)}`;
  }

  /** Human label from /p/{country}/{region}/{slug}/kinksters */
  function placeLabelFromPath(pathname) {
    const m = String(pathname || location.pathname).match(
      /\/p\/([^/]+)\/([^/]+)\/([^/]+)\/kinksters\/?/i
    );
    if (!m) return '';
    return decodeURIComponent(m[3]).replace(/-/g, ' ').trim();
  }

  function clearPlace() {
    return saveCfg({ lastQuery: '', lastPath: '' });
  }

  function loadBookmark() {
    const b = S.storage.get(BOOKMARK_KEY, null);
    return b && typeof b === 'object' && b.path ? b : null;
  }

  function saveBookmark(partial) {
    const prev = loadBookmark() || {};
    const next = {
      path: String(partial.path || prev.path || '').replace(/\?.*$/, ''),
      page: Math.max(1, Number(partial.page) || Number(prev.page) || 1),
      placeLabel: String(partial.placeLabel != null ? partial.placeLabel : prev.placeLabel || ''),
      ts: Date.now(),
    };
    if (!next.path) return null;
    S.storage.set(BOOKMARK_KEY, next);
    return next;
  }

  function clearBookmark() {
    S.storage.set(BOOKMARK_KEY, null);
  }

  function resumeBookmarkUrl(bm) {
    const b = bm || loadBookmark();
    if (!b?.path) return null;
    const page = Math.max(1, Number(b.page) || 1);
    const base = b.path.startsWith('http') ? b.path : `https://fetlife.com${b.path}`;
    try {
      const u = new URL(base);
      if (page > 1) u.searchParams.set('page', String(page));
      else u.searchParams.delete('page');
      return u.toString();
    } catch (_) {
      return page > 1 ? `${base}${base.includes('?') ? '&' : '?'}page=${page}` : base;
    }
  }

  /** Prefer live kinksters URL slug when on a place list; else lastQuery. */
  function displayPlaceQuery() {
    const fromUrl = placeLabelFromPath(location.pathname);
    if (fromUrl) return fromUrl;
    return loadCfg().lastQuery || '';
  }

  /**
   * Jump to kinksters for a place name.
   * Place is navigation-only — does NOT copy into ASL locationInclude (that caused
   * stale-city filters when switching regions).
   * @param {string} placeName e.g. "Palo Alto"
   * @param {{ syncAsl?: boolean, mode?: 'kinksters'|'search' }} opts
   */
  function goPlace(placeName, opts) {
    const q = String(placeName || '').trim();
    if (!q) return { ok: false, reason: 'empty' };
    const cfg = saveCfg({ lastQuery: q });
    // Opt-in only — never default-sync place into ASL vitals filter.
    if (opts?.syncAsl && FL.genderFilter?.saveCfg && FL.genderFilter?.loadCfg) {
      const g = FL.genderFilter.loadCfg();
      FL.genderFilter.saveCfg({ ...g, locationInclude: q });
      FL.genderFilter.apply?.();
    }
    const mode = opts?.mode || 'kinksters';
    if (mode === 'search') {
      const url = placesSearchUrl(q);
      if (!url) return { ok: false, reason: 'empty' };
      location.assign(url);
      return { ok: true, mode: 'search', url };
    }
    const path = kinkstersPath(q, cfg);
    const url = kinkstersUrl(q, cfg);
    saveCfg({ lastQuery: q, lastPath: path });
    if (path && location.pathname.replace(/\/+$/, '') === path.replace(/\/+$/, '')) {
      FL.overlay?.open?.('autofollow');
      return { ok: true, mode: 'kinksters', url, already: true };
    }
    location.assign(url);
    return { ok: true, mode: 'kinksters', url };
  }

  FL.placeNav = {
    CFG_KEY,
    BOOKMARK_KEY,
    loadCfg,
    saveCfg,
    clearPlace,
    loadBookmark,
    saveBookmark,
    clearBookmark,
    resumeBookmarkUrl,
    displayPlaceQuery,
    slugifyPlace,
    kinkstersPath,
    kinkstersUrl,
    placesSearchUrl,
    placeLabelFromPath,
    goPlace,
  };
})(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);

/* ---- packages/fetlife-suite/features/login-redirect.js ---- */
/* Login / home → last kinksters place (no hardcoded city) */
(function (global) {
  'use strict';
  const US = global.__TBCC_US__;
  const S = US.shared;
  const FL = (US.fetlife = US.fetlife || {});

  const SESSION_KEY = 'tbcc_fl_login_redirect_done';

  function destUrl() {
    const place = FL.placeNav?.loadCfg?.() || {};
    if (place.lastPath) return `https://fetlife.com${place.lastPath}`;
    if (place.lastQuery && FL.placeNav?.kinkstersUrl) {
      return FL.placeNav.kinkstersUrl(place.lastQuery, place);
    }
    return null;
  }

  function shouldRedirect() {
    const path = (location.pathname || '').replace(/\/+$/, '') || '/';
    if (path !== '/' && path !== '/home') return false;
    const dest = destUrl();
    if (!dest) return false;
    try {
      const destPath = new URL(dest).pathname.replace(/\/+$/, '');
      if (path === destPath) return false;
    } catch (_) {
      /* ignore */
    }
    return true;
  }

  function go() {
    if (sessionStorage.getItem(SESSION_KEY)) return;
    if (!shouldRedirect()) return;
    const dest = destUrl();
    if (!dest) return;
    sessionStorage.setItem(SESSION_KEY, '1');
    location.replace(dest);
  }

  FL.features = FL.features || {};
  FL.features.loginRedirect = {
    start() {
      go();
      this._unsub = S.spa.onChange(() => setTimeout(go, 80));
    },
    stop() {
      this._unsub?.();
    },
  };
})(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);

/* ---- packages/fetlife-suite/features/gender-filter.js ---- */
/* ASL-style list filter: hide males / require female / require location text */
(function (global) {
  'use strict';
  const US = global.__TBCC_US__;
  const S = US.shared;
  const FL = (US.fetlife = US.fetlife || {});

  const HIDDEN = 'data-tbcc-fl-gender-hidden';
  const CFG_KEY = 'tbcc_fl_gender_filter_v1';
  const STYLE_ID = 'tbcc-fl-gender-style';

  const DEFAULT_CFG = {
    hideMale: true,
    hideFtM: false,
    /**
     * Hide cards whose sex parses as something other than F.
     * Unknown sex stays visible; auto-follow still refuses non-F when skip is on.
     */
    femaleOnly: true,
    locationInclude: '',
  };

  function loadCfg() {
    const saved = S.storage.get(CFG_KEY, null);
    return { ...DEFAULT_CFG, ...(saved && typeof saved === 'object' ? saved : {}) };
  }

  function saveCfg(cfg) {
    S.storage.set(CFG_KEY, cfg);
  }

  function ensureHideCss() {
    S.ensureStyle(
      STYLE_ID,
      `[${HIDDEN}="1"]{display:none!important;pointer-events:none!important;height:0!important;overflow:hidden!important;margin:0!important;padding:0!important;border:none!important}`
    );
  }

  /**
   * Parse FetLife vitals: "28F Domme", "27M Switch", "25MtF", "47CD/TV", "46M Exploring".
   */
  function parseSex(text) {
    const t = String(text || '')
      .replace(/\u00a0/g, ' ')
      .replace(/[\u200b\u200c\u200d\ufeff]/g, '')
      .replace(/\s+/g, ' ')
      .trim();
    if (!t) return null;
    const m =
      t.match(/\b(\d{1,2})\s*(CD\/TV|MtF|FtM|GF|GQ|IS|TG|TV|CD|[MF])\b/i) ||
      t.match(/\b(\d{1,2})(CD\/TV|MtF|FtM|GF|GQ|IS|TG|TV|CD|[MF])\b/i) ||
      t.match(/(?:^|[^\dA-Za-z])(\d{2})\s*([MF])(?=[^a-zA-Z]|$)/);
    if (!m) return null;
    return String(m[2]).toUpperCase();
  }

  function controlLabel(btn) {
    const span = btn.querySelector?.('span');
    return (span ? span.textContent : btn.textContent || '').replace(/\s+/g, ' ').trim();
  }

  function isFollowControl(btn) {
    return /^(Follow|Following|Follow Pending|Follow Back|Unfollow)$/i.test(controlLabel(btn));
  }

  function followControls(el) {
    if (!el || !el.querySelectorAll) return [];
    return [...el.querySelectorAll('button')].filter(isFollowControl);
  }

  /**
   * Prefer short vitals nodes; fall back to full card text.
   * Does not depend on /users/id URLs (FetLife often uses nickname links).
   */
  function vitalsText(card) {
    if (!card) return '';
    const nodes = card.querySelectorAll?.(
      '.fl-member-card__info, [class*="member-card__info"], span, div, p, a'
    );
    if (nodes && nodes.length) {
      let best = '';
      for (const n of nodes) {
        // Own text only — avoid sucking in the whole grid.
        let own = '';
        for (const child of n.childNodes || []) {
          if (child.nodeType === 3) own += child.textContent || '';
        }
        own = own.replace(/\s+/g, ' ').trim();
        if (!own || own.length > 80) continue;
        if (parseSex(own)) return own;
        if (!best && /\d{2}/.test(own)) best = own;
      }
      if (best) return best;
    }
    return (card.innerText || card.textContent || '').replace(/\s+/g, ' ').trim();
  }

  /**
   * Walk up from a Follow button (or vitals node) to the single-profile card.
   * Stop when the ancestor contains more than one Follow control (grid).
   */
  function resolveMemberCard(start) {
    if (!start) return null;
    let el = start.nodeType === 1 ? start : start.parentElement;
    let fallback = null;
    for (let i = 0; i < 20 && el && el !== document.body && el !== document.documentElement; i++) {
      const controls = followControls(el);
      if (controls.length > 1) break;

      const text = (el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim();
      const sex = parseSex(vitalsText(el)) || parseSex(text);
      const hasImg = !!el.querySelector?.('img');

      if (controls.length === 1) {
        fallback = el;
        // Card sweet spot: one follow control + vitals and/or avatar.
        if (sex || (hasImg && text.length > 12 && text.length < 900)) {
          return el;
        }
      }

      // Started from a vitals span — grow until we pick up the Follow button.
      if (controls.length === 0 && sex && text.length < 120) {
        fallback = fallback || el;
      }

      el = el.parentElement;
    }
    return fallback;
  }

  function isMaleSex(sex, cfg) {
    if (!sex) return false;
    if (sex === 'M') return !!cfg.hideMale;
    if (sex === 'FTM' && cfg.hideFtM) return true;
    return false;
  }

  function locationNeedles(cfg) {
    return String(cfg.locationInclude || '')
      .split(/[,]+/)
      .map((s) => s.trim().toLowerCase())
      .filter(Boolean);
  }

  function onGeoScopedListPage() {
    return /\/p\/[^/]+\/[^/]+\/[^/]+\/kinksters/i.test(location.pathname || '');
  }

  /** Single-member profile (not kinksters lists) — never ASL-remove header actions. */
  function onMemberProfilePage() {
    const path = String(location.pathname || '').replace(/\/+$/, '') || '/';
    if (/\/kinksters(\/|$)/i.test(path)) return false;
    if (/^\/users\/\d+(\/|$)/i.test(path)) return true;
    const m = path.match(
      /^\/([^/]+)(?:\/(about|pictures|videos|posts|friends|followers|following|activity|wall|relations))?$/i
    );
    if (!m) return false;
    const reserved = new Set([
      'home',
      'search',
      'login',
      'signup',
      'groups',
      'events',
      'videos',
      'pictures',
      'posts',
      'inbox',
      'notifications',
      'settings',
      'support',
      'p',
      'explore',
      'feed',
      'kinksters',
      'city',
      'places',
      'fetishes',
      'writings',
      'ads',
      'admin',
      'api',
      'oauth',
      'logout',
      'chat',
      'messages',
    ]);
    return !reserved.has(String(m[1] || '').toLowerCase());
  }

  function locationOk(text, cfg) {
    const needles = locationNeedles(cfg);
    if (!needles.length) return true;
    if (onGeoScopedListPage()) return true;
    const t = String(text || '').toLowerCase();
    return needles.some((n) => t.includes(n));
  }

  function shouldHideCard(text, cfg) {
    const sex = parseSex(text);
    if (isMaleSex(sex, cfg)) return true;
    if (cfg.femaleOnly && sex && sex !== 'F') return true;
    if (!locationOk(text, cfg)) return true;
    return false;
  }

  function shouldSkipFollow(card, cfg) {
    const c = cfg || loadCfg();
    const text = vitalsText(card) || (card?.innerText || card?.textContent || '');
    const sex = parseSex(text);
    if (isMaleSex(sex, c)) return true;
    if (c.femaleOnly) return sex !== 'F';
    if (c.hideMale && sex === 'M') return true;
    if (!locationOk(text, c)) return true;
    return false;
  }

  function hideCard(card) {
    if (!card || !card.isConnected) return;
    card.setAttribute(HIDDEN, '1');
    // Remove from DOM so flex/grid gaps collapse (display:none left empty slots).
    try {
      card.remove();
    } catch (_) {
      try {
        card.style.setProperty('display', 'none', 'important');
      } catch (__) { /* ignore */ }
    }
  }

  function unhideCard(card) {
    if (!card) return;
    card.removeAttribute(HIDDEN);
    try {
      card.style.removeProperty('display');
    } catch (_) { /* ignore */ }
  }

  /** Common parent of member cards (kinksters list). */
  function listRoot() {
    const marked = document.querySelector('[data-tbcc-fl-list="1"]');
    if (marked) return marked;
    const cards = cardRoots().filter((c) => c && c.isConnected);
    if (cards.length >= 2) {
      let a = cards[0];
      let b = cards[1];
      const path = new Set();
      for (let el = a; el; el = el.parentElement) path.add(el);
      for (let el = b; el; el = el.parentElement) {
        if (path.has(el) && el !== document.body && el !== document.documentElement) {
          return el;
        }
      }
    }
    if (cards[0]?.parentElement) return cards[0].parentElement;
    return null;
  }

  function cardRootsIn(root) {
    if (!root) return cardRoots();
    const cards = new Set();
    root.querySelectorAll('button').forEach((btn) => {
      if (!isFollowControl(btn)) return;
      const card = resolveMemberCard(btn);
      if (card && root.contains(card)) cards.add(card);
    });
    return [...cards];
  }

  function ensureListLayout(root) {
    if (!root) return;
    root.setAttribute('data-tbcc-fl-list', '1');
    S.ensureStyle(
      'tbcc-fl-list-layout',
      `[data-tbcc-fl-list="1"]{
        display:flex!important;flex-direction:column!important;flex-wrap:nowrap!important;
        gap:10px!important;width:100%!important;
      }
      [data-tbcc-fl-list="1"]>*{width:100%!important;max-width:100%!important;float:none!important;
        position:relative!important;left:auto!important;right:auto!important;grid-column:auto!important;
        grid-row:auto!important;}`
    );
  }

  /**
   * Discover cards primarily via Follow controls — works without /users/\d+ hrefs.
   */
  function cardRoots() {
    const cards = new Set();

    document.querySelectorAll('button').forEach((btn) => {
      if (!isFollowControl(btn)) return;
      const card = resolveMemberCard(btn);
      if (card) cards.add(card);
    });

    // Also anchor on short vitals text nodes (age+sex) in case Follow label differs.
    const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, {
      acceptNode(node) {
        if (!node || node.id === 'tbcc-fl-overlay') return NodeFilter.FILTER_REJECT;
        if (node.closest?.('#tbcc-fl-overlay')) return NodeFilter.FILTER_REJECT;
        const tag = node.tagName;
        if (tag !== 'SPAN' && tag !== 'DIV' && tag !== 'P' && tag !== 'A' && tag !== 'LI') {
          return NodeFilter.FILTER_SKIP;
        }
        let own = '';
        for (const child of node.childNodes || []) {
          if (child.nodeType === 3) own += child.textContent || '';
        }
        own = own.replace(/\s+/g, ' ').trim();
        if (own.length < 3 || own.length > 64) return NodeFilter.FILTER_SKIP;
        if (!parseSex(own)) return NodeFilter.FILTER_SKIP;
        return NodeFilter.FILTER_ACCEPT;
      },
    });
    let n;
    while ((n = walker.nextNode())) {
      const card = resolveMemberCard(n);
      if (card) cards.add(card);
    }

    return [...cards];
  }

  function apply() {
    ensureHideCss();
    if (onMemberProfilePage()) {
      // Profile header Follow/Friend/Message share one ancestor with vitals — never strip them.
      return { cards: 0, hidden: 0, kept: 0, skipped: 'profile' };
    }
    const cfg = loadCfg();
    const active =
      cfg.hideMale || cfg.hideFtM || cfg.femaleOnly || locationNeedles(cfg).length > 0;

    // Mark list root *before* removals so infinite scroll can still append into an emptied list.
    const root = listRoot();
    if (root) ensureListLayout(root);

    if (!active) {
      return { cards: 0, hidden: 0, kept: cardRoots().length };
    }

    const cards = cardRoots();
    let hidden = 0;
    let kept = 0;
    for (const card of cards) {
      if (!card.isConnected) continue;
      const text = vitalsText(card) || card.innerText || card.textContent || '';
      if (shouldHideCard(text, cfg)) {
        hideCard(card);
        hidden += 1;
      } else {
        kept += 1;
      }
    }

    console.info(`[FL suite] ASL apply: ${cards.length} cards, ${hidden} removed, ${kept} kept`, cfg);
    return { cards: cards.length, hidden, kept };
  }

  FL.genderFilter = {
    loadCfg,
    saveCfg,
    parseSex,
    vitalsText,
    resolveMemberCard,
    cardRoots,
    cardRootsIn,
    listRoot,
    ensureListLayout,
    apply,
    onMemberProfilePage,
    shouldHideCard,
    shouldSkipFollow,
    isMaleCard(el) {
      const card = resolveMemberCard(el) || el;
      return shouldHideCard(vitalsText(card) || card?.innerText || '', loadCfg());
    },
    isFilteredCard(el) {
      const card = resolveMemberCard(el) || el;
      if (card?.getAttribute?.(HIDDEN) === '1') return true;
      return shouldHideCard(vitalsText(card) || card?.innerText || '', loadCfg());
    },
  };

  FL.features = FL.features || {};
  FL.features.genderFilter = {
    start() {
      ensureHideCss();
      apply();
      this._unsub = S.observer.subscribe(() => apply());
      this._unsubSpa = S.spa.onChange(() => setTimeout(apply, 200));
      [400, 1200, 2500].forEach((ms) => setTimeout(apply, ms));
    },
    stop() {
      this._unsub?.();
      this._unsubSpa?.();
    },
  };
})(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);

/* ---- packages/fetlife-suite/features/auto-follow.js ---- */
/* Auto-follow with scroll + speed — adapted from FetLife Auto-Follow */
(function (global) {
  'use strict';
  const US = global.__TBCC_US__;
  const S = US.shared;
  const FL = (US.fetlife = US.fetlife || {});

  const SPEED_PRESETS = {
    instant: { min: 10, max: 50, label: 'Instant (10-50ms)' },
    fast: { min: 50, max: 200, label: 'Fast (50-200ms)' },
    normal: { min: 200, max: 500, label: 'Normal (200-500ms)' },
    slow: { min: 500, max: 1000, label: 'Slow (500-1000ms)' },
    stealth: { min: 1000, max: 2000, label: 'Stealth (1-2s)' },
  };

  const CFG_KEY = 'tbcc_fl_autofollow_cfg_v1';
  const DEFAULT_CFG = {
    speed: 'fast',
    skipMale: true,
    autoStartOnKinksters: true,
    /** Open auto-follow panel on /search (global search) pages. */
    openOnSearch: true,
  };

  let MIN_DELAY = 50;
  let MAX_DELAY = 200;
  const MAX_RETRIES = 3;
  const SCROLL_DELAY = 1000;

  let followCount = 0;
  let isRunning = false;
  let started = false;

  function loadCfg() {
    const saved = S.storage.get(CFG_KEY, null);
    return { ...DEFAULT_CFG, ...(saved && typeof saved === 'object' ? saved : {}) };
  }

  function saveCfg(cfg) {
    S.storage.set(CFG_KEY, cfg);
  }

  function applySpeed(key) {
    const p = SPEED_PRESETS[key] || SPEED_PRESETS.fast;
    MIN_DELAY = p.min;
    MAX_DELAY = p.max;
  }

  function sleep(ms) {
    return new Promise((r) => setTimeout(r, ms));
  }

  function randomDelay() {
    return sleep(Math.random() * (MAX_DELAY - MIN_DELAY) + MIN_DELAY);
  }

  async function scrollToBottom() {
    window.scrollTo(0, document.body.scrollHeight);
    await sleep(SCROLL_DELAY);
  }

  function buttonLabel(btn) {
    const span = btn.querySelector('span');
    return (span ? span.textContent : btn.textContent || '').trim();
  }

  function cardForButton(btn) {
    return FL.genderFilter?.resolveMemberCard?.(btn) || fallbackCardForButton(btn);
  }

  function fallbackCardForButton(btn) {
    let el = btn;
    for (let i = 0; i < 8 && el; i++) {
      if (el.querySelector?.('a[href*="/users/"]')) return el;
      el = el.parentElement;
    }
    return btn.parentElement || btn;
  }

  function findFollowButtons() {
    const cfg = loadCfg();
    const gfCfg = FL.genderFilter?.loadCfg?.() || {};
    const buttons = [];
    // Re-apply ASL hide before each scan so newly scrolled cards are gone.
    try {
      FL.genderFilter?.apply?.();
    } catch (_) { /* ignore */ }

    document.querySelectorAll('button').forEach((btn) => {
      if (buttonLabel(btn) !== 'Follow') return;
      const card = cardForButton(btn);
      if (!card) return;
      if (card.getAttribute?.('data-tbcc-fl-gender-hidden') === '1') return;
      if (card.closest?.('[data-tbcc-fl-gender-hidden="1"]')) return;
      if (cfg.skipMale) {
        if (FL.genderFilter?.shouldSkipFollow?.(card, gfCfg)) return;
        if (FL.genderFilter?.isFilteredCard?.(card)) return;
      }
      buttons.push(btn);
    });
    return buttons;
  }

  async function followUser(button, retryCount = 0) {
    if (buttonLabel(button) !== 'Follow') return false;
    const cfg = loadCfg();
    const gfCfg = FL.genderFilter?.loadCfg?.() || {};
    const card = cardForButton(button);
    if (cfg.skipMale && FL.genderFilter?.shouldSkipFollow?.(card, gfCfg)) {
      return false;
    }
    button.focus();
    await sleep(10 + Math.random() * 15);
    // Final gate immediately before click — vitals may have painted late.
    if (cfg.skipMale && FL.genderFilter?.shouldSkipFollow?.(cardForButton(button), gfCfg)) {
      return false;
    }
    button.click();
    await sleep(75 + Math.random() * 50);
    const newText = buttonLabel(button);
    if (
      newText === 'Following' ||
      newText === 'Unfollow' ||
      newText === 'Follow pending' ||
      newText === 'Pending' ||
      button.disabled
    ) {
      followCount += 1;
      FL.autoFollow?.onProgress?.(followCount);
      return true;
    }
    if (retryCount < MAX_RETRIES) {
      await sleep(200 + Math.random() * 100);
      return followUser(button, retryCount + 1);
    }
    return false;
  }

  async function startAutoFollow() {
    if (isRunning) return;
    const cfg = loadCfg();
    applySpeed(cfg.speed);
    isRunning = true;
    followCount = 0;
    FL.autoFollow?.onState?.(true, followCount);

    const processed = new WeakSet();
    let consecutiveEmpty = 0;

    while (isRunning) {
      const all = findFollowButtons();
      const fresh = all.filter((b) => !processed.has(b));
      if (!fresh.length) {
        consecutiveEmpty += 1;
        if (consecutiveEmpty >= 3) {
          const before = all.length;
          await scrollToBottom();
          await sleep(SCROLL_DELAY / 2);
          const after = findFollowButtons().length;
          if (after <= before) break;
          consecutiveEmpty = 0;
          continue;
        }
        await sleep(200);
        continue;
      }
      consecutiveEmpty = 0;
      for (const btn of fresh) {
        if (!isRunning) break;
        processed.add(btn);
        await followUser(btn);
        if (isRunning) await randomDelay();
      }
      if (!isRunning) break;
      if (fresh.length < 10) await scrollToBottom();
      else await sleep(50);
    }

    isRunning = false;
    FL.autoFollow?.onState?.(false, followCount);
  }

  function stopAutoFollow() {
    isRunning = false;
    FL.autoFollow?.onState?.(false, followCount);
  }

  function isKinkstersPage() {
    return /\/kinksters/i.test(location.pathname);
  }

  function isSearchPage() {
    const path = location.pathname || '';
    return /\/search/i.test(path) || /[?&]q=/i.test(location.search || '');
  }

  FL.autoFollow = {
    SPEED_PRESETS,
    loadCfg,
    saveCfg,
    start: startAutoFollow,
    stop: stopAutoFollow,
    isRunning: () => isRunning,
    getCount: () => followCount,
    onProgress: null,
    onState: null,
    /** @deprecated use FL.placeNav.goPlace */
    goSanJoseFemales() {
      return FL.placeNav?.goPlace?.('San Jose', { syncAsl: true });
    },
    goPlace(placeName, opts) {
      return FL.placeNav?.goPlace?.(placeName, opts);
    },
  };

  FL.features = FL.features || {};
  FL.features.autoFollow = {
    start() {
      if (started) return;
      started = true;
      const cfg = loadCfg();
      applySpeed(cfg.speed);
      const maybeOpen = () => {
        if (isKinkstersPage()) {
          FL.overlay?.open?.('autofollow');
          if (cfg.autoStartOnKinksters && !isRunning) startAutoFollow();
          return;
        }
        if (cfg.openOnSearch !== false && isSearchPage()) {
          FL.overlay?.open?.('autofollow');
        }
      };
      setTimeout(maybeOpen, 800);
      this._unsubSpa = S.spa.onChange(() => setTimeout(maybeOpen, 400));
    },
    stop() {
      started = false;
      stopAutoFollow();
      this._unsubSpa?.();
    },
  };
})(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);

/* ---- packages/fetlife-suite/features/infinite-scroll.js ---- */
/* Kinksters infinite scroll — append next pages; ASL-filter as we go */
(function (global) {
  'use strict';
  const US = global.__TBCC_US__;
  const S = US.shared;
  const FL = (US.fetlife = US.fetlife || {});

  const STATUS_ID = 'tbcc-fl-infinite-status';
  const MIN_KEEPERS = 16;
  const MAX_PAGES_PER_TOPUP = 8;
  const FETCH_GAP_MS = 450;

  let started = false;
  let loading = false;
  let stopped = false;
  let nextPage = null;
  let totalResults = null;
  let pageSize = 20;
  let pagesFetched = 0;
  let keepersAppended = 0;
  let seenKeys = new Set();
  let onScroll = null;
  let topUpTimer = null;

  function isKinkstersList() {
    return /\/kinksters/i.test(location.pathname || '');
  }

  function sleep(ms) {
    return new Promise((r) => setTimeout(r, ms));
  }

  function parseResultMeta() {
    const text = document.body?.innerText || '';
    const m = text.match(/(\d[\d,]*)\s*[-–]\s*(\d[\d,]*)\s+of\s+(\d[\d,]*)/i);
    if (!m) return null;
    const start = parseInt(m[1].replace(/,/g, ''), 10);
    const end = parseInt(m[2].replace(/,/g, ''), 10);
    const total = parseInt(m[3].replace(/,/g, ''), 10);
    const size = Math.max(1, end - start + 1);
    const page = Math.max(1, Math.ceil(end / size));
    return { start, end, total, pageSize: size, page };
  }

  function pageFromUrl() {
    try {
      const p = new URL(location.href).searchParams.get('page');
      if (p) return Math.max(1, parseInt(p, 10) || 1);
    } catch (_) { /* ignore */ }
    return null;
  }

  function urlForPage(page) {
    const u = new URL(location.href);
    u.searchParams.set('page', String(page));
    return u.href;
  }

  function cardKey(card) {
    const links = [...(card.querySelectorAll?.('a[href]') || [])];
    for (const a of links) {
      const href = a.getAttribute('href') || '';
      if (/\/users\/\d+/.test(href)) return href.replace(/#.*$/, '');
      if (/^\/[A-Za-z0-9._-]{2,40}\/?$/.test(href)) return href.replace(/\/$/, '');
    }
    const t = (card.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 60);
    return t || Math.random().toString(36);
  }

  function seedSeenFromDom() {
    const gf = FL.genderFilter;
    const cards = gf?.cardRoots?.() || [];
    for (const c of cards) seenKeys.add(cardKey(c));
  }

  function hidePagination() {
    S.ensureStyle(
      'tbcc-fl-hide-pager',
      `[data-tbcc-fl-pager-hidden="1"]{display:none!important}`
    );
    const candidates = [...document.querySelectorAll('nav, div, ul, ol')];
    for (const el of candidates) {
      if (el.closest?.('#tbcc-fl-overlay')) continue;
      const t = (el.innerText || '').replace(/\s+/g, ' ').trim();
      if (t.length > 120) continue;
      if (/\bPrev\b/i.test(t) && /\bNext\b/i.test(t) && /\b\d+\b/.test(t)) {
        el.setAttribute('data-tbcc-fl-pager-hidden', '1');
      }
    }
  }

  function ensureStatus() {
    let el = document.getElementById(STATUS_ID);
    if (el) return el;
    S.ensureStyle(
      STATUS_ID + '-css',
      `#${STATUS_ID}{
        position:fixed;z-index:999990;left:50%;transform:translateX(-50%);bottom:18px;
        background:#141414;color:#e8e8e8;border:1px solid #333;border-radius:999px;
        padding:8px 16px;font:12px/1.3 system-ui,sans-serif;box-shadow:0 6px 20px rgba(0,0,0,.45);
        max-width:min(92vw,520px);text-align:center;pointer-events:none;
      }`
    );
    el = document.createElement('div');
    el.id = STATUS_ID;
    document.documentElement.appendChild(el);
    return el;
  }

  function updateStatus(extra) {
    const el = ensureStatus();
    const kept = FL.genderFilter?.cardRoots?.()?.length ?? 0;
    const total = totalResults != null ? totalResults.toLocaleString() : '?';
    const page = nextPage != null ? nextPage - 1 : '?';
    el.textContent =
      extra ||
      `TBCC infinite · ${kept} on page · through index page ${page} · ${total} total` +
        (loading ? ' · loading…' : stopped ? ' · done' : '');
  }

  function nearBottom() {
    const doc = document.documentElement;
    const scrollPos = window.scrollY + window.innerHeight;
    const height = Math.max(doc.scrollHeight, document.body?.scrollHeight || 0);
    return scrollPos >= height - 900;
  }

  async function fetchDoc(page) {
    const url = urlForPage(page);
    const res = await fetch(url, {
      credentials: 'include',
      headers: {
        Accept: 'text/html',
        'X-Requested-With': 'XMLHttpRequest',
      },
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const html = await res.text();
    return new DOMParser().parseFromString(html, 'text/html');
  }

  /**
   * Extract member cards from a fetched document using Follow controls.
   */
  function extractCards(doc) {
    const gf = FL.genderFilter;
    if (!gf?.resolveMemberCard) return [];
    const out = [];
    const seen = new Set();
    doc.querySelectorAll('button').forEach((btn) => {
      const span = btn.querySelector('span');
      const lab = (span ? span.textContent : btn.textContent || '').replace(/\s+/g, ' ').trim();
      if (!/^(Follow|Following|Follow Pending|Follow Back|Unfollow)$/i.test(lab)) return;
      const card = gf.resolveMemberCard(btn);
      if (!card || seen.has(card)) return;
      seen.add(card);
      out.push(card);
    });
    return out;
  }

  function appendKeepers(cards) {
    const gf = FL.genderFilter;
    const cfg = gf?.loadCfg?.() || {};
    const root = gf?.listRoot?.();
    if (!root || !gf) return 0;
    gf.ensureListLayout?.(root);
    let added = 0;
    for (const remote of cards) {
      const text = gf.vitalsText(remote) || remote.innerText || '';
      if (gf.shouldHideCard(text, cfg)) continue;
      const key = cardKey(remote);
      if (seenKeys.has(key)) continue;
      const node = document.importNode(remote, true);
      // Strip scripts from imported HTML
      node.querySelectorAll?.('script').forEach((s) => s.remove());
      const localKey = cardKey(node);
      if (seenKeys.has(localKey)) continue;
      seenKeys.add(key);
      seenKeys.add(localKey);
      node.setAttribute('data-tbcc-fl-appended', '1');
      root.appendChild(node);
      added += 1;
      keepersAppended += 1;
    }
    return added;
  }

  async function loadNextPage() {
    if (loading || stopped || nextPage == null) return 0;
    loading = true;
    updateStatus();
    try {
      const doc = await fetchDoc(nextPage);
      const cards = extractCards(doc);
      const added = appendKeepers(cards);
      pagesFetched += 1;
      nextPage += 1;
      // Breadcrumb: last completed page while scrolling kinksters.
      try {
        const path = location.pathname.replace(/\/+$/, '');
        if (/\/kinksters$/i.test(path)) {
          FL.placeNav?.saveBookmark?.({
            path,
            page: Math.max(1, nextPage - 1),
            placeLabel: FL.placeNav.placeLabelFromPath?.(path) || '',
          });
        }
      } catch (_) { /* ignore */ }
      // Stop when a page returns no cards at all (end of list / blocked).
      if (!cards.length) {
        stopped = true;
      }
      // Soft cap: if we've passed total/pageSize
      if (totalResults != null && pageSize > 0) {
        const maxPage = Math.ceil(totalResults / pageSize);
        if (nextPage > maxPage + 1) stopped = true;
      }
      console.info(`[FL suite] infinite page→ +${added} keepers (${cards.length} raw)`, {
        nextPage,
        keepersAppended,
      });
      return added;
    } catch (err) {
      console.warn('[FL suite] infinite fetch failed', err);
      updateStatus(`TBCC infinite · fetch error — retry on scroll (${String(err.message || err)})`);
      return 0;
    } finally {
      loading = false;
      updateStatus();
      // Re-run ASL on any leftover males that snuck in via markup quirks
      try {
        FL.genderFilter?.apply?.();
      } catch (_) { /* ignore */ }
    }
  }

  async function topUp(minKeepers) {
    const target = minKeepers ?? MIN_KEEPERS;
    let guard = 0;
    while (!stopped && guard < MAX_PAGES_PER_TOPUP) {
      const kept = FL.genderFilter?.cardRoots?.()?.filter((c) => c.isConnected).length || 0;
      if (kept >= target && !nearBottom()) break;
      if (!nearBottom() && kept >= Math.min(8, target)) break;
      guard += 1;
      await loadNextPage();
      await sleep(FETCH_GAP_MS);
      if (stopped) break;
    }
    updateStatus();
  }

  function scheduleTopUp() {
    if (topUpTimer) clearTimeout(topUpTimer);
    topUpTimer = setTimeout(() => {
      topUpTimer = null;
      void topUp(MIN_KEEPERS);
    }, 200);
  }

  function onScrollHandler() {
    if (!started || stopped) return;
    if (nearBottom()) void topUp(MIN_KEEPERS);
  }

  FL.infiniteScroll = {
    status: () => ({
      nextPage,
      totalResults,
      pagesFetched,
      keepersAppended,
      loading,
      stopped,
    }),
    loadMore: () => topUp(MIN_KEEPERS + 20),
  };

  FL.features = FL.features || {};
  FL.features.infiniteScroll = {
    start() {
      if (started) return;
      if (!isKinkstersList()) return;
      started = true;
      stopped = false;
      loading = false;
      keepersAppended = 0;
      pagesFetched = 0;
      seenKeys = new Set();

      const meta = parseResultMeta();
      if (meta) {
        totalResults = meta.total;
        pageSize = meta.pageSize;
        nextPage = meta.page + 1;
      } else {
        nextPage = (pageFromUrl() || 1) + 1;
      }

      seedSeenFromDom();
      hidePagination();
      FL.genderFilter?.ensureListLayout?.(FL.genderFilter.listRoot?.());
      FL.genderFilter?.apply?.();
      updateStatus();

      onScroll = onScrollHandler;
      window.addEventListener('scroll', onScroll, { passive: true });
      this._unsubSpa = S.spa.onChange(() => {
        if (!isKinkstersList()) return;
        setTimeout(() => {
          hidePagination();
          seedSeenFromDom();
          scheduleTopUp();
        }, 300);
      });

      // After ASL removes males, refill the viewport from following pages.
      [600, 1500, 3000].forEach((ms) => setTimeout(scheduleTopUp, ms));
      console.info('[FL suite] infinite scroll on', { nextPage, totalResults, pageSize });
    },
    stop() {
      started = false;
      stopped = true;
      if (onScroll) window.removeEventListener('scroll', onScroll);
      onScroll = null;
      if (topUpTimer) clearTimeout(topUpTimer);
      this._unsubSpa?.();
      document.getElementById(STATUS_ID)?.remove();
    },
  };
})(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);

/* ---- packages/fetlife-suite/data/privacy-presets.js ---- */
(function (global) {
  'use strict';
  const US = global.__TBCC_US__;
  const FL = (US.fetlife = US.fetlife || {});
  FL.privacyPresets = {
  "version": 1,
  "sourceNote": "Baseline Lockdown mirrors Documents/fetlife-privacy-config.txt (Jul 2026). Edit this file, then npm run build:nobump in tbcc/userscripts.",
  "settingsUrl": "https://fetlife.com/settings/account",
  "activeKey": "tbcc_fl_privacy_active_v1",
  "pendingKey": "tbcc_fl_privacy_pending_v1",
  "levels": [
    {
      "id": "lockdown",
      "label": "1 · Lockdown",
      "short": "Current conservative baseline",
      "blurb": "Follow approval on, not recommended, places section hidden, RSVPs Only Me, wall No One.",
      "settings": {
        "followAllow": true,
        "followApprovalRequired": true,
        "allowRecommended": false,
        "friendRequests": "All FetLifers",
        "tags": "Only Friends of Friends",
        "eventInvites": "Friends and People I Follow",
        "groupInvites": "Friends and People I Follow",
        "kinkyPopular": { "pictures": true, "videos": true, "writings": true },
        "freshPervy": { "pictures": true, "videos": true, "writings": true },
        "searchContent": { "pictures": true, "videos": true, "writings": true, "statuses": true },
        "locationVisibility": { "city": true, "state": true, "country": true },
        "placesOverrideHide": true,
        "eventRsvp": "Only Me",
        "inboxProfile": "Open",
        "wallPosts": "No One",
        "viewCounts": true,
        "crushingOn": true,
        "communityLists": "Friends and Followers",
        "giftSupport": true
      }
    },
    {
      "id": "guarded",
      "label": "2 · Guarded",
      "short": "Slightly more findable locally",
      "blurb": "Still approve followers; show in Places at city level; RSVPs Friends; wall Friends.",
      "settings": {
        "followAllow": true,
        "followApprovalRequired": true,
        "allowRecommended": false,
        "friendRequests": "All FetLifers",
        "tags": "Only Friends of Friends",
        "eventInvites": "Friends and People I Follow",
        "groupInvites": "Friends and People I Follow",
        "kinkyPopular": { "pictures": true, "videos": true, "writings": true },
        "freshPervy": { "pictures": true, "videos": true, "writings": true },
        "searchContent": { "pictures": true, "videos": true, "writings": true, "statuses": true },
        "locationVisibility": { "city": true, "state": true, "country": true },
        "placesOverrideHide": false,
        "eventRsvp": "Friends",
        "inboxProfile": "Open",
        "wallPosts": "Friends",
        "viewCounts": true,
        "crushingOn": true,
        "communityLists": "Friends and Followers",
        "giftSupport": true
      }
    },
    {
      "id": "social",
      "label": "3 · Social",
      "short": "Discoverable + easier contact",
      "blurb": "No follow approval, allow recommendations, tags Friends, broader RSVP visibility.",
      "settings": {
        "followAllow": true,
        "followApprovalRequired": false,
        "allowRecommended": true,
        "friendRequests": "All FetLifers",
        "tags": "Friends",
        "eventInvites": "Friends and People I Follow",
        "groupInvites": "Friends and People I Follow",
        "kinkyPopular": { "pictures": true, "videos": true, "writings": true },
        "freshPervy": { "pictures": true, "videos": true, "writings": true },
        "searchContent": { "pictures": true, "videos": true, "writings": true, "statuses": true },
        "locationVisibility": { "city": true, "state": true, "country": true },
        "placesOverrideHide": false,
        "eventRsvp": "Friends and People I Follow",
        "inboxProfile": "Open",
        "wallPosts": "Friends",
        "viewCounts": true,
        "crushingOn": true,
        "communityLists": "Friends and Followers",
        "giftSupport": true
      }
    },
    {
      "id": "open",
      "label": "4 · Open",
      "short": "Most relaxed discoverability",
      "blurb": "Recommended, tags All FetLifers, wider invites/RSVPs. Still FetLife-only.",
      "settings": {
        "followAllow": true,
        "followApprovalRequired": false,
        "allowRecommended": true,
        "friendRequests": "All FetLifers",
        "tags": "All FetLifers",
        "eventInvites": "All FetLifers",
        "groupInvites": "Friends and People I Follow",
        "kinkyPopular": { "pictures": true, "videos": true, "writings": true },
        "freshPervy": { "pictures": true, "videos": true, "writings": true },
        "searchContent": { "pictures": true, "videos": true, "writings": true, "statuses": true },
        "locationVisibility": { "city": true, "state": true, "country": true },
        "placesOverrideHide": false,
        "eventRsvp": "All FetLifers",
        "inboxProfile": "Open",
        "wallPosts": "Friends",
        "viewCounts": true,
        "crushingOn": true,
        "communityLists": "All FetLifers",
        "giftSupport": true
      }
    }
  ]
};
})(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);

/* ---- packages/fetlife-suite/data/genders.js ---- */
(function (global) {
  'use strict';
  const US = global.__TBCC_US__;
  const FL = (US.fetlife = US.fetlife || {});
  FL.genderCatalog = [
  "Female",
  "Woman",
  "Man",
  "Cisgender",
  "Transgender",
  "Trans Woman",
  "Trans Man",
  "Trans Nonbinary",
  "Transmasculine",
  "Transfeminine",
  "Nonbinary",
  "Genderqueer",
  "Genderfluid",
  "Genderflux",
  "Genderfae",
  "Gender Neutral",
  "Gender Non-Conforming",
  "Gender Expansive",
  "Agender",
  "Apagender",
  "Genderless",
  "Gendervoid",
  "Bigender",
  "Polygender",
  "Omnigender",
  "Pangender",
  "Androgyne",
  "Androx",
  "Gynx",
  "Masc",
  "Femme",
  "Butch",
  "Crossdresser",
  "Genderfuck",
  "Intersex",
  "Intersex Male",
  "Intersex Female",
  "Two-Spirit",
  "Hijra",
  "Maverique",
  "Xenogender",
  "Plural",
  "Questioning",
  "Unsure",
  "Not Applicable"
];
})(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);

/* ---- packages/fetlife-suite/features/privacy-console.js ---- */
/* FLConsole — apply FetLife account privacy presets via Settings page UI */
(function (global) {
  'use strict';
  const US = global.__TBCC_US__;
  const S = US.shared;
  const FL = (US.fetlife = US.fetlife || {});

  const PRESETS = () => FL.privacyPresets || { levels: [], settingsUrl: 'https://fetlife.com/settings/account' };
  let started = false;
  let lastStatus = '';

  function levels() {
    return PRESETS().levels || [];
  }

  function getLevel(id) {
    return levels().find((l) => l.id === id) || null;
  }

  function activeId() {
    return S.storage.get(PRESETS().activeKey || 'tbcc_fl_privacy_active_v1', 'lockdown');
  }

  function setActiveId(id) {
    S.storage.set(PRESETS().activeKey || 'tbcc_fl_privacy_active_v1', id);
  }

  function pendingId() {
    return S.storage.get(PRESETS().pendingKey || 'tbcc_fl_privacy_pending_v1', null);
  }

  function setPending(id) {
    if (id) S.storage.set(PRESETS().pendingKey || 'tbcc_fl_privacy_pending_v1', id);
    else S.storage.set(PRESETS().pendingKey || 'tbcc_fl_privacy_pending_v1', null);
  }

  function setStatus(msg) {
    lastStatus = String(msg || '');
    FL.privacyConsole?.onStatus?.(lastStatus);
  }

  function onSettingsPage() {
    const path = location.pathname || '';
    return /\/settings/i.test(path);
  }

  function norm(s) {
    return String(s || '')
      .replace(/\s+/g, ' ')
      .trim()
      .toLowerCase();
  }

  function clickableFromText(needle) {
    const n = norm(needle);
    if (!n) return null;
    const nodes = document.querySelectorAll('button, a, label, [role="button"], [role="radio"], [role="option"]');
    for (const el of nodes) {
      const t = norm(el.textContent);
      if (!t) continue;
      if (t === n || t.includes(n)) return el;
    }
    return null;
  }

  function setCheckboxNearLabel(labelText, wantOn) {
    const label = clickableFromText(labelText);
    if (!label) return false;
    const root = label.closest('label') || label.parentElement || label;
    const input =
      (label.tagName === 'INPUT' && label) ||
      root.querySelector?.('input[type="checkbox"]') ||
      label.querySelector?.('input[type="checkbox"]');
    if (input && input.type === 'checkbox') {
      if (!!input.checked !== !!wantOn) {
        input.click();
        return true;
      }
      return true;
    }
    // Toggle-looking buttons / switches
    const pressed = label.getAttribute?.('aria-checked') || label.getAttribute?.('aria-pressed');
    if (pressed != null) {
      const on = pressed === 'true';
      if (on !== !!wantOn) label.click();
      return true;
    }
    return false;
  }

  function chooseOptionNearHeading(headingText, optionText) {
    const heading = Array.from(document.querySelectorAll('h1,h2,h3,h4,legend,label,div,span,p')).find((el) => {
      const t = norm(el.textContent);
      return t === norm(headingText) || (t.length < 80 && t.includes(norm(headingText)));
    });
    if (!heading) {
      const opt = clickableFromText(optionText);
      if (opt) {
        opt.click();
        return true;
      }
      return false;
    }
    let scope = heading.closest('section,fieldset,form,div') || heading.parentElement;
    for (let i = 0; i < 4 && scope; i++) {
      const cand = Array.from(scope.querySelectorAll('button, a, label, [role="radio"], [role="option"], select option')).find(
        (el) => norm(el.textContent) === norm(optionText) || norm(el.textContent).includes(norm(optionText))
      );
      if (cand) {
        if (cand.tagName === 'OPTION') {
          cand.selected = true;
          cand.parentElement?.dispatchEvent(new Event('change', { bubbles: true }));
        } else {
          cand.click();
        }
        return true;
      }
      scope = scope.parentElement;
    }
    const fallback = clickableFromText(optionText);
    if (fallback) {
      fallback.click();
      return true;
    }
    return false;
  }

  function applySettingsObject(settings) {
    const hits = [];
    const miss = [];
    const tryCheck = (label, want) => {
      if (setCheckboxNearLabel(label, want)) hits.push(label);
      else miss.push(label);
    };
    const tryChoice = (heading, option) => {
      if (chooseOptionNearHeading(heading, option)) hits.push(`${heading}→${option}`);
      else miss.push(`${heading}→${option}`);
    };

    tryCheck('Allow members to follow me', settings.followAllow !== false);
    tryCheck('New followers must be approved first', !!settings.followApprovalRequired);
    tryCheck('Allow being recommended to potential followers', !!settings.allowRecommended);
    tryChoice('Who can send you a friend request?', settings.friendRequests);
    tryChoice('Who can tag you in a picture or video?', settings.tags);
    tryChoice('Who can invite you to an event?', settings.eventInvites);
    tryChoice('Who can invite you to a group?', settings.groupInvites);

    const kp = settings.kinkyPopular || {};
    tryCheck('Kinky & Popular', true); // section presence
    if ('pictures' in kp) tryCheck('Pictures', !!kp.pictures);
    // Fresh / search checkboxes often share "Pictures" labels — best-effort only.

    tryCheck("Don't display my profile in the places section", !!settings.placesOverrideHide);
    tryChoice("Who can see on my profile what events I've RSVP'd to?", settings.eventRsvp);
    tryChoice('Who can post on your wall?', settings.wallPosts);
    tryCheck('Display view counts on my posts', settings.viewCounts !== false);
    tryCheck('Allow others to crush on me', settings.crushingOn !== false);
    tryChoice('Who can add you to a Community List?', settings.communityLists);
    tryCheck('Allow people to gift me support', settings.giftSupport !== false);

    return { hits: hits.length, miss: miss.length, missList: miss.slice(0, 12) };
  }

  async function applyPendingIfAny() {
    const id = pendingId();
    if (!id || !onSettingsPage()) return null;
    const level = getLevel(id);
    if (!level) {
      setPending(null);
      return null;
    }
    setStatus(`Applying ${level.label}…`);
    await new Promise((r) => setTimeout(r, 600));
    const result = applySettingsObject(level.settings || {});
    setActiveId(id);
    setPending(null);
    const msg =
      result.miss > 0
        ? `Applied ${level.label}: ${result.hits} matched, ${result.miss} not found — review Settings manually.`
        : `Applied ${level.label}: ${result.hits} controls updated. Confirm Save if FetLife asks.`;
    setStatus(msg);
    console.info('[FLConsole] privacy apply', id, result);
    FL.overlay?.open?.('flconsole');
    return result;
  }

  /**
   * Queue preset and open FetLife Settings so controls can be driven.
   * Privacy is server-side — this is intentional navigation, not silent mutation.
   */
  function applyPreset(id) {
    const level = getLevel(id);
    if (!level) {
      setStatus(`Unknown preset: ${id}`);
      return { ok: false };
    }
    setPending(id);
    setActiveId(id);
    setStatus(`Opening Settings to apply ${level.label}…`);
    if (onSettingsPage()) {
      applyPendingIfAny();
      return { ok: true, queued: false, id };
    }
    const url = PRESETS().settingsUrl || 'https://fetlife.com/settings/account';
    location.assign(url);
    return { ok: true, queued: true, id };
  }

  FL.privacyConsole = {
    levels,
    getLevel,
    activeId,
    applyPreset,
    getStatus: () => lastStatus,
    onStatus: null,
    checklist(id) {
      const level = getLevel(id) || getLevel(activeId());
      if (!level) return [];
      const s = level.settings || {};
      return [
        `Follow allow: ${s.followAllow}`,
        `Follow approval: ${s.followApprovalRequired}`,
        `Recommended: ${s.allowRecommended}`,
        `Friend requests: ${s.friendRequests}`,
        `Tags: ${s.tags}`,
        `Places hidden: ${s.placesOverrideHide}`,
        `Event RSVP: ${s.eventRsvp}`,
        `Wall: ${s.wallPosts}`,
      ];
    },
  };

  FL.features = FL.features || {};
  FL.features.privacyConsole = {
    start() {
      if (started) return;
      started = true;
      setTimeout(() => applyPendingIfAny(), 900);
      this._unsub = S.spa.onChange(() => setTimeout(() => applyPendingIfAny(), 500));
    },
    stop() {
      started = false;
      this._unsub?.();
    },
  };
})(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);

/* ---- packages/fetlife-suite/features/overlay.js ---- */
/* TBCC-style chevron overlay: collapsible + paginated suite control */
(function (global) {
  'use strict';
  const US = global.__TBCC_US__;
  const S = US.shared;
  const FL = (US.fetlife = US.fetlife || {});

  const COMMUNITY = global.__TBCC_EDITION__ === 'community';
  const SUITE_TITLE = COMMUNITY ? 'FetLife Enhancer' : 'TBCC FetLife Suite';
  const ROOT_ID = 'tbcc-fl-overlay';
  const TOP_KEY = 'tbcc_fl_overlay_top_v1';
  const UI_KEY = 'tbcc_fl_overlay_ui_v1';
  const SYNC_KEYS = [
    UI_KEY,
    TOP_KEY,
    'tbcc_fl_suite_flags_v1',
    'tbcc_fl_gender_filter_v1',
    'tbcc_fl_autofollow_cfg_v1',
    'tbcc_fl_social_proof_v1',
    'tbcc_fl_story_enabled_v1',
    'tbcc_fl_muted_users_v1',
    'tbcc_fl_place_nav_v1',
    'tbcc_fl_kinksters_bookmark_v1',
    'tbcc_fl_privacy_active_v1',
    'tbcc_fl_intel_rows_v1',
    'tbcc_fl_intel_meta_v1',
    'tbcc_fl_title_kw_v1',
  ];
  const INTEL_ROWS_KEY = 'tbcc_fl_intel_rows_v1';
  const INTEL_META_KEY = 'tbcc_fl_intel_meta_v1';
  const KW_KEY = 'tbcc_fl_title_kw_v1';
  const JUMP_STACK_ID = 'tbcc-fl-jump-stack';
  const PAGES_ALL = [
    { id: 'features', title: 'Features' },
    { id: 'autofollow', title: 'Auto-follow' },
    { id: 'gender', title: 'ASL filter' },
    { id: 'keywords', title: 'Keywords' },
    { id: 'flconsole', title: 'FLConsole' },
    { id: 'stories', title: 'Profile & stories' },
    { id: 'mute', title: 'Mute' },
    { id: 'intel', title: 'Intel' },
  ];
  const PAGES = COMMUNITY ? PAGES_ALL.filter((p) => p.id !== 'intel') : PAGES_ALL;

  let pageIndex = 0;
  let collapsed = true;
  let widthMode = 'slim';
  let hooks = {};
  let suppressUiPersist = false;
  let syncBound = false;

  function clampOverlayTop(px) {
    const max = Math.max(8, window.innerHeight - 140);
    return Math.min(max, Math.max(8, Math.round(Number(px) || 72)));
  }

  function loadOverlayTop() {
    const saved = S.storage.get(TOP_KEY, 72);
    return clampOverlayTop(saved);
  }

  function saveOverlayTop(px) {
    S.storage.set(TOP_KEY, clampOverlayTop(px));
  }

  function loadUiState() {
    const saved = S.storage.get(UI_KEY, null);
    const ui = saved && typeof saved === 'object' ? saved : {};
    let idx = Number(ui.pageIndex);
    if (!Number.isFinite(idx) || idx < 0 || idx >= PAGES.length) idx = 0;
    return {
      // Default collapsed when no saved UI (keeps Friend/Follow/Message clickable).
      collapsed: ui.collapsed !== false,
      pageIndex: idx,
      widthMode: (() => {
        const w = String(ui.widthMode || 'slim');
        return w === 'wide' || w === 'normal' || w === 'slim' ? w : 'slim';
      })(),
    };
  }

  function persistUiState() {
    if (suppressUiPersist) return;
    S.storage.set(UI_KEY, {
      collapsed: !!collapsed,
      pageIndex,
      widthMode,
    });
  }

  function applyOverlayTop(root) {
    if (!root) return;
    root.style.top = `${loadOverlayTop()}px`;
  }

  function applyUiState(ui, { renderBody = true } = {}) {
    if (!ui) return;
    suppressUiPersist = true;
    try {
      if (typeof ui.collapsed === 'boolean') collapsed = ui.collapsed;
      if (ui.widthMode === 'slim' || ui.widthMode === 'normal' || ui.widthMode === 'wide') {
        widthMode = ui.widthMode;
      }
      if (Number.isFinite(ui.pageIndex) && ui.pageIndex >= 0 && ui.pageIndex < PAGES.length) {
        pageIndex = ui.pageIndex;
      }
      const root = document.getElementById(ROOT_ID);
      if (root) {
        syncCollapsedUi(root);
        applyOverlayTop(root);
        if (renderBody) render();
      }
    } finally {
      suppressUiPersist = false;
    }
  }

  function bindCrossTabSync() {
    if (syncBound || typeof S.storage.subscribe !== 'function') return;
    syncBound = true;
    S.storage.subscribe(SYNC_KEYS, (key) => {
      if (key === UI_KEY) {
        applyUiState(loadUiState(), { renderBody: true });
        return;
      }
      if (key === TOP_KEY) {
        applyOverlayTop(document.getElementById(ROOT_ID));
        return;
      }
      // Hydrate / re-apply before refreshing the open panel.
      if (key === 'tbcc_fl_suite_flags_v1') {
        hooks.flags?.hydrate?.();
        hooks.onFlagsChange?.();
      }
      if (key === 'tbcc_fl_gender_filter_v1') FL.genderFilter?.apply?.();
      if (key === 'tbcc_fl_social_proof_v1') FL.socialProof?.apply?.();
      const root = document.getElementById(ROOT_ID);
      if (root && !collapsed) render();
    });
  }

  function bindChevronDrag(root, chevron) {
    let drag = null;
    const DRAG_THRESHOLD = 5;

    chevron.addEventListener('pointerdown', (e) => {
      if (e.button != null && e.button !== 0) return;
      drag = {
        pointerId: e.pointerId,
        startY: e.clientY,
        startTop: root.getBoundingClientRect().top,
        moved: false,
      };
      try {
        chevron.setPointerCapture(e.pointerId);
      } catch (_) { /* ignore */ }
      e.preventDefault();
    });

    chevron.addEventListener('pointermove', (e) => {
      if (!drag || e.pointerId !== drag.pointerId) return;
      const dy = e.clientY - drag.startY;
      if (!drag.moved && Math.abs(dy) < DRAG_THRESHOLD) return;
      drag.moved = true;
      const next = clampOverlayTop(drag.startTop + dy);
      root.style.top = `${next}px`;
      chevron.style.cursor = 'grabbing';
    });

    const endDrag = (e) => {
      if (!drag || (e && e.pointerId !== drag.pointerId)) return;
      const wasDrag = drag.moved;
      const top = root.getBoundingClientRect().top;
      if (wasDrag) saveOverlayTop(top);
      chevron.style.cursor = 'grab';
      try {
        if (e) chevron.releasePointerCapture(e.pointerId);
      } catch (_) { /* ignore */ }
      drag = null;
      if (wasDrag) {
        // Suppress the click that follows a drag so we don't toggle open/closed.
        chevron.dataset.suppressClick = '1';
        setTimeout(() => {
          delete chevron.dataset.suppressClick;
        }, 0);
      }
    };

    chevron.addEventListener('pointerup', endDrag);
    chevron.addEventListener('pointercancel', endDrag);
    chevron.addEventListener('lostpointercapture', () => {
      if (drag) {
        if (drag.moved) saveOverlayTop(root.getBoundingClientRect().top);
        drag = null;
        chevron.style.cursor = 'grab';
      }
    });
  }

  function syncCollapsedUi(root) {
    root.classList.toggle('collapsed', collapsed);
    root.classList.toggle('slim', widthMode === 'slim');
    root.classList.toggle('wide', widthMode === 'wide');
    const chevron = root.querySelector('.tbcc-chevron');
    if (chevron) chevron.textContent = collapsed ? 'FL ▸' : 'FL ◂';
    const Rail = global.TBCCSuiteRail;
    if (Rail) {
      Rail.syncJumpStack({
        stackId: JUMP_STACK_ID,
        overlayEl: root,
        visible: true,
        collapsed,
      });
    }
  }

  function loadKeywords() {
    const saved = S.storage.get(KW_KEY, null);
    if (saved && typeof saved === 'object') {
      return {
        titleInclude: String(saved.titleInclude || ''),
        titleExclude: String(saved.titleExclude || ''),
      };
    }
    return { titleInclude: '', titleExclude: '' };
  }

  function saveKeywords(kw) {
    S.storage.set(KW_KEY, {
      titleInclude: String(kw.titleInclude || ''),
      titleExclude: String(kw.titleExclude || ''),
    });
  }

  function keywordTargets() {
    const selectors = ['#stories-list > *', '#fl-masonry-wrap .fl-masonry-col > *', '#stories > *'];
    const found = new Set();
    for (const sel of selectors) {
      document.querySelectorAll(sel).forEach((el) => {
        if (el.classList?.contains('infinite-loading-container')) return;
        if ((el.textContent || '').trim().length < 20) return;
        if (el.closest(`#${ROOT_ID}`)) return;
        found.add(el);
      });
    }
    return [...found].filter((el) => ![...found].some((o) => o !== el && o.contains(el)));
  }

  function applyKeywordFilters() {
    const Rail = global.TBCCSuiteRail;
    const kw = loadKeywords();
    const match = Rail
      ? (hay) => Rail.matchesKeywords(hay, kw.titleInclude, kw.titleExclude)
      : (hay) => {
          const text = String(hay || '').toLowerCase();
          const ex = String(kw.titleExclude || '')
            .toLowerCase()
            .split(/[\s,]+/)
            .filter(Boolean);
          if (ex.some((k) => text.includes(k))) return false;
          const inc = String(kw.titleInclude || '')
            .toLowerCase()
            .split(/[\s,]+/)
            .filter(Boolean);
          if (!inc.length) return true;
          return inc.every((k) => text.includes(k));
        };
    keywordTargets().forEach((el) => {
      const ok = match(el.textContent || '');
      el.classList.toggle('tbcc-suite-kw-filtered', !ok);
    });
  }

  function mountKeywordBar() {
    const Rail = global.TBCCSuiteRail;
    if (!Rail) return;
    const kw = loadKeywords();
    Rail.mountKeywordBar({
      barId: 'tbcc-fl-kw-bar',
      hint: 'Refines feed/story text on this page (space/comma separated). Same fields as Keywords tab.',
      getInclude: () => loadKeywords().titleInclude,
      getExclude: () => loadKeywords().titleExclude,
      shouldMount: () => true,
      onChange: (inc, exc) => {
        saveKeywords({ titleInclude: inc, titleExclude: exc });
        applyKeywordFilters();
      },
    });
    void kw;
  }

  function ensureDom() {
    if (document.getElementById(ROOT_ID)) return;

    S.ensureStyle(
      ROOT_ID + '-css',
      `
      #${ROOT_ID} {
        position: fixed; z-index: 1000000; top: 72px; right: 0;
        display: flex; align-items: stretch; font: 13px/1.4 system-ui, sans-serif;
        color: #e8e8e8; pointer-events: none;
      }
      #${ROOT_ID} * { box-sizing: border-box; }
      #${ROOT_ID} .tbcc-chevron {
        pointer-events: auto; width: 28px; min-height: 120px;
        background: #141414; border: 1px solid #333; border-right: none;
        border-radius: 10px 0 0 10px; cursor: grab; color: #f43f5e;
        display: flex; align-items: center; justify-content: center;
        writing-mode: vertical-rl; text-orientation: mixed; letter-spacing: .08em;
        font-size: 11px; font-weight: 700; padding: 10px 0;
        touch-action: none; user-select: none;
      }
      #${ROOT_ID} .tbcc-chevron:active { cursor: grabbing; }
      #${ROOT_ID} .tbcc-panel {
        pointer-events: auto; width: min(300px, calc(100vw - 40px));
        max-height: min(72vh, 560px); background: #121212; border: 1px solid #333;
        border-right: none; border-radius: 12px 0 0 12px;
        box-shadow: -8px 0 28px rgba(0,0,0,.45); display: flex; flex-direction: column;
        overflow: hidden;
      }
      #${ROOT_ID}.collapsed .tbcc-panel { display: none; }
      #${ROOT_ID}.slim .tbcc-panel { width: min(240px, calc(100vw - 40px)); }
      #${ROOT_ID}.wide .tbcc-panel { width: min(360px, calc(100vw - 40px)); }
      #${ROOT_ID} .tbcc-head {
        display: flex; align-items: center; gap: 8px; padding: 10px 12px;
        background: #1a1a1a; border-bottom: 1px solid #2a2a2a;
      }
      #${ROOT_ID} .tbcc-head strong { flex: 1; font-size: 13px; }
      #${ROOT_ID} .tbcc-head button, #${ROOT_ID} .tbcc-foot button, #${ROOT_ID} .tbcc-body button.primary {
        background: #2a2a2a; color: #eee; border: 1px solid #444; border-radius: 6px;
        padding: 6px 10px; cursor: pointer;
      }
      #${ROOT_ID} .tbcc-head button:hover, #${ROOT_ID} .tbcc-foot button:hover {
        border-color: #f43f5e; color: #fff;
      }
      #${ROOT_ID} button.primary {
        background: #9f1239; border-color: #e11d48; width: 100%; margin-top: 8px; font-weight: 600;
      }
      #${ROOT_ID} button.primary.running { background: #3f1d1d; }
      #${ROOT_ID} .tbcc-tabs {
        display: flex; gap: 4px; padding: 8px; border-bottom: 1px solid #2a2a2a; overflow-x: auto;
      }
      #${ROOT_ID} .tbcc-tabs button {
        flex: 0 0 auto; background: transparent; border: 1px solid #333; color: #aaa;
        border-radius: 999px; padding: 4px 10px; cursor: pointer; font-size: 11px;
      }
      #${ROOT_ID} .tbcc-tabs button.active {
        background: #e11d48; border-color: #e11d48; color: #fff;
      }
      #${ROOT_ID} .tbcc-body { padding: 12px; overflow: auto; flex: 1; }
      #${ROOT_ID} .tbcc-foot {
        display: flex; gap: 8px; padding: 8px 12px; border-top: 1px solid #2a2a2a; background: #1a1a1a;
      }
      #${ROOT_ID} .tbcc-foot .page-ind { flex: 1; color: #888; font-size: 12px; align-self: center; }
      #${ROOT_ID} label.row {
        display: flex; gap: 8px; align-items: flex-start; padding: 6px 0; cursor: pointer;
      }
      #${ROOT_ID} .hint { color: #888; font-size: 12px; margin: 0 0 10px; }
      #${ROOT_ID} select, #${ROOT_ID} input[type="text"] {
        width: 100%; background: #1e1e1e; color: #eee; border: 1px solid #444;
        border-radius: 6px; padding: 6px 8px;
      }
      #${ROOT_ID} .field-row {
        display: flex; gap: 6px; align-items: stretch; margin-bottom: 4px;
      }
      #${ROOT_ID} .field-row input[type="text"] { flex: 1; min-width: 0; }
      #${ROOT_ID} button.clear-btn {
        flex: 0 0 auto; margin: 0; padding: 6px 10px; background: #2a2228; color: #eee;
        border: 1px solid #514049; border-radius: 6px; cursor: pointer; font-size: 12px;
      }
      #${ROOT_ID} button.clear-btn:hover { border-color: #f43f5e; }
      #${ROOT_ID} .stat { font-variant-numeric: tabular-nums; color: #fda4af; margin-top: 8px; }
      #${ROOT_ID} details.section {
        border: 1px solid #2a2a2a; border-radius: 8px; margin-bottom: 8px; padding: 0 8px;
      }
      #${ROOT_ID} details.section > summary {
        cursor: pointer; padding: 8px 4px; font-weight: 600; color: #ccc; list-style: none;
      }
      #${ROOT_ID} details.section > summary::-webkit-details-marker { display: none; }
      #${ROOT_ID} details.section > summary::before {
        content: '▸'; display: inline-block; margin-right: 6px; color: #f43f5e;
      }
      #${ROOT_ID} details.section[open] > summary::before { content: '▾'; }
    `
    );

    const root = document.createElement('div');
    root.id = ROOT_ID;
    root.innerHTML = `
      <button type="button" class="tbcc-chevron" title="${SUITE_TITLE}">FL ▸</button>
      <div class="tbcc-panel">
        <div class="tbcc-head">
          <strong>${SUITE_TITLE}</strong>
          <button type="button" data-act="width" title="Cycle panel width">Width</button>
          <button type="button" data-act="collapse">Hide</button>
        </div>
        <div class="tbcc-tabs"></div>
        <div class="tbcc-body"></div>
        <div class="tbcc-foot">
          <button type="button" data-jump="top" title="Back to top">↑</button>
          <button type="button" data-act="prev">Prev</button>
          <span class="page-ind"></span>
          <button type="button" data-act="next">Next</button>
          <button type="button" data-jump="bottom" title="Back to bottom">↓</button>
        </div>
      </div>`;
    document.documentElement.appendChild(root);
    applyOverlayTop(root);
    syncCollapsedUi(root);
    global.TBCCSuiteRail?.ensureStyles?.();
    global.TBCCSuiteRail?.bindFootJumps?.(root);

    const tabs = root.querySelector('.tbcc-tabs');
    PAGES.forEach((p, i) => {
      const b = document.createElement('button');
      b.type = 'button';
      b.textContent = p.title;
      b.dataset.page = p.id;
      b.addEventListener('click', () => {
        pageIndex = i;
        persistUiState();
        render();
      });
      tabs.appendChild(b);
    });

    const chevron = root.querySelector('.tbcc-chevron');
    bindChevronDrag(root, chevron);
    chevron.addEventListener('click', () => {
      if (chevron.dataset.suppressClick) return;
      collapsed = !collapsed;
      syncCollapsedUi(root);
      persistUiState();
    });
    root.querySelector('[data-act="collapse"]').addEventListener('click', () => {
      collapsed = true;
      syncCollapsedUi(root);
      persistUiState();
    });
    root.querySelector('[data-act="width"]')?.addEventListener('click', (e) => {
      e.preventDefault();
      e.stopPropagation();
      widthMode = widthMode === 'slim' ? 'normal' : widthMode === 'normal' ? 'wide' : 'slim';
      syncCollapsedUi(root);
      persistUiState();
    });
    root.querySelector('[data-act="prev"]').addEventListener('click', () => {
      pageIndex = (pageIndex + PAGES.length - 1) % PAGES.length;
      persistUiState();
      render();
    });
    root.querySelector('[data-act="next"]').addEventListener('click', () => {
      pageIndex = (pageIndex + 1) % PAGES.length;
      persistUiState();
      render();
    });

    window.addEventListener(
      'resize',
      () => {
        applyOverlayTop(root);
      },
      { passive: true }
    );
  }

  function renderFeatures(body) {
    const flags = hooks.flags;
    const labels = hooks.labels || {};
    body.innerHTML = `<p class="hint">Toggle modules. Changes apply immediately.</p>`;
    Object.keys(flags.all()).forEach((key) => {
      if (COMMUNITY && key === 'socialProof') return;
      const lab = document.createElement('label');
      lab.className = 'row';
      const cb = document.createElement('input');
      cb.type = 'checkbox';
      cb.checked = flags.get(key);
      cb.addEventListener('change', () => {
        flags.set(key, cb.checked);
        hooks.onFlagsChange?.(key, cb.checked);
      });
      lab.appendChild(cb);
      lab.appendChild(document.createTextNode(labels[key] || key));
      body.appendChild(lab);
    });
  }

  function renderAutoFollow(body) {
    const af = FL.autoFollow;
    const cfg = af?.loadCfg?.() || {};
    const placeCfg = FL.placeNav?.loadCfg?.() || {};
    const bm = FL.placeNav?.loadBookmark?.();
    const placeDisplay = FL.placeNav?.displayPlaceQuery?.() || placeCfg.lastQuery || '';
    const bmLabel = bm
      ? `Resume p.${bm.page}${bm.placeLabel ? ` · ${bm.placeLabel}` : ''}`
      : '';
    body.innerHTML = `
      <p class="hint">Clicks Follow on visible cards; infinite scroll fills the pool. Place opens /p/…/kinksters (navigation only — not the ASL vitals filter).</p>
      <label class="row">Place (city / area)</label>
      <div class="field-row">
        <input type="text" data-af="place" placeholder="e.g. Bangkok" />
        <button type="button" class="clear-btn" data-af="clear-place" title="Clear saved place">Clear</button>
      </div>
      <div style="display:flex;gap:8px;margin-top:8px">
        <button type="button" class="primary" data-af="go-place" style="margin-top:0;flex:1">Open kinksters</button>
        <button type="button" class="clear-btn" data-af="go-search" style="flex:0 0 auto">Search</button>
      </div>
      <p class="hint" data-af="place-hint" style="margin-top:6px">Default region for US slugs: California. Wrong slug? use Search.</p>
      <div style="display:flex;gap:8px;margin:8px 0;flex-wrap:wrap">
        <button type="button" class="clear-btn" data-af="resume" ${bm ? '' : 'disabled'} style="flex:1">${bm ? bmLabel : 'No bookmark yet'}</button>
        <button type="button" class="clear-btn" data-af="clear-bm" ${bm ? '' : 'disabled'}>Clear bookmark</button>
      </div>
      <label class="row">Speed</label>
      <select data-af="speed"></select>
      <label class="row"><input type="checkbox" data-af="skipMale" /> Respect ASL sex filters when following</label>
      <p class="hint" style="margin-top:0">When on: skip cards the ASL tab would remove (sex rules). ASL location needles do not apply on place kinksters pages.</p>
      <label class="row"><input type="checkbox" data-af="autoStartOnKinksters" /> Auto-start on /kinksters</label>
      <label class="row"><input type="checkbox" data-af="openOnSearch" /> Open this panel on /search</label>
      <div class="stat" data-af="stat">Followed: ${af?.getCount?.() || 0}</div>
      <button type="button" class="primary" data-af="toggle">Start auto-follow</button>
    `;
    const placeInput = body.querySelector('[data-af="place"]');
    placeInput.value = placeDisplay;
    const sel = body.querySelector('[data-af="speed"]');
    Object.entries(af?.SPEED_PRESETS || {}).forEach(([k, v]) => {
      const opt = document.createElement('option');
      opt.value = k;
      opt.textContent = v.label;
      if (k === (cfg.speed || 'fast')) opt.selected = true;
      sel.appendChild(opt);
    });
    body.querySelector('[data-af="skipMale"]').checked = cfg.skipMale !== false;
    body.querySelector('[data-af="autoStartOnKinksters"]').checked = cfg.autoStartOnKinksters !== false;
    body.querySelector('[data-af="openOnSearch"]').checked = cfg.openOnSearch !== false;

    const persist = () => {
      af.saveCfg({
        speed: sel.value,
        skipMale: body.querySelector('[data-af="skipMale"]').checked,
        autoStartOnKinksters: body.querySelector('[data-af="autoStartOnKinksters"]').checked,
        openOnSearch: body.querySelector('[data-af="openOnSearch"]').checked,
      });
    };
    sel.addEventListener('change', persist);
    body.querySelector('[data-af="skipMale"]').addEventListener('change', persist);
    body.querySelector('[data-af="autoStartOnKinksters"]').addEventListener('change', persist);
    body.querySelector('[data-af="openOnSearch"]').addEventListener('change', persist);

    const persistPlace = () => {
      FL.placeNav?.saveCfg?.({ lastQuery: placeInput.value.trim() });
    };
    placeInput.addEventListener('change', persistPlace);
    placeInput.addEventListener('blur', persistPlace);

    body.querySelector('[data-af="clear-place"]').addEventListener('click', () => {
      placeInput.value = '';
      FL.placeNav?.clearPlace?.();
      const hint = body.querySelector('[data-af="place-hint"]');
      if (hint) hint.textContent = 'Place cleared (saved). ASL location filter is unchanged — clear it on the ASL tab if needed.';
    });

    const go = (mode) => {
      const q = placeInput.value.trim();
      const hint = body.querySelector('[data-af="place-hint"]');
      if (!q) {
        if (hint) hint.textContent = 'Enter a place name first (e.g. Bangkok).';
        return;
      }
      // Navigation only — do not sync into ASL locationInclude.
      const r = FL.placeNav?.goPlace?.(q, { syncAsl: false, mode });
      if (hint && r?.url) hint.textContent = `Going: ${r.url}`;
    };
    body.querySelector('[data-af="go-place"]').addEventListener('click', () => go('kinksters'));
    body.querySelector('[data-af="go-search"]').addEventListener('click', () => go('search'));
    placeInput.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') {
        e.preventDefault();
        go('kinksters');
      }
    });

    body.querySelector('[data-af="resume"]').addEventListener('click', () => {
      const url = FL.placeNav?.resumeBookmarkUrl?.();
      if (!url) return;
      location.assign(url);
    });
    body.querySelector('[data-af="clear-bm"]').addEventListener('click', () => {
      FL.placeNav?.clearBookmark?.();
      renderAutoFollow(body);
    });

    const btn = body.querySelector('[data-af="toggle"]');
    const syncBtn = () => {
      const running = af?.isRunning?.();
      btn.textContent = running ? `Stop (${af.getCount()} followed)` : 'Start auto-follow';
      btn.classList.toggle('running', !!running);
      body.querySelector('[data-af="stat"]').textContent = `Followed: ${af?.getCount?.() || 0}`;
    };
    btn.addEventListener('click', () => {
      if (af.isRunning()) af.stop();
      else {
        try {
          const path = location.pathname.replace(/\/+$/, '');
          if (/\/kinksters$/i.test(path)) {
            const page = Number(new URL(location.href).searchParams.get('page')) || 1;
            FL.placeNav?.saveBookmark?.({
              path,
              page,
              placeLabel: FL.placeNav.placeLabelFromPath?.(path) || placeInput.value.trim(),
            });
          }
        } catch (_) { /* ignore */ }
        af.start();
        const hint = body.querySelector('[data-af="place-hint"]');
        if (hint) hint.textContent = 'Bookmarking page progress — Resume later from this tab.';
      }
      syncBtn();
    });
    af.onProgress = () => syncBtn();
    af.onState = () => syncBtn();
    syncBtn();
  }

  function renderFlConsole(body) {
    const pc = FL.privacyConsole;
    const active = pc?.activeId?.() || 'lockdown';
    const levels = pc?.levels?.() || FL.privacyPresets?.levels || [];
    const status = pc?.getStatus?.() || '';
    body.innerHTML = `
      <p class="hint"><b>FLConsole</b> — one-tap FetLife <em>account</em> privacy tiers. These live on FetLife’s Settings page (not just this extension). Applying opens Settings and sets matching controls; review before you leave.</p>
      <div data-fc="levels"></div>
      <p class="hint" data-fc="status" style="margin-top:8px">${status || `Active preset: ${active}`}</p>
      <details class="section">
        <summary>Checklist for active preset</summary>
        <ul data-fc="check" style="margin:8px 0;padding-left:18px;color:#bbb;font-size:12px"></ul>
      </details>
      <details class="section">
        <summary>Gender catalog (reference)</summary>
        <p class="hint">From your FetLife gender list — for future ASL filters. Not applied automatically yet.</p>
        <p style="font-size:11px;color:#999;line-height:1.5" data-fc="genders"></p>
      </details>
    `;
    const wrap = body.querySelector('[data-fc="levels"]');
    levels.forEach((lv) => {
      const b = document.createElement('button');
      b.type = 'button';
      b.className = 'primary';
      b.style.marginTop = '6px';
      b.textContent = lv.id === active ? `✓ ${lv.label}` : lv.label;
      b.title = lv.blurb || lv.short || '';
      const note = document.createElement('p');
      note.className = 'hint';
      note.style.margin = '2px 0 8px';
      note.textContent = lv.short || '';
      b.addEventListener('click', () => {
        pc?.applyPreset?.(lv.id);
        const st = body.querySelector('[data-fc="status"]');
        if (st) st.textContent = pc?.getStatus?.() || `Queued ${lv.label}`;
        renderFlConsole(body);
      });
      wrap.appendChild(b);
      wrap.appendChild(note);
    });
    const ul = body.querySelector('[data-fc="check"]');
    (pc?.checklist?.(active) || []).forEach((line) => {
      const li = document.createElement('li');
      li.textContent = line;
      ul.appendChild(li);
    });
    const gEl = body.querySelector('[data-fc="genders"]');
    if (gEl) gEl.textContent = (FL.genderCatalog || []).join(' · ');
    if (pc) {
      pc.onStatus = (msg) => {
        const st = body.querySelector('[data-fc="status"]');
        if (st) st.textContent = msg;
      };
    }
  }

  function renderKeywords(body) {
    const kw = loadKeywords();
    body.innerHTML = `
      <p class="hint">Include/Exclude for feed story text (same sticky bar). Include = all must match; exclude = hide if any. Space/comma separated.</p>
      <label class="row">Include (all must match)</label>
      <input type="text" data-kw-inc placeholder="e.g. rope bondage" autocomplete="off" />
      <label class="row">Exclude (hide if any)</label>
      <input type="text" data-kw-exc placeholder="e.g. cishet" autocomplete="off" />
      <div style="display:flex;gap:8px;margin-top:10px">
        <button type="button" class="primary" data-kw-apply>Apply</button>
        <button type="button" class="clear-btn" data-kw-clear>Clear</button>
      </div>
      <p class="hint" data-kw-status style="margin-top:8px">Ready</p>
    `;
    const inc = body.querySelector('[data-kw-inc]');
    const exc = body.querySelector('[data-kw-exc]');
    const status = body.querySelector('[data-kw-status]');
    inc.value = kw.titleInclude || '';
    exc.value = kw.titleExclude || '';
    const apply = () => {
      saveKeywords({ titleInclude: inc.value.trim(), titleExclude: exc.value.trim() });
      applyKeywordFilters();
      const bar = document.getElementById('tbcc-fl-kw-bar');
      if (bar) {
        const bi = bar.querySelector('[data-kw="inc"]');
        const be = bar.querySelector('[data-kw="exc"]');
        if (bi) bi.value = inc.value.trim();
        if (be) be.value = exc.value.trim();
      }
      if (status) status.textContent = 'Keywords applied';
    };
    body.querySelector('[data-kw-apply]')?.addEventListener('click', apply);
    body.querySelector('[data-kw-clear]')?.addEventListener('click', () => {
      inc.value = '';
      exc.value = '';
      apply();
    });
    inc.addEventListener('change', apply);
    exc.addEventListener('change', apply);
  }

  function renderGender(body) {
    const gf = FL.genderFilter;
    const cfg = gf?.loadCfg?.() || {};
    const onGeoKinksters = /\/p\/[^/]+\/[^/]+\/[^/]+\/kinksters/i.test(location.pathname);
    body.innerHTML = `
      <p class="hint">Client-side ASL filter. Matching cards are <b>removed</b> so the list reflows. Location needles apply on search/global lists — on place kinksters pages the URL is the geo scope (location text is ignored).</p>
      <label class="row"><input type="checkbox" data-g="hideMale" /> Hide Male (M)</label>
      <label class="row"><input type="checkbox" data-g="femaleOnly" /> Female only (hide non-F when sex known)</label>
      <label class="row"><input type="checkbox" data-g="hideFtM" /> Also hide FtM</label>
      <label class="row">Location must include (comma-separated)</label>
      <div class="field-row">
        <input type="text" data-g="location" placeholder="blank = any (vitals text match)" />
        <button type="button" class="clear-btn" data-g="clear-location" title="Clear location needles">Clear</button>
      </div>
      <p class="hint" data-g="status" style="margin-top:8px">${
        onGeoKinksters
          ? 'On a place kinksters list — location needles idle; Clear still wipes saved text for other pages.'
          : 'Persists on change. Clear writes blank immediately.'
      }</p>
      <button type="button" class="primary" data-g="apply">Re-apply filter</button>
    `;
    body.querySelector('[data-g="hideMale"]').checked = cfg.hideMale !== false;
    body.querySelector('[data-g="femaleOnly"]').checked = cfg.femaleOnly !== false;
    body.querySelector('[data-g="hideFtM"]').checked = !!cfg.hideFtM;
    const locInput = body.querySelector('[data-g="location"]');
    locInput.value = cfg.locationInclude != null ? cfg.locationInclude : '';
    const save = () => {
      gf.saveCfg({
        hideMale: body.querySelector('[data-g="hideMale"]').checked,
        femaleOnly: body.querySelector('[data-g="femaleOnly"]').checked,
        hideFtM: body.querySelector('[data-g="hideFtM"]').checked,
        locationInclude: locInput.value.trim(),
      });
      const result = gf.apply?.() || {};
      const hint = body.querySelector('[data-g="status"]');
      if (hint) {
        hint.textContent =
          result.skipped === 'profile'
            ? 'ASL skipped on member profiles (keeps Friend / Follow / Message).'
            : result.cards != null
              ? `Last apply: ${result.hidden || 0} removed / ${result.kept ?? '?'} kept`
              : 'Filter re-applied';
      }
    };
    body.querySelector('[data-g="hideMale"]').addEventListener('change', save);
    body.querySelector('[data-g="femaleOnly"]').addEventListener('change', save);
    body.querySelector('[data-g="hideFtM"]').addEventListener('change', save);
    locInput.addEventListener('change', save);
    locInput.addEventListener('blur', save);
    body.querySelector('[data-g="clear-location"]').addEventListener('click', () => {
      locInput.value = '';
      gf.saveCfg({
        hideMale: body.querySelector('[data-g="hideMale"]').checked,
        femaleOnly: body.querySelector('[data-g="femaleOnly"]').checked,
        hideFtM: body.querySelector('[data-g="hideFtM"]').checked,
        locationInclude: '',
      });
      gf.apply?.();
      const hint = body.querySelector('[data-g="status"]');
      if (hint) hint.textContent = 'Location needles cleared.';
    });
    body.querySelector('[data-g="apply"]').addEventListener('click', () => {
      save();
      gf.apply();
    });
  }

  function renderStories(body) {
    if (typeof FL.socialProof?.renderStoriesPanel === 'function') {
      FL.socialProof.renderStoriesPanel(body);
      return;
    }
    body.innerHTML = `
      <strong style="display:block;margin-bottom:8px">Story types</strong>
      <div data-story-catalog></div>
    `;
    FL.storyFilter?.mountCatalog?.(body.querySelector('[data-story-catalog]'));
  }

  function renderMute(body) {
    body.innerHTML = `<p class="hint">Mute adds a (mute) link next to comment authors. List is stored in extension/local storage.</p>
      <p class="stat">Muted IDs: ${(S.storage.get('tbcc_fl_muted_users_v1', '') || '(none)')}</p>`;
  }

  function render() {
    ensureDom();
    const root = document.getElementById(ROOT_ID);
    const page = PAGES[pageIndex];
    root.querySelectorAll('.tbcc-tabs button').forEach((b) => {
      b.classList.toggle('active', b.dataset.page === page.id);
    });
    root.querySelector('.page-ind').textContent = `${pageIndex + 1} / ${PAGES.length} — ${page.title}`;
    const body = root.querySelector('.tbcc-body');
    if (page.id === 'features') renderFeatures(body);
    else if (page.id === 'autofollow') renderAutoFollow(body);
    else if (page.id === 'gender') renderGender(body);
    else if (page.id === 'keywords') renderKeywords(body);
    else if (page.id === 'flconsole') renderFlConsole(body);
    else if (page.id === 'stories') renderStories(body);
    else if (page.id === 'mute') renderMute(body);
    else if (page.id === 'intel') FL.overlayIntel?.render?.(body, render);
  }

  FL.overlay = {
    mount(opts) {
      hooks = opts || {};
      const ui = loadUiState();
      pageIndex = ui.pageIndex;
      collapsed = ui.collapsed;
      widthMode = ui.widthMode || 'slim';
      ensureDom();
      syncCollapsedUi(document.getElementById(ROOT_ID));
      applyOverlayTop(document.getElementById(ROOT_ID));
      mountKeywordBar();
      applyKeywordFilters();
      render();
      bindCrossTabSync();
      if (!global.__tbccFlKwObs) {
        global.__tbccFlKwObs = new MutationObserver(() => {
          clearTimeout(global.__tbccFlKwT);
          global.__tbccFlKwT = setTimeout(applyKeywordFilters, 300);
        });
        try {
          global.__tbccFlKwObs.observe(document.body, { childList: true, subtree: true });
        } catch (_) {}
      }
      if (typeof GM_registerMenuCommand === 'function') {
        try {
          GM_registerMenuCommand(SUITE_TITLE + ': open overlay', () => FL.overlay.open());
        } catch (_) { /* ignore */ }
      }
    },
    open(pageId) {
      ensureDom();
      const root = document.getElementById(ROOT_ID);
      collapsed = false;
      if (pageId) {
        const i = PAGES.findIndex((p) => p.id === pageId);
        if (i >= 0) pageIndex = i;
      }
      syncCollapsedUi(root);
      applyOverlayTop(root);
      persistUiState();
      render();
    },
    collapse() {
      const root = document.getElementById(ROOT_ID);
      if (!root) return;
      collapsed = true;
      syncCollapsedUi(root);
      persistUiState();
    },
    refresh() {
      const root = document.getElementById(ROOT_ID);
      if (!root) return;
      applyUiState(loadUiState(), { renderBody: true });
    },
  };
})(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);

/* ---- packages/fetlife-suite/boot.js ---- */
/* FetLife suite boot — flags + overlay + feature sync */
(function (global) {
  'use strict';
  const US = global.__TBCC_US__;
  const S = US.shared;
  const FL = (US.fetlife = US.fetlife || {});

  const COMMUNITY = global.__TBCC_EDITION__ === 'community';
  const FLAG_KEY = 'tbcc_fl_suite_flags_v1';
  const DEFAULTS = {
    loginRedirect: true,
    homeFeed: true,
    storyFilter: true,
    mute: true,
    newestDiscussions: true,
    genderFilter: true,
    autoFollow: true,
    infiniteScroll: true,
    socialProof: !COMMUNITY,
    privacyConsole: true,
  };
  if (COMMUNITY) delete DEFAULTS.socialProof;

  const LABELS = {
    loginRedirect: 'Redirect login/home → last kinksters place',
    homeFeed: 'Home feed masonry + pills',
    storyFilter: 'Client-side story type filter',
    mute: 'Comment mute buttons',
    newestDiscussions: 'Groups → newest discussions',
    genderFilter: 'ASL filter (female / location)',
    autoFollow: 'Auto-follow controls (panel)',
    infiniteScroll: 'Kinksters infinite scroll (fill gaps)',
    privacyConsole: 'FLConsole privacy presets',
  };
  if (FL.socialProofFlagLabel) LABELS.socialProof = FL.socialProofFlagLabel;

  const flags = S.createFlags(FLAG_KEY, DEFAULTS);
  // Force-enable new defaults for users who already have an old flags blob
  {
    const saved = S.storage.get(FLAG_KEY, null);
    const upgrade = (key) => {
      if (!saved || typeof saved !== 'object' || !(key in saved)) flags.set(key, true);
    };
    upgrade('autoFollow');
    upgrade('genderFilter');
    upgrade('loginRedirect');
    upgrade('infiniteScroll');
    if (!COMMUNITY) upgrade('socialProof');
    upgrade('privacyConsole');
  }

  const running = Object.create(null);

  function syncFeatures() {
    const feats = FL.features || {};
    for (const name of Object.keys(DEFAULTS)) {
      const want = flags.get(name);
      const feat = feats[name];
      if (!feat) continue;
      if (want && !running[name]) {
        feat.start();
        running[name] = true;
      } else if (!want && running[name]) {
        feat.stop?.();
        running[name] = false;
      }
    }
  }

  function onRemoteFlags() {
    flags.hydrate?.();
    syncFeatures();
    FL.overlay.refresh?.();
  }

  function boot() {
    FL.overlay.mount({
      flags,
      labels: LABELS,
      onFlagsChange() {
        syncFeatures();
      },
    });
    syncFeatures();

    // Keep module flags live across FetLife tabs.
    if (typeof S.storage.subscribe === 'function') {
      S.storage.subscribe(FLAG_KEY, onRemoteFlags);
    }

    // Open overlay on kinksters landing (persists open state to other tabs).
    if (/\/kinksters/i.test(location.pathname)) {
      setTimeout(() => FL.overlay.open('autofollow'), 700);
    }

    console.info(COMMUNITY ? '[AOF FetLife Enhancer] community ready' : '[TBCC FetLife Suite] v1.8 ready', flags.all());
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', boot);
  } else {
    boot();
  }
})(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);