From 7734e3977e83fe1a29f75c35cd3925bcbd023a42 Mon Sep 17 00:00:00 2001
From: dragonwocky <thedragonring.bod@gmail.com>
Date: Tue, 28 Sep 2021 23:22:49 +1000
Subject: [PATCH] searchable categorised mod lists + bypass-preview extension
 update

---
 extension/CHANGELOG.md                        |   9 +-
 extension/api/env.mjs                         |   2 +-
 extension/api/registry.mjs                    |  44 +++-
 extension/api/web.mjs                         |   6 +
 extension/dep/twind-content.mjs               |  43 ++++
 .../client.mjs                                |  56 ++---
 .../mod.json                                  |   2 +-
 .../markdown.css                              |  92 ++++---
 .../menu.css                                  |   4 +
 .../menu.mjs                                  | 227 +++++++++++++++---
 .../mod.json                                  |   2 +-
 .../router.mjs                                |  23 +-
 12 files changed, 362 insertions(+), 148 deletions(-)
 create mode 100644 extension/dep/twind-content.mjs

diff --git a/extension/CHANGELOG.md b/extension/CHANGELOG.md
index 48aec82..0a676cf 100644
--- a/extension/CHANGELOG.md
+++ b/extension/CHANGELOG.md
@@ -8,19 +8,16 @@ a complete rework of the enhancer including a port to the browser as a chrome ex
 - new: cross-environment mod loader structure.
 - new: notifications sourced from an online endpoint for sending global user alerts.
 - new: simplify user installs by depending on the chrome web store and [notion-repackaged](https://github.com/notion-enhancer/notion-repackaged).
-- improved: split the core mod into the theming & menu mods.
+- new: separate menu profiles for mod configurations.
+- improved: split the core mod into separate mods for specific features.
 - improved: theming variables that are more specific, less laggy, and less complicated.
 - improved: merged bracketed-links into tweaks.
+- improved: a redesigned menu with separate categories for mods and a sidebar for configuring options.
 - removed: integrated scrollbar tweak (notion now includes by default).
 - removed: js insert. css insert moved to tweaks mod.
 - removed: majority of layout and font size variables - better to leave former to notion and use `ctrl +` for latter.
 - bugfix: bypass csp restrictions.
 
-#### todo
-
-- improved: a redesigned menu with a better overview of all mods.
-- new: separate menu profiles for mod configurations.
-
 **below this point the enhancer was desktop-only. in v0.11.0 it was been ported to also**
 **run as a chrome extension. changes made to both are indicated above.**
 
diff --git a/extension/api/env.mjs b/extension/api/env.mjs
index 71d8355..2d5dbe4 100644
--- a/extension/api/env.mjs
+++ b/extension/api/env.mjs
@@ -42,7 +42,7 @@ export const reloadTabs = () => chrome.runtime.sendMessage({ action: 'reloadTabs
 /** a notification displayed when the menu is opened for the first time */
 export const welcomeNotification = {
   id: '84e2d49b-c3dc-44b4-a154-cf589676bfa0',
-  // color: 'blue',
+  color: 'purple',
   icon: 'message-circle',
   message: 'Welcome! Come chat with us on Discord.',
   link: 'https://discord.gg/sFWPXtA',
diff --git a/extension/api/registry.mjs b/extension/api/registry.mjs
index e9719eb..872affc 100644
--- a/extension/api/registry.mjs
+++ b/extension/api/registry.mjs
@@ -38,20 +38,25 @@ async function validate(mod) {
   const check = async (
     key,
     value,
-    type,
+    types,
     {
       extension = '',
-      error = `invalid ${key} (${extension ? `${extension} ` : ''}${type}): ${JSON.stringify(
+      error = `invalid ${key} (${extension ? `${extension} ` : ''}${types}): ${JSON.stringify(
         value
       )}`,
       optional = false,
     } = {}
   ) => {
-    const test = await is(
-      type === 'file' && value ? `repo/${mod._dir}/${value}` : value,
-      type,
-      { extension }
-    );
+    let test;
+    for (const type of types.split('|')) {
+      if (type === 'file') {
+        test =
+          value && !value.startsWith('http')
+            ? await is(`repo/${mod._dir}/${value}`, type, { extension })
+            : false;
+      } else test = await is(value, type, { extension });
+      if (test) break;
+    }
     if (!test) {
       if (optional && (await is(value, 'undefined'))) return true;
       if (error) _errors.push({ source: mod._dir, message: error });
@@ -72,11 +77,23 @@ async function validate(mod) {
       return mod.environments.map((tag) => check('environments.env', tag, 'env'));
     }),
     check('description', mod.description, 'string'),
-    // file doubles for url here
-    check('preview', mod.preview, 'file', { optional: true }),
-    check('tags', mod.tags, 'array').then((passed) =>
-      passed ? mod.tags.map((tag) => check('tags.tag', tag, 'string')) : 0
-    ),
+    check('preview', mod.preview, 'file|url', { optional: true }),
+    check('tags', mod.tags, 'array').then((passed) => {
+      if (!passed) return false;
+      const containsCategory = mod.tags.filter((tag) =>
+        ['core', 'extension', 'theme'].includes(tag)
+      ).length;
+      if (!containsCategory) {
+        _errors.push({
+          source: mod._dir,
+          message: `invalid tags (must contain at least one of 'core', 'extension', or 'theme'): ${JSON.stringify(
+            mod.tags
+          )}`,
+        });
+        return false;
+      }
+      return mod.tags.map((tag) => check('tags.tag', tag, 'string'));
+    }),
     check('authors', mod.authors, 'array').then((passed) => {
       if (!passed) return false;
       return mod.authors.map((author) => [
@@ -219,7 +236,8 @@ export const list = async (filter = (mod) => true) => {
         const mod = await getJSON(`repo/${dir}/mod.json`);
         mod._dir = dir;
         if (await validate(mod)) _cache.push(mod);
-      } catch {
+      } catch (e) {
+        console.log(e);
         _errors.push({ source: dir, message: 'invalid mod.json' });
       }
     }
diff --git a/extension/api/web.mjs b/extension/api/web.mjs
index 017e810..dd476f7 100644
--- a/extension/api/web.mjs
+++ b/extension/api/web.mjs
@@ -50,6 +50,12 @@ export const whenReady = (selectors = []) => {
   });
 };
 
+/**
+ * parse the current location search params into a usable form
+ * @returns {map<string,string>} a map of the url search params
+ */
+export const queryParams = () => new URLSearchParams(window.location.search);
+
 /**
  * replace special html characters with escaped versions
  * @param {string} str
diff --git a/extension/dep/twind-content.mjs b/extension/dep/twind-content.mjs
new file mode 100644
index 0000000..ca4c2bf
--- /dev/null
+++ b/extension/dep/twind-content.mjs
@@ -0,0 +1,43 @@
+/**
+ * Twind v0.16.16
+ * @license MIT
+ * @source https://unpkg.com/@twind/content@0.1.0/content.js?module
+ */
+
+import { directive as o } from './twind.mjs';
+var c = new Set([
+    'open-quote',
+    'close-quote',
+    'no-open-quote',
+    'no-close-quote',
+    'normal',
+    'none',
+    'inherit',
+    'initial',
+    'unset',
+  ]),
+  n = (t) => t.join('-'),
+  s = (t) => {
+    switch (t[0]) {
+      case 'data':
+        return `attr(${n(t)})`;
+      case 'attr':
+      case 'counter':
+        return `${t[0]}(${n(t.slice(1))})`;
+      case 'var':
+        return `var(--${n(t)})`;
+      case void 0:
+        return 'attr(data-content)';
+      default:
+        return JSON.stringify(n(t));
+    }
+  },
+  i = (t, { theme: r }) => {
+    let e = Array.isArray(t) ? n(t) : t;
+    return {
+      content:
+        (e && r('content', [e], '')) || (c.has(e) && e) || (Array.isArray(t) ? s(t) : e),
+    };
+  },
+  u = (t, r) => (Array.isArray(t) ? i(t, r) : o(i, t));
+export { u as content };
diff --git a/extension/repo/bypass-preview@cb6fd684-f113-4a7a-9423-8f0f0cff069f/client.mjs b/extension/repo/bypass-preview@cb6fd684-f113-4a7a-9423-8f0f0cff069f/client.mjs
index b8ff0a7..4c640f6 100644
--- a/extension/repo/bypass-preview@cb6fd684-f113-4a7a-9423-8f0f0cff069f/client.mjs
+++ b/extension/repo/bypass-preview@cb6fd684-f113-4a7a-9423-8f0f0cff069f/client.mjs
@@ -1,41 +1,33 @@
 /*
- * notion-enhancer core: bypass-preview
+ * notion-enhancer: bypass-preview
  * (c) 2021 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/)
  * (https://notion-enhancer.github.io/) under the MIT license
  */
 
 'use strict';
 
-import { web } from '../../api/_.mjs';
+export default async function (api, db) {
+  const { web } = api;
+  await web.whenReady();
 
-web.whenReady().then(async () => {
-  const openAsPage = document.querySelector(
-    '.notion-peek-renderer [style*="height: 45px;"] a'
-  );
-  if (openAsPage) openAsPage.click();
-});
-
-function getCurrentPage() {
-  const previewID = location.search
-    .slice(1)
-    .split('&')
-    .map((opt) => opt.split('='))
-    .find((opt) => opt[0] === 'p');
-  if (previewID) return { type: 'preview', id: previewID[1] };
-  return { type: 'page', id: location.pathname.split(/(-|\/)/g).reverse()[0] };
-}
-let lastPage = getCurrentPage();
-web.addDocumentObserver((event) => {
-  const currentPage = getCurrentPage();
-  if (currentPage.id !== lastPage.id || currentPage.type !== lastPage.type) {
-    const openAsPage = document.querySelector(
-      '.notion-peek-renderer [style*="height: 45px;"] a'
-    );
-    if (openAsPage) {
-      if (currentPage.id === lastPage.id && currentPage.type === 'preview') {
-        history.back();
-      } else openAsPage.click();
-    }
-    lastPage = getCurrentPage();
+  let _lastPage = {};
+  function getCurrentPage() {
+    if (web.queryParams().get('p')) return { type: 'preview', id: web.queryParams().get('p') };
+    return { type: 'page', id: location.pathname.split(/(-|\/)/g).reverse()[0] };
   }
-});
+
+  web.addDocumentObserver((event) => {
+    const currentPage = getCurrentPage();
+    if (currentPage.id !== _lastPage.id || currentPage.type !== _lastPage.type) {
+      const openAsPage = document.querySelector(
+        '.notion-peek-renderer [style*="height: 45px;"] a'
+      );
+      if (openAsPage) {
+        if (currentPage.id === _lastPage.id && currentPage.type === 'preview') {
+          history.back();
+        } else openAsPage.click();
+      }
+      _lastPage = getCurrentPage();
+    }
+  });
+}
diff --git a/extension/repo/calendar-scroll@b1c7db33-dfee-489a-a76c-0dd66f7ed29a/mod.json b/extension/repo/calendar-scroll@b1c7db33-dfee-489a-a76c-0dd66f7ed29a/mod.json
index f86b47b..8590126 100644
--- a/extension/repo/calendar-scroll@b1c7db33-dfee-489a-a76c-0dd66f7ed29a/mod.json
+++ b/extension/repo/calendar-scroll@b1c7db33-dfee-489a-a76c-0dd66f7ed29a/mod.json
@@ -13,7 +13,7 @@
     }
   ],
   "js": {
-    "client": ["client.mjs?"]
+    "client": ["client.mjs"]
   },
   "css": {
     "client": ["client.css"]
diff --git a/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/markdown.css b/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/markdown.css
index 1ab8d7c..2391359 100644
--- a/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/markdown.css
+++ b/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/markdown.css
@@ -4,68 +4,64 @@
  * (https://notion-enhancer.github.io/) under the MIT license
  */
 
-::selection {
-  background: var(--theme--selected);
-}
-
-.markdown table {
+.enhancer--markdown table {
   border-spacing: 0;
-  border: 1px solid var(--theme--divider);
+  border: 1px solid var(--theme--ui_divider);
 }
-.markdown table th {
+.enhancer--markdown table th {
   text-align: left;
 }
-.markdown table th,
-.markdown table td {
+.enhancer--markdown table th,
+.enhancer--markdown table td {
   padding: 5px 8px 6px;
-  border: 1px solid var(--theme--divider);
+  border: 1px solid var(--theme--ui_divider);
 }
-.markdown h1 {
-  font-size: var(--theme--font_heading1-size);
+.enhancer--markdown h1 {
+  font-size: 1.875rem;
   margin: 1rem 0 0.5rem 0;
 }
-.markdown h2 {
-  font-size: var(--theme--font_heading2-size);
+.enhancer--markdown h2 {
+  font-size: 1.5rem;
   margin: 1rem 0 0.5rem 0;
 }
-.markdown h3 {
-  font-size: var(--theme--font_heading3-size);
+.enhancer--markdown h3 {
+  font-size: 1.25rem;
   margin: 1rem 0 0.5rem 0;
 }
-.markdown ul,
-.markdown ol {
+.enhancer--markdown ul,
+.enhancer--markdown ol {
   padding-left: 1.25rem;
 }
-.markdown li {
+.enhancer--markdown li {
   margin: 0.4rem 0;
 }
-.markdown ol li {
+.enhancer--markdown ol li {
   padding-left: 0.25rem;
 }
-.markdown blockquote {
+.enhancer--markdown blockquote {
   border-left: 2px solid currentColor;
   padding-left: 0.75rem;
   margin: 0.5rem 0;
 }
-.markdown hr {
-  border: 0.5px solid var(--theme--divider);
+.enhancer--markdown hr {
+  border: 0.5px solid var(--theme--ui_divider);
 }
-.markdown a {
+.enhancer--markdown a {
   opacity: 0.7;
   text-decoration: none;
-  border-bottom: 0.05em solid currentColor;
+  border-bottom: 0.05em solid var(--theme--text_ui);
 }
-.markdown a:hover {
-  opacity: 1;
+.enhancer--markdown a:hover {
+  opacity: 0.9;
 }
 
-.markdown :not(pre) > code {
+.enhancer--markdown :not(pre) > code {
   padding: 0.2em 0.4em;
   border-radius: 3px;
   background: var(--theme--code_inline);
   color: var(--theme--code_inline-text);
 }
-.markdown pre {
+.enhancer--markdown pre {
   padding: 2em 1.25em;
   border-radius: 3px;
   tab-size: 2;
@@ -74,10 +70,10 @@
   background: var(--theme--code);
   color: var(--theme--code_plain);
 }
-.markdown pre,
-.markdown code {
+.enhancer--markdown pre,
+.enhancer--markdown code {
   font-family: var(--theme--font_code);
-  font-size: var(--theme--font_code-size);
+  font-size: 0.796875rem;
   text-align: left;
   word-spacing: normal;
   word-break: normal;
@@ -89,7 +85,7 @@
 /*
  * https://prismjs.com/plugins/inline-color/
  */
-.inline-color-wrapper {
+.enhancer--markdown .inline-color-wrapper {
   /*
 	 * base64 svg (https://stackoverflow.com/a/21626701/7595472 - prevents visual glitches)
 	 * <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2 2">
@@ -108,7 +104,7 @@
   border: 0.5px solid var(--theme--code_plain);
   overflow: hidden;
 }
-.inline-color {
+.enhancer--markdown .inline-color {
   display: block;
   height: 120%;
   width: 120%;
@@ -117,8 +113,8 @@
 /*
  * https://prismjs.com/plugins/match-braces/
  */
-.token.punctuation.brace-hover,
-.token.punctuation.brace-selected {
+.enhancer--markdown .token.punctuation.brace-hover,
+.enhancer--markdown .token.punctuation.brace-selected {
   outline: solid 1px;
 }
 
@@ -126,43 +122,43 @@
  * https://prismjs.com/plugins/show-language/
  * https://prismjs.com/plugins/copy-to-clipboard/
  */
-.code-toolbar {
+.enhancer--markdown .code-toolbar {
   position: relative;
 }
-.code-toolbar .toolbar-item {
+.enhancer--markdown .code-toolbar .toolbar-item {
   position: absolute;
   top: 0.35rem;
   display: inline-block;
   transition: opacity 200ms ease-in-out;
   opacity: 0;
 }
-.code-toolbar .toolbar-item:first-child {
+.enhancer--markdown .code-toolbar .toolbar-item:first-child {
   left: 0.8rem;
 }
-.code-toolbar .toolbar-item:last-child {
+.enhancer--markdown .code-toolbar .toolbar-item:last-child {
   right: 0.8rem;
 }
-.code-toolbar:hover .toolbar-item,
-.code-toolbar:focus-within .toolbar-item {
+.enhancer--markdown .code-toolbar:hover .toolbar-item,
+.enhancer--markdown .code-toolbar:focus-within .toolbar-item {
   opacity: 1;
 }
-.code-toolbar .toolbar-item > * {
+.enhancer--markdown .code-toolbar .toolbar-item > * {
   padding: 0.25rem 0.35rem;
-  color: var(--theme--text_property);
-  font-size: var(--theme--font_ui_small-size);
+  color: var(--theme--text_ui);
+  font-size: 11px;
   font-family: inherit;
 }
-.code-toolbar .toolbar-item .copy-to-clipboard-button {
+.enhancer--markdown .code-toolbar .toolbar-item .copy-to-clipboard-button {
   border: none;
   background: none;
   cursor: pointer;
   border-radius: 3px;
   transition: background 100ms ease-in-out;
 }
-.code-toolbar .toolbar-item .copy-to-clipboard-button:hover {
+.enhancer--markdown .code-toolbar .toolbar-item .copy-to-clipboard-button:hover {
   background: var(--theme--button-hover);
 }
-.code-toolbar .toolbar-item .copy-to-clipboard-button svg {
+.enhancer--markdown .code-toolbar .toolbar-item .copy-to-clipboard-button svg {
   width: 1em;
   margin-right: 0.5em;
 }
diff --git a/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/menu.css b/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/menu.css
index 879e49c..ecf5bee 100644
--- a/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/menu.css
+++ b/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/menu.css
@@ -4,6 +4,10 @@
  * (https://notion-enhancer.github.io/) under the MIT license
  */
 
+::selection {
+  background: var(--theme--accent_blue-selection);
+}
+
 ::-webkit-scrollbar {
   width: 10px;
   height: 10px;
diff --git a/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/menu.mjs b/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/menu.mjs
index afb4a1b..149ab3a 100644
--- a/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/menu.mjs
+++ b/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/menu.mjs
@@ -9,6 +9,8 @@
 // css-in-js for better component generation
 
 import { tw, apply, setup } from '../../dep/twind.mjs';
+import { content } from '../../dep/twind-content.mjs';
+const pseudoContent = content('""');
 
 const mapColorVariables = (color) => ({
   'text': `var(--theme--text_${color})`,
@@ -38,13 +40,16 @@ setup({
         'secondary': 'var(--theme--bg_secondary)',
         'popup': 'var(--theme--bg_popup)',
         'divider': 'var(--theme--ui_divider)',
+        'input': 'var(--theme--ui_input)',
       },
       'icon': 'var(--theme--icon)',
-      'icon_ui': 'var(--theme--icon_ui)',
+      'icon-ui': 'var(--theme--icon_ui)',
       'foreground': 'var(--theme--text)',
-      'foreground_ui': 'var(--theme--text_ui)',
+      'foreground-ui': 'var(--theme--text_ui)',
       'interactive': 'var(--theme--ui_interactive)',
       'interactive-hover': 'var(--theme--ui_interactive-hover)',
+      'tag': 'var(--theme--tag_default)',
+      'tag-text': 'var(--theme--tag_default-text)',
       'toggle': {
         'on': 'var(--theme--ui_toggle-on)',
         'off': 'var(--theme--ui_toggle-off)',
@@ -52,9 +57,12 @@ setup({
       },
       'accent': {
         'blue': 'var(--theme--accent_blue)',
-        'blue-contrast': 'var(--theme--accent_blue-text)',
+        'blue-hover': 'var(--theme--accent_blue-hover)',
+        'blue-active': 'var(--theme--accent_blue-active)',
+        'blue-text': 'var(--theme--accent_blue-text)',
         'red': 'var(--theme--accent_red)',
-        'red-contrast': 'var(--theme--accent_red-text)',
+        'red-hover': 'var(--theme--accent_red-hover)',
+        'red-text': 'var(--theme--accent_red-text)',
       },
       'grey': mapColorVariables('grey'),
       'brown': mapColorVariables('brown'),
@@ -66,6 +74,11 @@ setup({
       'pink': mapColorVariables('pink'),
       'red': mapColorVariables('red'),
     },
+    extend: {
+      maxHeight: {
+        'full-16': 'calc(100% - 4rem)',
+      },
+    },
   },
 });
 
@@ -73,8 +86,9 @@ setup({
 
 import * as api from '../../api/_.mjs';
 import { render } from '../../api/web.mjs';
-const { env, fs, registry, web } = api,
-  db = await registry.db('a6621988-551d-495a-97d8-3c568bca2e9e');
+const { env, fmt, fs, registry, storage, web } = api,
+  db = await registry.db('a6621988-551d-495a-97d8-3c568bca2e9e'),
+  profile = await storage.get(['currentprofile'], 'default');
 
 web.addHotkeyListener(await db.get(['hotkey']), env.focusNotion);
 
@@ -102,25 +116,29 @@ const notifications = {
     const style = tw`p-2 ${
         color
           ? `bg-${color}-tag text-${color}-tag-text border border-${color}-text hover:bg-${color}-text`
-          : 'bg-notion-popup text-foreground hover:bg-interactive-hover border border-notion-divider'
+          : 'bg-tag text-tag-text hover:bg-interactive-hover border border-notion-divider'
       } flex items-center rounded-full mt-3 shadow-md cursor-pointer`,
       $notification = web.render(
         link
           ? web.html`<a href="${web.escape(
               link
             )}" class="${style}" role="alert" target="_blank"></a>`
-          : web.html`<p class="${style}" role="alert"></p>`,
+          : web.html`<p class="${style}" role="alert" tabindex="0"></p>`,
         web.html`<span class="${tw`font-semibold mx-2 flex-auto`}">
             ${message}
           </span>`,
         web.html`${web.icon(icon, { class: tw`fill-current opacity-75 h-4 w-4 mx-2` })}`
-      );
-    $notification.addEventListener('click', async () => {
-      if (id !== undefined) {
-        notifications.cache.push(id);
-        await db.set(['notifications'], notifications.cache);
-      }
-      $notification.remove();
+      ),
+      resolve = async () => {
+        if (id !== undefined) {
+          notifications.cache.push(id);
+          await db.set(['notifications'], notifications.cache);
+        }
+        $notification.remove();
+      };
+    $notification.addEventListener('click', resolve);
+    $notification.addEventListener('keyup', (event) => {
+      if (['Enter', ' '].includes(event.key)) resolve();
     });
     web.render(notifications.$container, $notification);
   },
@@ -149,10 +167,10 @@ if (errors.length) {
 
 // mod config
 
-const $container = web.html`<div class="${tw`flex w-full h-full`}"></div>`,
-  $nav = web.html`<nav class="${tw`px-4 py-3 flex items-center border-b border-notion-divider space-x-4`}"></nav>`,
-  $main = web.html`<main class="${tw`transition px-4 py-3`}">abc</main>`,
-  $footer = web.html`<footer></footer>`,
+const $container = web.html`<div class="${tw`flex w-full h-full overflow-hidden`}"></div>`,
+  $nav = web.html`<nav class="${tw`px-4 py-3 flex items-center border-b border-notion-divider space-x-4 h-16`}"></nav>`,
+  $main = web.html`<main class="${tw`transition px-4 py-3 overflow-y-auto max-h-full-16`}">abc</main>`,
+  // $footer = web.html`<footer></footer>`,
   $sidebar = web.html`<article class="${tw`h-full w-96 bg-notion-secondary border-l border-notion-divider`}"></article>`;
 
 const $notion = web.html`<h1 class="${tw`flex items-center font-semibold text-xl cursor-pointer select-none mr-4`}">
@@ -167,7 +185,8 @@ $notion.children[0].addEventListener('click', env.focusNotion);
 const navItemStyle = tw`px-3 py-2 rounded-md text-sm font-medium bg-interactive hover:bg-interactive-hover`,
   selectedNavItemStyle = tw`px-3 py-2 rounded-md text-sm font-medium ring-1 ring-notion-divider bg-notion-secondary`;
 
-const $extensionsNavItem = web.html`<a href="?view=extensions" class="${navItemStyle}">extensions</a>`,
+const $coreNavItem = web.html`<a href="?view=core" class="${navItemStyle}">core</a>`,
+  $extensionsNavItem = web.html`<a href="?view=extensions" class="${navItemStyle}">extensions</a>`,
   $themesNavItem = web.html`<a href="?view=themes" class="${navItemStyle}">themes</a>`,
   $supportNavItem = web.html`<a href="https://discord.gg/sFWPXtA" class="${navItemStyle}">support</a>`;
 
@@ -176,27 +195,177 @@ web.render(
   web.render(
     $container,
     web.render(
-      web.html`<div class="${tw`h-full flex-auto`}"></div>`,
-      web.render($nav, $notion, $extensionsNavItem, $themesNavItem, $supportNavItem),
-      $main,
-      $footer
+      web.html`<div class="${tw`h-full flex-auto w-min`}"></div>`,
+      web.render(
+        $nav,
+        $notion,
+        $coreNavItem,
+        $extensionsNavItem,
+        $themesNavItem,
+        $supportNavItem
+      ),
+      $main
+      // $footer
     ),
     $sidebar
   )
 );
 
+const components = {
+  preview: (url) => web.html`<img
+    class="${tw`object-cover w-full h-32`}"
+    src="${web.escape(url)}"
+    alt=""
+  />`,
+  title: (title) => {
+    const style = tw`mb-2 text-xl font-semibold tracking-tight flex items-center`;
+    return web.html`<h4 class="${style}"><span>${web.escape(title)}</span></h4>`;
+  },
+  version: (version) => {
+    const style = tw`mt-px ml-3 p-1 font-normal text-xs leading-none bg-tag text-tag-text rounded`;
+    return web.html`<span class="${style}">v${web.escape(version)}</span>`;
+  },
+  tags: (tags) => {
+    if (!tags.length) return '';
+    return web.render(
+      web.html`<p class="${tw`text-foreground-ui mb-2 text-xs`}"></p>`,
+      tags.map((tag) => `#${web.escape(tag)}`).join(' ')
+    );
+  },
+  description: (description) => {
+    return web.html`<p class="${tw`mb-2 text-sm`} enhancer--markdown">
+      ${fmt.md.renderInline(description)}
+    </p>`;
+  },
+  authors: (authors) => {
+    const author = (author) => web.html`<a class="${tw`flex items-center mb-2`}"
+      href="${web.escape(author.homepage)}"
+    >
+      <img class="${tw`inline object-cover w-5 h-5 rounded-full mr-2`}"
+        src="${web.escape(author.avatar)}" alt="${web.escape(author.name)}'s avatar"
+      /> <span>${web.escape(author.name)}</span>
+    </a>`;
+    return web.render(
+      web.html`<p class="${tw`text-sm font-medium`}"></p>`,
+      ...authors.map(author)
+    );
+  },
+  toggle: (
+    checked,
+    {
+      customLabelStyle = '',
+      customCheckStyle = '',
+      customBoxStyle = '',
+      customFeatureStyle = '',
+    }
+  ) => {
+    const checkStyle = tw`appearance-none checked:sibling:(bg-toggle-on after::translate-x-4) ${customCheckStyle}`,
+      boxStyle = tw`w-9 h-5 p-0.5 flex items-center bg-toggle-off rounded-full duration-300 ease-in-out ${customBoxStyle}`,
+      featureStyle = tw`after::(${pseudoContent} w-4 h-4 bg-toggle-feature rounded-full duration-300) ${customFeatureStyle}`,
+      $label = web.html`<label tabindex="0" class="${tw`relative text-sm ${customLabelStyle}`}"></label>`,
+      $input = web.html`<input tabindex="-1" type="checkbox" class="${checkStyle}" ${
+        checked ? 'checked' : ''
+      }/>`;
+    $label.addEventListener('keyup', (event) => {
+      if (['Enter', ' '].includes(event.key)) {
+        $input.checked = !$input.checked;
+      }
+    });
+    return web.render(
+      $label,
+      $input,
+      web.html`<span class="${boxStyle} ${featureStyle}"></span>`
+    );
+  },
+};
+
+components.mod = async (mod) => {
+  const $toggle = components.toggle(await registry.enabled(mod.id), {
+    customLabelStyle: 'flex w-full mt-auto',
+    customCheckStyle: 'ml-auto',
+  });
+  $toggle.addEventListener('change', (event) => {
+    storage.set(['profiles', profile, '_mods', mod.id], event.target.checked);
+  });
+  const style = tw`relative h-full w-full flex flex-col overflow-hidden rounded-lg shadow-lg
+    bg-notion-secondary border border-notion-divider`;
+  return web.render(
+    web.html`<article class="${tw`w-1/3 px-2.5 py-2.5 box-border`}"></article>`,
+    web.render(
+      web.html`<div class="${style}"></div>`,
+      mod.preview
+        ? components.preview(
+            mod.preview.startsWith('http')
+              ? mod.preview
+              : fs.localPath(`repo/${mod._dir}/${mod.preview}`)
+          )
+        : '',
+      web.render(
+        web.html`<div class="${tw`px-4 py-3 flex flex-col flex-auto`}"></div>`,
+        web.render(components.title(mod.name), components.version(mod.version)),
+        components.tags(mod.tags),
+        components.description(mod.description),
+        components.authors(mod.authors),
+        mod.environments.includes(env.name) && !registry.core.includes(mod.id) ? $toggle : ''
+      )
+    )
+  );
+};
+
+components.modList = async (category) => {
+  const $search = web.html`<input type="search" class="${tw`transition block w-full px-3 py-2 text-sm rounded-md flex
+    bg-notion-input text-foreground
+    hover:(ring ring-accent-blue-hover) focus:(outline-none ring ring-accent-blue-active)`}"
+    placeholder="Search ('/' to focus)">`,
+    $list = web.html`<div class="${tw`flex flex-wrap`}"></div>`,
+    mods = await registry.list(
+      (mod) => mod.environments.includes(env.name) && mod.tags.includes(category)
+    );
+  web.addHotkeyListener(['/'], () => $search.focus());
+  $search.addEventListener('input', (event) => {
+    const query = $search.value.toLowerCase(),
+      hiddenStyle = tw`hidden`;
+    for (const $mod of $list.children) {
+      const matches = !query || $mod.innerText.toLowerCase().includes(query);
+      $mod.classList[matches ? 'remove' : 'add'](hiddenStyle);
+    }
+  });
+  for (const mod of mods) {
+    mod.tags = mod.tags.filter((tag) => tag !== category);
+    web.render($list, await components.mod(mod));
+    mod.tags.unshift(category);
+  }
+  return web.render(
+    web.html`<div></div>`,
+    web.render(web.html`<div class="${tw`mx-2.5 my-2.5`}"></div>`, $search),
+    $list
+  );
+};
+
 import * as router from './router.mjs';
 
-router.addView('extensions', () => {
+router.addView('core', async () => {
+  $extensionsNavItem.className = navItemStyle;
+  $themesNavItem.className = navItemStyle;
+  $coreNavItem.className = selectedNavItemStyle;
+  web.empty($main);
+  return web.render($main, await components.modList('core'));
+});
+
+router.addView('extensions', async () => {
+  $coreNavItem.className = navItemStyle;
   $themesNavItem.className = navItemStyle;
   $extensionsNavItem.className = selectedNavItemStyle;
   web.empty($main);
-  web.render($main, 123);
+  return web.render($main, await components.modList('extension'));
 });
-router.addView('themes', () => {
+
+router.addView('themes', async () => {
+  $coreNavItem.className = navItemStyle;
   $extensionsNavItem.className = navItemStyle;
   $themesNavItem.className = selectedNavItemStyle;
   web.empty($main);
-  web.render($main, 456);
+  return web.render($main, await components.modList('theme'));
 });
-router.listen('extensions', $main);
+
+router.loadView('extensions', $main);
diff --git a/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/mod.json b/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/mod.json
index e9248c7..a7ac01b 100644
--- a/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/mod.json
+++ b/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/mod.json
@@ -14,7 +14,7 @@
   ],
   "css": {
     "client": ["client.css"],
-    "menu": ["menu.css"]
+    "menu": ["menu.css", "markdown.css"]
   },
   "js": {
     "client": ["client.mjs"]
diff --git a/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/router.mjs b/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/router.mjs
index 2575fb9..110608b 100644
--- a/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/router.mjs
+++ b/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/router.mjs
@@ -8,8 +8,6 @@
 
 import { web } from '../../api/_.mjs';
 
-export const queryParams = () => new URLSearchParams(window.location.search);
-
 let _defaultView = '',
   $viewRoot;
 const _views = new Map();
@@ -26,7 +24,7 @@ function router(event) {
   const anchor = event.path.find((anchor) => anchor.nodeName === 'A');
   if (location.search !== anchor.getAttribute('href')) {
     window.history.pushState(null, null, anchor.href);
-    listen();
+    loadView();
   }
 }
 function navigator(event) {
@@ -38,33 +36,24 @@ function navigator(event) {
   history.replaceState({ search: location.search, hash }, null, `#${hash}`);
 }
 
-export async function listen(defaultView = null, $elem = null) {
+export async function loadView(defaultView = null) {
   if (defaultView) _defaultView = defaultView;
-  if ($elem) $viewRoot = $elem;
-  if (!$viewRoot) throw new Error('no view root set.');
   if (!_defaultView) throw new Error('no view root set.');
 
-  const query = queryParams(),
+  const query = web.queryParams(),
     fallbackView = () => {
       window.history.replaceState(null, null, `?view=${_defaultView}`);
-      return listen();
+      return loadView();
     };
   if (!query.get('view') || document.body.dataset.view !== query.get('view')) {
     if (_views.get(query.get('view'))) {
-      $viewRoot.style.opacity = 0;
-      const loadFunc = _views.get(query.get('view'))();
-      setTimeout(async () => {
-        await loadFunc;
-        requestAnimationFrame(() => {
-          $viewRoot.style.opacity = '';
-        });
-      }, 200);
+      await _views.get(query.get('view'))();
     } else return fallbackView();
   } else return fallbackView();
 }
 
 window.addEventListener('popstate', (event) => {
-  if (event.state) listen();
+  if (event.state) loadView();
   document.getElementById(location.hash.slice(1))?.scrollIntoView(true);
   document.documentElement.scrollTop = 0;
 });