diff --git a/.eslintrc.yml b/.eslintrc.yml
index 87af0074..0871bcad 100644
--- a/.eslintrc.yml
+++ b/.eslintrc.yml
@@ -1,7 +1,7 @@
 # https://github.com/eslint/eslint/blob/master/docs/rules/README.md
 
 parserOptions:
-  ecmaVersion: 2015
+  ecmaVersion: 2017
 
 env:
   browser: true
diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index 95cf61d4..e5f505f3 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -1173,6 +1173,10 @@
     "message": "Case-sensitive",
     "description": "Tooltip for the 'Aa' icon that enables case-sensitive search in the editor shown on Ctrl-F"
   },
+  "searchGlobalStyles": {
+    "message": "Also search global styles",
+    "description": "Checkbox label in the popup's inline style search, shown when the text to search is entered"
+  },
   "searchNumberOfResults": {
     "message": "Number of matches",
     "description": "Tooltip for the number of found search results in the editor shown on Ctrl-F"
@@ -1181,6 +1185,10 @@
     "message": "Number of matches in code and applies-to values",
     "description": "Tooltip for the number of found search results in the editor shown on Ctrl-F"
   },
+  "searchStyleQueryHint": {
+    "message": "Search style names case-insensitively:\nsome words - all words in any order\n\"some phrase\" - this exact phrase without quotes\n2020 - a year like this also shows styles updated in 2020",
+    "description": "Tooltip shown for the text input in the popup's inline style finder"
+  },
   "searchRegexp": {
     "message": "Use /re/ syntax for regexp search",
     "description": "Label after the search input field in the editor shown on Ctrl-F"
diff --git a/background/background-worker.js b/background/background-worker.js
index f44013e5..ddb33d53 100644
--- a/background/background-worker.js
+++ b/background/background-worker.js
@@ -12,7 +12,6 @@ createAPI({
   compileUsercss,
   parseUsercssMeta(text, indexOffset = 0) {
     loadScript(
-      '/js/polyfill.js',
       '/vendor/usercss-meta/usercss-meta.min.js',
       '/vendor-overwrites/colorpicker/colorconverter.js',
       '/js/meta-parser.js'
@@ -21,7 +20,6 @@ createAPI({
   },
   nullifyInvalidVars(vars) {
     loadScript(
-      '/js/polyfill.js',
       '/vendor/usercss-meta/usercss-meta.min.js',
       '/vendor-overwrites/colorpicker/colorconverter.js',
       '/js/meta-parser.js'
diff --git a/background/background.js b/background/background.js
index 6851006e..d03250b6 100644
--- a/background/background.js
+++ b/background/background.js
@@ -1,8 +1,8 @@
 /* global download prefs openURL FIREFOX CHROME
-  URLS ignoreChromeError usercssHelper
+  URLS ignoreChromeError chromeLocal semverCompare
   styleManager msg navigatorUtil workerUtil contentScripts sync
   findExistingTab activateTab isTabReplaceable getActiveTab
-  tabManager */
+*/
 
 'use strict';
 
@@ -111,14 +111,6 @@ navigatorUtil.onUrlChange(({tabId, frameId}, type) => {
   }
 });
 
-tabManager.onUpdate(({tabId, url, oldUrl = ''}) => {
-  if (usercssHelper.testUrl(url) && !oldUrl.startsWith(URLS.installUsercss)) {
-    usercssHelper.testContents(tabId, url).then(data => {
-      if (data.code) usercssHelper.openInstallerPage(tabId, url, data);
-    });
-  }
-});
-
 if (FIREFOX) {
   // FF misses some about:blank iframes so we inject our content script explicitly
   navigatorUtil.onDOMContentLoaded(webNavIframeHelperFF, {
@@ -139,7 +131,7 @@ if (chrome.commands) {
 }
 
 // *************************************************************************
-chrome.runtime.onInstalled.addListener(({reason}) => {
+chrome.runtime.onInstalled.addListener(({reason, previousVersion}) => {
   // save install type: "admin", "development", "normal", "sideload" or "other"
   // "normal" = addon installed from webstore
   chrome.management.getSelf(info => {
@@ -156,6 +148,14 @@ chrome.runtime.onInstalled.addListener(({reason}) => {
   });
   // themes may change
   delete localStorage.codeMirrorThemes;
+  // inline search cache for USO is not needed anymore, TODO: remove this by the middle of 2021
+  if (semverCompare(previousVersion, '1.5.13') <= 0) {
+    setTimeout(async () => {
+      const del = Object.keys(await chromeLocal.get())
+        .filter(key => key.startsWith('usoSearchCache'));
+      if (del.length) chromeLocal.remove(del);
+    }, 15e3);
+  }
 });
 
 // *************************************************************************
diff --git a/background/db.js b/background/db.js
index 2549a3ce..223d3870 100644
--- a/background/db.js
+++ b/background/db.js
@@ -1,4 +1,4 @@
-/* global chromeLocal ignoreChromeError workerUtil createChromeStorageDB */
+/* global chromeLocal workerUtil createChromeStorageDB */
 /* exported db */
 /*
 Initialize a database. There are some problems using IndexedDB in Firefox:
@@ -10,29 +10,18 @@ https://www.reddit.com/r/firefox/comments/7ijuaq/firefox_59_webextensions_can_us
 'use strict';
 
 const db = (() => {
-  let exec;
-  const preparing = prepare();
-  return {
-    exec: (...args) =>
-      preparing.then(() => exec(...args))
+  const DATABASE = 'stylish';
+  const STORE = 'styles';
+  const FALLBACK = 'dbInChromeStorage';
+  const dbApi = {
+    async exec(...args) {
+      dbApi.exec = await tryUsingIndexedDB().catch(useChromeStorage);
+      return dbApi.exec(...args);
+    },
   };
+  return dbApi;
 
-  function prepare() {
-    return withPromise(shouldUseIndexedDB).then(
-      ok => {
-        if (ok) {
-          useIndexedDB();
-        } else {
-          useChromeStorage();
-        }
-      },
-      err => {
-        useChromeStorage(err);
-      }
-    );
-  }
-
-  function shouldUseIndexedDB() {
+  async function tryUsingIndexedDB() {
     // we use chrome.storage.local fallback if IndexedDB doesn't save data,
     // which, once detected on the first run, is remembered in chrome.storage.local
     // for reliablility and in localStorage for fast synchronous access
@@ -42,115 +31,81 @@ const db = (() => {
     if (typeof indexedDB === 'undefined') {
       throw new Error('indexedDB is undefined');
     }
-    // test localStorage
-    const fallbackSet = localStorage.dbInChromeStorage;
-    if (fallbackSet === 'true') {
-      return false;
+    switch (await getFallback()) {
+      case true: throw null;
+      case false: break;
+      default: await testDB();
     }
-    if (fallbackSet === 'false') {
-      return true;
-    }
-    // test storage.local
-    return chromeLocal.get('dbInChromeStorage')
-      .then(data => {
-        if (data && data.dbInChromeStorage) {
-          return false;
-        }
-        return testDBSize()
-          .then(ok => ok || testDBMutation());
-      });
+    return useIndexedDB();
   }
 
-  function withPromise(fn) {
-    try {
-      return Promise.resolve(fn());
-    } catch (err) {
-      return Promise.reject(err);
-    }
+  async function getFallback() {
+    return localStorage[FALLBACK] === 'true' ? true :
+      localStorage[FALLBACK] === 'false' ? false :
+        chromeLocal.getValue(FALLBACK);
   }
 
-  function testDBSize() {
-    return dbExecIndexedDB('getAllKeys', IDBKeyRange.lowerBound(1), 1)
-      .then(event => (
-        event.target.result &&
-        event.target.result.length &&
-        event.target.result[0]
-      ));
-  }
-
-  function testDBMutation() {
-    return dbExecIndexedDB('put', {id: -1})
-      .then(() => dbExecIndexedDB('get', -1))
-      .then(event => {
-        if (!event.target.result) {
-          throw new Error('failed to get previously put item');
-        }
-        if (event.target.result.id !== -1) {
-          throw new Error('item id is wrong');
-        }
-        return dbExecIndexedDB('delete', -1);
-      })
-      .then(() => true);
+  async function testDB() {
+    let e = await dbExecIndexedDB('getAllKeys', IDBKeyRange.lowerBound(1), 1);
+    // throws if result is null
+    e = e.target.result[0];
+    const id = `${performance.now()}.${Math.random()}.${Date.now()}`;
+    await dbExecIndexedDB('put', {id});
+    e = await dbExecIndexedDB('get', id);
+    // throws if result or id is null
+    await dbExecIndexedDB('delete', e.target.result.id);
   }
 
   function useChromeStorage(err) {
-    exec = createChromeStorageDB().exec;
-    chromeLocal.set({dbInChromeStorage: true}, ignoreChromeError);
+    chromeLocal.setValue(FALLBACK, true);
     if (err) {
-      chromeLocal.setValue('dbInChromeStorageReason', workerUtil.cloneError(err));
+      chromeLocal.setValue(FALLBACK + 'Reason', workerUtil.cloneError(err));
       console.warn('Failed to access indexedDB. Switched to storage API.', err);
     }
-    localStorage.dbInChromeStorage = 'true';
+    localStorage[FALLBACK] = 'true';
+    return createChromeStorageDB().exec;
   }
 
   function useIndexedDB() {
-    exec = dbExecIndexedDB;
-    chromeLocal.set({dbInChromeStorage: false}, ignoreChromeError);
-    localStorage.dbInChromeStorage = 'false';
+    chromeLocal.setValue(FALLBACK, false);
+    localStorage[FALLBACK] = 'false';
+    return dbExecIndexedDB;
   }
 
-  function dbExecIndexedDB(method, ...args) {
-    return open().then(database => {
-      if (!method) {
-        return database;
-      }
-      if (method === 'putMany') {
-        return putMany(database, ...args);
-      }
-      const mode = method.startsWith('get') ? 'readonly' : 'readwrite';
-      const transaction = database.transaction(['styles'], mode);
-      const store = transaction.objectStore('styles');
-      return storeRequest(store, method, ...args);
+  async function dbExecIndexedDB(method, ...args) {
+    const mode = method.startsWith('get') ? 'readonly' : 'readwrite';
+    const store = (await open()).transaction([STORE], mode).objectStore(STORE);
+    const fn = method === 'putMany' ? putMany : storeRequest;
+    return fn(store, method, ...args);
+  }
+
+  function storeRequest(store, method, ...args) {
+    return new Promise((resolve, reject) => {
+      const request = store[method](...args);
+      request.onsuccess = resolve;
+      request.onerror = reject;
     });
+  }
 
-    function storeRequest(store, method, ...args) {
-      return new Promise((resolve, reject) => {
-        const request = store[method](...args);
-        request.onsuccess = resolve;
-        request.onerror = reject;
+  function putMany(store, _method, items) {
+    return Promise.all(items.map(item => storeRequest(store, 'put', item)));
+  }
+
+  function open() {
+    return new Promise((resolve, reject) => {
+      const request = indexedDB.open(DATABASE, 2);
+      request.onsuccess = () => resolve(request.result);
+      request.onerror = reject;
+      request.onupgradeneeded = create;
+    });
+  }
+
+  function create(event) {
+    if (event.oldVersion === 0) {
+      event.target.result.createObjectStore(STORE, {
+        keyPath: 'id',
+        autoIncrement: true,
       });
     }
-
-    function open() {
-      return new Promise((resolve, reject) => {
-        const request = indexedDB.open('stylish', 2);
-        request.onsuccess = () => resolve(request.result);
-        request.onerror = reject;
-        request.onupgradeneeded = event => {
-          if (event.oldVersion === 0) {
-            event.target.result.createObjectStore('styles', {
-              keyPath: 'id',
-              autoIncrement: true,
-            });
-          }
-        };
-      });
-    }
-
-    function putMany(database, items) {
-      const transaction = database.transaction(['styles'], 'readwrite');
-      const store = transaction.objectStore('styles');
-      return Promise.all(items.map(item => storeRequest(store, 'put', item)));
-    }
   }
 })();
diff --git a/background/style-manager.js b/background/style-manager.js
index 4ddb6411..4ae2a5c2 100644
--- a/background/style-manager.js
+++ b/background/style-manager.js
@@ -1,6 +1,6 @@
 /* eslint no-eq-null: 0, eqeqeq: [2, "smart"] */
 /* global createCache db calcStyleDigest db tryRegExp styleCodeEmpty styleSectionGlobal
-  getStyleWithNoCode msg sync uuidv4 */
+  getStyleWithNoCode msg sync uuidv4 URLS */
 /* exported styleManager */
 'use strict';
 
@@ -226,6 +226,13 @@ const styleManager = (() => {
     if (!reason) {
       reason = style ? 'update' : 'install';
     }
+    let url = !data.url && data.updateUrl;
+    if (url) {
+      const usoId = URLS.extractUsoArchiveId(url);
+      url = usoId && `${URLS.usoArchive}?style=${usoId}` ||
+            URLS.extractGreasyForkId(url) && url.match(/^.*?\/\d+/)[0];
+      if (url) data.url = data.installationUrl = url;
+    }
     // FIXME: update updateDate? what about usercss config?
     return calcStyleDigest(data)
       .then(digest => {
diff --git a/background/usercss-helper.js b/background/usercss-helper.js
index 3f6081f6..00b3a99b 100644
--- a/background/usercss-helper.js
+++ b/background/usercss-helper.js
@@ -1,15 +1,8 @@
-/* global API_METHODS usercss styleManager deepCopy openURL download URLS */
+/* global API_METHODS usercss styleManager deepCopy */
 /* exported usercssHelper */
 'use strict';
 
 const usercssHelper = (() => {
-  const installCodeCache = {};
-  const clearInstallCode = url => delete installCodeCache[url];
-  const isResponseText = r => /^text\/(css|plain)(;.*?)?$/i.test(r.headers.get('content-type'));
-  // in Firefox we have to use a content script to read file://
-  const fileLoader = !chrome.app && // not relying on navigator.ua which can be spoofed
-    (tabId => browser.tabs.executeScript(tabId, {file: '/content/install-hook-usercss.js'}).then(r => r[0]));
-
   API_METHODS.installUsercss = installUsercss;
   API_METHODS.editSaveUsercss = editSaveUsercss;
   API_METHODS.configUsercssVars = configUsercssVars;
@@ -17,50 +10,6 @@ const usercssHelper = (() => {
   API_METHODS.buildUsercss = build;
   API_METHODS.findUsercss = find;
 
-  API_METHODS.getUsercssInstallCode = url => {
-    // when the installer tab is reloaded after the cache is expired, this will throw intentionally
-    const {code, timer} = installCodeCache[url];
-    clearInstallCode(url);
-    clearTimeout(timer);
-    return code;
-  };
-
-  return {
-
-    testUrl(url) {
-      return url.includes('.user.') &&
-        /^(https?|file|ftps?):/.test(url) &&
-        /\.user\.(css|styl)$/.test(url.split(/[#?]/, 1)[0]);
-    },
-
-    /** @return {Promise<{ code:string, inTab:boolean } | false>} */
-    testContents(tabId, url) {
-      const isFile = url.startsWith('file:');
-      const inTab = isFile && Boolean(fileLoader);
-      return Promise.resolve(isFile || fetch(url, {method: 'HEAD'}).then(isResponseText))
-        .then(ok => ok && (inTab ? fileLoader(tabId) : download(url)))
-        .then(code => /==userstyle==/i.test(code) && {code, inTab});
-    },
-
-    openInstallerPage(tabId, url, {code, inTab} = {}) {
-      const newUrl = `${URLS.installUsercss}?updateUrl=${encodeURIComponent(url)}`;
-      if (inTab) {
-        browser.tabs.get(tabId).then(tab =>
-          openURL({
-            url: `${newUrl}&tabId=${tabId}`,
-            active: tab.active,
-            index: tab.index + 1,
-            openerTabId: tabId,
-            currentWindow: null,
-          }));
-      } else {
-        const timer = setTimeout(clearInstallCode, 10e3, url);
-        installCodeCache[url] = {code, timer};
-        chrome.tabs.update(tabId, {url: newUrl});
-      }
-    },
-  };
-
   function buildMeta(style) {
     if (style.usercssData) {
       return Promise.resolve(style);
diff --git a/background/usercss-install-helper.js b/background/usercss-install-helper.js
new file mode 100644
index 00000000..b854564a
--- /dev/null
+++ b/background/usercss-install-helper.js
@@ -0,0 +1,82 @@
+/* global API_METHODS openURL download URLS tabManager */
+'use strict';
+
+(() => {
+  const installCodeCache = {};
+  const clearInstallCode = url => delete installCodeCache[url];
+  const isContentTypeText = type => /^text\/(css|plain)(;.*?)?$/i.test(type);
+
+  // in Firefox we have to use a content script to read file://
+  const fileLoader = !chrome.app && (
+    async tabId =>
+      (await browser.tabs.executeScript(tabId, {file: '/content/install-hook-usercss.js'}))[0]);
+
+  const urlLoader =
+    async (tabId, url) => (
+      url.startsWith('file:') ||
+      tabManager.get(tabId, isContentTypeText.name) ||
+      isContentTypeText((await fetch(url, {method: 'HEAD'})).headers.get('content-type'))
+    ) && download(url);
+
+  API_METHODS.getUsercssInstallCode = url => {
+    // when the installer tab is reloaded after the cache is expired, this will throw intentionally
+    const {code, timer} = installCodeCache[url];
+    clearInstallCode(url);
+    clearTimeout(timer);
+    return code;
+  };
+
+  // Faster installation on known distribution sites to avoid flicker of css text
+  chrome.webRequest.onBeforeSendHeaders.addListener(({tabId, url}) => {
+    openInstallerPage(tabId, url, {});
+    // Silently suppressing navigation like it never happened
+    return {redirectUrl: 'javascript:void 0'}; // eslint-disable-line no-script-url
+  }, {
+    urls: [
+      URLS.usoArchiveRaw + 'usercss/*.user.css',
+      '*://greasyfork.org/scripts/*/code/*.user.css',
+      '*://sleazyfork.org/scripts/*/code/*.user.css',
+    ],
+    types: ['main_frame'],
+  }, ['blocking']);
+
+  // Remember Content-Type to avoid re-fetching of the headers in urlLoader as it can be very slow
+  chrome.webRequest.onHeadersReceived.addListener(({tabId, responseHeaders}) => {
+    const h = responseHeaders.find(h => h.name.toLowerCase() === 'content-type');
+    tabManager.set(tabId, isContentTypeText.name, h && isContentTypeText(h.value) || undefined);
+  }, {
+    urls: '%css,%css?*,%styl,%styl?*'.replace(/%/g, '*://*/*.user.').split(','),
+    types: ['main_frame'],
+  }, ['responseHeaders']);
+
+  tabManager.onUpdate(async ({tabId, url, oldUrl = ''}) => {
+    if (url.includes('.user.') &&
+        /^(https?|file|ftps?):/.test(url) &&
+        /\.user\.(css|styl)$/.test(url.split(/[#?]/, 1)[0]) &&
+        !oldUrl.startsWith(URLS.installUsercss)) {
+      const inTab = url.startsWith('file:') && Boolean(fileLoader);
+      const code = await (inTab ? fileLoader : urlLoader)(tabId, url);
+      if (/==userstyle==/i.test(code)) {
+        openInstallerPage(tabId, url, {code, inTab});
+      }
+    }
+  });
+
+  function openInstallerPage(tabId, url, {code, inTab} = {}) {
+    const newUrl = `${URLS.installUsercss}?updateUrl=${encodeURIComponent(url)}`;
+    if (inTab) {
+      browser.tabs.get(tabId).then(tab =>
+        openURL({
+          url: `${newUrl}&tabId=${tabId}`,
+          active: tab.active,
+          index: tab.index + 1,
+          openerTabId: tabId,
+          currentWindow: null,
+        }));
+    } else {
+      const timer = setTimeout(clearInstallCode, 10e3, url);
+      installCodeCache[url] = {code, timer};
+      chrome.tabs.update(tabId, {url: newUrl});
+    }
+  }
+})();
diff --git a/edit/editor-worker.js b/edit/editor-worker.js
index 6ef51eef..62ef380c 100644
--- a/edit/editor-worker.js
+++ b/edit/editor-worker.js
@@ -16,7 +16,6 @@ createAPI({
   },
   metalint: code => {
     loadScript(
-      '/js/polyfill.js',
       '/vendor/usercss-meta/usercss-meta.min.js',
       '/vendor-overwrites/colorpicker/colorconverter.js',
       '/js/meta-parser.js'
diff --git a/edit/source-editor.js b/edit/source-editor.js
index a9f8bf22..b3292606 100644
--- a/edit/source-editor.js
+++ b/edit/source-editor.js
@@ -46,7 +46,7 @@ function createSourceEditor({style, onTitleChanged}) {
   metaCompiler.onUpdated(meta => {
     style.usercssData = meta;
     style.name = meta.name;
-    style.url = meta.homepageURL;
+    style.url = meta.homepageURL || style.installationUrl;
     updateMeta();
   });
 
diff --git a/install-usercss/install-usercss.js b/install-usercss/install-usercss.js
index a72b8359..f78044a7 100644
--- a/install-usercss/install-usercss.js
+++ b/install-usercss/install-usercss.js
@@ -318,7 +318,9 @@
     let sequence = null;
     if (tabId < 0) {
       getData = DirectDownloader();
-      sequence = API.getUsercssInstallCode(initialUrl).catch(getData);
+      sequence = API.getUsercssInstallCode(initialUrl)
+        .then(code => code || getData())
+        .catch(getData);
     } else {
       getData = PortDownloader();
       sequence = getData({timer: false});
diff --git a/js/dom.js b/js/dom.js
index db9d9f0f..4e40bc83 100644
--- a/js/dom.js
+++ b/js/dom.js
@@ -98,7 +98,11 @@ document.addEventListener('wheel', event => {
     return;
   }
   if (el.tagName === 'SELECT') {
-    el.selectedIndex = Math.max(0, Math.min(el.length - 1, el.selectedIndex + Math.sign(event.deltaY)));
+    const old = el.selectedIndex;
+    el.selectedIndex = Math.max(0, Math.min(el.length - 1, old + Math.sign(event.deltaY)));
+    if (el.selectedIndex !== old) {
+      el.dispatchEvent(new Event('change', {bubbles: true}));
+    }
     event.preventDefault();
   }
   event.stopImmediatePropagation();
diff --git a/js/messaging.js b/js/messaging.js
index e7f0e9da..ec51267c 100644
--- a/js/messaging.js
+++ b/js/messaging.js
@@ -62,7 +62,19 @@ const URLS = {
   // TODO: remove when "minimum_chrome_version": "61" or higher
   chromeProtectsNTP: CHROME >= 61,
 
-  userstylesOrgJson: 'https://userstyles.org/styles/chrome/',
+  uso: 'https://userstyles.org/',
+  usoJson: 'https://userstyles.org/styles/chrome/',
+
+  usoArchive: 'https://33kk.github.io/uso-archive/',
+  usoArchiveRaw: 'https://raw.githubusercontent.com/33kk/uso-archive/flomaster/data/',
+  extractUsoArchiveId: url =>
+    url &&
+    url.startsWith(URLS.usoArchiveRaw) &&
+    parseInt(url.match(/\/(\d+)\.user\.css|$/)[1]),
+
+  extractGreasyForkId: url =>
+    /^https:\/\/(?:greasy|sleazy)fork\.org\/scripts\/(\d+)[^/]*\/code\/[^/]*\.user\.css$/.test(url) &&
+    RegExp.$1,
 
   supported: url => (
     url.startsWith('http') && (FIREFOX || !url.startsWith(URLS.browserWebStore)) ||
@@ -438,7 +450,7 @@ function download(url, {
   function collapseUsoVars(url) {
     if (queryPos < 0 ||
         url.length < 2000 ||
-        !url.startsWith(URLS.userstylesOrgJson) ||
+        !url.startsWith(URLS.usoJson) ||
         !/^get$/i.test(method)) {
       return url;
     }
diff --git a/js/polyfill.js b/js/polyfill.js
index 3b22c96d..859de6e2 100644
--- a/js/polyfill.js
+++ b/js/polyfill.js
@@ -3,27 +3,33 @@
 // eslint-disable-next-line no-unused-expressions
 self.INJECTED !== 1 && (() => {
 
-  // this part runs in workers, content scripts, our extension pages
+  //#region for content scripts and our extension pages
 
-  if (!Object.entries) {
-    Object.entries = obj => Object.keys(obj).map(k => [k, obj[k]]);
+  if (!window.browser || !browser.runtime) {
+    const createTrap = (base, parent) => {
+      const target = typeof base === 'function' ? () => {} : {};
+      target.isTrap = true;
+      return new Proxy(target, {
+        get: (target, prop) => {
+          if (target[prop]) return target[prop];
+          if (base[prop] && (typeof base[prop] === 'object' || typeof base[prop] === 'function')) {
+            target[prop] = createTrap(base[prop], base);
+            return target[prop];
+          }
+          return base[prop];
+        },
+        apply: (target, thisArg, args) => base.apply(parent, args)
+      });
+    };
+    window.browser = createTrap(chrome, null);
   }
-  if (!Object.values) {
-    Object.values = obj => Object.keys(obj).map(k => obj[k]);
-  }
-
-  // don't use self.chrome. It is undefined in Firefox
-  if (typeof chrome !== 'object') return;
-  // the rest is for content scripts and our extension pages
-
-  self.browser = polyfillBrowser();
 
   /* Promisifies the specified `chrome` methods into `browser`.
     The definitions is an object like this: {
       'storage.sync': ['get', 'set'], // if deeper than one level, combine the path via `.`
       windows: ['create', 'update'], // items and sub-objects will only be created if present in `chrome`
     } */
-  self.promisifyChrome = definitions => {
+  window.promisifyChrome = definitions => {
     for (const [scopeName, methods] of Object.entries(definitions)) {
       const path = scopeName.split('.');
       const src = path.reduce((obj, p) => obj && obj[p], chrome);
@@ -43,90 +49,18 @@ self.INJECTED !== 1 && (() => {
   };
 
   if (!chrome.tabs) return;
-  // the rest is for our extension pages
 
-  if (typeof document === 'object') {
-    const ELEMENT_METH = {
-      append: {
-        base: [Element, Document, DocumentFragment],
-        fn: (node, frag) => {
-          node.appendChild(frag);
-        }
-      },
-      prepend: {
-        base: [Element, Document, DocumentFragment],
-        fn: (node, frag) => {
-          node.insertBefore(frag, node.firstChild);
-        }
-      },
-      before: {
-        base: [Element, CharacterData, DocumentType],
-        fn: (node, frag) => {
-          node.parentNode.insertBefore(frag, node);
-        }
-      },
-      after: {
-        base: [Element, CharacterData, DocumentType],
-        fn: (node, frag) => {
-          node.parentNode.insertBefore(frag, node.nextSibling);
-        }
-      }
-    };
+  //#endregion
+  //#region for our extension pages
 
-    for (const [key, {base, fn}] of Object.entries(ELEMENT_METH)) {
-      for (const cls of base) {
-        if (cls.prototype[key]) {
-          continue;
-        }
-        cls.prototype[key] = function (...nodes) {
-          const frag = document.createDocumentFragment();
-          for (const node of nodes) {
-            frag.appendChild(typeof node === 'string' ? document.createTextNode(node) : node);
-          }
-          fn(this, frag);
-        };
-      }
+  for (const storage of ['localStorage', 'sessionStorage']) {
+    try {
+      window[storage]._access_check = 1;
+      delete window[storage]._access_check;
+    } catch (err) {
+      Object.defineProperty(window, storage, {value: {}});
     }
   }
-  try {
-    if (!localStorage) {
-      throw new Error('localStorage is null');
-    }
-    localStorage._access_check = 1;
-    delete localStorage._access_check;
-  } catch (err) {
-    Object.defineProperty(self, 'localStorage', {value: {}});
-  }
-  try {
-    if (!sessionStorage) {
-      throw new Error('sessionStorage is null');
-    }
-    sessionStorage._access_check = 1;
-    delete sessionStorage._access_check;
-  } catch (err) {
-    Object.defineProperty(self, 'sessionStorage', {value: {}});
-  }
 
-  function polyfillBrowser() {
-    if (typeof browser === 'object' && browser.runtime) {
-      return browser;
-    }
-    return createTrap(chrome, null);
-
-    function createTrap(base, parent) {
-      const target = typeof base === 'function' ? () => {} : {};
-      target.isTrap = true;
-      return new Proxy(target, {
-        get: (target, prop) => {
-          if (target[prop]) return target[prop];
-          if (base[prop] && (typeof base[prop] === 'object' || typeof base[prop] === 'function')) {
-            target[prop] = createTrap(base[prop], base);
-            return target[prop];
-          }
-          return base[prop];
-        },
-        apply: (target, thisArg, args) => base.apply(parent, args)
-      });
-    }
-  }
+  //#endregion
 })();
diff --git a/manifest.json b/manifest.json
index 9d59db44..a3fc105f 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,7 +1,7 @@
 {
   "name": "Stylus",
   "version": "1.5.13",
-  "minimum_chrome_version": "49",
+  "minimum_chrome_version": "55",
   "description": "__MSG_description__",
   "homepage_url": "https://add0n.com/stylus.html",
   "manifest_version": 2,
@@ -51,6 +51,7 @@
       "background/icon-manager.js",
       "background/background.js",
       "background/usercss-helper.js",
+      "background/usercss-install-helper.js",
       "background/style-via-api.js",
       "background/search-db.js",
       "background/update.js",
diff --git a/popup.html b/popup.html
index 41cd7852..677edb81 100644
--- a/popup.html
+++ b/popup.html
@@ -120,9 +120,7 @@
         
           
           
-          
+          
         
         
           
@@ -254,6 +252,27 @@
   
   
diff --git a/popup/hotkeys.js b/popup/hotkeys.js
index fe058ea1..157a34bb 100644
--- a/popup/hotkeys.js
+++ b/popup/hotkeys.js
@@ -33,7 +33,8 @@ const hotkeys = (() => {
   }
 
   function onKeyDown(event) {
-    if (event.ctrlKey || event.altKey || event.metaKey || !enabled) {
+    if (event.ctrlKey || event.altKey || event.metaKey || !enabled ||
+        /^(text|search)$/.test((document.activeElement || {}).type)) {
       return;
     }
     let entry;
diff --git a/popup/popup.js b/popup/popup.js
index f6aea8c9..fb2b10bf 100644
--- a/popup/popup.js
+++ b/popup/popup.js
@@ -13,7 +13,8 @@ const handleEvent = {};
 
 const ABOUT_BLANK = 'about:blank';
 const ENTRY_ID_PREFIX_RAW = 'style-';
-const ENTRY_ID_PREFIX = '#' + ENTRY_ID_PREFIX_RAW;
+
+$.entry = styleOrId => $(`#${ENTRY_ID_PREFIX_RAW}${styleOrId.id || styleOrId}`);
 
 if (CHROME >= 66 && CHROME <= 69) { // Chrome 66-69 adds a gap, https://crbug.com/821143
   document.head.appendChild($create('style', 'html { overflow: overlay }'));
@@ -27,7 +28,7 @@ initTabUrls()
       onDOMready().then(() => initPopup(frames)),
       ...frames
         .filter(f => f.url && !f.isDupe)
-        .map(({url}) => API.getStylesByUrl(url).then(styles => ({styles, url}))),
+        .map(({url}) => getStyleDataMerged(url).then(styles => ({styles, url}))),
     ]))
   .then(([, ...results]) => {
     if (results[0]) {
@@ -53,17 +54,19 @@ if (CHROME_HAS_BORDER_BUG) {
 }
 
 function onRuntimeMessage(msg) {
+  if (!tabURL) return;
+  let ready = Promise.resolve();
   switch (msg.method) {
     case 'styleAdded':
     case 'styleUpdated':
       if (msg.reason === 'editPreview' || msg.reason === 'editPreviewEnd') return;
-      handleUpdate(msg);
+      ready = handleUpdate(msg);
       break;
     case 'styleDeleted':
       handleDelete(msg.style.id);
       break;
   }
-  dispatchEvent(new CustomEvent(msg.method, {detail: msg}));
+  ready.then(() => dispatchEvent(new CustomEvent(msg.method, {detail: msg})));
 }
 
 
@@ -141,8 +144,7 @@ function initPopup(frames) {
   }
 
   if (!tabURL) {
-    document.body.classList.add('blocked');
-    document.body.insertBefore(template.unavailableInfo, document.body.firstChild);
+    blockPopup();
     return;
   }
 
@@ -315,24 +317,30 @@ function showStyles(frameResults) {
   const entries = new Map();
   frameResults.forEach(({styles = [], url}, index) => {
     styles.forEach(style => {
-      const {id} = style.data;
+      const {id} = style;
       if (!entries.has(id)) {
         style.frameUrl = index === 0 ? '' : url;
-        entries.set(id, createStyleElement(Object.assign(style.data, style)));
+        entries.set(id, createStyleElement(style));
       }
     });
   });
   if (entries.size) {
-    installed.append(...sortStyles([...entries.values()]));
+    resortEntries([...entries.values()]);
   } else {
-    installed.appendChild(template.noStyles.cloneNode(true));
+    installed.appendChild(template.noStyles);
   }
   window.dispatchEvent(new Event('showStyles:done'));
 }
 
+function resortEntries(entries) {
+  // `entries` is specified only at startup, after that we respect the prefs
+  if (entries || prefs.get('popup.autoResort')) {
+    installed.append(...sortStyles(entries || $$('.entry', installed)));
+  }
+}
 
 function createStyleElement(style) {
-  let entry = $(ENTRY_ID_PREFIX + style.id);
+  let entry = $.entry(style);
   if (!entry) {
     entry = template.style.cloneNode(true);
     entry.setAttribute('style-id', style.id);
@@ -469,11 +477,7 @@ Object.assign(handleEvent, {
     event.stopPropagation();
     API
       .toggleStyle(handleEvent.getClickedStyleId(event), this.checked)
-      .then(() => {
-        if (prefs.get('popup.autoResort')) {
-          installed.append(...sortStyles($$('.entry', installed)));
-        }
-      });
+      .then(() => resortEntries());
   },
 
   toggleExclude(event, type) {
@@ -672,38 +676,25 @@ Object.assign(handleEvent, {
 });
 
 
-function handleUpdate({style, reason}) {
-  if (!tabURL) return;
-
-  fetchStyle()
-    .then(style => {
-      if (!style) {
-        return;
-      }
-      if ($(ENTRY_ID_PREFIX + style.id)) {
-        createStyleElement(style);
-        return;
-      }
-      document.body.classList.remove('blocked');
-      $$.remove('.blocked-info, #no-styles');
-      createStyleElement(style);
-    })
-    .catch(console.error);
-
-  function fetchStyle() {
-    if (reason === 'toggle' && $(ENTRY_ID_PREFIX + style.id)) {
-      return Promise.resolve(style);
-    }
-    return API.getStylesByUrl(tabURL, style.id)
-      .then(([result]) => result && Object.assign(result.data, result));
+async function handleUpdate({style, reason}) {
+  if (reason !== 'toggle' || !$.entry(style)) {
+    style = await getStyleDataMerged(tabURL, style.id);
+    if (!style) return;
   }
+  const el = createStyleElement(style);
+  if (!el.parentNode) {
+    installed.appendChild(el);
+    blockPopup(false);
+  }
+  resortEntries();
 }
 
 
 function handleDelete(id) {
-  $.remove(ENTRY_ID_PREFIX + id);
-  if (!$('.entry')) {
-    installed.appendChild(template.noStyles.cloneNode(true));
+  const el = $.entry(id);
+  if (el) {
+    el.remove();
+    if (!$('.entry')) installed.appendChild(template.noStyles);
   }
 }
 
@@ -721,3 +712,21 @@ function waitForTabUrlFF(tab) {
     ]);
   });
 }
+
+/* Merges the extra props from API into style data.
+ * When `id` is specified returns a single object otherwise an array */
+async function getStyleDataMerged(url, id) {
+  const styles = (await API.getStylesByUrl(url, id))
+    .map(r => Object.assign(r.data, r));
+  return id ? styles[0] : styles;
+}
+
+function blockPopup(isBlocked = true) {
+  document.body.classList.toggle('blocked', isBlocked);
+  if (isBlocked) {
+    document.body.prepend(template.unavailableInfo);
+  } else {
+    template.unavailableInfo.remove();
+    template.noStyles.remove();
+  }
+}
diff --git a/popup/search-results.css b/popup/search-results.css
index e1c805a6..a7eb69cf 100755
--- a/popup/search-results.css
+++ b/popup/search-results.css
@@ -54,21 +54,15 @@ body.search-results-shown {
   background-color: #fff;
 }
 
-.search-result .lds-spinner {
+#search-results .lds-spinner {
   transform: scale(.5);
   filter: invert(1) drop-shadow(1px 1px 3px #000);
 }
 
-.search-result-empty .lds-spinner {
-  transform: scale(.5);
+#search-results .search-result-empty .lds-spinner {
   filter: opacity(.2);
 }
 
-.search-result-fadein {
-  animation: fadein 1s;
-  animation-fill-mode: both;
-}
-
 .search-result-screenshot {
   height: 140px;
   width: 100%;
@@ -257,6 +251,24 @@ body.search-results-shown {
   padding-left: 16px;
 }
 
+#search-params {
+  display: flex;
+  position: relative;
+  margin-top: -.5rem;
+  margin-bottom: 1.25rem;
+  flex-wrap: wrap;
+}
+
+#search-params > * {
+  margin-top: .5rem;
+}
+
+#search-query {
+  min-width: 3em;
+  margin-right: .5em;
+  flex: 1 1 0;
+}
+
 /* spinner: https://github.com/loadingio/css-spinner */
 .lds-spinner {
   -webkit-user-select: none;
diff --git a/popup/search-results.js b/popup/search-results.js
index d46982ac..590a2337 100755
--- a/popup/search-results.js
+++ b/popup/search-results.js
@@ -1,105 +1,100 @@
-/* global tabURL handleEvent $ $$ prefs template FIREFOX chromeLocal debounce
-  $create t API tWordBreak formatDate tryCatch tryJSONparse LZString
-  promisifyChrome download */
+/* global URLS tabURL handleEvent $ $$ prefs template FIREFOX debounce
+  $create t API tWordBreak formatDate tryCatch download */
 'use strict';
 
-window.addEventListener('showStyles:done', function _() {
-  window.removeEventListener('showStyles:done', _);
-
-  if (!tabURL) {
-    return;
-  }
-
-  //region Init
-
-  const BODY_CLASS = 'search-results-shown';
+window.addEventListener('showStyles:done', () => {
+  if (!tabURL) return;
   const RESULT_ID_PREFIX = 'search-result-';
-
-  const BASE_URL = 'https://userstyles.org';
-  const JSON_URL = BASE_URL + '/styles/chrome/';
-  const API_URL = BASE_URL + '/api/v1/styles/';
-  const UPDATE_URL = 'https://update.userstyles.org/%.md5';
-
+  const INDEX_URL = URLS.usoArchiveRaw + 'search-index.json';
   const STYLUS_CATEGORY = 'chrome-extension';
-
-  const DISPLAY_PER_PAGE = 10;
-  // Millisecs to wait before fetching next batch of search results.
-  const DELAY_AFTER_FETCHING_STYLES = 0;
-  // Millisecs to wait before fetching .JSON for next search result.
-  const DELAY_BEFORE_SEARCHING_STYLES = 0;
-
-  // update USO style install counter
-  // if the style isn't uninstalled in the popup
-  const PINGBACK_DELAY = 60e3;
-
-  const BLANK_PIXEL_DATA = 'data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAA' +
-                           'C1HAwCAAAAC0lEQVR42mOcXQ8AAbsBHLLDr5MAAAAASUVORK5CYII=';
-
-  const CACHE_SIZE = 1e6;
-  const CACHE_PREFIX = 'usoSearchCache/';
-  const CACHE_DURATION = 24 * 3600e3;
-  const CACHE_CLEANUP_THROTTLE = 10e3;
-  const CACHE_CLEANUP_NEEDED = CACHE_PREFIX + 'clean?';
-  const CACHE_EXCEPT_PROPS = ['css', 'discussions', 'additional_info'];
-
-  let searchTotalPages;
-  let searchCurrentPage = 1;
-  let searchExhausted = 0; // 1: once, 2: twice (first host.jp, then host)
-
-  // currently active USO requests
-  const xhrSpoofIds = new Set();
-  // used as an HTTP header name to identify spoofed requests
-  const xhrSpoofTelltale = getRandomId();
-
-  const processedResults = [];
-  const unprocessedResults = [];
-
-  let loading = false;
-  // Category for the active tab's URL.
-  let category;
+  const PAGE_LENGTH = 10;
+  // update USO style install counter if the style isn't uninstalled immediately
+  const PINGBACK_DELAY = 5e3;
+  const BUSY_DELAY = .5e3;
+  const USO_AUTO_PIC_SUFFIX = '-after.png';
+  const BLANK_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
+  const dom = {};
+  /**
+   * @typedef IndexEntry
+   * @prop {'uso' | 'uso-android'} f - format
+   * @prop {Number} i - id
+   * @prop {string} n - name
+   * @prop {string} c - category
+   * @prop {Number} u - updatedTime
+   * @prop {Number} t - totalInstalls
+   * @prop {Number} w - weeklyInstalls
+   * @prop {Number} r - rating
+   * @prop {Number} ai -  authorId
+   * @prop {string} an -  authorName
+   * @prop {string} sn -  screenshotName
+   * @prop {boolean} sa -  screenshotArchived
+   */
+  /** @type IndexEntry[] */
+  let results;
+  /** @type IndexEntry[] */
+  let index;
+  let category = '';
+  let searchGlobals = $('#search-globals').checked;
+  /** @type string[] */
+  let query = [];
+  /** @type 'n' | 'u' | 't' | 'w' | 'r'  */
+  let order = 't';
   let scrollToFirstResult = true;
-
   let displayedPage = 1;
   let totalPages = 1;
-  let totalResults = 0;
+  let ready;
 
-  // fade-in when the entry took that long to replace its placeholder
-  const FADEIN_THRESHOLD = 50;
+  calcCategory();
 
-  const dom = {};
+  const $class = sel => (sel instanceof Node ? sel : $(sel)).classList;
+  const show = sel => $class(sel).remove('hidden');
+  const hide = sel => $class(sel).add('hidden');
 
   Object.assign($('#find-styles-link'), {
-    href: BASE_URL + '/styles/browse/' + getCategory(),
+    href: URLS.usoArchive,
     onclick(event) {
       if (!prefs.get('popup.findStylesInline') || dom.container) {
+        this.search = `${new URLSearchParams({category, search: $('#search-query').value})}`;
         handleEvent.openURLandHide.call(this, event);
         return;
       }
       event.preventDefault();
-
       this.textContent = this.title;
       this.title = '';
-
       init();
-      load();
+      ready = start();
     },
   });
 
   return;
 
   function init() {
-    promisifyChrome({
-      'storage.local': ['getBytesInUse'], // FF doesn't implement it
-    });
-    setTimeout(() => document.body.classList.add(BODY_CLASS));
-
-    $('#find-styles-inline-group').classList.add('hidden');
-
+    setTimeout(() => document.body.classList.add('search-results-shown'));
+    hide('#find-styles-inline-group');
+    $('#search-globals').onchange = function () {
+      searchGlobals = this.checked;
+      ready = ready.then(start);
+    };
+    $('#search-query').oninput = function () {
+      query = [];
+      const text = this.value.trim().toLocaleLowerCase();
+      const thisYear = new Date().getFullYear();
+      for (let re = /"(.+?)"|(\S+)/g, m; (m = re.exec(text));) {
+        const n = Number(m[2]);
+        query.push(n >= 2000 && n <= thisYear ? n : m[1] || m[2]);
+      }
+      ready = ready.then(start);
+    };
+    $('#search-order').value = order;
+    $('#search-order').onchange = function () {
+      order = this.value;
+      results.sort(comparator);
+      render();
+    };
+    dom.list = $('#search-results-list');
     dom.container = $('#search-results');
     dom.container.dataset.empty = '';
-
     dom.error = $('#search-results-error');
-
     dom.nav = {};
     const navOnClick = {prev, next};
     for (const place of ['top', 'bottom']) {
@@ -113,10 +108,6 @@ window.addEventListener('showStyles:done', function _() {
       }
     }
 
-    dom.list = $('#search-results-list');
-
-    addEventListener('scroll', loadMoreIfNeeded, {passive: true});
-
     if (FIREFOX) {
       let lastShift;
       addEventListener('resize', () => {
@@ -130,43 +121,24 @@ window.addEventListener('showStyles:done', function _() {
     }
 
     addEventListener('styleDeleted', ({detail: {style: {id}}}) => {
-      const result = processedResults.find(r => r.installedStyleId === id);
+      restoreScrollPosition();
+      const result = results.find(r => r.installedStyleId === id);
       if (result) {
-        result.installed = false;
-        result.installedStyleId = -1;
-        window.clearTimeout(result.pingbackTimer);
-        renderActionButtons($('#' + RESULT_ID_PREFIX + result.id));
+        clearTimeout(result.pingbackTimer);
+        renderActionButtons(result.i, -1);
       }
     });
 
-    addEventListener('styleAdded', ({detail: {style: {id, md5Url}}}) => {
-      const usoId = parseInt(md5Url && md5Url.match(/\d+|$/)[0]);
-      const result = usoId && processedResults.find(r => r.id === usoId);
-      if (result) {
-        result.installed = true;
-        result.installedStyleId = id;
-        renderActionButtons($('#' + RESULT_ID_PREFIX + usoId));
+    addEventListener('styleAdded', async ({detail: {style}}) => {
+      restoreScrollPosition();
+      const usoId = calcUsoId(style) ||
+                    calcUsoId(await API.getStyle(style.id, true));
+      if (usoId && results.find(r => r.i === usoId)) {
+        renderActionButtons(usoId, style.id);
       }
     });
-
-    chromeLocal.getValue(CACHE_CLEANUP_NEEDED).then(value =>
-      value && debounce(cleanupCache, CACHE_CLEANUP_THROTTLE));
   }
 
-  //endregion
-  //region Loader
-
-  /**
-   * Sets loading status of search results.
-   * @param {Boolean} isLoading If search results are idle (false) or still loading (true).
-   */
-  function setLoading(isLoading) {
-    if (loading !== isLoading) {
-      loading = isLoading;
-      // Refresh elements that depend on `loading` state.
-      render();
-    }
-  }
 
   function showSpinner(parent) {
     parent = parent instanceof Node ? parent : $(parent);
@@ -174,166 +146,92 @@ window.addEventListener('showStyles:done', function _() {
       new Array(12).fill($create('div')).map(e => e.cloneNode())));
   }
 
-  /** Increments displayedPage and loads results. */
   function next() {
-    if (loading) {
-      debounce(next, 100);
-      return;
-    }
-    displayedPage += 1;
+    displayedPage = Math.min(totalPages, displayedPage + 1);
     scrollToFirstResult = true;
     render();
-    loadMoreIfNeeded();
   }
 
-  /** Decrements currentPage and loads results. */
   function prev() {
-    if (loading) {
-      debounce(next, 100);
-      return;
-    }
     displayedPage = Math.max(1, displayedPage - 1);
     scrollToFirstResult = true;
     render();
   }
 
-  /**
-   * Display error message to user.
-   * @param {string} message  Message to display to user.
-   */
   function error(reason) {
-    dom.error.textContent = reason === 404 ? t('searchResultNoneFound') : reason;
-    dom.error.classList.remove('hidden');
-    dom.container.classList.toggle('hidden', !processedResults.length);
-    document.body.classList.toggle('search-results-shown', processedResults.length > 0);
+    dom.error.textContent = reason;
+    show(dom.error);
+    hide(dom.list);
     if (dom.error.getBoundingClientRect().bottom < 0) {
       dom.error.scrollIntoView({behavior: 'smooth', block: 'start'});
     }
   }
 
-  /**
-   * Initializes search results container, starts fetching results.
-   */
-  function load() {
-    if (searchExhausted > 1) {
-      if (!processedResults.length) {
-        error(404);
+  async function start() {
+    show(dom.container);
+    show(dom.list);
+    hide(dom.error);
+    results = [];
+    try {
+      for (let retry = 0; !results.length && retry <= 2; retry++) {
+        results = await search({retry});
       }
-      return;
-    }
-
-    setLoading(true);
-    dom.container.classList.remove('hidden');
-    dom.error.classList.add('hidden');
-
-    category = category || getCategory();
-
-    search({category})
-      .then(function process(results) {
-        const data = results.data.filter(sameCategoryNoDupes);
-
-        if (!data.length && searchExhausted <= 1) {
-          const old = category;
-          const uso = (processedResults[0] || {}).subcategory;
-          category = uso !== category && uso || getCategory({retry: true});
-          if (category !== old) return search({category, restart: true}).then(process);
-        }
-
-        const numIrrelevant = results.data.length - data.length;
-        totalResults += results.current_page === 1 ? results.total_entries : 0;
-        totalResults = Math.max(0, totalResults - numIrrelevant);
-        totalPages = Math.ceil(totalResults / DISPLAY_PER_PAGE);
-
-        setLoading(false);
-
-        if (data.length) {
-          unprocessedResults.push(...data);
-          processNextResult();
-        } else if (numIrrelevant) {
-          load();
-        } else if (!processedResults.length) {
-          return Promise.reject(404);
-        }
-      })
-      .catch(error);
-  }
-
-  function loadMoreIfNeeded(event) {
-    let pageToPrefetch = displayedPage;
-    if (event instanceof Event) {
-      if ((loadMoreIfNeeded.prefetchedPage || 0) <= pageToPrefetch &&
-          document.scrollingElement.scrollTop > document.scrollingElement.scrollHeight / 2) {
-        loadMoreIfNeeded.prefetchedPage = ++pageToPrefetch;
-      } else {
-        return;
+      if (results.length) {
+        const installedStyles = await API.getAllStyles(true);
+        const allUsoIds = new Set(installedStyles.map(calcUsoId));
+        results = results.filter(r => !allUsoIds.has(r.i));
       }
-    }
-    if (processedResults.length < pageToPrefetch * DISPLAY_PER_PAGE) {
-      setTimeout(load, DELAY_BEFORE_SEARCHING_STYLES);
+      render();
+      (results.length ? show : hide)(dom.list);
+      if (!results.length && !$('#search-query').value) {
+        error(t('searchResultNoneFound'));
+      }
+    } catch (reason) {
+      error(reason);
     }
   }
 
-  /**
-   * Processes the next search result in `unprocessedResults` and adds to `processedResults`.
-   * Skips installed/non-applicable styles.
-   * Fetches more search results if unprocessedResults is empty.
-   * Recurses until shouldLoadMore() is false.
-   */
-  function processNextResult() {
-    const result = unprocessedResults.shift();
-    if (!result) {
-      loadMoreIfNeeded();
-      return;
-    }
-    const md5Url = UPDATE_URL.replace('%', result.id);
-    API.styleExists({md5Url}).then(exist => {
-      if (exist) {
-        totalResults = Math.max(0, totalResults - 1);
-      } else {
-        processedResults.push(result);
-        render();
-      }
-      setTimeout(processNextResult, !exist && DELAY_AFTER_FETCHING_STYLES);
-    });
-  }
-
-  //endregion
-  //region UI
-
   function render() {
-    let start = (displayedPage - 1) * DISPLAY_PER_PAGE;
-    const end = displayedPage * DISPLAY_PER_PAGE;
-
+    totalPages = Math.ceil(results.length / PAGE_LENGTH);
+    displayedPage = Math.min(displayedPage, totalPages) || 1;
+    let start = (displayedPage - 1) * PAGE_LENGTH;
+    const end = displayedPage * PAGE_LENGTH;
     let plantAt = 0;
     let slot = dom.list.children[0];
-
     // keep rendered elements with ids in the range of interest
     while (
-      plantAt < DISPLAY_PER_PAGE &&
-      slot && slot.id === 'search-result-' + (processedResults[start] || {}).id
+      plantAt < PAGE_LENGTH &&
+      slot && slot.id === 'search-result-' + (results[start] || {}).i
     ) {
       slot = slot.nextElementSibling;
       plantAt++;
       start++;
     }
-
-    const plantEntry = entry => {
+    // add new elements
+    while (start < Math.min(end, results.length)) {
+      const entry = createSearchResultNode(results[start++]);
       if (slot) {
         dom.list.replaceChild(entry, slot);
         slot = entry.nextElementSibling;
       } else {
         dom.list.appendChild(entry);
       }
-      entry.classList.toggle('search-result-fadein',
-        !slot || performance.now() - slot._plantedTime > FADEIN_THRESHOLD);
-      return entry;
-    };
-
-    while (start < Math.min(end, processedResults.length)) {
-      plantEntry(createSearchResultNode(processedResults[start++]));
       plantAt++;
     }
-
+    // remove extraneous elements
+    const pageLen = end > results.length &&
+      results.length % PAGE_LENGTH ||
+      Math.min(results.length, PAGE_LENGTH);
+    while (dom.list.children.length > pageLen) {
+      dom.list.lastElementChild.remove();
+    }
+    if (results.length && 'empty' in dom.container.dataset) {
+      delete dom.container.dataset.empty;
+    }
+    if (scrollToFirstResult && (!FIREFOX || FIREFOX >= 55)) {
+      debounce(doScrollToFirstResult);
+    }
+    // navigation
     for (const place in dom.nav) {
       const nav = dom.nav[place];
       nav._prev.disabled = displayedPage <= 1;
@@ -341,34 +239,6 @@ window.addEventListener('showStyles:done', function _() {
       nav._page.textContent = displayedPage;
       nav._total.textContent = totalPages;
     }
-
-    // Fill in remaining search results with blank results + spinners
-    const maxResults = end > totalResults &&
-      totalResults % DISPLAY_PER_PAGE ||
-      DISPLAY_PER_PAGE;
-    while (plantAt < maxResults) {
-      if (!slot || slot.id.startsWith(RESULT_ID_PREFIX)) {
-        const entry = plantEntry(template.emptySearchResult.cloneNode(true));
-        entry._plantedTime = performance.now();
-        showSpinner(entry);
-      }
-      plantAt++;
-      if (!processedResults.length) {
-        break;
-      }
-    }
-
-    while (dom.list.children.length > maxResults) {
-      dom.list.lastElementChild.remove();
-    }
-
-    if (processedResults.length && 'empty' in dom.container.dataset) {
-      delete dom.container.dataset.empty;
-    }
-
-    if (scrollToFirstResult && (!FIREFOX || FIREFOX >= 55)) {
-      debounce(doScrollToFirstResult);
-    }
   }
 
   function doScrollToFirstResult() {
@@ -379,96 +249,61 @@ window.addEventListener('showStyles:done', function _() {
   }
 
   /**
-   * Constructs and adds the given search result to the popup's Search Results container.
-   * @param {Object} result The SearchResult object from userstyles.org
+   * @param {IndexEntry} result
+   * @returns {Node}
    */
   function createSearchResultNode(result) {
-    /*
-      userstyleSearchResult format: {
-        id: 100835,
-        name: "Reddit Flat Dark",
-        screenshot_url: "19339_after.png",
-        description: "...",
-        user: {
-          id: 48470,
-          name: "holloh"
-        },
-        style_settings: [...]
-      }
-    */
-
     const entry = template.searchResult.cloneNode(true);
-    Object.assign(entry, {
-      _result: result,
-      id: RESULT_ID_PREFIX + result.id,
-    });
-
+    const {
+      i: id,
+      n: name,
+      r: rating,
+      u: updateTime,
+      w: weeklyInstalls,
+      t: totalInstalls,
+      an: author,
+      sa: shotArchived,
+      sn: shotName,
+    } = entry._result = result;
+    entry.id = RESULT_ID_PREFIX + id;
+    // title
     Object.assign($('.search-result-title', entry), {
       onclick: handleEvent.openURLandHide,
-      href: BASE_URL + result.url
+      href: URLS.usoArchive + `?category=${category}&style=${id}`
     });
-
-    const displayedName = result.name.length < 300 ? result.name : result.name.slice(0, 300) + '...';
-    $('.search-result-title span', entry).textContent = tWordBreak(displayedName);
-
-    const screenshot = $('.search-result-screenshot', entry);
-    let url = result.screenshot_url;
-    if (!url) {
-      url = BLANK_PIXEL_DATA;
-      screenshot.classList.add('no-screenshot');
-    } else if (/^[0-9]*_after.(jpe?g|png|gif)$/i.test(url)) {
-      url = BASE_URL + '/style_screenshot_thumbnails/' + url;
-    }
-    screenshot.src = url;
-    if (url !== BLANK_PIXEL_DATA) {
-      screenshot.classList.add('search-result-fadein');
-      screenshot.onload = () => {
-        screenshot.classList.remove('search-result-fadein');
-      };
-    }
-
-    const description = result.description
-      .replace(/<[^>]*>/g, ' ')
-      .replace(/([^.][.。?!]|[\s,].{50,70})\s+/g, '$1\n')
-      .replace(/([\r\n]\s*){3,}/g, '\n\n');
-    Object.assign($('.search-result-description', entry), {
-      textContent: description,
-      title: description,
+    $('.search-result-title span', entry).textContent =
+      tWordBreak(name.length < 300 ? name : name.slice(0, 300) + '...');
+    // screenshot
+    const auto = URLS.uso + `auto_style_screenshots/${id}${USO_AUTO_PIC_SUFFIX}`;
+    Object.assign($('.search-result-screenshot', entry), {
+      src: shotName && !shotName.endsWith(USO_AUTO_PIC_SUFFIX)
+        ? `${shotArchived ? URLS.usoArchiveRaw : URLS.uso + 'style_'}screenshots/${shotName}`
+        : auto,
+      _src: auto,
+      onerror: fixScreenshot,
     });
-
+    // author
     Object.assign($('[data-type="author"] a', entry), {
-      textContent: result.user.name,
-      title: result.user.name,
-      href: BASE_URL + '/users/' + result.user.id,
+      textContent: author,
+      title: author,
+      href: URLS.usoArchive + '?author=' + encodeURIComponent(author).replace(/%20/g, '+'),
       onclick: handleEvent.openURLandHide,
     });
-
-    let ratingClass;
-    let ratingValue = result.rating;
-    if (ratingValue === null) {
-      ratingClass = 'none';
-      ratingValue = '';
-    } else if (ratingValue >= 2.5) {
-      ratingClass = 'good';
-      ratingValue = ratingValue.toFixed(1);
-    } else if (ratingValue >= 1.5) {
-      ratingClass = 'okay';
-      ratingValue = ratingValue.toFixed(1);
-    } else {
-      ratingClass = 'bad';
-      ratingValue = ratingValue.toFixed(1);
-    }
-    $('[data-type="rating"]', entry).dataset.class = ratingClass;
-    $('[data-type="rating"] dd', entry).textContent = ratingValue;
-
+    // rating
+    $('[data-type="rating"]', entry).dataset.class =
+      !rating ? 'none' :
+        rating >= 2.5 ? 'good' :
+          rating >= 1.5 ? 'okay' :
+            'bad';
+    $('[data-type="rating"] dd', entry).textContent = rating && rating.toFixed(1) || '';
+    // time
     Object.assign($('[data-type="updated"] time', entry), {
-      dateTime: result.updated,
-      textContent: formatDate(result.updated)
+      dateTime: updateTime * 1000,
+      textContent: formatDate(updateTime * 1000)
     });
-
-    $('[data-type="weekly"] dd', entry).textContent = formatNumber(result.weekly_install_count);
-    $('[data-type="total"] dd', entry).textContent = formatNumber(result.total_install_count);
-
+    // totals
+    $('[data-type="weekly"] dd', entry).textContent = formatNumber(weeklyInstalls);
+    $('[data-type="total"] dd', entry).textContent = formatNumber(totalInstalls);
     renderActionButtons(entry);
     return entry;
   }
@@ -484,141 +319,123 @@ window.addEventListener('showStyles:done', function _() {
     );
   }
 
-  function renderActionButtons(entry) {
-    if (!entry) {
-      return;
+  function fixScreenshot() {
+    const {_src} = this;
+    if (_src && _src !== this.src) {
+      this.src = _src;
+      delete this._src;
+    } else {
+      this.src = BLANK_PIXEL;
+      this.onerror = null;
     }
-    const result = entry._result;
+  }
 
-    if (result.installed && !('installed' in entry.dataset)) {
+  function renderActionButtons(entry, installedId) {
+    if (Number(entry)) {
+      entry = $('#' + RESULT_ID_PREFIX + entry);
+    }
+    if (!entry) return;
+    const result = entry._result;
+    if (typeof installedId === 'number') {
+      result.installed = installedId > 0;
+      result.installedStyleId = installedId;
+    }
+    const isInstalled = result.installed;
+    if (isInstalled && !('installed' in entry.dataset)) {
       entry.dataset.installed = '';
       $('.search-result-status', entry).textContent = t('clickToUninstall');
-    } else if (!result.installed && 'installed' in entry.dataset) {
+    } else if (!isInstalled && 'installed' in entry.dataset) {
       delete entry.dataset.installed;
       $('.search-result-status', entry).textContent = '';
+      hide('.search-result-customize', entry);
     }
+    Object.assign($('.search-result-screenshot', entry), {
+      onclick: isInstalled ? uninstall : install,
+      title: isInstalled ? '' : t('installButton'),
+    });
+    $('.search-result-uninstall', entry).onclick = uninstall;
+    $('.search-result-install', entry).onclick = install;
+  }
 
-    const screenshot = $('.search-result-screenshot', entry);
-    screenshot.onclick = result.installed ? onUninstallClicked : onInstallClicked;
-    screenshot.title = result.installed ? '' : t('installButton');
-
-    const uninstallButton = $('.search-result-uninstall', entry);
-    uninstallButton.onclick = onUninstallClicked;
-
-    const installButton = $('.search-result-install', entry);
-    installButton.onclick = onInstallClicked;
-    if ((result.style_settings || []).length > 0) {
-      // Style has customizations
-      installButton.classList.add('customize');
-      uninstallButton.classList.add('customize');
-
-      const customizeButton = $('.search-result-customize', entry);
-      customizeButton.dataset.href = BASE_URL + result.url;
-      customizeButton.dataset.sendMessage = JSON.stringify({method: 'openSettings'});
-      customizeButton.classList.remove('hidden');
-      customizeButton.onclick = function (event) {
-        event.stopPropagation();
-        handleEvent.openURLandHide.call(this, event);
-      };
+  function renderFullInfo(entry, style) {
+    let {description, vars} = style.usercssData;
+    // description
+    description = (description || '')
+      .replace(/<[^>]*>/g, ' ')
+      .replace(/([^.][.。?!]|[\s,].{50,70})\s+/g, '$1\n')
+      .replace(/([\r\n]\s*){3,}/g, '\n\n');
+    Object.assign($('.search-result-description', entry), {
+      textContent: description,
+      title: description,
+    });
+    // config button
+    if (vars) {
+      const btn = $('.search-result-customize', entry);
+      btn.onclick = () => $('.configure', $.entry(style)).click();
+      show(btn);
     }
   }
 
-  function onUninstallClicked(event) {
-    event.stopPropagation();
+  async function install() {
     const entry = this.closest('.search-result');
-    saveScrollPosition(entry);
-    API.deleteStyle(entry._result.installedStyleId)
-      .then(restoreScrollPosition);
-  }
-
-  /** Installs the current userstyleSearchResult into Stylus. */
-  function onInstallClicked(event) {
-    event.stopPropagation();
-
-    const entry = this.closest('.search-result');
-    const result = entry._result;
+    const result = /** @type IndexEntry */ entry._result;
+    const {i: id} = result;
     const installButton = $('.search-result-install', entry);
 
     showSpinner(entry);
     saveScrollPosition(entry);
     installButton.disabled = true;
     entry.style.setProperty('pointer-events', 'none', 'important');
+    // FIXME: move this to background page and create an API like installUSOStyle
+    result.pingbackTimer = setTimeout(download, PINGBACK_DELAY,
+      `${URLS.uso}/styles/install/${id}?source=stylish-ch`);
 
-    // Fetch settings to see if we should display "configure" button
-    Promise.all([
-      fetchStyleJson(result),
-      fetchStyleSettings(result),
-      API.download({url: UPDATE_URL.replace('%', result.id)})
-    ])
-    .then(([style, settings, md5]) => {
-      pingback(result);
-      // show a 'config-on-homepage' icon in the popup
-      style.updateUrl += settings.length ? '?' : '';
-      style.originalMd5 = md5;
-      return API.installStyle(style);
-    })
-    .catch(reason => {
-      const usoId = result.id;
-      console.debug('install:saveStyle(usoID:', usoId, ') => [ERROR]: ', reason);
-      error('Error while downloading usoID:' + usoId + '\nReason: ' + reason);
-    })
-    .then(() => {
-      $.remove('.lds-spinner', entry);
-      installButton.disabled = false;
-      entry.style.pointerEvents = '';
-      restoreScrollPosition();
-    });
-
-    function fetchStyleSettings(result) {
-      return result.style_settings ||
-        fetchStyle(result.id).then(style => {
-          result.style_settings = style.style_settings || [];
-          return result.style_settings;
-        });
+    const updateUrl = `${URLS.usoArchiveRaw}usercss/${id}.user.css`;
+    try {
+      const sourceCode = await download(updateUrl);
+      const style = await API.installUsercss({sourceCode, updateUrl});
+      renderFullInfo(entry, style);
+    } catch (reason) {
+      error(`Error while downloading usoID:${id}\nReason: ${reason}`);
     }
+    $.remove('.lds-spinner', entry);
+    installButton.disabled = false;
+    entry.style.pointerEvents = '';
   }
 
-  function pingback(result) {
-    const wnd = window;
-    // FIXME: move this to background page and create an API like installUSOStyle
-    result.pingbackTimer = wnd.setTimeout(wnd.download, PINGBACK_DELAY,
-      BASE_URL + '/styles/install/' + result.id + '?source=stylish-ch');
+  function uninstall() {
+    const entry = this.closest('.search-result');
+    saveScrollPosition(entry);
+    API.deleteStyle(entry._result.installedStyleId);
   }
 
   function saveScrollPosition(entry) {
-    dom.scrollPosition = entry.getBoundingClientRect().top;
-    dom.scrollPositionElement = entry;
+    dom.scrollPos = entry.getBoundingClientRect().top;
+    dom.scrollPosElement = entry;
   }
 
   function restoreScrollPosition() {
-    const t0 = performance.now();
-    new MutationObserver((mutations, observer) => {
-      if (performance.now() - t0 < 1000) {
-        window.scrollBy(0, dom.scrollPositionElement.getBoundingClientRect().top - dom.scrollPosition);
-      }
-      observer.disconnect();
-    }).observe(document.body, {childList: true, subtree: true, attributes: true});
+    window.scrollBy(0, dom.scrollPosElement.getBoundingClientRect().top - dom.scrollPos);
   }
 
-  //endregion
-  //region USO API wrapper
-
   /**
    * Resolves the Userstyles.org "category" for a given URL.
+   * @returns {boolean} true if the category has actually changed
    */
-  function getCategory({retry} = {}) {
+  function calcCategory({retry} = {}) {
     const u = tryCatch(() => new URL(tabURL));
+    const old = category;
     if (!u) {
       // Invalid URL
-      return '';
+      category = '';
     } else if (u.protocol === 'file:') {
-      return 'file:';
+      category = 'file:';
     } else if (u.protocol === location.protocol) {
-      return STYLUS_CATEGORY;
+      category = STYLUS_CATEGORY;
     } else {
       const parts = u.hostname.replace(/\.(?:com?|org)(\.\w{2,3})$/, '$1').split('.');
       const [tld, main = u.hostname, third, fourth] = parts.reverse();
-      const keepTld = !retry && !(
+      const keepTld = retry !== 1 && !(
         tld === 'com' ||
         tld === 'org' && main !== 'userstyles'
       );
@@ -626,214 +443,63 @@ window.addEventListener('showStyles:done', function _() {
         fourth ||
         third && third !== 'www' && third !== 'm'
       );
-      return (keepThird && `${third}.` || '') + main + (keepTld || keepThird ? `.${tld}` : '');
+      category = (keepThird && `${third}.` || '') + main + (keepTld || keepThird ? `.${tld}` : '');
     }
+    return category !== old;
   }
 
-  function sameCategoryNoDupes(result) {
+  async function fetchIndex() {
+    const timer = setTimeout(showSpinner, BUSY_DELAY, dom.list);
+    index = (await download(INDEX_URL, {responseType: 'json'}))
+      .filter(res => res.f === 'uso');
+    clearTimeout(timer);
+    $.remove(':scope > .lds-spinner', dom.list);
+    return index;
+  }
+
+  async function search({retry} = {}) {
+    return retry && !calcCategory({retry})
+      ? []
+      : (index || await fetchIndex()).filter(isResultMatching).sort(comparator);
+  }
+
+  function isResultMatching(res) {
     return (
-      result.subcategory &&
-      !processedResults.some(pr => pr.id === result.id) &&
-      (category !== STYLUS_CATEGORY || /\bStylus\b/i.test(result.name + result.description)) &&
-      category.split('.').includes(result.subcategory.split('.')[0])
+      res.c === category ||
+      searchGlobals && res.c === 'global' && (query.length || calcHaystack(res)._nLC.includes(category))
+    ) && (
+      category === STYLUS_CATEGORY
+       ? /\bStylus\b/.test(res.n)
+       : !query.length || query.every(isInHaystack, calcHaystack(res))
     );
   }
 
-  /**
-   * Fetches the JSON style object from userstyles.org (containing code, sections, updateUrl, etc).
-   * Stores (caches) the JSON within the given result, to avoid unnecessary network usage.
-   * Style JSON is fetched from the /styles/chrome/{id}.json endpoint.
-   * @param {Object} result A search result object from userstyles.org
-   * @returns {Promise