TrustSpark add-on source

Manifest version: 1.0.12. These are the complete files packaged in the Firefox add-on, copied without changes. This page follows the latest website deployment; your installed version may differ.

background.js

Download file · SHA-256: e9f4f844467a819466f1ef421aa2af5b35142a8b35cfa959904ebe15da51063a

// Generated by node scripts/build.cjs. Edit app/ instead.
(function () {
  "use strict";
  var DEFAULT_CONFIG = {
    karma: {
      enabled: true,
      ceiling: 400000,
      poor:     { below: 2,    weight: 0 },
      adequate: { below: 1000, weight: 1 },
      good:     { above: 1000, weight: 2 },
    },
    comments: {
      enabled: true,
      ceiling: 1000,
      poor:     { below: 3,   weight: 0 },
      adequate: { below: 100, weight: 1 },
      good:     { above: 99,  weight: 2 },
    },
    submissions: {
      enabled: true,
      ceiling: 1 / 14,
      poor:     { above: 1,      weight: 0 },
      adequate: { above: 1 / 7,  weight: 1 },
      good:     { below: 1 / 7,  weight: 2 },
    },
    comment_rate: {
      enabled: false,
      ceiling: 10,
      poor:     { below: 1 / 30, weight: 0 },
      adequate: { below: 1,      weight: 1 },
      good:     { above: 1,      weight: 2 },
    },
    created: {
      enabled: true,
      ceiling: '2009-01-01',
      poor:     { below: 365,  weight: 0 },
      adequate: { below: 1825, weight: 1 },
      good:     { above: 1825, weight: 2 },
    },
    minTrustScore: 2,
    sortSparklines: false,
    invertSparklines: false,
    sortByTrust: false,
  };

  function deepMerge(defaults, overrides) {
    var result = JSON.parse(JSON.stringify(defaults));
    if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return result;
    Object.keys(defaults).forEach(function (key) {
      var value = overrides[key];
      if (defaults[key] && typeof defaults[key] === 'object') {
        result[key] = deepMerge(defaults[key], value);
      } else if (typeof value === typeof defaults[key]) {
        if (typeof value === 'number' && (!Number.isFinite(value) ||
            (key === 'ceiling' && value <= 0) ||
            (key !== 'weight' && value < 0))) return;
        if (typeof value === 'string' && (!Number.isFinite(Date.parse(value)) || Date.parse(value) >= Date.now())) return;
        result[key] = value;
      }
    });
    return result;
  }

  // ── Signal evaluation ──────────────────────────────────────────────

  function evalKarma(profile, cfg) {
    var k = profile.karma || 0;
    var bar = Math.min(1, Math.log(Math.max(0, k) + 1) / Math.log(cfg.ceiling + 1));
    var condition;
    if (k < cfg.poor.below)       condition = 'poor';
    else if (k < cfg.adequate.below) condition = 'adequate';
    else                              condition = 'good';
    return { bar: bar, condition: condition };
  }

  function evalComments(profile, cfg) {
    var c = profile.total_comments || 0;
    var bar = Math.min(1, c / cfg.ceiling);
    var condition;
    if (c < cfg.poor.below)       condition = 'poor';
    else if (c < cfg.adequate.below) condition = 'adequate';
    else                              condition = 'good';
    return { bar: bar, condition: condition };
  }

  function evalSubmissions(profile, cfg) {
    var rate = profile.submissions_rate || 0;
    var bar = Math.min(1, rate > 0 ? Math.min(1, cfg.ceiling / rate) : 1);
    var condition;
    if (rate > cfg.poor.above)        condition = 'poor';
    else if (rate > cfg.adequate.above) condition = 'adequate';
    else                                 condition = 'good';
    return { bar: bar, condition: condition };
  }

  function evalCommentRate(profile, cfg) {
    var rate = profile.comments_rate || 0;
    var bar = Math.min(1, rate / cfg.ceiling);
    var condition;
    if (rate < cfg.poor.below)       condition = 'poor';
    else if (rate < cfg.adequate.below) condition = 'adequate';
    else                                condition = 'good';
    return { bar: bar, condition: condition };
  }

  function evalCreated(profile, cfg) {
    var created = new Date(profile.created_at).getTime();
    var ceiling = new Date(cfg.ceiling).getTime();
    var now = Date.now();
    var age = now - created;
    var maxAge = now - ceiling;
    var bar = Math.min(1, age / maxAge);
    var days = age / (24 * 3600 * 1000);
    var condition;
    if (days < cfg.poor.below)       condition = 'poor';
    else if (days < cfg.adequate.below) condition = 'adequate';
    else                                 condition = 'good';
    return { bar: bar, condition: condition };
  }

  var COLORS = { poor: '#e74c3c', adequate: '#f39c12', good: '#27ae60' };

  function formatNumber(n) {
    if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
    if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
    return String(n);
  }

  function friendlyRate(dailyRate) {
    if (dailyRate >= 1)       return dailyRate.toFixed(1) + '/day';
    if (dailyRate * 7 >= 1)   return (dailyRate * 7).toFixed(1) + '/week';
    if (dailyRate * 30 >= 1)  return (dailyRate * 30).toFixed(1) + '/month';
    return (dailyRate * 365).toFixed(1) + '/year';
  }

  function friendlyAge(isoDate) {
    var ms = Date.now() - new Date(isoDate).getTime();
    var days = ms / (24 * 3600 * 1000);
    if (days >= 365.25) return Math.floor(days / 365.25) + 'y ' + Math.floor((days % 365.25) / 30) + 'mo';
    if (days >= 30)     return Math.floor(days / 30) + 'mo';
    return Math.floor(days) + 'd';
  }

  function signalsEnabled(config) {
    return ['karma', 'comments', 'submissions', 'comment_rate', 'created'].some(function (key) { return config[key].enabled; });
  }

  function evaluateProfile(profile, config) {
    var signals = [];
    if (config.karma.enabled) {
      var r = evalKarma(profile, config.karma);
      signals.push({ name: 'Karma', detail: formatNumber(profile.karma), bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.karma[r.condition].weight });
    }
    if (config.comments.enabled) {
      var r = evalComments(profile, config.comments);
      signals.push({ name: 'Comments', detail: formatNumber(profile.total_comments), bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.comments[r.condition].weight });
    }
    if (config.submissions.enabled) {
      var r = evalSubmissions(profile, config.submissions);
      signals.push({ name: 'Submissions', detail: formatNumber(profile.total_submissions) + ' (' + friendlyRate(profile.submissions_rate) + ')', bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.submissions[r.condition].weight });
    }
    if (config.comment_rate.enabled) {
      var r = evalCommentRate(profile, config.comment_rate);
      signals.push({ name: 'Comment Rate', detail: friendlyRate(profile.comments_rate || 0), bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.comment_rate[r.condition].weight });
    }
    if (config.created.enabled) {
      var r = evalCreated(profile, config.created);
      signals.push({ name: 'Age', detail: friendlyAge(profile.created_at), bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.created[r.condition].weight });
    }
    return signals;
  }

  var PRESETS = {
    default: DEFAULT_CONFIG,
    'high-submission-penalty': deepMerge(DEFAULT_CONFIG, { submissions: { poor: { weight: -5 } } }),
  };

  function getConfig(saved, preset) {
    return deepMerge(PRESETS[preset || 'default'], saved);
  }

  function validateConfig(candidate) {
    var badOrder = ['karma', 'comments', 'comment_rate', 'created'].some(function (key) {
      return candidate[key].enabled && candidate[key].poor.below > candidate[key].adequate.below;
    }) || (candidate.submissions.enabled && candidate.submissions.adequate.above > candidate.submissions.poor.above);
    return badOrder ? 'Check thresholds: the adequate range must lie between poor and good.' : '';
  }


  var PROFILE_TTL_MS = 6 * 60 * 60 * 1000;
  function classifyProfile(profile) {
    if (!profile) return { status: 'missing', profile: null };
    var valid = Number.isFinite(Date.parse(profile.created_at)) && Number.isFinite(profile.karma) &&
      ['total_comments', 'total_submissions', 'submissions_rate'].every(function (key) {
        return Number.isFinite(profile[key]) && profile[key] >= 0;
      }) && (profile.comments_rate == null || (Number.isFinite(profile.comments_rate) && profile.comments_rate >= 0));
    if (!valid) return { status: 'unavailable', profile: null };
    var checked = Date.parse(profile.checked_at);
    var fresh = Number.isFinite(checked) && checked <= Date.now() + 300000 && Date.now() - checked < PROFILE_TTL_MS;
    return { status: fresh ? 'ready' : 'stale', profile: profile };
  }

// A single, bounded queue is shared by all HN tabs.
var CACHE_KEY = 'ts-profile-cache';
var CACHE_TTL_MS = PROFILE_TTL_MS;
var MAX_STALE_AGE_MS = 7 * 24 * 60 * 60 * 1000;
var MISSING_TTL_MS = 5 * 60 * 1000;
var MAX_CACHE_ENTRIES = 500;
var MAX_ACTIVE_PROFILES = 3; // At most nine HTTP requests across all tabs.
var REQUEST_TIMEOUT_MS = 15000;
var HN_USER_URL = 'https://hacker-news.firebaseio.com/v0/user/';
var ALGOLIA_URL = 'https://hn.algolia.com/api/v1/search';
var cache = Object.create(null);
var cacheReady;
var pending = new Map();
var queue = [];
var active = 0;
var saveTimer;
var writes = Promise.resolve();

function pruneCache() {
  var now = Date.now();
  var keys = Object.keys(cache).filter(function (key) {
    var entry = cache[key];
    var valid = entry && Number.isFinite(entry.cachedAt) &&
      now - entry.cachedAt < (entry.profile ? MAX_STALE_AGE_MS : MISSING_TTL_MS);
    if (!valid) delete cache[key];
    return valid;
  });
  keys.sort(function (a, b) { return cache[b].cachedAt - cache[a].cachedAt; });
  keys.slice(MAX_CACHE_ENTRIES).forEach(function (key) { delete cache[key]; });
}

function loadCache() {
  if (!cacheReady) {
    cacheReady = browser.storage.local.get(CACHE_KEY).then(function (result) {
      Object.assign(cache, result[CACHE_KEY] || {});
      pruneCache();
      scheduleSave(); // Also persist pruning of a legacy, unbounded cache.
    }).catch(function () { /* Storage failure must not prevent loading profiles. */ });
  }
  return cacheReady;
}

function scheduleSave() {
  if (saveTimer) return;
  saveTimer = setTimeout(function () {
    saveTimer = null;
    pruneCache();
    // Capture a bounded snapshot and serialize writes so older writes cannot win.
    var snapshot = Object.assign(Object.create(null), cache);
    writes = writes.then(function () {
      return browser.storage.local.set({ [CACHE_KEY]: snapshot });
    }).catch(function (err) { console.warn('[TrustSpark] Cache write failed:', err); });
  }, 250);
}

function fetchJSON(url, signal) {
  return fetch(url, { credentials: 'omit', signal: signal }).then(function (r) {
    if (!r.ok) throw new Error('Profile API ' + r.status);
    return r.json();
  });
}

async function fetchUserProfile(username) {
  var controller = new AbortController();
  var timeout = setTimeout(function () { controller.abort(); }, REQUEST_TIMEOUT_MS);
  try {
    var results = await Promise.all([
      fetchJSON(HN_USER_URL + encodeURIComponent(username) + '.json', controller.signal),
      fetchJSON(ALGOLIA_URL + '?tags=story,author_' + encodeURIComponent(username) + '&hitsPerPage=0', controller.signal),
      fetchJSON(ALGOLIA_URL + '?tags=comment,author_' + encodeURIComponent(username) + '&hitsPerPage=0', controller.signal),
    ]);
    var user = results[0];
    if (user === null) return null;
    if (!user || user.id !== username || !Number.isFinite(user.created) ||
        !Number.isFinite(user.karma) || !Number.isFinite(results[1].nbHits) ||
        !Number.isFinite(results[2].nbHits) || results[1].nbHits < 0 || results[2].nbHits < 0) throw new Error('Invalid profile response');
    var createdMs = user.created * 1000;
    var daysAlive = (Date.now() - createdMs) / 86400000;
    return {
      checked_at: new Date().toISOString(),
      created_at: new Date(createdMs).toISOString(),
      karma: user.karma,
      bio_length: (user.about || '').length,
      total_submissions: results[1].nbHits,
      total_comments: results[2].nbHits,
      submissions_rate: daysAlive > 0 ? results[1].nbHits / daysAlive : 0,
      comments_rate: daysAlive > 0 ? results[2].nbHits / daysAlive : 0,
    };
  } finally {
    clearTimeout(timeout);
    controller.abort(); // Cancel sibling requests if one failed.
  }
}

function drainQueue() {
  while (active < MAX_ACTIVE_PROFILES && queue.length) {
    var job = queue.shift();
    active++;
    runJob(job);
  }
}

async function runJob(job) {
  try {
    var profile = await fetchUserProfile(job.username);
    cache[job.username] = { profile: profile, cachedAt: Date.now() };
    pruneCache();
    scheduleSave();
    job.resolve(profile ? classifyProfile(profile) : { status: 'missing', profile: null });
  } catch (err) {
    // Do not cache failed requests as zero-valued (low-trust) accounts.
    var previous = cache[job.username];
    job.resolve(previous && previous.profile ? { status: 'stale', profile: previous.profile } : { status: 'unavailable', profile: null });
  } finally {
    pending.delete(job.username);
    active--;
    drainQueue();
  }
}

function getProfile(username, force) {
  var entry = cache[username];
  if (!force && entry && Date.now() - entry.cachedAt < (entry.profile ? CACHE_TTL_MS : MISSING_TTL_MS)) {
    return Promise.resolve(entry.profile ? classifyProfile(entry.profile) : { status: 'missing', profile: null });
  }
  if (pending.has(username)) return pending.get(username);
  var promise = new Promise(function (resolve) { queue.push({ username: username, resolve: resolve }); });
  pending.set(username, promise);
  drainQueue();
  return promise;
}

browser.runtime.onMessage.addListener(function (msg) {
  if (!msg || !['getProfiles', 'getProfileStates'].includes(msg.type) || !Array.isArray(msg.usernames)) return;
  var usernames = Array.from(new Set(msg.usernames.filter(function (u) {
    return typeof u === 'string' && /^[a-zA-Z0-9_-]{1,50}$/.test(u);
  }))).slice(0, 2000);
  return loadCache().then(function () {
    return Promise.all(usernames.map(function (u) {
      return getProfile(u, msg.force === true).then(function (p) { return [u, p]; });
    }));
  }).then(function (pairs) {
    var out = Object.create(null);
    pairs.forEach(function (pair) {
      if (msg.type === 'getProfileStates') out[pair[0]] = pair[1];
      else if (pair[1].status === 'ready') out[pair[0]] = pair[1].profile;
    });
    return out;
  });
});

})();
content.js

Download file · SHA-256: 99686c5d646c6c717ebaed6f8f82a9c80e1945afe1bb119a4b848647cbcb6cf3

// Generated by node scripts/build.cjs. Edit app/ instead.
(function () {
  "use strict";
  var DEFAULT_CONFIG = {
    karma: {
      enabled: true,
      ceiling: 400000,
      poor:     { below: 2,    weight: 0 },
      adequate: { below: 1000, weight: 1 },
      good:     { above: 1000, weight: 2 },
    },
    comments: {
      enabled: true,
      ceiling: 1000,
      poor:     { below: 3,   weight: 0 },
      adequate: { below: 100, weight: 1 },
      good:     { above: 99,  weight: 2 },
    },
    submissions: {
      enabled: true,
      ceiling: 1 / 14,
      poor:     { above: 1,      weight: 0 },
      adequate: { above: 1 / 7,  weight: 1 },
      good:     { below: 1 / 7,  weight: 2 },
    },
    comment_rate: {
      enabled: false,
      ceiling: 10,
      poor:     { below: 1 / 30, weight: 0 },
      adequate: { below: 1,      weight: 1 },
      good:     { above: 1,      weight: 2 },
    },
    created: {
      enabled: true,
      ceiling: '2009-01-01',
      poor:     { below: 365,  weight: 0 },
      adequate: { below: 1825, weight: 1 },
      good:     { above: 1825, weight: 2 },
    },
    minTrustScore: 2,
    sortSparklines: false,
    invertSparklines: false,
    sortByTrust: false,
  };

  function deepMerge(defaults, overrides) {
    var result = JSON.parse(JSON.stringify(defaults));
    if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return result;
    Object.keys(defaults).forEach(function (key) {
      var value = overrides[key];
      if (defaults[key] && typeof defaults[key] === 'object') {
        result[key] = deepMerge(defaults[key], value);
      } else if (typeof value === typeof defaults[key]) {
        if (typeof value === 'number' && (!Number.isFinite(value) ||
            (key === 'ceiling' && value <= 0) ||
            (key !== 'weight' && value < 0))) return;
        if (typeof value === 'string' && (!Number.isFinite(Date.parse(value)) || Date.parse(value) >= Date.now())) return;
        result[key] = value;
      }
    });
    return result;
  }

  // ── Signal evaluation ──────────────────────────────────────────────

  function evalKarma(profile, cfg) {
    var k = profile.karma || 0;
    var bar = Math.min(1, Math.log(Math.max(0, k) + 1) / Math.log(cfg.ceiling + 1));
    var condition;
    if (k < cfg.poor.below)       condition = 'poor';
    else if (k < cfg.adequate.below) condition = 'adequate';
    else                              condition = 'good';
    return { bar: bar, condition: condition };
  }

  function evalComments(profile, cfg) {
    var c = profile.total_comments || 0;
    var bar = Math.min(1, c / cfg.ceiling);
    var condition;
    if (c < cfg.poor.below)       condition = 'poor';
    else if (c < cfg.adequate.below) condition = 'adequate';
    else                              condition = 'good';
    return { bar: bar, condition: condition };
  }

  function evalSubmissions(profile, cfg) {
    var rate = profile.submissions_rate || 0;
    var bar = Math.min(1, rate > 0 ? Math.min(1, cfg.ceiling / rate) : 1);
    var condition;
    if (rate > cfg.poor.above)        condition = 'poor';
    else if (rate > cfg.adequate.above) condition = 'adequate';
    else                                 condition = 'good';
    return { bar: bar, condition: condition };
  }

  function evalCommentRate(profile, cfg) {
    var rate = profile.comments_rate || 0;
    var bar = Math.min(1, rate / cfg.ceiling);
    var condition;
    if (rate < cfg.poor.below)       condition = 'poor';
    else if (rate < cfg.adequate.below) condition = 'adequate';
    else                                condition = 'good';
    return { bar: bar, condition: condition };
  }

  function evalCreated(profile, cfg) {
    var created = new Date(profile.created_at).getTime();
    var ceiling = new Date(cfg.ceiling).getTime();
    var now = Date.now();
    var age = now - created;
    var maxAge = now - ceiling;
    var bar = Math.min(1, age / maxAge);
    var days = age / (24 * 3600 * 1000);
    var condition;
    if (days < cfg.poor.below)       condition = 'poor';
    else if (days < cfg.adequate.below) condition = 'adequate';
    else                                 condition = 'good';
    return { bar: bar, condition: condition };
  }

  var COLORS = { poor: '#e74c3c', adequate: '#f39c12', good: '#27ae60' };

  function formatNumber(n) {
    if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
    if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
    return String(n);
  }

  function friendlyRate(dailyRate) {
    if (dailyRate >= 1)       return dailyRate.toFixed(1) + '/day';
    if (dailyRate * 7 >= 1)   return (dailyRate * 7).toFixed(1) + '/week';
    if (dailyRate * 30 >= 1)  return (dailyRate * 30).toFixed(1) + '/month';
    return (dailyRate * 365).toFixed(1) + '/year';
  }

  function friendlyAge(isoDate) {
    var ms = Date.now() - new Date(isoDate).getTime();
    var days = ms / (24 * 3600 * 1000);
    if (days >= 365.25) return Math.floor(days / 365.25) + 'y ' + Math.floor((days % 365.25) / 30) + 'mo';
    if (days >= 30)     return Math.floor(days / 30) + 'mo';
    return Math.floor(days) + 'd';
  }

  function signalsEnabled(config) {
    return ['karma', 'comments', 'submissions', 'comment_rate', 'created'].some(function (key) { return config[key].enabled; });
  }

  function evaluateProfile(profile, config) {
    var signals = [];
    if (config.karma.enabled) {
      var r = evalKarma(profile, config.karma);
      signals.push({ name: 'Karma', detail: formatNumber(profile.karma), bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.karma[r.condition].weight });
    }
    if (config.comments.enabled) {
      var r = evalComments(profile, config.comments);
      signals.push({ name: 'Comments', detail: formatNumber(profile.total_comments), bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.comments[r.condition].weight });
    }
    if (config.submissions.enabled) {
      var r = evalSubmissions(profile, config.submissions);
      signals.push({ name: 'Submissions', detail: formatNumber(profile.total_submissions) + ' (' + friendlyRate(profile.submissions_rate) + ')', bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.submissions[r.condition].weight });
    }
    if (config.comment_rate.enabled) {
      var r = evalCommentRate(profile, config.comment_rate);
      signals.push({ name: 'Comment Rate', detail: friendlyRate(profile.comments_rate || 0), bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.comment_rate[r.condition].weight });
    }
    if (config.created.enabled) {
      var r = evalCreated(profile, config.created);
      signals.push({ name: 'Age', detail: friendlyAge(profile.created_at), bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.created[r.condition].weight });
    }
    return signals;
  }

  var PRESETS = {
    default: DEFAULT_CONFIG,
    'high-submission-penalty': deepMerge(DEFAULT_CONFIG, { submissions: { poor: { weight: -5 } } }),
  };

  function getConfig(saved, preset) {
    return deepMerge(PRESETS[preset || 'default'], saved);
  }

  function validateConfig(candidate) {
    var badOrder = ['karma', 'comments', 'comment_rate', 'created'].some(function (key) {
      return candidate[key].enabled && candidate[key].poor.below > candidate[key].adequate.below;
    }) || (candidate.submissions.enabled && candidate.submissions.adequate.above > candidate.submissions.poor.above);
    return badOrder ? 'Check thresholds: the adequate range must lie between poor and good.' : '';
  }


  var PROFILE_TTL_MS = 6 * 60 * 60 * 1000;
  function classifyProfile(profile) {
    if (!profile) return { status: 'missing', profile: null };
    var valid = Number.isFinite(Date.parse(profile.created_at)) && Number.isFinite(profile.karma) &&
      ['total_comments', 'total_submissions', 'submissions_rate'].every(function (key) {
        return Number.isFinite(profile[key]) && profile[key] >= 0;
      }) && (profile.comments_rate == null || (Number.isFinite(profile.comments_rate) && profile.comments_rate >= 0));
    if (!valid) return { status: 'unavailable', profile: null };
    var checked = Date.parse(profile.checked_at);
    var fresh = Number.isFinite(checked) && checked <= Date.now() + 300000 && Date.now() - checked < PROFILE_TTL_MS;
    return { status: fresh ? 'ready' : 'stale', profile: profile };
  }

  // ── Filter / reveal helpers ──────────────────────────────────────────

  function findFilterTargets(link) {
    var comtr = link.closest('tr.comtr');
    if (comtr) return [comtr];
    var row = link.closest('tr');
    if (!row) return [];
    var prev = row.previousElementSibling;
    if (prev && prev.classList.contains('athing')) return [prev, row];
    return [row];
  }

  // Blur content rather than table rows. Offscreen content keeps its layout but
  // does not retain an active filter; the margin prepares it before scrolling in.
  var blurObserver = typeof IntersectionObserver === 'function' ? new IntersectionObserver(function (entries) {
    entries.forEach(function (entry) {
      if (entry.target.classList.contains('ts-filter-content')) {
        entry.target.classList.toggle('ts-blur-active', entry.isIntersecting);
      }
    });
  }, { rootMargin: '200px' }) : null;
  var uiStyle = document.createElement('style');
  uiStyle.textContent = '.ts-filter-content{visibility:hidden;cursor:pointer}' +
    '.ts-filter-content.ts-blur-active{visibility:visible;filter:blur(5px)}' +
    '.coll .ts-filter-content{filter:none}' +
    '.ts-filter-content:focus-visible{outline:2px solid #ff6600;outline-offset:2px}' +
    '.ts-sparkline:focus-visible,.ts-profile-details:focus-visible{outline:2px solid #ff6600;outline-offset:2px}' +
    '@media(pointer:coarse){.ts-sparkline::before,.ts-profile-details::before{content:"";position:absolute;inset:-7px -2px;}}';
  document.head.appendChild(uiStyle);

  function removeFilter(el) {
    el.classList.remove('ts-filtered');
    if (el._tsFilterContent) {
      var host = el._tsFilterContent;
      if (blurObserver) blurObserver.unobserve(host);
      host.classList.remove('ts-filter-content', 'ts-blur-active');
      host.removeEventListener('click', el._tsFilterListener, true);
      host.removeEventListener('keydown', el._tsFilterKeyListener);
      el._tsFilterAttributes.forEach(function (attribute) {
        if (attribute[1] === null) host.removeAttribute(attribute[0]);
        else host.setAttribute(attribute[0], attribute[1]);
      });
      delete el._tsFilterContent;
      delete el._tsFilterListener;
      delete el._tsFilterKeyListener;
      delete el._tsFilterAttributes;
    }
  }

  function applyFilterGroup(targets) {
    if (!targets.length || targets[0].classList.contains('ts-filtered')) return;
    var host = targets[0].querySelector('.comment, .titleline');
    if (!host) return; // Leave unfamiliar HN layouts readable.
    targets.forEach(function (el) { el.classList.add('ts-filtered'); });
    host.classList.add('ts-filter-content');
    if (blurObserver) blurObserver.observe(host);
    else host.classList.add('ts-blur-active');
    var showContent = function () { targets.forEach(removeFilter); };
    var revealClick = function (event) {
      event.preventDefault();
      event.stopPropagation();
      showContent();
    };
    host.addEventListener('click', revealClick, true);
    var revealKey = function (event) {
      if (event.target === host && (event.key === 'Enter' || event.key === ' ')) revealClick(event);
    };
    host.addEventListener('keydown', revealKey);
    targets[0]._tsFilterContent = host;
    targets[0]._tsFilterListener = revealClick;
    targets[0]._tsFilterKeyListener = revealKey;
    targets[0]._tsFilterAttributes = ['tabindex', 'role', 'aria-label'].map(function (name) {
      return [name, host.getAttribute(name)];
    });
    host.setAttribute('tabindex', '0');
    host.setAttribute('role', 'group');
    host.setAttribute('aria-label', 'Blurred content — press Enter or Space to reveal');
  }

  // One tooltip per document, built only when requested, including on touch.
  var tooltip = null;
  var activeSparkline = null;
  var pinnedTooltip = false;
  var tooltipHideTimer;
  var sparklineDetails = new WeakMap();

  function hideTooltip() {
    clearTimeout(tooltipHideTimer);
    if (tooltip) tooltip.hidden = true;
    if (activeSparkline) {
      activeSparkline.setAttribute('aria-expanded', 'false');
      activeSparkline.removeAttribute('aria-describedby');
    }
    activeSparkline = null;
    pinnedTooltip = false;
  }

  function showTooltip(container, pinned) {
    hideTooltip();
    if (!tooltip) {
      tooltip = document.createElement('div');
      tooltip.id = 'ts-profile-tooltip';
      tooltip.className = 'ts-tooltip';
      tooltip.setAttribute('role', 'tooltip');
      tooltip.style.cssText = 'position:fixed;background:#f6f6ef;border:1px solid #828282;border-radius:3px;padding:6px 10px;min-width:160px;box-sizing:border-box;max-width:calc(100vw - 16px);max-height:calc(100vh - 16px);overflow:auto;z-index:99999;box-shadow:0 2px 8px rgba(0,0,0,0.18);font:8pt/1.5 Verdana,Geneva,sans-serif;color:#828282;overflow-wrap:anywhere;';
      document.body.appendChild(tooltip);
    }
    tooltip.replaceChildren();
    var details = sparklineDetails.get(container);
    var freshness = document.createElement('div');
    freshness.textContent = (details.status && details.status !== 'ready' ? stateLabel(details.status) + '. ' : '') + 'Last checked: ' + (details.checkedAt || 'unknown');
    freshness.className = 'ts-last-checked';
    freshness.style.cssText = 'font-size:8pt;margin-top:4px;color:#666;';
    details.signals.forEach(function (s) {
      var row = document.createElement('div');
      row.className = 'ts-signal-detail';
      var marker = document.createElement('span');
      marker.className = 'ts-signal-marker';
      marker.setAttribute('aria-hidden', 'true');
      marker.style.cssText = 'display:inline-block;width:8px;height:8px;background:' + s.color + ';border-radius:1px;margin-right:4px;';
      var value = document.createElement('b');
      value.style.color = '#000';
      value.textContent = s.detail;
      var condition = document.createElement('span');
      condition.textContent = ' (' + s.condition + ', ' + s.weight + ' points)';
      condition.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap;';
      row.title = s.condition + ', ' + s.weight + ' trust points';
      row.append(marker, document.createTextNode(s.name + ': '), value, condition);
      tooltip.appendChild(row);
    });
    if (!details.status || details.status === 'ready') {
      var separator = document.createElement('hr');
      separator.style.cssText = 'border:none;border-top:1px solid #ccc;margin:4px 0;';
      tooltip.appendChild(separator);
      var points = details.signals.reduce(function (total, signal) { return total + signal.weight; }, 0);
      var maxPoints = ['karma', 'comments', 'submissions', 'comment_rate', 'created'].reduce(function (total, key) {
        return total + (config[key].enabled ? Math.max(0, config[key].poor.weight, config[key].adequate.weight, config[key].good.weight) : 0);
      }, 0);
      var ratio = maxPoints > 0 ? points / maxPoints : 0;
      var summary = document.createElement('div');
      summary.className = 'ts-trust-summary';
      var score = document.createElement('b');
      score.textContent = maxPoints > 0 ? points + '/' + maxPoints : String(points);
      score.style.color = maxPoints > 0 ? (ratio >= 0.66 ? COLORS.good : ratio >= 0.33 ? COLORS.adequate : COLORS.poor) : '#828282';
      summary.append(document.createTextNode('Trust points: '), score);
      tooltip.appendChild(summary);
    }
    tooltip.appendChild(freshness);
    tooltip.setAttribute('role', details.record ? 'dialog' : 'tooltip');
    if (details.record) {
      tooltip.setAttribute('aria-label', stateLabel(details.record.status) + ' for ' + details.record.username);
      var retry = document.createElement('button');
      retry.type = 'button';
      retry.className = 'ts-retry';
      retry.textContent = 'Retry';
      retry.style.cssText = 'font:inherit;min-height:24px;margin-top:4px;cursor:pointer;';
      retry.addEventListener('click', function () {
        hideTooltip();
        details.link.focus({ preventScroll: true });
        retryProfile(details.record);
      });
      tooltip.appendChild(retry);
    } else tooltip.removeAttribute('aria-label');
    tooltip.hidden = false;
    tooltip.style.left = '8px';
    tooltip.style.top = '8px';
    var anchor = container.getBoundingClientRect();
    var rect = tooltip.getBoundingClientRect();
    var width = document.documentElement.clientWidth;
    var height = window.innerHeight;
    tooltip.style.left = Math.max(8, Math.min(anchor.left, width - rect.width - 8)) + 'px';
    var top = anchor.top - rect.height - 4;
    if (top < 8) top = anchor.bottom + 4;
    tooltip.style.top = Math.max(8, Math.min(top, height - rect.height - 8)) + 'px';
    activeSparkline = container;
    pinnedTooltip = pinned;
    container.setAttribute('aria-expanded', 'true');
    container.setAttribute('aria-describedby', 'ts-profile-tooltip');
  }

  // HN handles collapse/hide clicks with stopImmediatePropagation; dismiss first.
  document.addEventListener('click', function (e) {
    if (activeSparkline && !activeSparkline.contains(e.target) && (!tooltip || !tooltip.contains(e.target))) hideTooltip();
  }, true);

  document.addEventListener('click', function (e) {
    var container = e.target.closest('.ts-sparkline, .ts-profile-details');
    if (container) {
      e.preventDefault();
      if (activeSparkline === container && pinnedTooltip) hideTooltip();
      else {
        showTooltip(container, true);
        var retry = tooltip.querySelector('.ts-retry');
        if (e.detail === 0 && retry) retry.focus({ preventScroll: true });
      }
    } else if (!tooltip || !tooltip.contains(e.target)) hideTooltip();
  });
  function scheduleTooltipHide() {
    clearTimeout(tooltipHideTimer);
    tooltipHideTimer = setTimeout(function () {
      if (!pinnedTooltip && tooltip && !tooltip.matches(':hover') &&
          !tooltip.contains(document.activeElement) && activeSparkline && !activeSparkline.matches(':hover')) hideTooltip();
    }, 150);
  }
  document.addEventListener('pointerover', function (e) {
    if (e.pointerType !== 'mouse') return;
    if (tooltip && tooltip.contains(e.target)) { clearTimeout(tooltipHideTimer); return; }
    var container = e.target.closest('.ts-sparkline, .ts-profile-details');
    if (container) {
      clearTimeout(tooltipHideTimer);
      if (!pinnedTooltip && activeSparkline !== container) showTooltip(container, false);
    }
  });
  document.addEventListener('pointerout', function (e) {
    if (pinnedTooltip || !activeSparkline) return;
    if (activeSparkline.contains(e.target) || (tooltip && tooltip.contains(e.target))) {
      if (!activeSparkline.contains(e.relatedTarget) && (!tooltip || !tooltip.contains(e.relatedTarget))) scheduleTooltipHide();
    }
  });
  document.addEventListener('keydown', function (e) {
    if (e.key === 'Escape' && activeSparkline) {
      var trigger = activeSparkline;
      var focusInPanel = tooltip && tooltip.contains(document.activeElement);
      hideTooltip();
      if (focusInPanel) trigger.focus({ preventScroll: true });
    }
  });
  document.addEventListener('focusout', function (e) {
    if (activeSparkline && (e.target === activeSparkline || (tooltip && tooltip.contains(e.target))) &&
        e.relatedTarget !== activeSparkline && (!tooltip || !tooltip.contains(e.relatedTarget))) hideTooltip();
  });
  window.addEventListener('scroll', hideTooltip, { passive: true });
  window.addEventListener('resize', hideTooltip, { passive: true });

  function createSparkline(signals, profile) {
    if (config.sortSparklines) {
      signals = signals.slice().sort(function (a, b) {
        return config.invertSparklines ? a.bar - b.bar : b.bar - a.bar;
      });
    }
    var container = document.createElement('button');
    container.type = 'button';
    container.className = 'ts-sparkline';
    container.setAttribute('aria-label', 'Show account trust signals');
    container.setAttribute('aria-expanded', 'false');
    container.setAttribute('aria-controls', 'ts-profile-tooltip');
    container.style.cssText = 'display:inline-flex;align-items:flex-end;gap:1px;height:10px;min-width:0;min-height:0;box-sizing:content-box;padding:0;margin:0 4px 0 0;line-height:10px;vertical-align:middle;position:relative;cursor:pointer;border:0;background:transparent;';
    signals.forEach(function (s) {
      var bar = document.createElement('span');
      var value = Number.isFinite(s.bar) ? Math.max(0, Math.min(1, s.bar)) : 0;
      bar.style.cssText = 'display:inline-block;width:3px;height:' + Math.max(2, Math.round(value * 10)) + 'px;background:' + s.color + ';border-radius:1px;';
      bar.title = s.name + ': ' + s.detail;
      container.appendChild(bar);
    });
    sparklineDetails.set(container, { signals: signals, status: 'ready', checkedAt: profile.checked_at });
    return container;
  }

  // ── Sort by trust ──────────────────────────────────────────────────

  var allProfiles = null;

  var sortLayouts = null;
  var sortIsActive = false;

  function userSortKey(username) {
    var profile = username && allProfiles && allProfiles[username];
    if (!profile) return { known: false, score: 0, barSum: 0 };
    var signals = evaluateProfile(profile, config);
    return {
      known: true,
      score: signals.reduce(function (sum, signal) { return sum + signal.weight; }, 0),
      barSum: signals.reduce(function (sum, signal) { return sum + signal.bar; }, 0),
    };
  }

  function isTrailer(row) {
    return row.classList.contains('morespace') || (!row.classList.contains('comtr') && !row.classList.contains('athing') && !!row.querySelector('a.morelink, form'));
  }

  function commentIndent(row) {
    var cell = row.querySelector('td.ind');
    if (!cell) return null;
    var indent = cell.getAttribute('indent');
    if (indent !== null) return Number(indent);
    var image = cell.querySelector('img[width]');
    return image ? Number(image.getAttribute('width')) : null;
  }

  function captureLayout(tbody, comments) {
    var units = [];
    var current = null;
    Array.from(tbody.children).forEach(function (row) {
      if (isTrailer(row)) { current = null; return; }
      var startsUnit = comments ? row.classList.contains('comtr') && commentIndent(row) === 0 :
        row.classList.contains('athing') && !row.classList.contains('comtr');
      if (startsUnit) {
        current = { rows: [], index: units.length, username: null };
        units.push(current);
      }
      if (!current) return;
      // Comment table footers are not part of the last root's subtree.
      if (comments && !row.classList.contains('comtr') && !row.classList.contains('spacer')) { current = null; return; }
      current.rows.push(row);
      if (!comments || startsUnit) {
        var link = row.querySelector('a.hnuser');
        if (link && !current.username) current.username = link.textContent.trim();
      }
    });
    if (units.length < 2) return null;
    var originalNodes = Array.from(tbody.childNodes);
    var movable = new Set(units.flatMap(function (unit) { return unit.rows; }));
    var ranks = units.map(function (unit) {
      var element = comments ? null : unit.rows[0].querySelector('.rank');
      return element ? { element: element, text: element.textContent } : null;
    }).filter(Boolean);
    var navigation = [];
    if (comments) units.forEach(function (unit) {
      unit.rows[0].querySelectorAll('a.clicky[href]').forEach(function (link) {
        if (!['next', 'prev'].includes(link.textContent.trim())) return;
        navigation.push({ link: link, href: link.getAttribute('href'), hidden: link.hidden });
      });
    });
    return { tbody: tbody, comments: comments, units: units, originalNodes: originalNodes, movable: movable, ranks: ranks, navigation: navigation };
  }

  function captureSortLayouts() {
    var tables = new Map();
    document.querySelectorAll('tr.comtr, tr.athing').forEach(function (row) {
      var comments = row.classList.contains('comtr');
      // Each direct row group is handled independently; nested HN tables stay intact.
      tables.set(row.parentNode, comments || tables.get(row.parentNode) || false);
    });
    return Array.from(tables, function (entry) { return captureLayout(entry[0], entry[1]); }).filter(Boolean);
  }

  function refreshSortLayouts() {
    if (!sortLayouts) return;
    sortLayouts = sortLayouts.map(function (layout) {
      if (!layout.tbody.isConnected) return null;
      var liveNodes = Array.from(layout.tbody.childNodes);
      var oldNodes = new Set(layout.originalNodes);
      if (liveNodes.length === oldNodes.size && liveNodes.every(function (node) { return oldNodes.has(node); })) return layout;
      // HN's Hide action removes a story and can append its replacement in place.
      // Rebuild from live nodes so sorting cannot resurrect a hidden story.
      var fresh = captureLayout(layout.tbody, layout.comments);
      if (!fresh) return null;
      var liveUnits = new Map(fresh.units.map(function (unit) { return [unit.rows[0], unit]; }));
      var baseline = [];
      layout.units.forEach(function (unit) {
        var live = liveUnits.get(unit.rows[0]);
        if (live) { baseline.push(live); liveUnits.delete(unit.rows[0]); }
      });
      baseline.push.apply(baseline, Array.from(liveUnits.values()));
      fresh.units = baseline;
      baseline.forEach(function (unit, index) { unit.index = index; });
      var rows = baseline.flatMap(function (unit) { return unit.rows; });
      var index = 0;
      fresh.originalNodes = fresh.originalNodes.map(function (node) { return fresh.movable.has(node) ? rows[index++] : node; });
      if (!layout.comments) {
        var firstRank = layout.ranks.length ? parseInt(layout.ranks[0].text, 10) || 1 : 1;
        fresh.ranks = baseline.map(function (unit, i) {
          var element = unit.rows[0].querySelector('.rank');
          return element ? { element: element, text: (firstRank + i) + '.' } : null;
        }).filter(Boolean);
      }
      fresh.navigation = fresh.navigation.map(function (saved) {
        return layout.navigation.find(function (old) { return old.link === saved.link; }) || saved;
      });
      return fresh;
    }).filter(Boolean);
  }

  function keepFocus(action) {
    var focused = document.activeElement;
    action();
    if (focused && focused !== document.body && focused.isConnected && document.activeElement !== focused) {
      focused.focus({ preventScroll: true });
    }
  }

  function placeRows(layout, rows) {
    var index = 0;
    var orderedNodes = layout.originalNodes.map(function (node) {
      return layout.movable.has(node) ? rows[index++] : node;
    });
    var currentNodes = Array.from(layout.tbody.childNodes);
    if (currentNodes.length === orderedNodes.length && currentNodes.every(function (node, i) { return node === orderedNodes[i]; })) return;
    var fragment = document.createDocumentFragment();
    // Fixed rows/text nodes retain their slots, including all pagination/footer rows.
    orderedNodes.forEach(function (node) { fragment.appendChild(node); });
    layout.tbody.appendChild(fragment);
  }

  function restoreSort() {
    if (!sortIsActive || !sortLayouts) return;
    refreshSortLayouts();
    keepFocus(function () {
      sortLayouts.forEach(function (layout) {
        placeRows(layout, layout.units.flatMap(function (unit) { return unit.rows; }));
        layout.ranks.forEach(function (rank) { rank.element.textContent = rank.text; });
        layout.navigation.forEach(function (saved) { saved.link.setAttribute('href', saved.href); saved.link.hidden = saved.hidden; });
      });
    });
    sortIsActive = false;
  }

  function applySortByTrust() {
    if (!config.sortByTrust || !signalsEnabled(config)) { restoreSort(); return; }
    if (!sortLayouts || !sortLayouts.length) sortLayouts = captureSortLayouts();
    else refreshSortLayouts();
    keepFocus(function () {
      sortLayouts.forEach(function (layout) {
        var ordered = layout.units.map(function (unit) { return Object.assign({}, unit, userSortKey(unit.username)); });
        ordered.sort(function (a, b) {
          // Unavailable data has no score and follows all known profiles, even negative ones.
          return Number(b.known) - Number(a.known) || b.score - a.score || b.barSum - a.barSum || a.index - b.index;
        });
        placeRows(layout, ordered.flatMap(function (unit) { return unit.rows; }));
        if (!layout.comments) {
          var firstRank = layout.ranks.length ? parseInt(layout.ranks[0].text, 10) || 1 : 1;
          ordered.forEach(function (unit, index) {
            var rank = unit.rows[0].querySelector('.rank');
            var text = (firstRank + index) + '.';
            if (rank && rank.textContent !== text) rank.textContent = text;
          });
        } else ordered.forEach(function (unit, index) {
          unit.rows[0].querySelectorAll('a.clicky[href]').forEach(function (link) {
            var label = link.textContent.trim();
            if (label !== 'prev' && label !== 'next') return;
            var target = ordered[index + (label === 'prev' ? -1 : 1)];
            var original = layout.navigation.find(function (saved) { return saved.link === link; });
            link.hidden = !!(original && original.hidden) || !target || !target.rows[0].id;
            if (!link.hidden) link.setAttribute('href', '#' + target.rows[0].id);
          });
        });
      });
    });
    sortIsActive = true;
  }

  // Resolve small batches in viewport order; never wait for a whole thread.
  var pageRecords = new Map();
  var renderQueue = new Map();
  var renderFrame = null;
  var profileLoader;
  var profileOptions = {};
  var activeBatches = 0;
  var MAX_BATCHES = 2;
  var PROFILES_PER_BATCH = 3;
  var RENDERS_PER_FRAME = 40;
  var observer;
  var freshnessTimer;

  function classifyPageProfile(profile) {
    var state = classifyProfile(profile);
    // A published demo is a snapshot, not the extension's refreshable cache.
    if (profileOptions.snapshot && state.status === 'stale') state.status = 'ready';
    if (profileOptions.snapshot && state.status === 'missing') state.status = 'unavailable';
    return state;
  }

  function checkFreshness() {
    clearTimeout(freshnessTimer);
    if (profileOptions.snapshot) { maybeSort(); return; }
    var nextExpiry = Infinity;
    pageRecords.forEach(function (record) {
      if (record.status !== 'ready') return;
      var state = classifyProfile(record.profile);
      if (state.status !== 'ready') {
        record.status = state.status;
        delete allProfiles[record.username];
        queueRecord(record);
      } else nextExpiry = Math.min(nextExpiry, Date.parse(record.profile.checked_at) + PROFILE_TTL_MS);
    });
    maybeSort();
    if (Number.isFinite(nextExpiry)) freshnessTimer = setTimeout(checkFreshness, Math.max(100, nextExpiry - Date.now()));
  }
  document.addEventListener('visibilitychange', function () { if (!document.hidden) checkFreshness(); });

  function queueRecord(record) {
    record.links.forEach(function (link) { renderQueue.set(link, record); });
    if (!renderFrame) renderFrame = requestAnimationFrame(flushRenders);
  }

  function stateLabel(status) {
    return { queued: 'Loading…', loading: 'Loading…', missing: 'Account not found', unavailable: 'Profile unavailable', failed: 'Profile unavailable', stale: 'Stale profile' }[status] || 'Profile unavailable';
  }

  function createProfileState(record, link) {
    var wrapper = document.createElement('span');
    wrapper.className = 'ts-profile-state';
    wrapper.dataset.tsStatus = record.status;
    wrapper.style.cssText = 'display:inline-block;width:15px;height:10px;line-height:10px;vertical-align:middle;margin-right:4px;text-align:center;font:10px/10px Verdana,Geneva,sans-serif;color:#828282;';
    var label = record.username + ': ' + stateLabel(record.status);
    wrapper.title = label;
    if (record.status === 'queued' || record.status === 'loading') {
      wrapper.textContent = '…';
      wrapper.setAttribute('aria-label', label);
      return wrapper;
    }
    var details = document.createElement('button');
    details.type = 'button';
    details.className = 'ts-profile-details';
    details.textContent = { missing: '–', unavailable: '!', stale: '↻' }[record.status] || '!';
    details.setAttribute('aria-label', label + ', show details and retry');
    details.setAttribute('aria-expanded', 'false');
    details.setAttribute('aria-controls', 'ts-profile-tooltip');
    details.style.cssText = 'font:inherit;position:relative;width:15px;height:10px;min-width:0;min-height:0;line-height:10px;box-sizing:content-box;padding:0;margin:0;cursor:pointer;border:0;background:transparent;color:' + (record.status === 'stale' ? COLORS.adequate : '#828282') + ';';
    sparklineDetails.set(details, { signals: [], status: record.status, checkedAt: record.profile && record.profile.checked_at, record: record, link: link });
    wrapper.appendChild(details);
    return wrapper;
  }

  function retryProfile(record) {
    if (record.status === 'queued' || record.status === 'loading') return;
    record.status = 'queued';
    record.force = true;
    record.signals = null;
    record.links.forEach(function (link) { record.visible.add(link); });
    queueRecord(record);
    pumpProfiles();
  }

  function renderLink(link, record) {
    if (!link.isConnected) return;
    if (link._tsIndicator) {
      if (link._tsIndicator === activeSparkline || link._tsIndicator.contains(activeSparkline)) hideTooltip();
      link._tsIndicator.remove();
      delete link._tsIndicator;
    }
    var targets = findFilterTargets(link);
    targets.forEach(removeFilter);
    if (!signalsEnabled(config)) return;
    var indicator;
    if (record.status === 'ready') {
      if (!record.signals) record.signals = evaluateProfile(record.profile, config);
      var signals = record.signals;
      indicator = createSparkline(signals, record.profile);
      indicator.dataset.tsStatus = 'ready';
      indicator.setAttribute('aria-label', record.username + ': show account trust signals');
      var points = signals.reduce(function (sum, signal) { return sum + signal.weight; }, 0);
      if (config.minTrustScore > 0 && points < config.minTrustScore) applyFilterGroup(targets);
    } else indicator = createProfileState(record, link);
    link.parentNode.insertBefore(indicator, link);
    link._tsIndicator = indicator;
  }

  function flushRenders() {
    renderFrame = null;
    var started = performance.now();
    var count = 0;
    for (var entry of renderQueue) {
      renderQueue.delete(entry[0]);
      renderLink(entry[0], entry[1]);
      if (++count >= RENDERS_PER_FRAME || performance.now() - started > 6) break;
    }
    if (renderQueue.size) renderFrame = requestAnimationFrame(flushRenders);
    else maybeSort();
  }

  function maybeSort() {
    if (!config.sortByTrust || !signalsEnabled(config)) { restoreSort(); return; }
    if (activeBatches || renderQueue.size || Array.from(pageRecords.values()).some(function (record) {
      return record.status === 'queued' || record.status === 'loading';
    })) return;
    applySortByTrust();
  }

  function pumpProfiles() {
    if (!profileLoader || !signalsEnabled(config)) return;
    while (activeBatches < MAX_BATCHES) {
      var waiting = Array.from(pageRecords.values()).filter(function (record) { return record.status === 'queued'; });
      waiting.sort(function (a, b) { return Number(b.visible.size > 0) - Number(a.visible.size > 0); });
      var forced = waiting.filter(function (record) { return record.force; });
      var batch = (forced.length ? forced : waiting).slice(0, PROFILES_PER_BATCH);
      if (!batch.length) break;
      activeBatches++;
      batch.forEach(function (record) { record.status = 'loading'; queueRecord(record); });
      loadBatch(batch);
    }
    maybeSort();
  }

  async function loadBatch(batch) {
    try {
      var profiles = await profileLoader(batch.map(function (record) { return record.username; }), batch.some(function (record) { return record.force; }));
      batch.forEach(function (record) {
        var value = profiles && profiles[record.username];
        var state = value && value.status ? value : classifyPageProfile(value);
        if (state.status === 'ready' || (profileOptions.snapshot && state.status === 'stale')) state = classifyPageProfile(state.profile);
        if (profileOptions.snapshot && state.status === 'missing') state = { status: 'unavailable', profile: null };
        record.profile = state.profile || null;
        record.status = state.status;
        record.force = false;
        if (record.status === 'ready') allProfiles[record.username] = record.profile;
        else delete allProfiles[record.username];
        queueRecord(record);
      });
    } catch (err) {
      batch.forEach(function (record) { record.status = record.profile ? 'stale' : 'unavailable'; record.force = false; delete allProfiles[record.username]; queueRecord(record); });
    } finally {
      activeBatches--;
      checkFreshness();
      pumpProfiles();
    }
  }

  function resetPage() {
    if (!config.sortByTrust || !signalsEnabled(config)) restoreSort();
    hideTooltip();
    document.querySelectorAll('.ts-filtered').forEach(removeFilter);
    document.querySelectorAll('.ts-sparkline, .ts-profile-state').forEach(function (el) { el.remove(); });
    document.querySelectorAll('a.hnuser').forEach(function (el) { delete el.dataset.tsProcessed; delete el._tsIndicator; });
    pageRecords.forEach(function (record) { record.signals = null; queueRecord(record); });
    pumpProfiles();
    maybeSort();
  }

  function startProfiles(loader, options) {
    profileLoader = loader;
    profileOptions = options || {};
    allProfiles = Object.create(null);
    document.querySelectorAll('a.hnuser').forEach(function (link) {
      var username = link.textContent.trim();
      if (!pageRecords.has(username)) pageRecords.set(username, { username: username, links: [], visible: new Set(), status: 'queued', profile: null });
      var record = pageRecords.get(username);
      record.links.push(link);
      var rect = link.getBoundingClientRect();
      if (link.getClientRects().length && rect.bottom >= -600 && rect.top <= window.innerHeight + 600) record.visible.add(link);
    });
    if (typeof IntersectionObserver === 'function') {
      observer = new IntersectionObserver(function (entries) {
        entries.forEach(function (entry) {
          var record = pageRecords.get(entry.target.textContent.trim());
          if (!record) return;
          if (entry.isIntersecting) record.visible.add(entry.target);
          else record.visible.delete(entry.target);
        });
      }, { rootMargin: '600px' });
      pageRecords.forEach(function (record) { record.links.forEach(function (link) { observer.observe(link); }); });
    }
    pageRecords.forEach(queueRecord);
    pumpProfiles();
  }

  var config = getConfig();
  function loadConfig() {
    return browser.storage.local.get('trustspark-config').then(function (result) {
      config = getConfig(result['trustspark-config']);
    }).catch(function () { /* Defaults remain available if storage fails. */ });
  }
  browser.storage.onChanged.addListener(function (changes, area) {
    if (area !== 'local' || !changes['trustspark-config']) return;
    config = getConfig(changes['trustspark-config'].newValue);
    resetPage();
  });
  loadConfig().then(function () {
    startProfiles(function (usernames, force) {
      return browser.runtime.sendMessage({ type: 'getProfileStates', usernames: usernames, force: force });
    });
  });

})();
icons/y18.svg

Download file · SHA-256: 80e255f7e1c2e57a74edee51ee54ebbf72fe200c8f2c14ad4666c874b90147ec

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18">
  <rect width="18" height="18" fill="#27ae60"/>
  <text x="9" y="14" text-anchor="middle" fill="white" font-size="14" font-family="Verdana,Geneva,sans-serif" font-weight="bold">Y</text>
</svg>
icons/y48.svg

Download file · SHA-256: 4239df2c3d0ac09db79eb89e36c60b638a0cbe74babff213f88f8e81a55f52c2

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
  <rect width="48" height="48" fill="#27ae60"/>
  <text x="24" y="36" text-anchor="middle" fill="white" font-size="36" font-family="Verdana,Geneva,sans-serif" font-weight="bold">Y</text>
</svg>
icons/y96.svg

Download file · SHA-256: 2ea7043b68702e45b2327d1250c9e65aed9eeb5a436b558e1990951f7cae56db

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">
  <rect width="96" height="96" fill="#27ae60"/>
  <text x="48" y="72" text-anchor="middle" fill="white" font-size="72" font-family="Verdana,Geneva,sans-serif" font-weight="bold">Y</text>
</svg>
manifest.json

Download file · SHA-256: 060e2bbd5ee7701a61b272a079763d573b3764afbc3d62ff904221ab03b1fa45

{
  "manifest_version": 2,
  "name": "Hacker News TrustSpark",
  "version": "1.0.12",
  "description": "Trust signals for Hacker News users",
  "permissions": [
    "storage",
    "*://news.ycombinator.com/*",
    "*://hacker-news.firebaseio.com/*",
    "*://hn.algolia.com/*"
  ],
  "icons": {
    "48": "icons/y48.svg",
    "96": "icons/y96.svg"
  },
  "browser_action": {
    "default_popup": "popup/popup.html",
    "default_icon": "icons/y18.svg"
  },
  "background": {
    "scripts": ["background.js"],
    "persistent": false
  },
  "content_scripts": [
    {
      "matches": ["*://news.ycombinator.com/*"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ],
  "browser_specific_settings": {
    "gecko": {
      "id": "trustspark@trustspark.dev",
      "strict_min_version": "142.0",
      "data_collection_permissions": {
        "required": ["none"]
      }
    },
    "gecko_android": {
      "id": "trustspark@trustspark.dev",
      "strict_min_version": "142.0",
      "data_collection_permissions": {
        "required": ["none"]
      }
    }
  }
}
popup/popup.css

Download file · SHA-256: 29b54999bce01ee94b94ad6fdcf5bb6c52a8ef15a0d1311e81fddaa5d3331ccb

.ts-settings, .ts-settings * {
  box-sizing: border-box;
}
.ts-settings {
  width: 340px;
  max-width: 100vw;
  max-height: 580px;
  overflow-y: auto;
  margin: 0;
  padding: 12px 16px;
  background: #f6f6ef;
  font-family: Verdana, Geneva, sans-serif;
  font-size: 9pt;
  color: #828282;
}
.ts-settings .header{
  color: #000;
  font-size: 11pt;
  font-weight: bold;
}
.ts-settings hr{
  border: none;
  border-top: 1px solid #ccc;
  margin: 8px 0;
}
.ts-settings .section{
  margin-bottom: 12px;
}
.ts-settings .section-header{
  color: #000;
  font-weight: bold;
  margin-bottom: 4px;
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.ts-settings .label-row{
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin: 4px 0;
}
.ts-settings input[type="number"],
.ts-settings input[type="date"] {
  width: 80px;
  padding: 1px 4px;
  border: 1px solid #ccc;
  border-radius: 2px;
  font-size: 9pt;
}
.ts-settings input.inline-input{
  width: 55px;
  padding: 1px 4px;
  border: 1px solid #ccc;
  border-radius: 2px;
  font-size: 9pt;
}
.ts-settings fieldset{
  border: 1px solid #ddd;
  border-radius: 3px;
  padding: 2px 8px 6px;
  margin: 4px 0;
}
.ts-settings fieldset legend{
  font-size: 8pt;
  font-weight: bold;
  padding: 0 4px;
}
.ts-settings .inline-row{
  display: flex;
  align-items: center;
  gap: 4px;
  margin: 4px 0;
  font-size: 8.5pt;
}
.ts-settings .hint{
  font-size: 8pt;
  color: #666;
  margin-top: 2px;
}
.ts-settings .bar-scale-hint{
  font-size: 8pt;
  color: #666;
  font-style: italic;
}
.ts-settings .btn-row{
  display: flex;
  gap: 8px;
}
.ts-settings .btn-save{
  padding: 4px 12px;
  background: #ff6600;
  color: #fff;
  border: none;
  border-radius: 3px;
  cursor: pointer;
  font-size: 9pt;
}
.ts-settings .btn-reset{
  padding: 4px 12px;
  background: #828282;
  color: #fff;
  border: none;
  border-radius: 3px;
  cursor: pointer;
  font-size: 9pt;
}

@media (pointer: coarse) {
  .ts-settings input[type="number"], .ts-settings input[type="date"], .ts-settings button { min-height: 32px; }
}
.ts-settings .label-row, .ts-settings .inline-row{ gap: 6px; }
.ts-settings .btn-row{ position: sticky; bottom: -12px; background: #f6f6ef; padding: 8px 0; }
.ts-settings #ts-status{ color: #444; }
@media (max-width: 360px) {
  .ts-settings { padding: 10px; }
  .ts-settings .section-header{ flex-wrap: wrap; gap: 4px; }
}
popup/popup.html

Download file · SHA-256: 49b003aebddb8d438e328e340346d578db9aff820e5c480ba42971c3c56f9d69

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>TrustSpark Settings</title>
  <link rel="stylesheet" href="popup.css">
</head>
<body class="ts-settings">
  <div id="popup-root"></div>
  <script src="popup.js"></script>
</body>
</html>
popup/popup.js

Download file · SHA-256: 2a469ae1829b3856ac84aa2dc30c2d4c694c4e6168203521198786ab799fe5c8

// Generated by node scripts/build.cjs. Edit app/ instead.
(function () {
  "use strict";
  var DEFAULT_CONFIG = {
    karma: {
      enabled: true,
      ceiling: 400000,
      poor:     { below: 2,    weight: 0 },
      adequate: { below: 1000, weight: 1 },
      good:     { above: 1000, weight: 2 },
    },
    comments: {
      enabled: true,
      ceiling: 1000,
      poor:     { below: 3,   weight: 0 },
      adequate: { below: 100, weight: 1 },
      good:     { above: 99,  weight: 2 },
    },
    submissions: {
      enabled: true,
      ceiling: 1 / 14,
      poor:     { above: 1,      weight: 0 },
      adequate: { above: 1 / 7,  weight: 1 },
      good:     { below: 1 / 7,  weight: 2 },
    },
    comment_rate: {
      enabled: false,
      ceiling: 10,
      poor:     { below: 1 / 30, weight: 0 },
      adequate: { below: 1,      weight: 1 },
      good:     { above: 1,      weight: 2 },
    },
    created: {
      enabled: true,
      ceiling: '2009-01-01',
      poor:     { below: 365,  weight: 0 },
      adequate: { below: 1825, weight: 1 },
      good:     { above: 1825, weight: 2 },
    },
    minTrustScore: 2,
    sortSparklines: false,
    invertSparklines: false,
    sortByTrust: false,
  };

  function deepMerge(defaults, overrides) {
    var result = JSON.parse(JSON.stringify(defaults));
    if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return result;
    Object.keys(defaults).forEach(function (key) {
      var value = overrides[key];
      if (defaults[key] && typeof defaults[key] === 'object') {
        result[key] = deepMerge(defaults[key], value);
      } else if (typeof value === typeof defaults[key]) {
        if (typeof value === 'number' && (!Number.isFinite(value) ||
            (key === 'ceiling' && value <= 0) ||
            (key !== 'weight' && value < 0))) return;
        if (typeof value === 'string' && (!Number.isFinite(Date.parse(value)) || Date.parse(value) >= Date.now())) return;
        result[key] = value;
      }
    });
    return result;
  }

  // ── Signal evaluation ──────────────────────────────────────────────

  function evalKarma(profile, cfg) {
    var k = profile.karma || 0;
    var bar = Math.min(1, Math.log(Math.max(0, k) + 1) / Math.log(cfg.ceiling + 1));
    var condition;
    if (k < cfg.poor.below)       condition = 'poor';
    else if (k < cfg.adequate.below) condition = 'adequate';
    else                              condition = 'good';
    return { bar: bar, condition: condition };
  }

  function evalComments(profile, cfg) {
    var c = profile.total_comments || 0;
    var bar = Math.min(1, c / cfg.ceiling);
    var condition;
    if (c < cfg.poor.below)       condition = 'poor';
    else if (c < cfg.adequate.below) condition = 'adequate';
    else                              condition = 'good';
    return { bar: bar, condition: condition };
  }

  function evalSubmissions(profile, cfg) {
    var rate = profile.submissions_rate || 0;
    var bar = Math.min(1, rate > 0 ? Math.min(1, cfg.ceiling / rate) : 1);
    var condition;
    if (rate > cfg.poor.above)        condition = 'poor';
    else if (rate > cfg.adequate.above) condition = 'adequate';
    else                                 condition = 'good';
    return { bar: bar, condition: condition };
  }

  function evalCommentRate(profile, cfg) {
    var rate = profile.comments_rate || 0;
    var bar = Math.min(1, rate / cfg.ceiling);
    var condition;
    if (rate < cfg.poor.below)       condition = 'poor';
    else if (rate < cfg.adequate.below) condition = 'adequate';
    else                                condition = 'good';
    return { bar: bar, condition: condition };
  }

  function evalCreated(profile, cfg) {
    var created = new Date(profile.created_at).getTime();
    var ceiling = new Date(cfg.ceiling).getTime();
    var now = Date.now();
    var age = now - created;
    var maxAge = now - ceiling;
    var bar = Math.min(1, age / maxAge);
    var days = age / (24 * 3600 * 1000);
    var condition;
    if (days < cfg.poor.below)       condition = 'poor';
    else if (days < cfg.adequate.below) condition = 'adequate';
    else                                 condition = 'good';
    return { bar: bar, condition: condition };
  }

  var COLORS = { poor: '#e74c3c', adequate: '#f39c12', good: '#27ae60' };

  function formatNumber(n) {
    if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
    if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
    return String(n);
  }

  function friendlyRate(dailyRate) {
    if (dailyRate >= 1)       return dailyRate.toFixed(1) + '/day';
    if (dailyRate * 7 >= 1)   return (dailyRate * 7).toFixed(1) + '/week';
    if (dailyRate * 30 >= 1)  return (dailyRate * 30).toFixed(1) + '/month';
    return (dailyRate * 365).toFixed(1) + '/year';
  }

  function friendlyAge(isoDate) {
    var ms = Date.now() - new Date(isoDate).getTime();
    var days = ms / (24 * 3600 * 1000);
    if (days >= 365.25) return Math.floor(days / 365.25) + 'y ' + Math.floor((days % 365.25) / 30) + 'mo';
    if (days >= 30)     return Math.floor(days / 30) + 'mo';
    return Math.floor(days) + 'd';
  }

  function signalsEnabled(config) {
    return ['karma', 'comments', 'submissions', 'comment_rate', 'created'].some(function (key) { return config[key].enabled; });
  }

  function evaluateProfile(profile, config) {
    var signals = [];
    if (config.karma.enabled) {
      var r = evalKarma(profile, config.karma);
      signals.push({ name: 'Karma', detail: formatNumber(profile.karma), bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.karma[r.condition].weight });
    }
    if (config.comments.enabled) {
      var r = evalComments(profile, config.comments);
      signals.push({ name: 'Comments', detail: formatNumber(profile.total_comments), bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.comments[r.condition].weight });
    }
    if (config.submissions.enabled) {
      var r = evalSubmissions(profile, config.submissions);
      signals.push({ name: 'Submissions', detail: formatNumber(profile.total_submissions) + ' (' + friendlyRate(profile.submissions_rate) + ')', bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.submissions[r.condition].weight });
    }
    if (config.comment_rate.enabled) {
      var r = evalCommentRate(profile, config.comment_rate);
      signals.push({ name: 'Comment Rate', detail: friendlyRate(profile.comments_rate || 0), bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.comment_rate[r.condition].weight });
    }
    if (config.created.enabled) {
      var r = evalCreated(profile, config.created);
      signals.push({ name: 'Age', detail: friendlyAge(profile.created_at), bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.created[r.condition].weight });
    }
    return signals;
  }

  var PRESETS = {
    default: DEFAULT_CONFIG,
    'high-submission-penalty': deepMerge(DEFAULT_CONFIG, { submissions: { poor: { weight: -5 } } }),
  };

  function getConfig(saved, preset) {
    return deepMerge(PRESETS[preset || 'default'], saved);
  }

  function validateConfig(candidate) {
    var badOrder = ['karma', 'comments', 'comment_rate', 'created'].some(function (key) {
      return candidate[key].enabled && candidate[key].poor.below > candidate[key].adequate.below;
    }) || (candidate.submissions.enabled && candidate.submissions.adequate.above > candidate.submissions.poor.above);
    return badOrder ? 'Check thresholds: the adequate range must lie between poor and good.' : '';
  }


  var PROFILE_TTL_MS = 6 * 60 * 60 * 1000;
  function classifyProfile(profile) {
    if (!profile) return { status: 'missing', profile: null };
    var valid = Number.isFinite(Date.parse(profile.created_at)) && Number.isFinite(profile.karma) &&
      ['total_comments', 'total_submissions', 'submissions_rate'].every(function (key) {
        return Number.isFinite(profile[key]) && profile[key] >= 0;
      }) && (profile.comments_rate == null || (Number.isFinite(profile.comments_rate) && profile.comments_rate >= 0));
    if (!valid) return { status: 'unavailable', profile: null };
    var checked = Date.parse(profile.checked_at);
    var fresh = Number.isFinite(checked) && checked <= Date.now() + 300000 && Date.now() - checked < PROFILE_TTL_MS;
    return { status: fresh ? 'ready' : 'stale', profile: profile };
  }

  function mountSettings(root, options) {
    var config = getConfig(options.config, options.preset);
    function saveConfig(value) { return Promise.resolve().then(function () { return options.save(getConfig(value, options.preset)); }); }
    function setNestedField(obj, path, value) {
      var parts = path.split('.');
      var cur = obj;
      for (var i = 0; i < parts.length - 1; i++) {
        if (!cur[parts[i]]) cur[parts[i]] = {};
        cur = cur[parts[i]];
      }
      cur[parts[parts.length - 1]] = value;
    }

    var tierColors = { Poor: '#e74c3c', Adequate: '#f39c12', Good: '#27ae60' };

    function signalSection(title, key, ceilingField, tiers) {
      var enabled = config[key].enabled;
      var checked = enabled ? ' checked' : '';
      var bodyDisplay = enabled ? 'block' : 'none';
      var bodyId = 'ts-section-' + key;

      var h = '<div class="section">';
      h += '<div class="section-header"><span>' + title + '</span>';
      h += '<label><input type="checkbox" data-signal="' + key + '" data-field="enabled"' + checked + ' data-toggle="' + bodyId + '"> Enabled</label></div>';
      h += '<div id="' + bodyId + '" style="display:' + bodyDisplay + '">';

      // Ceiling
      h += '<div class="label-row" style="margin-bottom:6px;">';
      h += '<span>' + ceilingField.label + ' <span class="bar-scale-hint">(bar scale)</span></span>';
      h += '<input type="' + (ceilingField.type || 'number') + '" data-signal="' + key + '" data-field="' + ceilingField.field + '" value="' + ceilingField.value + '" step="any">';
      h += '</div>';

      // Tier fieldsets
      tiers.forEach(function (tier) {
        var color = tierColors[tier.name] || '#828282';
        h += '<fieldset style="border-color:' + color + ';">';
        h += '<legend style="color:' + color + ';">' + tier.name + '</legend>';
        tier.fields.forEach(function (f) {
          if (f.prefix !== undefined) {
            h += '<div class="inline-row">';
            h += '<span>' + f.prefix + '</span>';
            h += '<input type="number" class="inline-input" data-signal="' + key + '" data-field="' + f.field + '" value="' + f.value + '" step="any">';
            h += '<span>' + f.suffix + '</span>';
            h += '</div>';
          } else {
            h += '<div class="label-row">';
            h += '<span style="font-size:8.5pt;">' + f.label + '</span>';
            h += '<input type="' + (f.type || 'number') + '" data-signal="' + key + '" data-field="' + f.field + '" value="' + f.value + '" step="any">';
            h += '</div>';
          }
        });
        h += '</fieldset>';
      });

      h += '</div></div>';
      return h;
    }

    function buildPopupHTML() {
      var html = '<span class="header">TrustSpark Settings</span>';
      if (options.preset === 'high-submission-penalty') html += '<p class="hint">Demo preset: High submission penalty</p>';
      html += '<hr>';

      html += signalSection('Karma', 'karma',
        { label: 'Ceiling', field: 'ceiling', value: config.karma.ceiling },
        [
          { name: 'Good', fields: [
            { prefix: 'Adds', suffix: 'trust points', field: 'good.weight', value: config.karma.good.weight },
          ]},
          { name: 'Adequate', fields: [
            { prefix: 'Below', suffix: 'karma points', field: 'adequate.below', value: config.karma.adequate.below },
            { prefix: 'Adds', suffix: 'trust points', field: 'adequate.weight', value: config.karma.adequate.weight },
          ]},
          { name: 'Poor', fields: [
            { prefix: 'Below', suffix: 'karma points', field: 'poor.below', value: config.karma.poor.below },
            { prefix: 'Adds', suffix: 'trust points', field: 'poor.weight', value: config.karma.poor.weight },
          ]},
        ]
      );

      html += signalSection('Comments', 'comments',
        { label: 'Ceiling', field: 'ceiling', value: config.comments.ceiling },
        [
          { name: 'Good', fields: [
            { prefix: 'Adds', suffix: 'trust points', field: 'good.weight', value: config.comments.good.weight },
          ]},
          { name: 'Adequate', fields: [
            { prefix: 'Below', suffix: 'total comments', field: 'adequate.below', value: config.comments.adequate.below },
            { prefix: 'Adds', suffix: 'trust points', field: 'adequate.weight', value: config.comments.adequate.weight },
          ]},
          { name: 'Poor', fields: [
            { prefix: 'Below', suffix: 'total comments', field: 'poor.below', value: config.comments.poor.below },
            { prefix: 'Adds', suffix: 'trust points', field: 'poor.weight', value: config.comments.poor.weight },
          ]},
        ]
      );

      html += signalSection('Submission Rate', 'submissions',
        { label: 'Ceiling (daily rate)', field: 'ceiling', value: Math.round(config.submissions.ceiling * 10000) / 10000 },
        [
          { name: 'Good', fields: [
            { prefix: 'Adds', suffix: 'trust points', field: 'good.weight', value: config.submissions.good.weight },
          ]},
          { name: 'Adequate', fields: [
            { prefix: 'More than', suffix: 'submissions per day', field: 'adequate.above', value: Math.round(config.submissions.adequate.above * 10000) / 10000 },
            { prefix: 'Adds', suffix: 'trust points', field: 'adequate.weight', value: config.submissions.adequate.weight },
          ]},
          { name: 'Poor', fields: [
            { prefix: 'More than', suffix: 'submissions per day', field: 'poor.above', value: config.submissions.poor.above },
            { prefix: 'Adds', suffix: 'trust points', field: 'poor.weight', value: config.submissions.poor.weight },
          ]},
        ]
      );

      html += signalSection('Comment Rate', 'comment_rate',
        { label: 'Ceiling (daily rate)', field: 'ceiling', value: config.comment_rate.ceiling },
        [
          { name: 'Good', fields: [
            { prefix: 'Adds', suffix: 'trust points', field: 'good.weight', value: config.comment_rate.good.weight },
          ]},
          { name: 'Adequate', fields: [
            { prefix: 'Fewer than', suffix: 'comments per day', field: 'adequate.below', value: config.comment_rate.adequate.below },
            { prefix: 'Adds', suffix: 'trust points', field: 'adequate.weight', value: config.comment_rate.adequate.weight },
          ]},
          { name: 'Poor', fields: [
            { prefix: 'Fewer than', suffix: 'comments per day', field: 'poor.below', value: Math.round(config.comment_rate.poor.below * 10000) / 10000 },
            { prefix: 'Adds', suffix: 'trust points', field: 'poor.weight', value: config.comment_rate.poor.weight },
          ]},
        ]
      );

      html += signalSection('Account Age', 'created',
        { label: 'Ceiling date', field: 'ceiling', value: config.created.ceiling, type: 'date' },
        [
          { name: 'Good', fields: [
            { prefix: 'Adds', suffix: 'trust points', field: 'good.weight', value: config.created.good.weight },
          ]},
          { name: 'Adequate', fields: [
            { prefix: 'Below', suffix: 'days old', field: 'adequate.below', value: config.created.adequate.below },
            { prefix: 'Adds', suffix: 'trust points', field: 'adequate.weight', value: config.created.adequate.weight },
          ]},
          { name: 'Poor', fields: [
            { prefix: 'Below', suffix: 'days old', field: 'poor.below', value: config.created.poor.below },
            { prefix: 'Adds', suffix: 'trust points', field: 'poor.weight', value: config.created.poor.weight },
          ]},
        ]
      );

      html += '<hr>';
      html += '<div class="section">';
      html += '<div class="section-header"><span>Trust Filter</span></div>';
      html += '<div class="label-row"><span>Min points to show (0&nbsp;=&nbsp;off)</span>';
      html += '<input type="number" min="0" step="1" data-top-field="minTrustScore" value="' + config.minTrustScore + '"></div>';
      html += '<div class="hint">Raw total points (e.g. 2 = hide content from accounts with 0 or 1 points). 0 = off.</div>';
      html += '</div>';

      html += '<hr>';
      html += '<div class="section">';
      html += '<div class="section-header"><span>Sorting</span></div>';
      html += '<div class="label-row"><span>Sort by trust (frontpage &amp; comments)</span>';
      html += '<input type="checkbox" data-top-field="sortByTrust"' + (config.sortByTrust ? ' checked' : '') + '></div>';
      html += '</div>';

      html += '<hr>';
      html += '<div class="section">';
      html += '<div class="section-header"><span>Sparkline Display</span></div>';
      html += '<div class="label-row"><span>Sort sparklines (highest to lowest)</span>';
      html += '<input type="checkbox" data-top-field="sortSparklines"' + (config.sortSparklines ? ' checked' : '') + '></div>';
      html += '<div class="label-row"><span>Invert sort (lowest to highest)</span>';
      html += '<input type="checkbox" data-top-field="invertSparklines"' + (config.invertSparklines ? ' checked' : '') + '></div>';
      html += '</div>';

      html += '<hr>';
      html += '<p id="ts-status" role="status"></p>';
      html += '<div class="btn-row">';
      html += '<button id="ts-save" class="btn-save">Save</button>';
      html += '<button id="ts-reset" class="btn-reset">Reset Defaults</button>';
      html += '</div>';

      return html;
    }

    function attachListeners() {
      root.querySelectorAll('input').forEach(function (input) {
        var field = input.dataset.field || input.dataset.topField || '';
        input.setAttribute('aria-label', (input.dataset.signal || 'Settings') + ': ' + input.parentNode.textContent.trim());
        if (input.type === 'number' || input.type === 'date') {
          input.required = true;
          if (input.type === 'number' && !field.endsWith('weight')) input.min = '0';
          if (input.type === 'date') input.max = new Date().toISOString().slice(0, 10);
        }
      });
      function syncSignal(input) {
        root.querySelector('#' + input.dataset.toggle).style.display = input.checked ? 'block' : 'none';
        root.querySelectorAll('input[data-signal="' + input.dataset.signal + '"]:not([data-field="enabled"])').forEach(function (field) { field.disabled = !input.checked; });
      }
      // Disabled signals must not block saving with hidden invalid fields.
      root.querySelectorAll('input[data-toggle]').forEach(function (input) {
        syncSignal(input);
        input.addEventListener('change', function () { syncSignal(input); });
      });

      root.querySelector('#ts-save').addEventListener('click', function () {
        var inputs = Array.from(root.querySelectorAll('input'));
        inputs.forEach(function (input) {
          if (input.type === 'number' && input.dataset.field === 'ceiling') input.setCustomValidity(Number(input.value) > 0 ? '' : 'Ceiling must be greater than zero.');
        });
        if (inputs.some(function (input) { return !input.reportValidity(); })) return;
        var candidate = getConfig(config, options.preset);
        // Collect values from inputs
        root.querySelectorAll('input[data-signal]').forEach(function (input) {
          if (input.disabled) return;
          var signal = input.dataset.signal;
          var field = input.dataset.field;
          if (input.type === 'checkbox') {
            candidate[signal][field] = input.checked;
          } else if (input.type === 'date') {
            setNestedField(candidate[signal], field, input.value);
          } else {
            setNestedField(candidate[signal], field, parseFloat(input.value) || 0);
          }
        });
        root.querySelectorAll('input[data-top-field]').forEach(function (input) {
          candidate[input.dataset.topField] = input.type === 'checkbox' ? input.checked : (parseFloat(input.value) || 0);
        });
        var error = validateConfig(candidate);
        if (error) {
          root.querySelector('#ts-status').textContent = error;
          return;
        }
        saveConfig(candidate).then(function () {
          config = candidate;
          root.querySelector('#ts-status').textContent = 'Settings saved.';
          var btn = root.querySelector('#ts-save');
          btn.textContent = 'Saved!';
          setTimeout(function () { btn.textContent = 'Save'; }, 1200);
          if (options.onSaved) options.onSaved();
        }).catch(function () { root.querySelector('#ts-status').textContent = 'Could not save settings. Please try again.'; });
      });

      root.querySelector('#ts-reset').addEventListener('click', function () {
        var defaults = getConfig(null, options.preset);
        saveConfig(defaults).then(function () {
          config = defaults;
          render();
          root.querySelector('#ts-status').textContent = 'Defaults restored.';
        }).catch(function () { root.querySelector('#ts-status').textContent = 'Could not reset settings. Please try again.'; });
      });
    }

    function render() {
      while (root.firstChild) root.removeChild(root.firstChild);
      var parser = new DOMParser();
      var parsed = parser.parseFromString(buildPopupHTML(), 'text/html');
      while (parsed.body.firstChild) root.appendChild(parsed.body.firstChild);
      attachListeners();
    }

    render();
  }

  browser.storage.local.get('trustspark-config').catch(function () { return {}; }).then(function (result) {
    mountSettings(document.getElementById('popup-root'), {
      config: result['trustspark-config'],
      save: function (value) { return browser.storage.local.set({ 'trustspark-config': value }); },
    });
  });

})();