diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..dfe0770
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+# Auto detect text files and perform LF normalization
+* text=auto
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..f3f8345
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,71 @@
+name: Deploy to GitHub Pages
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: "pages"
+ cancel-in-progress: true
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: '18'
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Clean dist
+ run: rm -rf dist
+
+ - name: Build
+ env:
+ NODE_ENV: production
+ BASE_URL: ${{ github.event.repository.name }}
+ run: npm run build
+
+ - name: Verify bundle location
+ run: |
+ echo "Checking bundle.js location..."
+ if [ ! -f "dist/js/bundle.js" ]; then
+ echo "Error: bundle.js not found in dist/js/"
+ echo "Contents of dist directory:"
+ ls -R dist/
+ exit 1
+ fi
+ echo "Bundle.js found at correct location"
+
+ - name: Setup Pages
+ uses: actions/configure-pages@v3
+
+ - name: Upload artifact
+ uses: actions/upload-pages-artifact@v2
+ with:
+ path: dist
+
+ deploy:
+ needs: build
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v2
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4f4c46c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,140 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+.pnpm-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+*.lcov
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# Snowpack dependency directory (https://snowpack.dev/)
+web_modules/
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional stylelint cache
+.stylelintcache
+
+# Microbundle cache
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variable files
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+.parcel-cache
+
+# Next.js build output
+.next
+out
+
+# Nuxt.js build / generate output
+.nuxt
+dist
+
+# Build artifacts
+bundle.js
+
+# Gatsby files
+.cache/
+# Comment in the public line in if your project uses Gatsby and not Next.js
+# https://nextjs.org/blog/next-9-1#public-directory-support
+# public
+
+# vuepress build output
+.vuepress/dist
+
+# vuepress v2.x temp and cache directory
+.temp
+.cache
+
+# Docusaurus cache and generated files
+.docusaurus
+
+# Serverless directories
+.serverless/
+
+# FuseBox cache
+.fusebox/
+
+# DynamoDB Local files
+.dynamodb/
+
+# TernJS port file
+.tern-port
+
+# Stores VSCode versions used for testing VSCode extensions
+.vscode-test
+
+# yarn v2
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnp.*
+*.vsix
+.vscode/settings.json
+*.pem
+*.crx
+*.zip
+/keywords/categories
+repomix-output.txt
diff --git a/.vscodeignore b/.vscodeignore
new file mode 100644
index 0000000..f369b5e
--- /dev/null
+++ b/.vscodeignore
@@ -0,0 +1,4 @@
+.vscode/**
+.vscode-test/**
+.gitignore
+vsc-extension-quickstart.md
diff --git a/CNAME b/CNAME
new file mode 100644
index 0000000..f191f78
--- /dev/null
+++ b/CNAME
@@ -0,0 +1 @@
+mutesky.app
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..036218e
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 Chrissy LeMaire
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4dd7e5d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,51 @@
+# Mutesky - Bulk manage Bluesky mutes with pre-populated keyword lists
+
+ Mutesky is a web app that gives you granular control over what appears in your Bluesky feed through curated keyword collections. Working directly with Bluesky's native mute system, it provides an intuitive interface to filter out content you'd rather not see. With over 1,400 pre-populated keywords organized into 20+ smart categories, Mutesky makes content filtering both easy and effective.
+
+
+
+## Key Features
+
+- **Instant Setup**: Pre-populated with 1,400+ keywords
+- **Smart Categories**: 20+ organized topic groups from politics to climate
+- **Two Ways to Filter**:
+ - Simple Mode: Quick topic-based filtering
+ - Advanced Mode: Fine-tune individual keywords
+- **Smart Search**: Filter keywords to find related terms, then enable/disable them all at once or individually
+- **Real-Time**: Changes hit your feed instantly
+- **Zero Storage**: Works directly with Bluesky's mute system - we never store your data
+
+## Get Started
+
+1. Visit [mutesky.app](https://mutesky.app)
+2. Sign in with your Bluesky account (ex. username.bsky.social)
+3. Pick your topics or dive into keyword management
+4. Click "Mute" to apply changes
+
+## Made With
+
+- Frontend: Vanilla JS, HTML, CSS
+- Integration: Bluesky/ATP API
+- Deployment: GitHub Pages
+- Build: Webpack
+
+## Local Development
+
+```bash
+# Install dependencies
+npm install
+
+# Start dev server
+npm run dev
+
+# Create production build
+npm run build
+```
+
+## Related Projects
+
+Check out [US Politician Labeler](https://bsky.app/profile/did:plc:bxnuth7kms5l57v2milp5gb3)
+
+## Coming Soon
+
+AI-powered dynamic keyword updates: An optional service that automatically identifies and updates mute keywords hourly based on emerging trends and topics.
diff --git a/callback.html b/callback.html
new file mode 100644
index 0000000..45a71ad
--- /dev/null
+++ b/callback.html
@@ -0,0 +1,31 @@
+
+
+
+ Bluesky Auth Callback
+
+
+
+
+
+
+
+
+
+
+
Authentication Successful
+
✨ Rendering keywords
+
+
+
Return to app
+
+
+
+
+
+
diff --git a/client-metadata.json b/client-metadata.json
new file mode 100644
index 0000000..a6b1bc7
--- /dev/null
+++ b/client-metadata.json
@@ -0,0 +1,19 @@
+{
+ "client_id": "https://mutesky.app/client-metadata.json",
+ "client_name": "Mutesky",
+ "client_uri": "https://mutesky.app",
+ "redirect_uris": [
+ "https://mutesky.app/callback.html"
+ ],
+ "scope": "atproto transition:generic",
+ "grant_types": [
+ "authorization_code",
+ "refresh_token"
+ ],
+ "response_types": [
+ "code"
+ ],
+ "token_endpoint_auth_method": "none",
+ "application_type": "web",
+ "dpop_bound_access_tokens": true
+}
diff --git a/css/base.css b/css/base.css
new file mode 100644
index 0000000..2172b69
--- /dev/null
+++ b/css/base.css
@@ -0,0 +1,172 @@
+/* Import Inter font */
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
+
+/* Theme Variables */
+:root {
+ /* Light Theme Colors */
+ --primary: #0085ff;
+ --primary-rgb: 0, 133, 255;
+ --primary-hover: #0066cc;
+ --primary-light: #e6f3ff;
+ --surface: #ffffff;
+ --background: #eef2f6;
+ --background-light: #ffffff;
+ --text: #000000;
+ --text-secondary: #536471;
+ --border: #e4e6eb;
+ --shadow: rgba(0, 0, 0, 0.08);
+ --disabled: #e4e6eb;
+ --danger: #f4212e;
+ --error: #dc3545;
+ --like: #f91880;
+ --repost: #00ba7c;
+ --link: #0085ff;
+
+ /* Font Size Variables */
+ --base-font-size: 15px;
+ --font-scale: 1;
+ --font-size-small: calc(0.867rem * var(--font-scale)); /* 13px equivalent */
+ --font-size-default: calc(1rem * var(--font-scale)); /* 15px equivalent */
+ --font-size-large: calc(1.133rem * var(--font-scale)); /* 17px equivalent */
+
+ /* Gradients */
+ --surface-gradient: linear-gradient(180deg,
+ rgba(255, 255, 255, 0.05) 0%,
+ rgba(255, 255, 255, 0.02) 100%);
+
+ /* Shadows */
+ --card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+ --hover-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
+
+ /* Spacing */
+ --spacing-xs: 4px;
+ --spacing-sm: 8px;
+ --spacing-md: 16px;
+ --spacing-lg: 24px;
+ --spacing-xl: 32px;
+
+ /* Layout */
+ --branding-width: 42%;
+ --content-width: 58%;
+ --min-width: 320px;
+
+ /* Other */
+ --border-radius: 8px;
+ --button-transition: 200ms ease-in-out;
+}
+
+/* Dark Theme */
+[data-theme="dark"] {
+ --surface: #15202b;
+ --background: #1e2732;
+ --background-light: #1a2634;
+ --text: #f7f9f9;
+ --text-secondary: #8b98a5;
+ --border: #38444d;
+ --shadow: rgba(255, 255, 255, 0.08);
+ --primary-light: rgba(0, 133, 255, 0.1);
+
+ /* Dark theme specific */
+ --surface-gradient: linear-gradient(180deg,
+ rgba(255, 255, 255, 0.03) 0%,
+ rgba(255, 255, 255, 0.01) 100%);
+ --card-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+ --hover-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
+}
+
+/* Reset & Base Styles */
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+html {
+ font-size: var(--base-font-size);
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ color: var(--text);
+ background: var(--background);
+ font-size: var(--font-size-default);
+ min-height: 100vh;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/* Links */
+a {
+ color: var(--link);
+ text-decoration: none;
+}
+
+a:hover {
+ text-decoration: underline;
+}
+
+/* Loading Overlay */
+.loading-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: var(--background);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 9999;
+ opacity: 1;
+ transition: opacity 0.3s ease-out;
+}
+
+.loading-overlay.hidden {
+ opacity: 0;
+ pointer-events: none;
+}
+
+.loading-spinner {
+ width: 40px;
+ height: 40px;
+ border: 3px solid var(--border);
+ border-top-color: var(--primary);
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* Utility Classes */
+.hidden {
+ display: none !important;
+}
+
+.visible {
+ display: block !important;
+}
+
+/* System Theme Detection */
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) {
+ /* Default to dark theme when system prefers dark */
+ --surface: #15202b;
+ --background: #1e2732;
+ --background-light: #1a2634;
+ --text: #f7f9f9;
+ --text-secondary: #8b98a5;
+ --border: #38444d;
+ --shadow: rgba(255, 255, 255, 0.08);
+ --primary-light: rgba(0, 133, 255, 0.1);
+ --surface-gradient: linear-gradient(180deg,
+ rgba(255, 255, 255, 0.03) 0%,
+ rgba(255, 255, 255, 0.01) 100%);
+ --card-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+ --hover-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
+ }
+}
diff --git a/css/callback.css b/css/callback.css
new file mode 100644
index 0000000..5253eef
--- /dev/null
+++ b/css/callback.css
@@ -0,0 +1,115 @@
+/* Remove duplicate theme variables since they're in base.css */
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif;
+ line-height: 1.5;
+ color: var(--text);
+ background: var(--background);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 100vh;
+ margin: 0;
+ font-size: 16px;
+}
+
+.callback-container {
+ background: var(--surface);
+ padding: clamp(16px, 4vw, 32px);
+ border-radius: var(--border-radius);
+ box-shadow: 0 2px 8px var(--shadow);
+ text-align: center;
+ width: clamp(280px, 90%, 400px);
+ margin: 16px;
+}
+
+h2 {
+ color: var(--text);
+ margin-bottom: 16px;
+ font-size: clamp(1.5rem, 5vw, 2rem);
+ line-height: 1.2;
+}
+
+.status-text {
+ color: var(--text-secondary);
+ margin: 16px 0;
+ font-size: clamp(1rem, 4vw, 1.125rem);
+}
+
+.progress-container {
+ background: var(--background);
+ border-radius: 8px;
+ height: 8px;
+ overflow: hidden;
+ margin: 24px 0;
+ position: relative;
+}
+
+.progress-bar {
+ background: var(--primary);
+ height: 100%;
+ width: 0%;
+ border-radius: 8px;
+ animation: progress 2s ease-out forwards;
+}
+
+.error-message {
+ color: var(--error);
+ margin: 16px 0;
+ display: none;
+ font-size: clamp(0.875rem, 3.5vw, 1rem);
+}
+
+.home-link {
+ color: var(--primary);
+ text-decoration: none;
+ display: inline-block;
+ margin-top: 16px;
+ font-size: clamp(1rem, 4vw, 1.125rem);
+ padding: 8px 16px;
+ border-radius: var(--border-radius);
+ transition: background-color 0.2s ease;
+}
+
+.home-link:hover {
+ color: var(--primary-hover);
+ text-decoration: none;
+ background-color: var(--surface-hover);
+}
+
+/* Animations */
+@keyframes progress {
+ 0% { width: 0%; }
+ 100% { width: 100%; }
+}
+
+.loading-dots::after {
+ content: '';
+ animation: dots 2s infinite;
+}
+
+@keyframes dots {
+ 0%, 20% { content: '.'; }
+ 40% { content: '..'; }
+ 60%, 100% { content: '...'; }
+}
+
+/* Error states */
+.error .progress-container,
+.error .loading-dots {
+ display: none;
+}
+
+.error .status-text {
+ display: none;
+}
+
+.error .error-message {
+ display: block;
+}
+
+/* Theme transition class */
+.js-loaded {
+ visibility: visible;
+ transition: color 0.2s ease, background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
+}
diff --git a/css/components.css b/css/components.css
new file mode 100644
index 0000000..583881e
--- /dev/null
+++ b/css/components.css
@@ -0,0 +1,2 @@
+/* Import all component styles */
+@import 'components/index.css';
diff --git a/css/components/advanced-mode.css b/css/components/advanced-mode.css
new file mode 100644
index 0000000..8ae7ca7
--- /dev/null
+++ b/css/components/advanced-mode.css
@@ -0,0 +1,154 @@
+/* Advanced Mode Layout */
+.advanced-layout {
+ display: flex;
+ height: calc(100vh - 72px - 40px); /* Subtract footer height */
+ overflow: hidden;
+ position: fixed;
+ top: 72px;
+ left: 0;
+ right: 0;
+ background: var(--background);
+}
+
+.advanced-filter-manager {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.categories-grid {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-lg);
+ padding: var(--spacing-lg);
+ overflow-y: auto;
+ overflow-x: hidden;
+ border-top-right-radius: var(--border-radius);
+ border-bottom-right-radius: var(--border-radius);
+ background: var(--surface);
+ margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xs) 0;
+ box-shadow: -1px 0 2px rgba(0, 0, 0, 0.05);
+ border-left: 1px solid var(--border);
+}
+
+.category-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: var(--spacing-md);
+}
+
+/* Hide checkboxes in category titles only in the categories-grid (right side) */
+.categories-grid .category-title input[type="checkbox"] {
+ display: none;
+}
+
+/* Category Items */
+.category-item {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-md);
+ padding: var(--spacing-sm) var(--spacing-md);
+ margin-bottom: var(--spacing-xs);
+ border-radius: var(--border-radius);
+ cursor: pointer;
+ transition: var(--transition);
+ border: 1px solid transparent;
+}
+
+/* Updated hover effect - very light gray background only */
+.category-item:hover {
+ background: rgba(128, 128, 128, 0.05);
+}
+
+/* Remove underline from category items and their links */
+.category-item,
+.category-item a,
+.category-item:hover,
+.category-item:hover a,
+.category-item a:hover {
+ text-decoration: none !important;
+}
+
+.category-name {
+ flex-grow: 1;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.category-count {
+ color: var(--text-secondary);
+ font-size: 13px;
+ padding: var(--spacing-xs) var(--spacing-sm);
+ background: var(--background-light);
+ border: 1px solid var(--border);
+ border-radius: var(--border-radius);
+ min-width: 60px;
+ text-align: center;
+}
+
+/* Keywords Section */
+.keywords-section {
+ flex: 2;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ padding: var(--spacing-lg);
+ overflow-y: auto;
+ overflow-x: hidden;
+ -webkit-overflow-scrolling: touch;
+}
+
+.keywords-container {
+ column-count: 3;
+ column-gap: 24px;
+ margin-top: 16px;
+}
+
+.last-updated {
+ position: sticky;
+ bottom: 0;
+ background: var(--surface);
+ padding: var(--spacing-sm) 0;
+ margin-top: var(--spacing-lg);
+ text-align: right;
+ border-top: 1px solid var(--border);
+}
+
+/* Media Queries */
+@media (max-width: 768px) {
+ .advanced-layout {
+ position: absolute;
+ height: auto;
+ min-height: calc(100vh - 72px - 40px); /* Subtract footer height */
+ overflow: visible;
+ overflow-x: hidden;
+ }
+
+ .advanced-filter-manager {
+ height: auto;
+ min-height: calc(100vh - 72px - 40px); /* Subtract footer height */
+ overflow-x: hidden;
+ }
+
+ .categories-grid,
+ .keywords-section {
+ height: auto;
+ min-height: 100%;
+ overflow-y: visible;
+ overflow-x: hidden;
+ }
+
+ .keywords-container {
+ column-count: 1;
+ }
+
+ /* Enable native scrolling on mobile */
+ body {
+ overflow-y: auto;
+ overflow-x: hidden;
+ -webkit-overflow-scrolling: touch;
+ }
+}
diff --git a/css/components/app-intro.css b/css/components/app-intro.css
new file mode 100644
index 0000000..4262339
--- /dev/null
+++ b/css/components/app-intro.css
@@ -0,0 +1,15 @@
+.app-intro {
+ max-width: 900px;
+ margin: 3.5rem auto 4rem;
+ padding: 0 var(--spacing-md);
+}
+
+.app-intro p {
+ font-size: 1.125rem;
+ line-height: 1.7;
+ color: var(--text-primary);
+ text-align: left;
+ margin-bottom: var(--spacing-xl);
+ letter-spacing: 0.01em;
+ opacity: 0.9;
+}
diff --git a/css/components/auth.css b/css/components/auth.css
new file mode 100644
index 0000000..8d9674e
--- /dev/null
+++ b/css/components/auth.css
@@ -0,0 +1,107 @@
+/* Auth Container */
+.bsky-connect {
+ background: var(--surface);
+ border-radius: 20px;
+ padding: 24px;
+ border: 1px solid var(--border);
+ position: relative;
+ overflow: visible;
+}
+
+.bsky-connect::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: var(--surface-gradient);
+ opacity: 0;
+ transition: opacity 0.2s ease-in-out;
+ border-radius: inherit;
+ pointer-events: none; /* Add this to fix click blocking */
+}
+
+.bsky-connect:hover::before {
+ opacity: 1;
+}
+
+/* Sign In Header */
+.sign-in-title {
+ font-size: 20px;
+ font-weight: 800;
+ margin: 0;
+ letter-spacing: -0.01em;
+ line-height: 1.3;
+}
+
+/* Auth Container Layout */
+.bsky-auth-container {
+ display: flex;
+ flex-direction: column;
+ margin-top: 24px;
+ gap: 24px;
+}
+
+/* Auth Section */
+.auth-section {
+ width: 100%;
+}
+
+/* Explanatory Text */
+.bsky-auth-message {
+ color: var(--text-secondary);
+ line-height: 1.5;
+ margin-bottom: 16px;
+}
+
+/* Input & Button Overrides */
+.bsky-handle-input,
+.btn-auth {
+ min-height: 48px;
+ height: auto;
+ padding: 14px 16px;
+ line-height: 1.3;
+ border-radius: 12px;
+ width: 100%;
+}
+
+.bsky-handle-input {
+ background: var(--background);
+ padding-left: 36px;
+ border: 1px solid rgba(0, 122, 255, 0.15);
+ transition: border-color 0.2s ease-in-out;
+}
+
+/* Error States */
+.bsky-auth-message.error {
+ color: var(--error);
+ background: rgba(var(--error-rgb), 0.1);
+ border-radius: 12px;
+ padding: 12px;
+ margin: 8px 0;
+}
+
+@media (max-width: 768px) {
+ .bsky-connect {
+ padding: 20px;
+ border-radius: 16px;
+ }
+
+ .bsky-auth-container {
+ gap: 20px;
+ }
+}
+
+@media (max-width: 480px) {
+ .bsky-connect {
+ padding: 16px;
+ border-radius: 12px;
+ }
+
+ .bsky-auth-container {
+ gap: 16px;
+ margin-top: 20px;
+ }
+
+ .bsky-auth-message {
+ font-size: 14px;
+ }
+}
diff --git a/css/components/buttons.css b/css/components/buttons.css
new file mode 100644
index 0000000..ed1adf2
--- /dev/null
+++ b/css/components/buttons.css
@@ -0,0 +1,116 @@
+/* Button Base Styles */
+.btn-auth,
+.btn-refresh,
+.btn-mute-keywords,
+.nav-mute-button {
+ padding: 8px 16px;
+ border-radius: 9999px; /* Bluesky uses fully rounded buttons */
+ cursor: pointer;
+ transition: var(--transition);
+ border: none;
+ font-size: 15px;
+ font-weight: 600;
+ line-height: 20px;
+ text-align: center;
+}
+
+/* Auth Button */
+.btn-auth {
+ background: var(--primary);
+ color: #ffffff;
+ padding: 12px 24px;
+ min-width: 120px;
+}
+
+.btn-auth-small {
+ padding: 8px 16px;
+ margin: 0;
+}
+
+.btn-auth:hover {
+ background: var(--primary-hover);
+}
+
+/* Refresh Button */
+.btn-refresh {
+ background: transparent;
+ color: var(--text);
+ border: 1px solid var(--border);
+ font-weight: 500;
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+}
+
+.btn-refresh:hover {
+ background: var(--background);
+ border-color: var(--border);
+}
+
+.btn-refresh:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ background: transparent;
+ border-color: var(--border);
+ color: var(--text-secondary);
+}
+
+/* Spinning animation for refresh button */
+.btn-refresh.spinning {
+ position: relative;
+}
+
+.btn-refresh.spinning::before {
+ content: '↻';
+ display: inline-block;
+ margin-right: 4px;
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+
+/* Mute Buttons */
+.btn-mute-keywords,
+.nav-mute-button {
+ background: var(--primary);
+ color: #ffffff;
+ display: none;
+}
+
+.btn-mute-keywords.visible,
+.nav-mute-button.visible {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+}
+
+.btn-mute-keywords:hover,
+.nav-mute-button:hover {
+ background: var(--primary-hover);
+}
+
+/* Secondary Button Style */
+.btn-secondary {
+ background: transparent;
+ color: var(--text);
+ border: 1px solid var(--border);
+}
+
+.btn-secondary:hover {
+ background: var(--background);
+}
+
+/* Outline Button Style */
+.btn-outline {
+ background: transparent;
+ color: var(--primary);
+ border: 1px solid var(--primary);
+}
+
+.btn-outline:hover {
+ background: var(--primary-light);
+}
diff --git a/css/components/cards/base.css b/css/components/cards/base.css
new file mode 100644
index 0000000..56257bd
--- /dev/null
+++ b/css/components/cards/base.css
@@ -0,0 +1,29 @@
+/* Card Base Styles */
+.feature-card,
+.context-card,
+.category-section {
+ background: var(--surface);
+ padding: 16px;
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ transition: var(--transition);
+}
+
+/* Hover state for cards */
+.feature-card:hover,
+.context-card:hover {
+ background: var(--background);
+}
+
+/* Avatar */
+.avatar {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ object-fit: cover;
+}
+
+.avatar-small {
+ width: 32px;
+ height: 32px;
+}
diff --git a/css/components/cards/category.css b/css/components/cards/category.css
new file mode 100644
index 0000000..5f60fbd
--- /dev/null
+++ b/css/components/cards/category.css
@@ -0,0 +1,60 @@
+/* Category Section Specific */
+.category-section {
+ margin-bottom: 16px;
+}
+
+.category-section .category-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 12px;
+}
+
+.category-section .category-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.category-section .category-title h3 {
+ margin: 0;
+ font-size: 20px;
+ font-weight: 700;
+ color: var(--text);
+}
+
+.category-section .count {
+ color: var(--text-secondary);
+ font-size: 15px;
+}
+
+.category-section .keywords-container {
+ columns: 3;
+ column-gap: 24px;
+ padding: 8px 0;
+}
+
+/* Content Section */
+.content-section {
+ padding: 16px;
+ border-bottom: 1px solid var(--border);
+}
+
+.content-section:last-child {
+ border-bottom: none;
+}
+
+/* List Items */
+.list-item {
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--border);
+ transition: var(--transition);
+}
+
+.list-item:last-child {
+ border-bottom: none;
+}
+
+.list-item:hover {
+ background: var(--background);
+}
diff --git a/css/components/cards/components.css b/css/components/cards/components.css
new file mode 100644
index 0000000..b431a9c
--- /dev/null
+++ b/css/components/cards/components.css
@@ -0,0 +1,46 @@
+/* Card Header */
+.card-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 12px;
+}
+
+.card-title {
+ font-size: 15px;
+ font-weight: 700;
+ color: var(--text);
+ margin: 0;
+}
+
+.card-subtitle {
+ font-size: 13px;
+ color: var(--text-secondary);
+ margin: 0;
+}
+
+/* Card Content */
+.card-content {
+ font-size: 15px;
+ line-height: 1.5;
+ color: var(--text);
+}
+
+/* Card Footer */
+.card-footer {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid var(--border);
+}
+
+/* Stats Display */
+.stats {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--text-secondary);
+ font-size: 13px;
+}
diff --git a/css/components/cards/index.css b/css/components/cards/index.css
new file mode 100644
index 0000000..49157ad
--- /dev/null
+++ b/css/components/cards/index.css
@@ -0,0 +1,5 @@
+/* Import all card-related styles */
+@import 'base.css';
+@import 'landing-feature.css';
+@import 'category.css';
+@import 'components.css';
diff --git a/css/components/cards/landing-feature.css b/css/components/cards/landing-feature.css
new file mode 100644
index 0000000..53308fd
--- /dev/null
+++ b/css/components/cards/landing-feature.css
@@ -0,0 +1,68 @@
+/* Landing Page Feature Cards */
+.landing-feature-card {
+ background: var(--surface);
+ padding: 24px;
+ border: 1px solid var(--border);
+ border-radius: 20px;
+ display: flex;
+ align-items: flex-start;
+ gap: 16px;
+ position: relative;
+ overflow: hidden;
+}
+
+.landing-feature-card .feature-icon {
+ font-size: 28px;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 48px;
+ height: 48px;
+ background: var(--background);
+ border-radius: 14px;
+}
+
+.landing-feature-card .feature-text {
+ flex: 1;
+ min-width: 0;
+}
+
+.landing-feature-card .feature-text h3 {
+ font-size: 20px;
+ font-weight: 700;
+ color: var(--primary);
+ margin: 0 0 8px 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ letter-spacing: -0.01em;
+}
+
+.landing-feature-card .feature-text p {
+ font-size: 15px;
+ line-height: 1.5;
+ color: var(--text-secondary);
+ margin: 0;
+}
+
+/* Media Queries */
+@media (max-width: 768px) {
+ .landing-feature-card {
+ padding: 20px;
+ }
+
+ .landing-feature-card .feature-icon {
+ width: 40px;
+ height: 40px;
+ font-size: 24px;
+ }
+
+ .landing-feature-card .feature-text h3 {
+ font-size: 18px;
+ }
+
+ .landing-feature-card .feature-text p {
+ font-size: 14px;
+ }
+}
diff --git a/css/components/context-builder.css b/css/components/context-builder.css
new file mode 100644
index 0000000..cc7fe05
--- /dev/null
+++ b/css/components/context-builder.css
@@ -0,0 +1,57 @@
+/* Simple Mode - Context Builder */
+.context-builder {
+ height: calc(100vh - 72px - 40px); /* Match advanced mode height (accounting for header and footer) */
+ overflow-y: auto; /* Enable native scrollbar behavior - only appears when needed */
+ overflow-x: hidden;
+ position: fixed;
+ top: 72px; /* Match header height */
+ left: 0;
+ right: 0;
+ background: var(--background);
+ padding: var(--spacing-md) 0; /* Reduced top padding for tighter layout */
+}
+
+/* Mobile-specific adjustments */
+@media (max-width: 768px) {
+ .context-builder {
+ top: 56px; /* Reduced header height on mobile */
+ height: calc(100vh - 56px - 32px); /* Adjusted height for mobile */
+ padding: var(--spacing-xs) 0; /* Minimal padding on mobile */
+ }
+}
+
+.context-builder-inner {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 0 var(--spacing-lg);
+ padding-top: 0; /* Remove additional top padding */
+}
+
+.context-selector {
+ margin-bottom: var(--spacing-xl);
+}
+
+.context-card {
+ border: 1px solid var(--border);
+ cursor: pointer;
+ transition: var(--transition);
+ background: var(--surface);
+}
+
+.context-card:hover {
+ border-color: var(--primary);
+}
+
+.context-card.selected {
+ background: var(--primary-light);
+ border-color: var(--primary);
+}
+
+.context-card h3 {
+ margin-bottom: var(--spacing-sm);
+}
+
+/* Hide bottom spacing div since we're using padding */
+.bottom-spacing {
+ display: none;
+}
diff --git a/css/components/exceptions.css b/css/components/exceptions.css
new file mode 100644
index 0000000..e341ca9
--- /dev/null
+++ b/css/components/exceptions.css
@@ -0,0 +1,51 @@
+/* Exceptions Panel */
+.exceptions-panel {
+ margin-top: var(--spacing-lg);
+ display: none;
+}
+
+.exceptions-panel.visible {
+ display: block;
+}
+
+.exception-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--spacing-sm);
+ margin-top: var(--spacing-md);
+}
+
+.exception-tag {
+ padding: var(--spacing-sm) var(--spacing-md);
+ background: var(--background);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: var(--border-radius);
+ cursor: pointer;
+ transition: var(--transition);
+ font-size: 14px;
+}
+
+.exception-tag:hover {
+ background: var(--primary-light);
+ color: var(--primary);
+ border-color: var(--primary);
+}
+
+.exception-tag.selected {
+ background: var(--primary);
+ color: white;
+ border-color: var(--primary);
+}
+
+/* Media Queries */
+@media (max-width: 768px) {
+ .exception-tags {
+ flex-direction: column;
+ }
+
+ .exception-tag {
+ width: 100%;
+ text-align: center;
+ }
+}
diff --git a/css/components/footer.css b/css/components/footer.css
new file mode 100644
index 0000000..931a3bd
--- /dev/null
+++ b/css/components/footer.css
@@ -0,0 +1,141 @@
+.app-footer {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ padding: 8px 16px;
+ background: var(--surface);
+ border-top: 1px solid var(--border);
+ font-size: 14px;
+ color: var(--text);
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ align-items: center;
+ z-index: 100;
+}
+
+.app-footer p {
+ margin: 0;
+}
+
+.footer-left {
+ text-align: left;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ visibility: visible;
+}
+
+/* Hide footer-left content when it would overflow */
+.footer-left:not(:hover):has(> *) {
+ max-width: min-content;
+}
+
+.footer-center {
+ text-align: center;
+}
+
+.footer-right {
+ text-align: right;
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+}
+
+.app-footer a {
+ color: var(--primary);
+ text-decoration: none;
+}
+
+.app-footer a:hover {
+ text-decoration: underline;
+}
+
+/* Theme Toggle Switch */
+.theme-toggle {
+ position: relative;
+ width: 64px;
+ height: 32px;
+ border-radius: 50px;
+ border: none;
+ background: none;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 4px 8px;
+ outline: none;
+ background-color: var(--background);
+ border: 2px solid var(--border);
+ transition: all 0.3s ease;
+ margin-left: 8px;
+}
+
+.theme-toggle:hover {
+ border-color: var(--primary);
+}
+
+.theme-toggle::before {
+ content: "";
+ position: absolute;
+ left: 4px;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ background: var(--primary);
+ transition: transform 0.3s ease, background-color 0.3s ease;
+}
+
+.theme-toggle.dark::before {
+ transform: translateX(28px);
+}
+
+.theme-toggle .toggle-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1;
+ color: var(--text);
+ font-size: 14px;
+ line-height: 1;
+ width: 20px;
+ height: 20px;
+ position: relative;
+}
+
+.theme-toggle .sun-icon {
+ margin-right: auto;
+ transform: translateX(-2px);
+}
+
+.theme-toggle .moon-icon {
+ margin-left: auto;
+ transform: translateX(1px);
+}
+
+/* Hide emoji in dark mode */
+.theme-toggle.dark .sun-icon {
+ opacity: 0.5;
+}
+
+/* Hide emoji in light mode */
+.theme-toggle:not(.dark) .moon-icon {
+ opacity: 0.5;
+}
+
+/* Mobile styles */
+@media (max-width: 768px) {
+ .theme-toggle,
+ .footer-left {
+ display: none !important;
+ }
+
+ .app-footer {
+ grid-template-columns: 1fr;
+ }
+
+ .footer-center {
+ grid-column: 1;
+ }
+}
diff --git a/css/components/forms.css b/css/components/forms.css
new file mode 100644
index 0000000..1cb2ee0
--- /dev/null
+++ b/css/components/forms.css
@@ -0,0 +1,7 @@
+/* Import split form components */
+@import './forms/search.css';
+@import './forms/handle-input.css';
+@import './forms/checkboxes.css';
+@import './forms/category-links.css';
+@import './forms/select.css';
+@import './forms/radio.css';
diff --git a/css/components/forms/category-links.css b/css/components/forms/category-links.css
new file mode 100644
index 0000000..6211b3b
--- /dev/null
+++ b/css/components/forms/category-links.css
@@ -0,0 +1,10 @@
+/* Category Links */
+.category-name {
+ color: var(--text);
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.category-name:hover {
+ color: var(--primary);
+}
diff --git a/css/components/forms/checkboxes.css b/css/components/forms/checkboxes.css
new file mode 100644
index 0000000..0a25000
--- /dev/null
+++ b/css/components/forms/checkboxes.css
@@ -0,0 +1,66 @@
+/* Checkbox */
+.keyword-checkbox {
+ display: flex;
+ align-items: center;
+ break-inside: avoid;
+ padding: 8px 0;
+ cursor: pointer;
+ color: var(--text);
+ font-size: 15px;
+ gap: 8px;
+}
+
+.keyword-checkbox input[type="checkbox"] {
+ appearance: none;
+ -webkit-appearance: none;
+ width: 18px;
+ height: 18px;
+ border: 1px solid var(--text-secondary);
+ border-radius: 4px;
+ background: transparent;
+ cursor: pointer;
+ position: relative;
+ transition: var(--transition);
+}
+
+.keyword-checkbox input[type="checkbox"]:checked {
+ background: var(--primary);
+ border-color: var(--primary);
+}
+
+.keyword-checkbox input[type="checkbox"]:checked::after {
+ content: '';
+ position: absolute;
+ left: 5px;
+ top: 2px;
+ width: 4px;
+ height: 8px;
+ border: solid white;
+ border-width: 0 2px 2px 0;
+ transform: rotate(45deg);
+}
+
+.keyword-checkbox input[type="checkbox"]:indeterminate {
+ background: transparent;
+ border-color: var(--primary);
+}
+
+.keyword-checkbox input[type="checkbox"]:indeterminate::after {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 10px;
+ height: 2px;
+ background: var(--primary);
+}
+
+.keyword-checkbox input[type="checkbox"]:focus {
+ outline: none;
+ border-color: var(--primary);
+}
+
+.keyword-checkbox:hover input[type="checkbox"]:not(:checked) {
+ border-color: var(--text);
+}
diff --git a/css/components/forms/handle-input.css b/css/components/forms/handle-input.css
new file mode 100644
index 0000000..f16ca19
--- /dev/null
+++ b/css/components/forms/handle-input.css
@@ -0,0 +1,53 @@
+/* Bluesky Handle Input */
+.input-wrapper {
+ position: relative;
+ width: 100%;
+}
+
+.input-wrapper::before {
+ content: '@';
+ position: absolute;
+ left: 16px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--text-secondary);
+ font-size: 15px;
+ z-index: 1;
+ pointer-events: none;
+}
+
+.bsky-handle-input {
+ width: 100%;
+ height: 48px;
+ padding: 0 16px 0 36px;
+ border: none;
+ border-radius: 8px;
+ font-size: 15px;
+ background: var(--background);
+ color: var(--text);
+ transition: var(--transition);
+}
+
+.bsky-handle-input::placeholder {
+ color: var(--text-secondary);
+ opacity: 0.7;
+}
+
+.bsky-handle-input:focus {
+ outline: none;
+ background: var(--background);
+ box-shadow: 0 0 0 2px var(--primary);
+}
+
+.bsky-handle-input.error {
+ background: rgba(220, 53, 69, 0.1);
+ box-shadow: 0 0 0 2px var(--danger);
+ animation: shake 0.5s;
+}
+
+/* Animation for error state */
+@keyframes shake {
+ 0%, 100% { transform: translateX(0); }
+ 25% { transform: translateX(-5px); }
+ 75% { transform: translateX(5px); }
+}
diff --git a/css/components/forms/radio.css b/css/components/forms/radio.css
new file mode 100644
index 0000000..1d95741
--- /dev/null
+++ b/css/components/forms/radio.css
@@ -0,0 +1,28 @@
+/* Radio Buttons */
+input[type="radio"] {
+ appearance: none;
+ -webkit-appearance: none;
+ width: 18px;
+ height: 18px;
+ border: 2px solid var(--border);
+ border-radius: 50%;
+ background: transparent;
+ cursor: pointer;
+ position: relative;
+ transition: var(--transition);
+}
+
+input[type="radio"]:checked {
+ border-color: var(--primary);
+}
+
+input[type="radio"]:checked::after {
+ content: '';
+ position: absolute;
+ left: 3px;
+ top: 3px;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--primary);
+}
diff --git a/css/components/forms/search.css b/css/components/forms/search.css
new file mode 100644
index 0000000..a8ab937
--- /dev/null
+++ b/css/components/forms/search.css
@@ -0,0 +1,21 @@
+/* Search Input */
+.sidebar-search {
+ width: 100%;
+ padding: 12px;
+ border: 1px solid var(--border);
+ border-radius: 9999px;
+ font-size: 15px;
+ background: transparent;
+ color: var(--text);
+ transition: var(--transition);
+}
+
+.sidebar-search::placeholder {
+ color: var(--text-secondary);
+}
+
+.sidebar-search:focus {
+ outline: none;
+ border-color: var(--primary);
+ background: transparent;
+}
diff --git a/css/components/forms/select.css b/css/components/forms/select.css
new file mode 100644
index 0000000..36270c8
--- /dev/null
+++ b/css/components/forms/select.css
@@ -0,0 +1,16 @@
+/* Select Inputs */
+select {
+ padding: 12px;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ font-size: 15px;
+ background: transparent;
+ color: var(--text);
+ cursor: pointer;
+ transition: var(--transition);
+}
+
+select:focus {
+ outline: none;
+ border-color: var(--primary);
+}
diff --git a/css/components/index.css b/css/components/index.css
new file mode 100644
index 0000000..bb604d2
--- /dev/null
+++ b/css/components/index.css
@@ -0,0 +1,21 @@
+/* Component Styles */
+@import 'buttons.css';
+@import 'forms.css';
+@import 'cards/index.css';
+@import 'toggles.css';
+@import 'auth.css';
+@import 'media.css';
+@import 'modals.css';
+@import 'profile.css';
+@import 'notifications.css';
+@import 'nav.css';
+@import 'landing.css';
+@import 'advanced-mode.css';
+@import 'scrollbars.css';
+@import 'context-builder.css';
+@import 'exceptions.css';
+@import 'footer.css';
+@import 'slider.css';
+@import 'app-intro.css';
+@import 'settings.css';
+@import 'simple-mode.css';
diff --git a/css/components/landing.css b/css/components/landing.css
new file mode 100644
index 0000000..4235a52
--- /dev/null
+++ b/css/components/landing.css
@@ -0,0 +1,7 @@
+/* Landing Page Styles - Split into modular files */
+@import 'landing/layout.landing.css';
+@import 'landing/branding.landing.css';
+@import 'landing/features.landing.css';
+@import 'landing/auth.landing.css';
+@import 'landing/theme-toggle.landing.css';
+@import 'landing/responsive.landing.css';
diff --git a/css/components/landing/auth.landing.css b/css/components/landing/auth.landing.css
new file mode 100644
index 0000000..fe6a71a
--- /dev/null
+++ b/css/components/landing/auth.landing.css
@@ -0,0 +1,35 @@
+/* Auth Section */
+.bsky-connect {
+ padding: 32px;
+ background: var(--background);
+ border-radius: 12px;
+ margin-bottom: 96px;
+ position: relative;
+}
+
+.bsky-connect:after {
+ content: "";
+ position: absolute;
+ bottom: -48px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 64px;
+ height: 4px;
+ background: var(--primary);
+ border-radius: 2px;
+ opacity: 0.3;
+}
+
+.sign-in-title {
+ font-size: 24px;
+ font-weight: 700;
+ margin-bottom: 24px;
+ color: var(--text);
+}
+
+.bsky-auth-message {
+ color: var(--text-secondary);
+ margin-bottom: 16px;
+ font-size: 16px;
+ line-height: 1.5;
+}
diff --git a/css/components/landing/branding.landing.css b/css/components/landing/branding.landing.css
new file mode 100644
index 0000000..fb04d39
--- /dev/null
+++ b/css/components/landing/branding.landing.css
@@ -0,0 +1,77 @@
+/* Branding Section (Left) */
+.branding-section {
+ flex: 0 0 var(--branding-width);
+ background: var(--background);
+ position: sticky;
+ top: 0;
+ height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 32px;
+}
+
+.branding-content {
+ text-align: center;
+ max-width: 420px;
+}
+
+.logo {
+ margin-bottom: 24px;
+ filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.1));
+}
+
+.logo img {
+ width: 245px;
+ height: auto;
+ display: block;
+ margin: 0 auto;
+}
+
+.branding-content h1 {
+ font-size: 48px;
+ font-weight: 800;
+ color: var(--text);
+ margin-bottom: 16px;
+ letter-spacing: -0.02em;
+}
+
+.tagline {
+ font-size: 20px;
+ line-height: 1.4;
+ color: var(--text-secondary);
+ margin: 0 auto;
+ max-width: 360px;
+}
+
+/* Built by Section */
+.built-by-section {
+ text-align: center;
+ margin-top: 64px;
+ padding-top: 32px;
+ border-top: 1px solid var(--border);
+}
+
+.built-by-content {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 16px;
+}
+
+.built-by-content p {
+ font-size: 16px;
+ line-height: 1.5;
+ color: var(--text-secondary);
+ margin: 0;
+}
+
+.built-by-content a {
+ color: var(--primary);
+ text-decoration: none;
+ transition: color 0.2s ease;
+}
+
+.built-by-content a:hover {
+ color: var(--primary-hover);
+}
diff --git a/css/components/landing/features.landing.css b/css/components/landing/features.landing.css
new file mode 100644
index 0000000..e534131
--- /dev/null
+++ b/css/components/landing/features.landing.css
@@ -0,0 +1,178 @@
+/* Feature Grid */
+.feature-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 24px;
+ margin-bottom: 32px;
+}
+
+.landing-feature-card {
+ background: var(--background);
+ padding: 24px;
+ border-radius: 12px;
+ display: flex;
+ align-items: flex-start;
+ gap: 16px;
+}
+
+.feature-icon {
+ font-size: 24px;
+ line-height: 1;
+}
+
+.feature-text h3 {
+ font-size: 18px;
+ font-weight: 600;
+ margin-bottom: 8px;
+ color: var(--text);
+}
+
+.feature-text p {
+ font-size: 14px;
+ line-height: 1.5;
+ color: var(--text-secondary);
+ margin: 0;
+}
+
+/* Detailed Features Section */
+.detailed-features {
+ padding-top: 0;
+}
+
+.section-intro {
+ text-align: center;
+ margin-bottom: 48px;
+ max-width: 600px;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.section-intro h2 {
+ font-size: 36px;
+ font-weight: 800;
+ color: var(--text);
+ margin-bottom: 16px;
+ letter-spacing: -0.02em;
+}
+
+.section-intro p {
+ font-size: 18px;
+ line-height: 1.6;
+ color: var(--text-secondary);
+}
+
+.feature-blocks {
+ display: flex;
+ flex-direction: column;
+ gap: 48px;
+}
+
+.feature-block {
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+ opacity: 0;
+ transform: translateY(20px);
+ animation: fadeInUp 0.6s ease forwards;
+}
+
+.feature-block:nth-child(1) { animation-delay: 0.1s; }
+.feature-block:nth-child(2) { animation-delay: 0.2s; }
+.feature-block:nth-child(3) { animation-delay: 0.3s; }
+.feature-block:nth-child(4) { animation-delay: 0.4s; }
+
+@keyframes fadeInUp {
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.feature-image {
+ border-radius: 12px;
+ overflow: hidden;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
+ background: var(--background);
+ aspect-ratio: 16 / 9;
+ background-size: cover;
+ background-position: center;
+ background-repeat: no-repeat;
+ transition: background-image 0.3s ease;
+ position: relative;
+}
+
+/* Add loading state */
+.feature-image.loading::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background-color: var(--background);
+ animation: pulse 1.5s ease-in-out infinite;
+}
+
+@keyframes pulse {
+ 0% { opacity: 0.6; }
+ 50% { opacity: 0.8; }
+ 100% { opacity: 0.6; }
+}
+
+/* Error state styling */
+.feature-image.image-load-error {
+ background-color: var(--background);
+ border: 2px dashed var(--border-color);
+}
+
+.feature-image.image-load-error::after {
+ content: '⚠️ Image failed to load';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ color: var(--text);
+ font-size: 14px;
+ text-align: center;
+ padding: 8px 16px;
+ background-color: var(--background);
+ border-radius: 6px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.feature-description {
+ color: var(--text);
+}
+
+.feature-description h3 {
+ font-size: 24px;
+ font-weight: 700;
+ margin-bottom: 16px;
+ color: var(--text);
+}
+
+.feature-description p {
+ font-size: 16px;
+ line-height: 1.6;
+ color: var(--text-secondary);
+ margin-bottom: 16px;
+}
+
+.feature-description ul {
+ list-style: none;
+ padding: 0;
+ margin: 16px 0;
+}
+
+.feature-description li {
+ font-size: 16px;
+ line-height: 1.6;
+ color: var(--text-secondary);
+ margin-bottom: 8px;
+ padding-left: 24px;
+ position: relative;
+}
+
+.feature-description li:before {
+ content: "•";
+ position: absolute;
+ left: 8px;
+ color: var(--primary);
+}
diff --git a/css/components/landing/layout.landing.css b/css/components/landing/layout.landing.css
new file mode 100644
index 0000000..7f2057c
--- /dev/null
+++ b/css/components/landing/layout.landing.css
@@ -0,0 +1,22 @@
+/* Split Layout for Landing Page */
+.split-layout {
+ display: flex;
+ min-height: 100vh;
+ background: var(--surface);
+}
+
+/* Content Section (Right) */
+.content-section {
+ flex: 0 0 var(--content-width);
+ background: var(--surface);
+ min-height: 100vh;
+ overflow-y: auto;
+}
+
+.content-wrapper {
+ max-width: 720px;
+ margin: 0 auto;
+ padding: 16px 32px;
+ /* Reduced top padding from 24px to 16px */
+ width: 100%;
+}
diff --git a/css/components/landing/responsive.landing.css b/css/components/landing/responsive.landing.css
new file mode 100644
index 0000000..00277bd
--- /dev/null
+++ b/css/components/landing/responsive.landing.css
@@ -0,0 +1,159 @@
+/* Responsive Layout */
+@media (max-width: 1200px) {
+ .content-wrapper {
+ padding: 32px 24px;
+ }
+}
+
+@media (max-width: 1024px) {
+ .split-layout {
+ flex-direction: column;
+ }
+
+ .branding-section {
+ position: relative;
+ flex: 0 0 auto;
+ width: 100%;
+ height: auto;
+ min-height: 50vh;
+ padding: 48px 24px;
+ }
+
+ .content-section {
+ flex: 0 0 auto;
+ width: 100%;
+ }
+
+ .content-wrapper {
+ padding: 32px 24px;
+ }
+
+ .branding-content {
+ max-width: 100%;
+ }
+
+ .tagline {
+ max-width: 480px;
+ }
+
+ .feature-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .feature-blocks {
+ gap: 64px;
+ }
+
+ .section-intro {
+ margin-bottom: 48px;
+ }
+
+ .bsky-connect {
+ margin-bottom: 80px;
+ }
+
+ .bsky-connect:after {
+ bottom: -40px;
+ width: 48px;
+ }
+
+ .built-by-section {
+ margin-top: 48px;
+ }
+}
+
+@media (max-width: 768px) {
+ .logo img {
+ width: 200px;
+ }
+
+ .section-intro h2 {
+ font-size: 32px;
+ }
+
+ .section-intro p {
+ font-size: 16px;
+ }
+
+ .feature-description h3 {
+ font-size: 20px;
+ }
+
+ .feature-description p,
+ .feature-description li {
+ font-size: 15px;
+ }
+
+ .feature-blocks {
+ gap: 48px;
+ }
+
+ .feature-block {
+ gap: 24px;
+ }
+
+ .built-by-section {
+ margin-top: 40px;
+ padding-top: 24px;
+ }
+
+ .built-by-content {
+ flex-direction: column;
+ gap: 12px;
+ }
+}
+
+@media (max-width: 480px) {
+ .branding-section {
+ padding: 32px 20px;
+ min-height: auto;
+ }
+
+ .content-wrapper {
+ padding: 24px 20px;
+ }
+
+ .logo img {
+ width: 160px;
+ }
+
+ .branding-content h1 {
+ font-size: 36px;
+ margin-bottom: 12px;
+ }
+
+ .tagline {
+ font-size: 18px;
+ }
+
+ .feature-blocks {
+ gap: 40px;
+ }
+
+ .section-intro {
+ margin-bottom: 40px;
+ }
+
+ .section-intro h2 {
+ font-size: 28px;
+ }
+
+ .bsky-connect {
+ margin-bottom: 64px;
+ padding: 24px;
+ }
+
+ .bsky-connect:after {
+ bottom: -32px;
+ width: 40px;
+ }
+
+ .built-by-section {
+ margin-top: 32px;
+ padding-top: 20px;
+ }
+
+ .built-by-content p {
+ font-size: 14px;
+ }
+}
diff --git a/css/components/landing/theme-toggle.landing.css b/css/components/landing/theme-toggle.landing.css
new file mode 100644
index 0000000..938eabb
--- /dev/null
+++ b/css/components/landing/theme-toggle.landing.css
@@ -0,0 +1,71 @@
+/* Theme Toggle Switch */
+.theme-toggle {
+ position: relative;
+ width: 64px;
+ height: 32px;
+ border-radius: 50px;
+ border: none;
+ background: none;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 4px 8px;
+ outline: none;
+ background-color: var(--background);
+ border: 2px solid var(--border);
+ transition: all 0.3s ease;
+ margin-left: 8px;
+}
+
+.theme-toggle:hover {
+ border-color: var(--primary);
+}
+
+.theme-toggle::before {
+ content: "";
+ position: absolute;
+ left: 4px;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ background: var(--primary);
+ transition: transform 0.3s ease, background-color 0.3s ease;
+}
+
+.theme-toggle.dark::before {
+ transform: translateX(28px);
+}
+
+.theme-toggle .toggle-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1;
+ color: var(--text);
+ font-size: 14px;
+ line-height: 1;
+ width: 20px;
+ height: 20px;
+ position: relative;
+}
+
+.theme-toggle .sun-icon {
+ margin-right: auto;
+ transform: translateX(-2px);
+}
+
+.theme-toggle .moon-icon {
+ margin-left: auto;
+ transform: translateX(1px);
+}
+
+/* Hide emoji in dark mode */
+.theme-toggle.dark .sun-icon {
+ opacity: 0.5;
+}
+
+/* Hide emoji in light mode */
+.theme-toggle:not(.dark) .moon-icon {
+ opacity: 0.5;
+}
diff --git a/css/components/media.css b/css/components/media.css
new file mode 100644
index 0000000..57d03ca
--- /dev/null
+++ b/css/components/media.css
@@ -0,0 +1,33 @@
+/* Media Queries */
+@media (max-width: 768px) {
+ /* Button Responsiveness */
+ .btn-auth {
+ width: 100%;
+ }
+
+ .btn-auth-small {
+ width: 100%;
+ }
+
+ /* Toggle Controls Responsiveness */
+ .mode-toggle,
+ .toggle-all-controls {
+ width: 100%;
+ }
+
+ /* Auth Container Responsiveness */
+ .bsky-connect {
+ flex-direction: column;
+ width: 100%;
+ }
+
+ .bsky-auth-container {
+ max-width: none;
+ width: 100%;
+ }
+
+ /* Category Section Responsiveness */
+ .category-section .keywords-container {
+ columns: 1;
+ }
+}
diff --git a/css/components/modals.css b/css/components/modals.css
new file mode 100644
index 0000000..ed92e3e
--- /dev/null
+++ b/css/components/modals.css
@@ -0,0 +1,179 @@
+/* Modal Overlay */
+.modal {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.4);
+ z-index: 1000;
+ backdrop-filter: blur(4px);
+}
+
+/* Modal Container */
+.modal-content {
+ position: relative;
+ background: var(--surface);
+ margin: 5vh auto;
+ padding: 20px;
+ border-radius: 16px;
+ width: 90%;
+ max-width: 600px;
+ border: 1px solid var(--border);
+}
+
+/* Modal Header */
+.modal-header {
+ margin-bottom: 16px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid var(--border);
+}
+
+.modal-header h2 {
+ margin: 0;
+ font-size: calc(1.333rem * var(--font-scale)); /* 20px equivalent */
+ font-weight: 700;
+ color: var(--text);
+}
+
+/* Modal Body */
+.modal-body {
+ margin-bottom: 16px;
+ color: var(--text);
+ font-size: var(--font-size-default);
+ line-height: 1.4;
+ max-height: 70vh;
+ overflow-y: auto;
+}
+
+/* Settings Groups */
+.settings-group {
+ margin-bottom: 20px;
+ padding-bottom: 16px;
+}
+
+.settings-group:last-child {
+ margin-bottom: 0;
+ padding-bottom: 0;
+}
+
+.settings-group h3 {
+ margin: 0 0 12px 0;
+ font-size: var(--font-size-default);
+ font-weight: 600;
+ color: var(--text);
+ display: block;
+}
+
+/* Settings Options */
+.settings-option {
+ margin: 12px 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.settings-option:first-child {
+ margin-top: 0;
+}
+
+.settings-option:last-child {
+ margin-bottom: 0;
+}
+
+.settings-option label {
+ cursor: pointer;
+ color: var(--text);
+ font-size: var(--font-size-default);
+ margin: 0;
+ padding: 0;
+}
+
+/* Modal Footer */
+.modal-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+ padding-top: 12px;
+ border-top: 1px solid var(--border);
+}
+
+/* Close Button */
+.modal-close {
+ position: absolute;
+ right: 12px;
+ top: 12px;
+ width: 32px;
+ height: 32px;
+ border-radius: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ color: var(--text);
+ border: none;
+ background: var(--background);
+ transition: all 0.2s ease;
+ font-size: calc(1.333rem * var(--font-scale)); /* 20px equivalent */
+ border: 1px solid var(--border);
+}
+
+.modal-close:hover {
+ background: var(--border);
+ transform: scale(1.05);
+}
+
+/* Modal Buttons */
+.modal .btn-primary {
+ padding: 8px 16px;
+ border-radius: 9999px;
+ cursor: pointer;
+ transition: var(--transition);
+ border: none;
+ background: var(--primary);
+ color: white;
+ font-size: var(--font-size-default);
+ font-weight: 600;
+}
+
+.modal .btn-primary:hover {
+ background: var(--primary-hover);
+}
+
+.modal .btn-secondary {
+ padding: 8px 16px;
+ border-radius: 9999px;
+ cursor: pointer;
+ transition: var(--transition);
+ border: 1px solid var(--border);
+ background: transparent;
+ color: var(--text);
+ font-size: var(--font-size-default);
+ font-weight: 600;
+}
+
+.modal .btn-secondary:hover {
+ background: var(--background);
+}
+
+/* Animation */
+@keyframes modalFadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(-20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.modal.visible {
+ display: block;
+}
+
+.modal.visible .modal-content {
+ animation: modalFadeIn 0.2s ease-out;
+}
diff --git a/css/components/nav.css b/css/components/nav.css
new file mode 100644
index 0000000..b3ee338
--- /dev/null
+++ b/css/components/nav.css
@@ -0,0 +1,5 @@
+/* Navigation Component Styles */
+@import './nav/top-nav.css';
+@import './nav/mode-toggle.css';
+@import './nav/user-menu.css';
+@import './nav/mobile.css';
diff --git a/css/components/nav/mobile.css b/css/components/nav/mobile.css
new file mode 100644
index 0000000..fe53fa3
--- /dev/null
+++ b/css/components/nav/mobile.css
@@ -0,0 +1,147 @@
+/* Mobile Navigation Styles */
+.hamburger-menu {
+ display: none;
+ flex-direction: column;
+ justify-content: space-between;
+ width: 28px;
+ height: 22px;
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+}
+
+.hamburger-menu span {
+ display: block;
+ width: 100%;
+ height: 2px;
+ background: var(--text);
+ transition: transform 0.3s ease;
+}
+
+.hamburger-menu:hover span {
+ background: var(--accent);
+}
+
+/* Mobile Styles */
+@media (max-width: 768px) {
+ .top-nav {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 var(--spacing-sm);
+ height: 55px;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ background: var(--surface);
+ z-index: 999;
+ border-bottom: 1px solid var(--border);
+ }
+
+ .brand {
+ flex: 0 0 auto;
+ }
+
+ .brand h1 {
+ font-size: 1.1rem;
+ margin: 0;
+ white-space: nowrap;
+ }
+
+ .hamburger-menu {
+ display: flex !important;
+ position: absolute;
+ top: 18px;
+ right: 16px;
+ z-index: 1001;
+ }
+
+ /* Hide desktop elements */
+ .mode-toggle,
+ .keywords-updated,
+ .user-name,
+ .profile-tooltip,
+ .profile-pic,
+ .profile-button {
+ display: none !important;
+ }
+
+ /* Mobile mode switches */
+ .mobile-mode-switches {
+ display: block;
+ border-bottom: 1px solid var(--border);
+ }
+
+ /* Style nav-mute-button for mobile */
+ .nav-mute-button {
+ margin: 0 var(--spacing-sm);
+ padding: 8px;
+ font-size: 0.9rem;
+ min-height: 36px;
+ display: none;
+ width: calc(100% - (var(--spacing-sm) * 2));
+ }
+
+ .nav-mute-button.visible {
+ display: block;
+ }
+
+ /* Menu items styling */
+ .user-menu-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 16px;
+ color: var(--text);
+ text-decoration: none;
+ transition: background-color 0.2s;
+ }
+
+ .user-menu-item:hover {
+ background: var(--surface-hover);
+ }
+
+ .user-menu-item svg {
+ width: 20px;
+ height: 20px;
+ flex-shrink: 0;
+ }
+
+ /* Ensure dropdown appears below hamburger */
+ .user-menu-dropdown {
+ display: none;
+ position: fixed;
+ top: 55px;
+ right: 0;
+ left: 0;
+ width: 100%;
+ background: var(--surface);
+ border-radius: 0;
+ box-shadow: var(--card-shadow);
+ z-index: 1000;
+ }
+
+ .user-menu.active .user-menu-dropdown {
+ display: block;
+ }
+
+ /* Hamburger animation */
+ .hamburger-menu.active span:nth-child(1) {
+ transform: translateY(10px) rotate(45deg);
+ }
+
+ .hamburger-menu.active span:nth-child(2) {
+ opacity: 0;
+ }
+
+ .hamburger-menu.active span:nth-child(3) {
+ transform: translateY(-10px) rotate(-45deg);
+ }
+
+ /* Adjust main content for fixed header */
+ body {
+ padding-top: 55px;
+ }
+}
diff --git a/css/components/nav/mode-toggle.css b/css/components/nav/mode-toggle.css
new file mode 100644
index 0000000..94d3551
--- /dev/null
+++ b/css/components/nav/mode-toggle.css
@@ -0,0 +1,25 @@
+/* Mode Toggle Styles */
+.mode-toggle {
+ display: flex;
+ gap: var(--spacing-sm);
+ padding: 4px;
+ background: var(--background);
+ border-radius: 20px;
+ border: 1px solid var(--border);
+}
+
+.mode-switch {
+ padding: 6px 12px;
+ border-radius: 16px;
+ border: none;
+ background: none;
+ color: var(--text);
+ cursor: pointer;
+ transition: background-color var(--button-transition), box-shadow var(--button-transition);
+}
+
+.mode-switch.active {
+ background: var(--surface);
+ color: var(--primary);
+ box-shadow: var(--card-shadow);
+}
diff --git a/css/components/nav/top-nav.css b/css/components/nav/top-nav.css
new file mode 100644
index 0000000..c0d1fee
--- /dev/null
+++ b/css/components/nav/top-nav.css
@@ -0,0 +1,91 @@
+/* Top Navigation Base Styles */
+.top-nav {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--spacing-md);
+ background: var(--surface);
+ border-bottom: 1px solid var(--border);
+ position: sticky;
+ top: 0;
+ z-index: 100;
+}
+
+.brand {
+ display: flex;
+ align-items: center;
+}
+
+.brand h1 {
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin: 0;
+ color: var(--text);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.nav-logo {
+ width: 32px;
+ height: auto;
+ display: block;
+ filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
+}
+
+.nav-group {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-md);
+}
+
+/* Right nav group with profile handling */
+.nav-group:last-child {
+ min-width: 0;
+}
+
+/* Ensure mute button is always visible and takes priority */
+.btn-mute-keywords {
+ padding: 8px 16px;
+ flex-shrink: 0;
+ order: -1; /* Ensure mute button comes first */
+ white-space: nowrap;
+}
+
+/* Make profile section and hamburger menu shrinkable */
+.nav-group:last-child .user-menu,
+.nav-group:last-child .hamburger-menu {
+ flex-shrink: 1;
+ min-width: 0;
+ transition: all 0.5s ease-in-out; /* Slower, smoother transition */
+}
+
+/* When space gets tight, collapse profile section */
+.nav-group:last-child:has(.btn-mute-keywords:not(.hidden)) .user-menu,
+.nav-group:last-child:has(.btn-mute-keywords:not(.hidden)) .hamburger-menu {
+ width: 0;
+ padding: 0;
+ margin: 0;
+ visibility: hidden;
+ opacity: 0;
+ pointer-events: none;
+ transition: all 0.5s ease-in-out; /* Match transition timing */
+}
+
+/* Ensure dropdown is visible when menu is active */
+.nav-group:last-child .user-menu.active {
+ visibility: visible;
+ opacity: 1;
+ width: auto;
+ padding: initial;
+ margin: initial;
+ pointer-events: auto;
+ transition: all 0.5s ease-in-out; /* Consistent transition */
+}
+
+/* Ensure dropdown is always visible when parent is active */
+.user-menu.active .user-menu-dropdown {
+ display: block;
+ visibility: visible;
+ opacity: 1;
+}
diff --git a/css/components/nav/user-menu-base.css b/css/components/nav/user-menu-base.css
new file mode 100644
index 0000000..b0cd31e
--- /dev/null
+++ b/css/components/nav/user-menu-base.css
@@ -0,0 +1,41 @@
+/* User Menu Base Styles */
+.user-menu {
+ position: relative;
+}
+
+.profile-button {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ padding: 8px;
+ border: none;
+ background: none;
+ color: var(--text);
+ cursor: pointer;
+ border-radius: var(--border-radius);
+ transition: var(--transition);
+}
+
+.profile-button:hover {
+ background: var(--background);
+}
+
+.profile-pic {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background: var(--background);
+ overflow: hidden;
+}
+
+.profile-pic img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+/* Override any parent constraints */
+.nav-group:last-child .user-menu {
+ min-width: auto !important;
+ width: auto !important;
+}
diff --git a/css/components/nav/user-menu-dropdown.css b/css/components/nav/user-menu-dropdown.css
new file mode 100644
index 0000000..2935b2c
--- /dev/null
+++ b/css/components/nav/user-menu-dropdown.css
@@ -0,0 +1,82 @@
+/* User Menu Dropdown Styles */
+
+/* Overlay background when menu is open */
+.user-menu.active::before {
+ content: '';
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 998;
+}
+
+/* Base dropdown styles for desktop */
+.user-menu-dropdown {
+ position: absolute;
+ top: calc(100% + 8px);
+ right: 0;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--border-radius-lg);
+ box-shadow: var(--card-shadow);
+ width: 320px;
+ min-width: 280px;
+ max-width: calc(100vw - 32px);
+ display: none;
+ z-index: 1001;
+}
+
+.user-menu.active .user-menu-dropdown {
+ display: block;
+}
+
+.user-menu-header {
+ padding: var(--spacing-md) var(--spacing-lg);
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--spacing-md);
+}
+
+.user-handle {
+ font-size: 1.1rem;
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex: 1;
+}
+
+.total-mutes {
+ font-size: 0.9rem;
+ color: var(--text-secondary);
+ padding: 2px 8px;
+ background: var(--background);
+ border-radius: var(--border-radius);
+ white-space: nowrap;
+}
+
+/* Mobile styles */
+@media (max-width: 768px) {
+ /* Reset any inherited positioning */
+ .user-menu {
+ position: static;
+ }
+
+ /* Fixed positioning for mobile dropdown */
+ .user-menu-dropdown {
+ position: fixed !important;
+ top: 55px !important;
+ right: 16px !important;
+ left: auto !important;
+ width: 280px !important;
+ min-width: 280px !important;
+ border-radius: var(--border-radius) !important;
+ max-height: calc(100vh - 71px);
+ overflow-y: auto;
+ margin: 0;
+ }
+}
diff --git a/css/components/nav/user-menu-items.css b/css/components/nav/user-menu-items.css
new file mode 100644
index 0000000..3d3464e
--- /dev/null
+++ b/css/components/nav/user-menu-items.css
@@ -0,0 +1,69 @@
+/* User Menu Items Styles */
+.user-menu-item {
+ padding: var(--spacing-md) var(--spacing-lg);
+ color: var(--text);
+ cursor: pointer;
+ transition: var(--transition);
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-md);
+ font-size: 0.95rem;
+ min-height: 44px;
+}
+
+.user-menu-item svg {
+ color: var(--text-secondary);
+ flex-shrink: 0;
+ position: relative;
+ top: 1px;
+ width: 20px;
+ height: 20px;
+}
+
+#refresh-data {
+ min-width: 160px;
+ justify-content: flex-start;
+}
+
+#refresh-data svg {
+ transform-origin: center;
+ flex-shrink: 0;
+ width: 20px;
+}
+
+#refresh-data span {
+ flex: 1;
+ text-align: left;
+ white-space: nowrap;
+}
+
+.user-menu-item:hover {
+ background: var(--background);
+ text-decoration: none;
+}
+
+.user-menu-item:hover svg {
+ color: var(--text);
+}
+
+.user-menu-item.logout {
+ color: var(--text);
+ margin-top: var(--spacing-sm);
+ border-top: 1px solid var(--border);
+ padding-top: calc(var(--spacing-md) + 4px);
+ gap: calc(var(--spacing-md) + 16px);
+}
+
+.user-menu-item.logout svg {
+ color: var(--text-secondary);
+ position: relative;
+ top: 4px;
+ margin-left: 3px;
+}
+
+.mobile-mode-switches {
+ display: none;
+ border-bottom: 1px solid var(--border);
+ padding-bottom: var(--spacing-sm);
+ margin-bottom: var(--spacing-sm);
+}
diff --git a/css/components/nav/user-menu-responsive.css b/css/components/nav/user-menu-responsive.css
new file mode 100644
index 0000000..2134d02
--- /dev/null
+++ b/css/components/nav/user-menu-responsive.css
@@ -0,0 +1,11 @@
+/* User Menu Responsive Styles */
+@media (max-width: 768px) {
+ .user-menu {
+ position: static;
+ }
+
+ /* Hide desktop elements */
+ .total-mutes {
+ display: none;
+ }
+}
diff --git a/css/components/nav/user-menu.css b/css/components/nav/user-menu.css
new file mode 100644
index 0000000..3f13f86
--- /dev/null
+++ b/css/components/nav/user-menu.css
@@ -0,0 +1,5 @@
+/* User Menu Styles */
+@import url('./user-menu-base.css');
+@import url('./user-menu-dropdown.css');
+@import url('./user-menu-items.css');
+@import url('./user-menu-responsive.css');
diff --git a/css/components/notifications.css b/css/components/notifications.css
new file mode 100644
index 0000000..98f6260
--- /dev/null
+++ b/css/components/notifications.css
@@ -0,0 +1,41 @@
+.notification {
+ position: fixed;
+ top: 80px; /* Increased from 20px to move it below the header */
+ left: 50%;
+ transform: translateX(-50%) translateY(-20px);
+ padding: 12px 24px;
+ border-radius: 8px;
+ background: #2ecc71;
+ color: white;
+ font-size: 14px;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
+ z-index: 1000;
+ opacity: 0;
+ transition: opacity 0.3s ease, transform 0.3s ease;
+ text-align: center;
+}
+
+.notification.show {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0);
+}
+
+.notification.error {
+ background: #e74c3c;
+}
+
+.notification.hide {
+ opacity: 0;
+ transform: translateX(-50%) translateY(-20px);
+}
+
+/* Mobile-friendly adjustments */
+@media screen and (max-width: 768px) {
+ .notification {
+ width: 90%;
+ max-width: 300px;
+ top: 60px;
+ padding: 10px 16px;
+ font-size: 13px;
+ }
+}
diff --git a/css/components/profile.css b/css/components/profile.css
new file mode 100644
index 0000000..cb4fa57
--- /dev/null
+++ b/css/components/profile.css
@@ -0,0 +1,90 @@
+.profile-button {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 8px;
+ border: none;
+ background: none;
+ cursor: pointer;
+ position: relative;
+}
+
+.profile-button .user-name,
+#user-display-name {
+ color: var(--text) !important;
+ font-weight: 600 !important;
+}
+
+.profile-pic {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background-color: var(--background);
+ position: relative;
+}
+
+.profile-tooltip {
+ display: none;
+ position: absolute;
+ background: rgba(0, 0, 0, 0.8);
+ color: white;
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 12px;
+ white-space: nowrap;
+ top: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ margin-top: 4px;
+ z-index: 1000;
+}
+
+.profile-pic:hover + .profile-tooltip {
+ display: block;
+}
+
+.user-menu-dropdown {
+ position: absolute;
+ top: 100%;
+ right: 0;
+ background: var(--surface);
+ border-radius: 8px;
+ box-shadow: 0 2px 8px var(--shadow);
+ min-width: 200px;
+ z-index: 1000;
+ display: none;
+}
+
+.user-menu-dropdown.visible {
+ display: block;
+}
+
+/* Base styles for all menu items */
+.user-menu-item {
+ padding: 8px 16px;
+ cursor: pointer;
+ color: var(--text) !important;
+ text-decoration: none !important;
+ display: block;
+ width: 100%;
+ text-align: left;
+ border: none;
+ background: none;
+ font-size: 15px; /* Match the default font size */
+ font-weight: 400;
+ line-height: 1.5;
+}
+
+/* Ensure all menu items have the same styles */
+.user-menu-item.refresh,
+.user-menu-item.logout,
+.user-menu-item.moderation-link {
+ font-size: 15px; /* Match the default font size */
+ font-weight: 400;
+}
+
+.user-menu-item:hover {
+ background-color: var(--background);
+ color: var(--text) !important;
+ text-decoration: none !important;
+}
diff --git a/css/components/scrollbars.css b/css/components/scrollbars.css
new file mode 100644
index 0000000..efcface
--- /dev/null
+++ b/css/components/scrollbars.css
@@ -0,0 +1,89 @@
+/* Light theme scrollbar colors */
+:root {
+ --scrollbar-track: #f0f0f0;
+ --scrollbar-thumb: #a8a8a8;
+ --scrollbar-thumb-hover: #8b98a5;
+}
+
+/* Dark theme scrollbar colors */
+[data-theme="dark"] {
+ --scrollbar-track: #1e2732;
+ --scrollbar-thumb: #38444d;
+ --scrollbar-thumb-hover: #536471;
+}
+
+/* Firefox scrollbar styles */
+* {
+ scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
+ scrollbar-width: auto;
+}
+
+/* Webkit scrollbar styles */
+::-webkit-scrollbar {
+ width: 12px;
+ height: 12px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--scrollbar-track);
+ border-radius: 6px;
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--scrollbar-thumb);
+ border-radius: 6px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--scrollbar-thumb-hover);
+}
+
+/* Ensure styles apply to specific components */
+.categories-sidebar,
+.categories-grid,
+.keywords-section,
+.context-builder,
+.simple-mode-container,
+.advanced-mode-container {
+ scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
+ scrollbar-width: auto;
+}
+
+.categories-sidebar::-webkit-scrollbar,
+.categories-grid::-webkit-scrollbar,
+.keywords-section::-webkit-scrollbar,
+.context-builder::-webkit-scrollbar,
+.simple-mode-container::-webkit-scrollbar,
+.advanced-mode-container::-webkit-scrollbar {
+ width: 12px;
+ height: 12px;
+}
+
+.categories-sidebar::-webkit-scrollbar-track,
+.categories-grid::-webkit-scrollbar-track,
+.keywords-section::-webkit-scrollbar-track,
+.context-builder::-webkit-scrollbar-track,
+.simple-mode-container::-webkit-scrollbar-track,
+.advanced-mode-container::-webkit-scrollbar-track {
+ background: var(--scrollbar-track);
+ border-radius: 6px;
+}
+
+.categories-sidebar::-webkit-scrollbar-thumb,
+.categories-grid::-webkit-scrollbar-thumb,
+.keywords-section::-webkit-scrollbar-thumb,
+.context-builder::-webkit-scrollbar-thumb,
+.simple-mode-container::-webkit-scrollbar-thumb,
+.advanced-mode-container::-webkit-scrollbar-thumb {
+ background: var(--scrollbar-thumb);
+ border-radius: 6px;
+}
+
+.categories-sidebar::-webkit-scrollbar-thumb:hover,
+.categories-grid::-webkit-scrollbar-thumb:hover,
+.keywords-section::-webkit-scrollbar-thumb:hover,
+.context-builder::-webkit-scrollbar-thumb:hover,
+.simple-mode-container::-webkit-scrollbar-thumb:hover,
+.advanced-mode-container::-webkit-scrollbar-thumb:hover {
+ background: var(--scrollbar-thumb-hover);
+}
diff --git a/css/components/settings.css b/css/components/settings.css
new file mode 100644
index 0000000..2afb6f1
--- /dev/null
+++ b/css/components/settings.css
@@ -0,0 +1,16 @@
+@import 'settings/settings-nav.css';
+@import 'settings/settings-user-menu.css';
+@import 'settings/settings-sidebar.css';
+@import 'settings/settings-modal.css';
+
+/* Settings Page Layout */
+.page {
+ min-height: 100vh;
+}
+
+/* Media Queries */
+@media (max-width: 768px) {
+ .advanced-layout {
+ flex-direction: column;
+ }
+}
diff --git a/css/components/settings/appearance-controls.css b/css/components/settings/appearance-controls.css
new file mode 100644
index 0000000..7863962
--- /dev/null
+++ b/css/components/settings/appearance-controls.css
@@ -0,0 +1,53 @@
+/* Appearance Settings */
+.settings-option {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.settings-option:last-child {
+ margin-bottom: 0;
+}
+
+/* Color Mode Selection */
+.mode-switch,
+.theme-switch,
+.font-switch {
+ flex: 1;
+ padding: 12px;
+ border: 2px solid var(--border);
+ border-radius: var(--border-radius);
+ background: var(--background-light);
+ color: var(--text);
+ font-size: 14px;
+ cursor: pointer;
+ transition: var(--transition);
+ text-align: center;
+ font-weight: 500;
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.mode-switch:hover,
+.theme-switch:hover,
+.font-switch:hover {
+ background: var(--background);
+ border-color: var(--border-hover);
+}
+
+.mode-switch.active,
+.theme-switch.active,
+.font-switch.active {
+ background: var(--primary-light);
+ border-color: var(--primary);
+ color: var(--primary);
+}
+
+.mode-switch.active:hover,
+.theme-switch.active:hover,
+.font-switch.active:hover {
+ background: var(--primary-light);
+ border-color: var(--primary);
+}
diff --git a/css/components/settings/search-controls.css b/css/components/settings/search-controls.css
new file mode 100644
index 0000000..e8d94d1
--- /dev/null
+++ b/css/components/settings/search-controls.css
@@ -0,0 +1,67 @@
+/* Search and Toggle Controls */
+.search-controls {
+ padding: var(--spacing-md);
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+}
+
+.search-input {
+ width: 100%;
+ height: 36px;
+ padding: 0 var(--spacing-md);
+ border: 2px solid var(--border);
+ border-radius: var(--border-radius);
+ background: var(--background-light);
+ color: var(--text);
+ font-size: 14px;
+ transition: var(--transition);
+}
+
+.search-input:focus {
+ outline: none;
+ border-color: var(--primary);
+ background: var(--surface);
+ box-shadow: 0 0 0 1px var(--primary-light);
+}
+
+.search-input:focus::placeholder {
+ color: transparent;
+}
+
+.search-input::placeholder {
+ color: var(--text-secondary);
+}
+
+.toggle-controls {
+ display: flex;
+ gap: var(--spacing-sm);
+ height: 36px;
+}
+
+.toggle-button {
+ flex: 1;
+ padding: 0 var(--spacing-md);
+ border: 2px solid var(--border);
+ border-radius: var(--border-radius);
+ background: var(--background-light);
+ color: var(--text);
+ font-size: 14px;
+ cursor: pointer;
+ transition: var(--transition);
+ text-align: center;
+ font-weight: 500;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.toggle-button:hover {
+ background: var(--background);
+ border-color: var(--primary);
+}
+
+.toggle-button:active {
+ background: var(--primary-light);
+}
diff --git a/css/components/settings/settings-modal-about.css b/css/components/settings/settings-modal-about.css
new file mode 100644
index 0000000..c4b57dd
--- /dev/null
+++ b/css/components/settings/settings-modal-about.css
@@ -0,0 +1,247 @@
+/* About Tab Styles */
+.about-content {
+ padding: 24px;
+ max-width: 600px;
+ margin: 0 auto;
+}
+
+.about-header {
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ margin-bottom: 24px;
+ padding-bottom: 24px;
+ border-bottom: 1px solid var(--border);
+}
+
+.creator-image-container {
+ width: 80px;
+ height: 80px;
+ flex-shrink: 0;
+ border-radius: 50%;
+ overflow: hidden;
+ background: var(--background);
+ border: 2px solid var(--border);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.creator-image {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ transition: transform 0.3s ease;
+}
+
+.creator-image:hover {
+ transform: scale(1.05);
+}
+
+.about-title {
+ flex: 1;
+}
+
+.about-title h2 {
+ margin: 0;
+ font-size: calc(var(--font-size-default) * 1.5);
+ color: var(--text);
+ font-weight: 600;
+}
+
+.version {
+ display: block;
+ color: var(--text-secondary);
+ font-size: var(--font-size-small);
+ margin-top: 4px;
+}
+
+.about-description {
+ margin-bottom: 24px;
+ line-height: 1.6;
+ color: var(--text);
+}
+
+.about-description p {
+ margin: 0;
+ font-size: calc(var(--font-size-default) * 1.1);
+}
+
+.about-links {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 16px;
+ background: var(--background);
+ border-radius: 12px;
+ margin-bottom: 32px;
+}
+
+.about-link {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px;
+ transition: background-color 0.2s ease;
+}
+
+.about-link:hover {
+ background: var(--surface);
+ border-radius: 8px;
+}
+
+.link-label {
+ color: var(--text-secondary);
+ font-size: var(--font-size-small);
+ min-width: 80px;
+}
+
+.link-value {
+ color: var(--primary);
+ text-decoration: none;
+ font-weight: 500;
+ transition: color 0.2s ease;
+}
+
+.link-value:hover {
+ color: var(--primary-hover);
+ text-decoration: underline;
+}
+
+/* Sponsor Section */
+.sponsor-section {
+ text-align: center;
+ padding: 24px;
+ margin-top: 24px;
+ background: linear-gradient(135deg, #ff79c6, #bd93f9);
+ border-radius: 16px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ transition: transform 0.3s ease, box-shadow 0.3s ease;
+}
+
+.sponsor-section:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
+}
+
+.sponsor-button {
+ display: inline-flex;
+ align-items: center;
+ gap: 12px;
+ padding: 16px 32px;
+ background: rgba(255, 255, 255, 0.95);
+ border-radius: 12px;
+ text-decoration: none;
+ color: #2a2a2a;
+ font-size: calc(var(--font-size-default) * 1.2);
+ font-weight: 600;
+ transition: transform 0.2s ease;
+}
+
+.sponsor-button:hover {
+ transform: scale(1.02);
+}
+
+.sponsor-button img {
+ height: 24px;
+ width: auto;
+}
+
+.sponsor-text {
+ color: white;
+ font-size: calc(var(--font-size-default) * 1.1);
+ margin-bottom: 16px;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
+}
+
+/* Mobile Styles */
+@media (max-width: 768px) {
+ .about-content {
+ padding: 16px 0;
+ }
+
+ .about-header {
+ flex-direction: column;
+ text-align: center;
+ gap: 16px;
+ padding-bottom: 20px;
+ margin-bottom: 20px;
+ }
+
+ .creator-image-container {
+ width: 96px;
+ height: 96px;
+ margin: 0 auto;
+ }
+
+ .about-title h2 {
+ font-size: calc(var(--font-size-default) * 1.3);
+ }
+
+ .about-description {
+ padding: 0 16px;
+ margin-bottom: 20px;
+ }
+
+ .about-description p {
+ font-size: var(--font-size-default);
+ }
+
+ .about-links {
+ margin: 0 16px 24px;
+ padding: 12px;
+ gap: 8px;
+ }
+
+ .about-link {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+ padding: 12px;
+ min-height: 44px; /* Better touch target */
+ }
+
+ .link-label {
+ min-width: auto;
+ }
+
+ .link-value {
+ font-size: calc(var(--font-size-default) * 0.95);
+ word-break: break-word;
+ }
+
+ .sponsor-section {
+ margin: 16px;
+ padding: 20px;
+ }
+
+ .sponsor-button {
+ width: 100%;
+ justify-content: center;
+ padding: 14px 24px;
+ font-size: var(--font-size-default);
+ min-height: 48px; /* Better touch target */
+ }
+
+ .sponsor-text {
+ font-size: var(--font-size-default);
+ margin-bottom: 12px;
+ }
+
+ /* Add touch feedback */
+ .about-link:active,
+ .sponsor-button:active {
+ opacity: 0.7;
+ transition: opacity 0.1s;
+ }
+}
+
+/* Small mobile adjustments */
+@media (max-width: 360px) {
+ .about-title h2 {
+ font-size: calc(var(--font-size-default) * 1.2);
+ }
+
+ .sponsor-button {
+ padding: 12px 20px;
+ font-size: calc(var(--font-size-default) * 0.95);
+ }
+}
diff --git a/css/components/settings/settings-modal-controls.css b/css/components/settings/settings-modal-controls.css
new file mode 100644
index 0000000..5934c81
--- /dev/null
+++ b/css/components/settings/settings-modal-controls.css
@@ -0,0 +1,109 @@
+/* Button Groups */
+.button-group {
+ display: flex;
+ background: var(--background);
+ border-radius: 24px;
+ padding: 4px;
+ width: 100%;
+ gap: 4px;
+}
+
+/* Theme Mode Switches */
+.button-group .theme-mode-switch {
+ flex: 1;
+ padding: 8px 16px;
+ border: none;
+ background: none;
+ color: var(--text-secondary);
+ cursor: pointer;
+ font-size: var(--font-size-base);
+ font-weight: 500;
+ border-radius: 20px;
+ transition: var(--transition);
+ min-height: 40px; /* Better touch target */
+}
+
+.button-group .theme-mode-switch:hover {
+ color: var(--text);
+}
+
+.button-group .theme-mode-switch.active {
+ background: var(--surface);
+ color: var(--text);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+/* Font Switches */
+.button-group .font-switch {
+ flex: 1;
+ padding: 8px 16px;
+ border: none;
+ background: none;
+ color: var(--text-secondary);
+ cursor: pointer;
+ font-size: var(--font-size-base);
+ font-weight: 500;
+ border-radius: 20px;
+ transition: var(--transition);
+ min-height: 40px; /* Better touch target */
+}
+
+.button-group .font-switch:hover {
+ color: var(--text);
+}
+
+.button-group .font-switch.active {
+ background: var(--surface);
+ color: var(--text);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+/* Mobile Styles */
+@media (max-width: 768px) {
+ .button-group {
+ flex-wrap: wrap;
+ background: none;
+ padding: 0;
+ gap: 8px;
+ }
+
+ /* Make buttons stack on very small screens */
+ @media (max-width: 360px) {
+ .button-group .theme-mode-switch,
+ .button-group .font-switch {
+ flex: 1 0 100%;
+ justify-content: center;
+ min-height: 44px; /* Even larger touch target for very small screens */
+ background: var(--background);
+ }
+ }
+
+ /* Larger buttons for better touch targets */
+ .button-group .theme-mode-switch,
+ .button-group .font-switch {
+ flex: 1 0 auto;
+ min-width: calc(33.33% - 6px); /* 3 buttons per row with gap */
+ padding: 10px 16px;
+ font-size: 0.95rem;
+ background: var(--background);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ }
+
+ /* Active state more prominent on mobile */
+ .button-group .theme-mode-switch.active,
+ .button-group .font-switch.active {
+ background: var(--primary);
+ color: var(--surface);
+ box-shadow: none;
+ }
+
+ /* Improve tap feedback */
+ .button-group .theme-mode-switch:active,
+ .button-group .font-switch:active {
+ transform: scale(0.98);
+ transition: transform 0.1s;
+ }
+}
diff --git a/css/components/settings/settings-modal-core.css b/css/components/settings/settings-modal-core.css
new file mode 100644
index 0000000..04bc42a
--- /dev/null
+++ b/css/components/settings/settings-modal-core.css
@@ -0,0 +1,182 @@
+/* Core Animations */
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* Modal Base Styles */
+.modal {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 1000;
+ overflow-y: auto;
+ padding: 20px;
+}
+
+.modal-content {
+ position: relative;
+ background: var(--surface);
+ margin: 40px auto;
+ padding: 24px;
+ border-radius: var(--border-radius);
+ max-width: 600px;
+ box-shadow: var(--modal-shadow);
+ animation: fadeIn 0.2s ease-out;
+}
+
+.modal-close {
+ position: absolute;
+ right: 16px;
+ top: 16px;
+ background: none;
+ border: none;
+ font-size: 24px;
+ color: var(--text-secondary);
+ cursor: pointer;
+ padding: 4px 8px;
+ border-radius: var(--border-radius-sm);
+ transition: var(--transition);
+ z-index: 2;
+}
+
+.modal-close:hover {
+ background: var(--surface-hover);
+ color: var(--text);
+}
+
+/* Settings Content */
+.settings-content {
+ display: none;
+ padding: 20px 0;
+}
+
+.settings-content.active {
+ display: block;
+}
+
+.settings-group {
+ margin-bottom: 24px;
+}
+
+.settings-group h3 {
+ margin: 0 0 16px;
+ font-size: 1.1rem;
+ color: var(--text);
+}
+
+.button-group {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+/* Mobile Styles */
+@media (max-width: 768px) {
+ .modal {
+ padding: 0;
+ background: var(--surface);
+ overflow: hidden;
+ }
+
+ .modal-content {
+ margin: 0;
+ width: 100%;
+ height: 100vh;
+ max-width: 100%;
+ max-height: 100vh;
+ border-radius: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ box-shadow: none;
+ overflow: hidden;
+ }
+
+ /* Fixed header with close button */
+ .modal-close {
+ position: fixed;
+ top: 0;
+ right: 0;
+ width: 48px;
+ height: 48px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ font-size: 20px;
+ background: var(--surface);
+ border-radius: 0;
+ border-left: 1px solid var(--border);
+ z-index: 3;
+ margin: 0;
+ }
+
+ /* Content area */
+ .settings-content {
+ padding: 64px 16px 16px;
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+ -webkit-overflow-scrolling: touch;
+ width: 100%;
+ max-width: 100vw;
+ box-sizing: border-box;
+ }
+
+ .settings-group {
+ margin-bottom: 20px;
+ width: 100%;
+ max-width: 100%;
+ }
+
+ /* Adjust button groups */
+ .button-group {
+ margin: 0;
+ width: 100%;
+ }
+
+ .button-group button {
+ flex: 1;
+ min-width: 0;
+ padding: 8px;
+ font-size: 0.9rem;
+ white-space: nowrap;
+ }
+
+ /* Adjust radio and checkbox options */
+ .settings-option {
+ padding: 12px 0;
+ width: 100%;
+ }
+
+ /* Adjust footer warning */
+ .modal-footer {
+ padding: 16px;
+ background: var(--surface);
+ border-top: 1px solid var(--border);
+ width: 100%;
+ box-sizing: border-box;
+ }
+
+ .settings-warning {
+ font-size: 0.9rem;
+ padding: 12px;
+ }
+
+ /* Ensure all content is contained */
+ * {
+ max-width: 100vw;
+ box-sizing: border-box;
+ }
+}
diff --git a/css/components/settings/settings-modal-footer.css b/css/components/settings/settings-modal-footer.css
new file mode 100644
index 0000000..1b87ce5
--- /dev/null
+++ b/css/components/settings/settings-modal-footer.css
@@ -0,0 +1,30 @@
+/* Modal Footer */
+.modal-footer {
+ padding: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 16px;
+}
+
+.modal-footer .btn-primary {
+ min-width: 200px;
+ height: 40px;
+ font-size: var(--font-size-default);
+ font-weight: 500;
+}
+
+/* Footer */
+.last-update {
+ padding: 16px;
+ border-top: 1px solid var(--border);
+ color: var(--text-secondary);
+ font-size: var(--font-size-small);
+ text-align: center;
+}
+
+/* Appearance Settings Specific Styles */
+.settings-content[data-content="appearance"] .settings-group h3 {
+ font-size: calc(var(--font-size-default) * 1.3);
+ margin-bottom: 16px;
+}
diff --git a/css/components/settings/settings-modal-header.css b/css/components/settings/settings-modal-header.css
new file mode 100644
index 0000000..3ffbb2d
--- /dev/null
+++ b/css/components/settings/settings-modal-header.css
@@ -0,0 +1,33 @@
+/* Modal Header */
+.modal-header {
+ padding: 16px;
+ border-bottom: 1px solid var(--border);
+}
+
+.modal-header h2 {
+ margin: 0;
+}
+
+.modal-close {
+ background: none;
+ border: none;
+ font-size: calc(1.6rem * var(--font-scale)); /* 24px equivalent */
+ cursor: pointer;
+ padding: 0;
+ color: var(--text-secondary);
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ transition: var(--transition);
+ position: absolute;
+ right: 16px;
+ top: 16px;
+}
+
+.modal-close:hover {
+ background: var(--background);
+ color: var(--text);
+}
diff --git a/css/components/settings/settings-modal-warning.css b/css/components/settings/settings-modal-warning.css
new file mode 100644
index 0000000..b38c79e
--- /dev/null
+++ b/css/components/settings/settings-modal-warning.css
@@ -0,0 +1,27 @@
+/* Settings Warning Message */
+.settings-warning {
+ background: var(--warning-background, #fff3cd);
+ border: 1px solid var(--warning-border, #ffeeba);
+ color: var(--warning-text, #856404);
+ padding: 8px 12px;
+ border-radius: 8px;
+ margin-right: 16px;
+ font-size: var(--font-size-small);
+ line-height: 1.3;
+ min-height: 40px;
+ align-items: center;
+ flex: 1;
+ display: none !important; /* Hidden by default, let JS control visibility */
+}
+
+/* Only show when JavaScript sets display:flex explicitly */
+.settings-warning.visible {
+ display: flex !important;
+ animation: fadeIn 0.3s ease-in-out;
+}
+
+/* Hide warning when not on muting tab */
+.settings-content[data-content="appearance"].active ~ .modal-footer .settings-warning,
+.settings-content[data-content="about"].active ~ .modal-footer .settings-warning {
+ display: none !important;
+}
diff --git a/css/components/settings/settings-modal.css b/css/components/settings/settings-modal.css
new file mode 100644
index 0000000..d76e1e2
--- /dev/null
+++ b/css/components/settings/settings-modal.css
@@ -0,0 +1,9 @@
+/* Settings Modal Styles - Split for maintainability */
+
+/* Order is important - core styles and animations first, then specific components */
+@import 'settings-modal-core.css';
+@import 'settings-modal-warning.css';
+@import 'settings-modal-header.css';
+@import 'settings-modal-controls.css';
+@import 'settings-modal-footer.css';
+@import 'settings-modal-about.css';
diff --git a/css/components/settings/settings-nav.css b/css/components/settings/settings-nav.css
new file mode 100644
index 0000000..90bcb6a
--- /dev/null
+++ b/css/components/settings/settings-nav.css
@@ -0,0 +1,48 @@
+/* Navigation */
+.top-nav {
+ background: var(--surface);
+ padding: var(--spacing-md) var(--spacing-lg);
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-md);
+}
+
+.brand h1 {
+ font-size: 1.5rem;
+ margin: 0;
+ flex: 1;
+}
+
+.mode-toggle {
+ position: absolute;
+ left: 50%;
+ transform: translateX(-50%);
+}
+
+.nav-group {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-md);
+ flex: 1;
+ justify-content: flex-end;
+}
+
+@media (max-width: 768px) {
+ .top-nav {
+ flex-direction: column;
+ align-items: stretch;
+ padding: 8px;
+ }
+
+ .mode-toggle {
+ position: static;
+ transform: none;
+ margin: 8px 0;
+ }
+
+ .nav-group {
+ justify-content: center;
+ gap: 8px;
+ }
+}
diff --git a/css/components/settings/settings-sidebar.css b/css/components/settings/settings-sidebar.css
new file mode 100644
index 0000000..9b7f744
--- /dev/null
+++ b/css/components/settings/settings-sidebar.css
@@ -0,0 +1,5 @@
+/* Settings Sidebar Component - Split into modular files */
+@import 'sidebar-layout.css';
+@import 'search-controls.css';
+@import 'settings-tabs.css';
+@import 'appearance-controls.css';
diff --git a/css/components/settings/settings-tabs.css b/css/components/settings/settings-tabs.css
new file mode 100644
index 0000000..15dec59
--- /dev/null
+++ b/css/components/settings/settings-tabs.css
@@ -0,0 +1,97 @@
+/* Settings Tabs */
+.settings-tabs {
+ display: flex;
+ gap: 0;
+ border-bottom: 1px solid var(--border);
+ background: var(--surface);
+ padding: 0 16px;
+}
+
+.settings-tab {
+ padding: 16px 24px;
+ border: none;
+ background: none;
+ color: var(--text-secondary);
+ cursor: pointer;
+ font-size: 18px;
+ font-weight: 600;
+ position: relative;
+ transition: var(--transition);
+ white-space: nowrap;
+}
+
+.settings-tab:hover {
+ color: var(--text);
+}
+
+.settings-tab.active {
+ color: var(--primary);
+}
+
+.settings-tab.active::after {
+ content: '';
+ position: absolute;
+ bottom: -1px;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background: var(--primary);
+}
+
+.settings-content {
+ display: none;
+ padding: 24px;
+}
+
+.settings-content.active {
+ display: block;
+}
+
+/* Mobile Styles */
+@media (max-width: 768px) {
+ .settings-tabs {
+ padding: 0;
+ height: 48px;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 48px;
+ z-index: 2;
+ background: var(--surface);
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ width: calc(100% - 48px);
+ overflow: hidden;
+ }
+
+ .settings-tab {
+ padding: 0;
+ height: 48px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ min-width: 0;
+ text-align: center;
+ width: 100%;
+ }
+
+ /* Adjust the active indicator */
+ .settings-tab.active::after {
+ height: 3px;
+ border-radius: 0;
+ }
+
+ /* Add touch feedback */
+ .settings-tab:active {
+ opacity: 0.7;
+ transition: opacity 0.1s;
+ }
+}
+
+/* Small mobile adjustments */
+@media (max-width: 360px) {
+ .settings-tab {
+ font-size: 13px;
+ }
+}
diff --git a/css/components/settings/settings-user-menu.css b/css/components/settings/settings-user-menu.css
new file mode 100644
index 0000000..de0fce7
--- /dev/null
+++ b/css/components/settings/settings-user-menu.css
@@ -0,0 +1,95 @@
+/* User Menu */
+.user-menu {
+ position: relative;
+}
+
+.profile-button {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ padding: 8px;
+ border-radius: 9999px;
+ transition: var(--transition);
+}
+
+.profile-button:hover {
+ background: var(--background);
+}
+
+.user-name {
+ color: var(--text);
+ font-size: 15px;
+ font-weight: 500;
+}
+
+.profile-pic {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background: var(--primary-light);
+}
+
+.user-menu-dropdown {
+ position: absolute;
+ top: 100%;
+ right: 0;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ box-shadow: 0 4px 12px var(--shadow);
+ min-width: 240px;
+ padding: 8px;
+ margin-top: 4px;
+ display: none;
+ z-index: 1000;
+}
+
+.user-menu-dropdown.visible {
+ display: block;
+}
+
+.user-menu-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 16px;
+ color: var(--text);
+ text-decoration: none;
+ border-radius: 8px;
+ transition: var(--transition);
+ cursor: pointer;
+ font-size: 15px;
+}
+
+.user-menu-item:hover {
+ background: var(--background);
+}
+
+.user-menu-item.profile-info {
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 8px;
+ padding-bottom: 12px;
+ cursor: default;
+}
+
+.user-menu-item.profile-info:hover {
+ background: none;
+}
+
+.user-menu-item.refresh {
+ color: var(--primary);
+}
+
+.user-menu-item.moderation-link {
+ color: var(--text);
+}
+
+.user-menu-item.logout {
+ color: var(--danger);
+ border-top: 1px solid var(--border);
+ margin-top: 8px;
+ padding-top: 12px;
+}
diff --git a/css/components/settings/sidebar-layout.css b/css/components/settings/sidebar-layout.css
new file mode 100644
index 0000000..f187971
--- /dev/null
+++ b/css/components/settings/sidebar-layout.css
@@ -0,0 +1,52 @@
+/* Sidebar Layout */
+.categories-sidebar {
+ width: 320px;
+ background: var(--surface);
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ border-top-left-radius: var(--border-radius);
+ border-bottom-left-radius: var(--border-radius);
+ margin: var(--spacing-xs) 0 var(--spacing-xs) var(--spacing-xs);
+ box-shadow: 1px 0 2px rgba(0, 0, 0, 0.05);
+}
+
+.sidebar-header {
+ padding: 24px;
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.category-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 16px;
+}
+
+/* Settings Groups */
+.settings-group {
+ margin-bottom: 24px;
+}
+
+.settings-group:last-child {
+ margin-bottom: 0;
+}
+
+.settings-group h3 {
+ margin-bottom: 16px;
+ font-size: 15px;
+ font-weight: 500;
+ color: var(--text);
+}
+
+@media (max-width: 768px) {
+ .categories-sidebar {
+ width: 100%;
+ height: auto;
+ margin: var(--spacing-xs);
+ border-radius: var(--border-radius);
+ }
+}
diff --git a/css/components/simple-mode.css b/css/components/simple-mode.css
new file mode 100644
index 0000000..75aac10
--- /dev/null
+++ b/css/components/simple-mode.css
@@ -0,0 +1,59 @@
+/* Container styles */
+#simple-mode {
+ max-width: 900px;
+ margin: 0 auto;
+}
+
+/* Component styles */
+simple-mode {
+ display: block;
+}
+
+simple-mode .intro-text {
+ font-size: 1.125rem;
+ line-height: 1.7;
+ color: var(--text-primary);
+ text-align: left;
+ margin: 0 auto 2rem;
+ letter-spacing: 0.01em;
+ opacity: 0.95;
+ max-width: 800px;
+ padding: 0 2rem;
+}
+
+simple-mode .interface-mode {
+ width: 100%;
+}
+
+/* Keep intro text at top with minimal spacing */
+simple-mode .context-builder-inner {
+ padding-top: var(--spacing-sm);
+}
+
+/* Exceptions panel heading */
+simple-mode #exceptions-panel h2 {
+ position: relative;
+}
+
+/* Mobile optimizations */
+@media (max-width: 768px) {
+ simple-mode .context-builder-inner {
+ padding-top: 0;
+ }
+
+ simple-mode .intro-text {
+ font-size: 1rem;
+ line-height: 1.5;
+ margin: var(--spacing-sm) 0 var(--spacing-sm) 0; /* Increased top margin slightly */
+ padding: 0 var(--spacing-md);
+ }
+
+ simple-mode #exceptions-panel h2 {
+ font-size: 0;
+ }
+
+ simple-mode #exceptions-panel h2::before {
+ content: "Keep showing...";
+ font-size: 1.5rem;
+ }
+}
diff --git a/css/components/slider.css b/css/components/slider.css
new file mode 100644
index 0000000..a595e71
--- /dev/null
+++ b/css/components/slider.css
@@ -0,0 +1,123 @@
+.filter-slider {
+ width: 100%;
+ margin: 2rem 0;
+}
+
+.filter-slider h2,
+.context-selector h2,
+.exceptions-panel h2 {
+ margin-bottom: var(--spacing-sm);
+ color: var(--text);
+ font-size: 1.5rem;
+ font-weight: 600;
+}
+
+.filter-note {
+ position: relative;
+ color: var(--text);
+ font-size: 1rem;
+ line-height: 1.5;
+ margin-bottom: var(--spacing-md);
+ padding: 0.75rem 1rem 0.75rem 2.5rem;
+ background: var(--warning-bg, rgba(255, 193, 7, 0.1));
+ border-left: 3px solid var(--warning, #ffc107);
+ border-radius: 4px;
+}
+
+.filter-note::before {
+ content: "⚠️";
+ position: absolute;
+ left: 0.75rem;
+ top: 0.75rem;
+}
+
+.filter-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: var(--spacing-md);
+ margin: 1rem 0;
+ width: 100%;
+}
+
+@media (max-width: 768px) {
+ .filter-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+.filter-card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--border-radius);
+ padding: var(--spacing-md);
+ cursor: pointer;
+ transition: var(--button-transition);
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-xs);
+}
+
+.filter-card h3 {
+ font-size: 1rem;
+ font-weight: 600;
+ margin: 0;
+ color: var(--text);
+}
+
+.filter-card p {
+ font-size: 0.85rem;
+ line-height: 1.4;
+ margin: 0;
+ color: var(--text-secondary);
+}
+
+.filter-card:hover {
+ background: var(--background-light);
+}
+
+.filter-card.active {
+ background: var(--primary);
+ border-color: var(--primary);
+}
+
+.filter-card.active h3,
+.filter-card.active p {
+ color: #ffffff;
+}
+
+/* Dark Theme Adjustments */
+[data-theme="dark"] .filter-card:hover {
+ background: var(--background);
+}
+
+[data-theme="dark"] .filter-card.active {
+ background: var(--primary);
+ border-color: var(--primary);
+}
+
+/* Context Builder Styles */
+.context-selector {
+ margin-bottom: var(--spacing-xl);
+}
+
+/* Exception Tags Styles */
+.exceptions-panel {
+ display: none;
+}
+
+.exceptions-panel.visible {
+ display: block;
+}
+
+.exception-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--spacing-sm);
+}
+
+/* Bottom Spacing */
+.bottom-spacing {
+ height: 60px;
+ width: 100%;
+}
diff --git a/css/components/toggles.css b/css/components/toggles.css
new file mode 100644
index 0000000..8a64ece
--- /dev/null
+++ b/css/components/toggles.css
@@ -0,0 +1 @@
+@import './toggles/index.css';
diff --git a/css/components/toggles/appearance-settings.css b/css/components/toggles/appearance-settings.css
new file mode 100644
index 0000000..7473be1
--- /dev/null
+++ b/css/components/toggles/appearance-settings.css
@@ -0,0 +1,46 @@
+/* Appearance Settings */
+#appearance-modal .settings-option {
+ display: flex;
+ background: var(--background);
+ padding: 3px;
+ border-radius: 9999px;
+ border: 1px solid var(--border);
+ margin-top: 8px;
+}
+
+#appearance-modal .mode-switch,
+#appearance-modal .theme-switch,
+#appearance-modal .font-switch {
+ flex: 1;
+ padding: 6px 16px;
+ border: none;
+ border-radius: 9999px;
+ background: transparent;
+ color: var(--text-secondary);
+ font-size: 15px;
+ font-weight: 400;
+ cursor: pointer;
+ transition: var(--transition);
+ text-align: center;
+}
+
+#appearance-modal .mode-switch:hover,
+#appearance-modal .theme-switch:hover,
+#appearance-modal .font-switch:hover {
+ color: var(--text);
+}
+
+#appearance-modal .mode-switch.active,
+#appearance-modal .theme-switch.active,
+#appearance-modal .font-switch.active {
+ background: var(--surface);
+ color: var(--text);
+ font-weight: 600;
+}
+
+#appearance-modal p {
+ color: var(--text-secondary);
+ font-size: 14px;
+ margin: 6px 0;
+ line-height: 1.4;
+}
diff --git a/css/components/toggles/checkboxes.css b/css/components/toggles/checkboxes.css
new file mode 100644
index 0000000..9c200d6
--- /dev/null
+++ b/css/components/toggles/checkboxes.css
@@ -0,0 +1,47 @@
+/* Checkbox Styling */
+.settings-option input[type="checkbox"] {
+ position: absolute;
+ opacity: 0;
+ width: 20px;
+ height: 20px;
+ margin: 0;
+ cursor: pointer;
+ z-index: 1;
+}
+
+.settings-option .checkbox-box {
+ position: relative;
+ width: 20px;
+ height: 20px;
+ border: 2px solid var(--text-secondary);
+ border-radius: 6px;
+ margin-right: 10px;
+ transition: all 0.2s ease;
+ pointer-events: none;
+ flex-shrink: 0;
+ background: var(--surface);
+}
+
+.settings-option input[type="checkbox"]:checked + .checkbox-box {
+ background: var(--primary);
+ border-color: var(--primary);
+}
+
+.settings-option .checkbox-box:after {
+ content: '';
+ position: absolute;
+ top: 45%;
+ left: 50%;
+ width: 10px;
+ height: 6px;
+ border-left: 2px solid white;
+ border-bottom: 2px solid white;
+ transform-origin: center;
+ transform: translate(-50%, -50%) scale(0) rotate(-45deg);
+ transition: transform 0.2s ease;
+ pointer-events: none;
+}
+
+.settings-option input[type="checkbox"]:checked + .checkbox-box:after {
+ transform: translate(-50%, -50%) scale(1) rotate(-45deg);
+}
diff --git a/css/components/toggles/index.css b/css/components/toggles/index.css
new file mode 100644
index 0000000..7547be9
--- /dev/null
+++ b/css/components/toggles/index.css
@@ -0,0 +1,6 @@
+@import './mode-toggle.css';
+@import './toggle-all.css';
+@import './radio-buttons.css';
+@import './checkboxes.css';
+@import './settings-groups.css';
+@import './appearance-settings.css';
diff --git a/css/components/toggles/mode-toggle.css b/css/components/toggles/mode-toggle.css
new file mode 100644
index 0000000..f84c09b
--- /dev/null
+++ b/css/components/toggles/mode-toggle.css
@@ -0,0 +1,35 @@
+/* Mode Toggle in Top Nav */
+.top-nav .mode-toggle {
+ display: flex;
+ gap: 4px;
+ background: var(--background);
+ padding: 4px;
+ border-radius: 9999px;
+ border: 1px solid var(--border);
+ width: auto;
+}
+
+.top-nav .mode-switch {
+ padding: 8px 24px;
+ border: none;
+ border-radius: 9999px;
+ background: transparent;
+ cursor: pointer;
+ transition: var(--transition);
+ color: var(--text-secondary);
+ font-size: 15px;
+ font-weight: 400;
+ text-align: center;
+ white-space: nowrap;
+}
+
+/* Common hover and active states */
+.mode-switch:hover {
+ color: var(--text);
+}
+
+.mode-switch.active {
+ background: var(--surface);
+ color: var(--text);
+ font-weight: 600;
+}
diff --git a/css/components/toggles/radio-buttons.css b/css/components/toggles/radio-buttons.css
new file mode 100644
index 0000000..3c4f1f4
--- /dev/null
+++ b/css/components/toggles/radio-buttons.css
@@ -0,0 +1,73 @@
+/* Muting Settings Radio Buttons */
+.settings-option {
+ position: relative;
+ display: flex;
+ align-items: center;
+ padding: 10px 14px;
+ margin: 6px 0;
+ border-radius: 12px;
+ transition: var(--transition);
+ cursor: pointer;
+ background: var(--background);
+ border: 1px solid transparent;
+}
+
+.settings-option:hover {
+ border-color: var(--border);
+}
+
+.settings-option input[type="radio"] {
+ position: absolute;
+ opacity: 0;
+ width: 20px;
+ height: 20px;
+ margin: 0;
+ cursor: pointer;
+ z-index: 1;
+}
+
+.settings-option .radio-circle {
+ position: relative;
+ width: 20px;
+ height: 20px;
+ border: 2px solid var(--text-secondary);
+ border-radius: 50%;
+ margin-right: 10px;
+ transition: all 0.2s ease;
+ pointer-events: none;
+ flex-shrink: 0;
+ background: var(--surface);
+}
+
+.settings-option input[type="radio"]:checked + .radio-circle {
+ border-color: var(--primary);
+ border-width: 2px;
+}
+
+.settings-option .radio-circle:after {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%) scale(0);
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--primary);
+ transition: transform 0.2s ease;
+ pointer-events: none;
+}
+
+.settings-option input[type="radio"]:checked + .radio-circle:after {
+ transform: translate(-50%, -50%) scale(1);
+}
+
+.settings-option label {
+ flex: 1;
+ font-size: 15px;
+ font-weight: 500;
+ color: var(--text);
+ margin-left: 8px;
+ cursor: pointer;
+ user-select: none;
+}
diff --git a/css/components/toggles/settings-groups.css b/css/components/toggles/settings-groups.css
new file mode 100644
index 0000000..2e72978
--- /dev/null
+++ b/css/components/toggles/settings-groups.css
@@ -0,0 +1,11 @@
+/* Settings Groups */
+.settings-group {
+ margin-bottom: 16px;
+}
+
+.settings-group h3 {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--text);
+ margin-bottom: 12px;
+}
diff --git a/css/components/toggles/toggle-all.css b/css/components/toggles/toggle-all.css
new file mode 100644
index 0000000..7140743
--- /dev/null
+++ b/css/components/toggles/toggle-all.css
@@ -0,0 +1,29 @@
+/* Toggle All Controls */
+.toggle-all-controls {
+ display: flex;
+ gap: 8px;
+ padding: 4px;
+}
+
+.toggle-all-btn {
+ flex: 1;
+ padding: 8px 16px;
+ border: 1px solid var(--border);
+ border-radius: 9999px;
+ background: var(--surface);
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text);
+ cursor: pointer;
+ transition: var(--transition);
+}
+
+.toggle-all-btn:hover {
+ background: var(--background);
+}
+
+.toggle-all-btn.active {
+ background: var(--primary);
+ color: white;
+ border-color: var(--primary);
+}
diff --git a/css/index.css b/css/index.css
new file mode 100644
index 0000000..b270823
--- /dev/null
+++ b/css/index.css
@@ -0,0 +1,8 @@
+/* Base styles and variables */
+@import './base.css';
+
+/* Reusable components */
+@import './components.css';
+
+/* Layout and structure */
+@import './layout.css';
diff --git a/css/layout.css b/css/layout.css
new file mode 100644
index 0000000..22328ab
--- /dev/null
+++ b/css/layout.css
@@ -0,0 +1,54 @@
+/* Import specific layout components */
+@import 'components/landing.css';
+@import 'components/settings.css';
+
+/* Landing Page Feature Grid */
+.feature-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 32px;
+ margin: 16px 0 40px;
+ /* Reduced top margin from 40px to 16px */
+ max-width: 100%;
+}
+
+/* Other Grid Layouts */
+.context-options {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: 24px;
+ margin-top: 24px;
+}
+
+.keywords-container {
+ column-count: 3;
+ column-gap: 24px;
+ margin-top: 16px;
+}
+
+/* Media Queries for Grid Layouts */
+@media (max-width: 1024px) {
+ .feature-grid {
+ gap: 24px;
+ margin: 16px 0 32px;
+ /* Adjusted margins */
+ }
+}
+
+@media (max-width: 768px) {
+ .keywords-container {
+ column-count: 1;
+ }
+
+ .feature-grid {
+ grid-template-columns: 1fr;
+ gap: 20px;
+ margin: 12px 0 24px;
+ /* Further reduced margins for mobile */
+ }
+
+ .context-options {
+ grid-template-columns: 1fr;
+ gap: 20px;
+ }
+}
diff --git a/docs/1-architecture/1-core-concepts.md b/docs/1-architecture/1-core-concepts.md
new file mode 100644
index 0000000..0985954
--- /dev/null
+++ b/docs/1-architecture/1-core-concepts.md
@@ -0,0 +1,191 @@
+# Core Architecture Concepts
+
+## Overview
+
+MuteSky is built around several key architectural concepts that form its foundation:
+
+## State Management
+
+### Core State Components
+```javascript
+export const state = {
+ // Authentication
+ authenticated: false,
+ did: null, // Track current user's DID
+
+ // Mode
+ mode: 'simple', // 'simple' or 'advanced'
+
+ // Keywords and Groups
+ keywordGroups: {}, // All available keyword groups
+ contextGroups: {}, // All available context groups
+ displayConfig: {}, // UI display configuration
+
+ // Keyword Sets
+ activeKeywords: new Set(), // Currently checked keywords (only from our list)
+ originalMutedKeywords: new Set(), // All user's muted keywords (for safety check)
+ sessionMutedKeywords: new Set(), // New keywords muted this session
+ manuallyUnchecked: new Set(), // Keywords user has manually unchecked (persists across sessions)
+
+ // Selection State
+ selectedContexts: new Set(), // Currently selected contexts
+ selectedExceptions: new Set(), // Categories marked as exceptions
+ selectedCategories: new Set(), // Currently selected categories
+
+ // UI State
+ searchTerm: '', // Current search filter
+ filterMode: 'all', // Current filter mode
+ menuOpen: false, // Menu visibility state
+ lastModified: null, // Last-Modified header from keywords file
+
+ // Filter Settings
+ filterLevel: 0, // Current filter level (0=Minimal to 3=Complete)
+ lastBulkAction: null // Track when enable/disable all is used
+}
+```
+
+### State Persistence
+
+1. Storage Key Generation
+```javascript
+function getStorageKey() {
+ if (!state.did) {
+ throw new Error('No DID set in state');
+ }
+ return `muteskyState-${state.did}`;
+}
+```
+
+2. Saved State Structure
+```javascript
+const saveData = {
+ activeKeywords: Array.from(state.activeKeywords),
+ selectedCategories: Array.from(state.selectedCategories),
+ selectedContexts: Array.from(state.selectedContexts),
+ selectedExceptions: Array.from(state.selectedExceptions),
+ manuallyUnchecked: Array.from(state.manuallyUnchecked),
+ mode: state.mode,
+ lastModified: state.lastModified,
+ filterLevel: state.filterLevel,
+ lastBulkAction: state.lastBulkAction
+}
+```
+
+3. State Loading Process
+- Clear existing selections (except manuallyUnchecked)
+- Load saved state from localStorage
+- Restore case-sensitive keywords using keyword map
+- Initialize default values if no saved state
+- Preserve manuallyUnchecked across errors
+
+4. Error Recovery
+```javascript
+try {
+ // Load state operations
+} catch (error) {
+ // Preserve manuallyUnchecked set
+ const unchecked = new Set(state.manuallyUnchecked);
+ resetState();
+ state.manuallyUnchecked = unchecked;
+}
+```
+
+### State Reset Behavior
+
+1. Preserved State:
+ - Authentication (did, authenticated)
+ - Mute state (originalMutedKeywords, sessionMutedKeywords)
+ - Manual unchecks (manuallyUnchecked)
+
+2. Reset State:
+ - Mode (returns to 'simple')
+ - Selections (contexts, exceptions, categories)
+ - UI state (search, filter, menu)
+ - Filter level (returns to 0)
+
+### Cache Management
+
+1. Cache Structure
+```javascript
+const cache = {
+ keywords: new Map(), // Cached keywords by category
+ categoryStates: new Map(), // Cached category states
+ contextKeywords: new Map(), // Cached context-specific keywords
+ activeKeywordsByCategory: new Map(), // Active keywords per category
+ lastUpdate: 0 // For throttling updates
+}
+```
+
+2. Cache Invalidation
+```javascript
+invalidateCategory(category) {
+ const now = Date.now();
+ if (now - this.lastUpdate < 50) return;
+ this.lastUpdate = now;
+ // Clear relevant caches
+}
+```
+
+3. Performance Optimization
+```javascript
+const debouncedUpdate = (() => {
+ let timeout;
+ return (fn) => {
+ if (timeout) clearTimeout(timeout);
+ timeout = setTimeout(() => {
+ fn();
+ notifyKeywordChanges();
+ }, 16);
+ };
+})();
+```
+
+## Mode System
+
+### Simple Mode
+- Context-based filtering with filter levels (0-3)
+- Keywords derived from selected contexts
+- Exceptions for granular control
+- Filter levels determine keyword thresholds:
+ * Level 0 (Minimal) = Most restrictive
+ * Level 1 (Moderate) = Balanced filtering
+ * Level 2 (Extensive) = Broader inclusion
+ * Level 3 (Complete) = Most inclusive
+
+### Advanced Mode
+- Direct keyword management
+- Source of truth for keyword state
+- Maintains original case of keywords
+- No automatic keyword rebuilding
+
+### Mode Synchronization
+- Advanced mode is source of truth
+- Simple mode derives from advanced mode state
+- Changes in advanced mode reflect in simple mode
+- Exceptions preserved across mode switches
+
+## Best Practices
+
+1. State Operations
+ - Always verify DID before state operations
+ - Use consistent auth state checking
+ - Maintain proper mode hierarchy
+ - Defer state saves to explicit actions
+
+2. Error Handling
+ - Preserve manuallyUnchecked across errors
+ - Handle case sensitivity properly
+ - Validate state before operations
+ - Provide clear error messages
+
+3. Performance
+ - Use cache for expensive calculations
+ - Throttle rapid updates (50ms threshold)
+ - Clear cache on filter level changes
+ - Batch process large operations
+
+4. Mode Management
+ - Respect mode hierarchy
+ - Preserve exceptions when valid
+ - Update UI immediately
+ - Defer persistence to mute/unmute
diff --git a/docs/1-architecture/10-custom-muting.md b/docs/1-architecture/10-custom-muting.md
new file mode 100644
index 0000000..9bef57f
--- /dev/null
+++ b/docs/1-architecture/10-custom-muting.md
@@ -0,0 +1,210 @@
+# Custom Muting System Implementation
+
+## Overview
+
+MuteSky's custom muting system is designed to be non-destructive - it respects and preserves user's existing muted keywords while providing a curated list of additional keywords to mute. This document details the implementation specifics and example scenarios.
+
+## Implementation Details
+
+### 1. Keyword Type Handling
+```javascript
+// Store all keywords in lowercase for comparison
+userKeywords.forEach(keyword => {
+ const lowerKeyword = keyword.toLowerCase();
+ state.originalMutedKeywords.add(lowerKeyword);
+
+ // If it's one of our managed keywords, add to activeKeywords with proper case
+ const originalCase = ourKeywordsMap.get(lowerKeyword);
+ if (originalCase) {
+ state.activeKeywords.add(originalCase);
+ cache.invalidateCategory(getKeywordCategory(originalCase));
+ }
+});
+```
+
+### 2. Safe Keyword Comparison
+```javascript
+// Check if keyword exists in our list (case-insensitive)
+const cachedKeywords = cache.getKeywords(category, true);
+const wasOriginallyMuted = state.originalMutedKeywords.has(keyword.toLowerCase());
+const isInOurList = cachedKeywords.has(keyword.toLowerCase());
+```
+
+### 3. Context Completion Check
+```javascript
+// Check if all categories in context are complete
+let allCategoriesComplete = true;
+context.categories.forEach(category => {
+ const categoryKeywords = cache.getKeywords(category, true);
+ const activeInCategory = cache.getActiveKeywordsForCategory(category);
+
+ if (activeInCategory.size < categoryKeywords.size) {
+ allCategoriesComplete = false;
+ state.selectedExceptions.add(category);
+ }
+});
+
+// If context is complete, clear all exceptions
+if (allCategoriesComplete) {
+ context.categories.forEach(category => {
+ state.selectedExceptions.delete(category);
+ cache.invalidateCategory(category);
+ });
+}
+```
+
+## Example Scenarios
+
+### 1. Mixed Keywords Scenario
+```javascript
+// Initial State
+const userMutedKeywords = ['biden', 'kitty', 'ELON'];
+const ourKeywordsList = ['Biden', 'DeSantis', 'Pence'];
+
+// Resulting State
+state.originalMutedKeywords = new Set(['biden', 'kitty', 'elon']); // All lowercase
+state.activeKeywords = new Set(['Biden']); // Original case from our list
+cache.keywords = new Map([
+ ['politics', new Set(['Biden', 'DeSantis', 'Pence'])]
+]);
+
+// UI State
+// ✓ Biden (checkmark, can unmute - matches 'biden')
+// □ DeSantis (no checkmark, can mute)
+// □ Pence (no checkmark, can mute)
+// Note: 'kitty' and 'ELON' preserved but not shown
+```
+
+### 2. Complete Context Selection
+```javascript
+// Context Structure
+const politicalContext = {
+ id: 'political-discord',
+ categories: [
+ {
+ id: 'us-political-figures',
+ keywords: ['Biden', 'Trump', 'Harris']
+ },
+ {
+ id: 'political-organizations',
+ keywords: ['Democrat', 'Republican']
+ }
+ ]
+};
+
+// When all keywords selected:
+// - Both categories show no exceptions
+// - Context appears fully selected in Simple mode
+// - No partial selection indicators
+// - Cache maintains efficient lookup
+```
+
+### 3. Partial Selection Handling
+```javascript
+// Category with some keywords selected
+const category = 'us-political-figures';
+const keywords = ['Biden', 'Trump', 'Harris'];
+const selectedKeywords = ['Biden', 'Trump'];
+
+// Results in:
+state.selectedExceptions.add(category); // Marked as partial
+cache.invalidateCategory(category); // Cache updated
+```
+
+## State Transitions
+
+### 1. Initial Load
+```javascript
+async function initializeState() {
+ // Get user's currently muted keywords
+ const mutedKeywords = await muteService.getMutedKeywords();
+
+ // Store in lowercase for comparison
+ state.originalMutedKeywords = new Set(
+ mutedKeywords.map(k => k.toLowerCase())
+ );
+
+ // Show checkmarks for our keywords that user has muted
+ const ourKeywordsMap = getOurKeywordsMap();
+ mutedKeywords.forEach(keyword => {
+ const originalCase = ourKeywordsMap.get(keyword.toLowerCase());
+ if (originalCase) {
+ state.activeKeywords.add(originalCase);
+ }
+ });
+}
+```
+
+### 2. Adding New Keywords
+```javascript
+function addKeyword(keyword) {
+ // Preserve case from our list
+ const originalCase = ourKeywordsMap.get(keyword.toLowerCase());
+ if (originalCase) {
+ state.activeKeywords.add(originalCase);
+ state.originalMutedKeywords.add(keyword.toLowerCase());
+ cache.invalidateCategory(getKeywordCategory(originalCase));
+ }
+}
+```
+
+### 3. Removing Keywords
+```javascript
+function removeKeyword(keyword) {
+ // Only remove if it's in our list
+ if (ourKeywordsMap.has(keyword.toLowerCase())) {
+ state.activeKeywords.delete(keyword);
+ // Note: originalMutedKeywords updated only after API call succeeds
+ cache.invalidateCategory(getKeywordCategory(keyword));
+ }
+}
+```
+
+## Edge Cases
+
+### 1. Case Mismatches
+```javascript
+// User has "BITCOIN" muted
+// Our list has "Bitcoin"
+// Result: "Bitcoin" shows as checked, preserves our case when toggled
+```
+
+### 2. Partial Context Selection
+```javascript
+// Some keywords in a context are muted externally
+// Result: Context shows as partially selected
+// Exceptions are properly tracked
+```
+
+### 3. Bulk Operations
+```javascript
+// Enable All with external mutes
+// Result: Preserves external mutes
+// Updates only our managed keywords
+```
+
+## Best Practices
+
+1. Case Sensitivity
+ - Always compare in lowercase
+ - Preserve original case for display
+ - Use consistent case in storage
+ - Handle case variants properly
+
+2. State Management
+ - Track both managed and custom keywords
+ - Preserve user preferences
+ - Handle state transitions cleanly
+ - Maintain cache consistency
+
+3. Performance
+ - Use efficient data structures
+ - Implement proper caching
+ - Batch operations when possible
+ - Handle large datasets gracefully
+
+4. Error Prevention
+ - Validate all operations
+ - Handle edge cases
+ - Preserve user data
+ - Maintain consistency
diff --git a/docs/1-architecture/11-simple-mode.md b/docs/1-architecture/11-simple-mode.md
new file mode 100644
index 0000000..8fe4c01
--- /dev/null
+++ b/docs/1-architecture/11-simple-mode.md
@@ -0,0 +1,318 @@
+# Simple Mode Technical Implementation
+
+## Overview
+
+Simple mode provides an intuitive interface for content filtering through contexts, filter levels, and exceptions. This document details the technical implementation of these components.
+
+## Core Components
+
+### 1. Filter Level System
+```javascript
+class SimpleMode extends HTMLElement {
+ constructor() {
+ this.currentLevel = 0; // Default level (Minimal/most restrictive)
+ }
+
+ updateLevel(level) {
+ if (level === this.currentLevel) return;
+ this.currentLevel = level;
+ state.filterLevel = level;
+ this.updateFilterUI();
+ }
+}
+```
+
+### 2. Weight Threshold System
+```javascript
+function getWeightThreshold(filterLevel) {
+ // Map levels to thresholds (0-3)
+ switch(filterLevel) {
+ case 0: return 3; // Minimal (most restrictive)
+ case 1: return 2; // Moderate
+ case 2: return 1; // Extensive
+ case 3: return 0; // Complete (most inclusive)
+ default: return 3; // Default to most restrictive
+ }
+}
+```
+
+### 3. Context Management System
+
+#### Context Selection Handler
+```javascript
+export function handleContextToggle(contextId) {
+ if (!state.authenticated) return;
+
+ const isSelected = state.selectedContexts.has(contextId);
+ const context = state.contextGroups[contextId];
+
+ // Store unchecked state
+ const uncheckedKeywords = new Set(state.manuallyUnchecked);
+
+ if (isSelected) {
+ // Remove context
+ state.selectedContexts.delete(contextId);
+
+ // Clear exceptions
+ context.categories.forEach(category => {
+ state.selectedExceptions.delete(category);
+ cache.invalidateCategory(category);
+ });
+
+ // Mark keywords for removal
+ const keywordsToRemove = new Set();
+ for (const category of context.categories) {
+ if (!state.selectedExceptions.has(category)) {
+ const keywords = cache.getKeywords(category, true);
+ for (const keyword of keywords) {
+ if (!uncheckedKeywords.has(keyword)) {
+ keywordsToRemove.add(keyword);
+ }
+ }
+ }
+ }
+
+ // Remove after counts calculated
+ for (const keyword of keywordsToRemove) {
+ state.activeKeywords.delete(keyword);
+ }
+ } else {
+ // Add context
+ state.selectedContexts.add(contextId);
+
+ // Add keywords
+ for (const category of context.categories) {
+ if (!state.selectedExceptions.has(category)) {
+ const keywords = cache.getKeywords(category, true);
+ for (const keyword of keywords) {
+ if (!uncheckedKeywords.has(keyword)) {
+ state.activeKeywords.add(keyword);
+ }
+ }
+ }
+ }
+ }
+
+ // Update UI with debouncing
+ const debouncedUpdate = createDebouncedUpdate();
+ await debouncedUpdate(async () => {
+ renderInterface();
+ await saveState();
+ });
+}
+```
+
+### 4. Exception System
+
+#### Exception Toggle Handler
+```javascript
+export function handleExceptionToggle(category) {
+ if (!state.authenticated) return;
+
+ // Store unchecked state
+ const uncheckedKeywords = new Set(state.manuallyUnchecked);
+
+ const wasException = state.selectedExceptions.has(category);
+ if (wasException) {
+ state.selectedExceptions.delete(category);
+ } else {
+ state.selectedExceptions.add(category);
+ }
+
+ cache.invalidateCategory(category);
+
+ // Rebuild keywords in simple mode
+ if (state.mode === 'simple') {
+ state.activeKeywords.clear();
+
+ // Rebuild from contexts
+ for (const contextId of state.selectedContexts) {
+ activateContextKeywords(contextId, cache);
+ }
+
+ // Re-apply original muted
+ for (const keyword of state.originalMutedKeywords) {
+ if (!state.activeKeywords.has(keyword)) {
+ state.activeKeywords.add(keyword);
+ }
+ }
+
+ // Re-apply unchecked
+ for (const keyword of uncheckedKeywords) {
+ state.activeKeywords.delete(keyword);
+ state.manuallyUnchecked.add(keyword);
+ }
+ }
+
+ // Update UI
+ const debouncedUpdate = createDebouncedUpdate();
+ await debouncedUpdate(async () => {
+ renderInterface();
+ await saveState();
+ });
+}
+```
+
+## Performance Optimizations
+
+### 1. Caching System
+```javascript
+const cache = {
+ keywords: new Map(),
+ categoryStates: new Map(),
+ contextKeywords: new Map(),
+ activeKeywordsByCategory: new Map(),
+ lastUpdate: 0,
+
+ invalidateCategory(category) {
+ const now = Date.now();
+ if (now - this.lastUpdate < 50) return;
+ this.lastUpdate = now;
+
+ this.keywords.delete(category);
+ this.categoryStates.delete(category);
+ this.contextKeywords.delete(category);
+ this.activeKeywordsByCategory.delete(category);
+ }
+};
+```
+
+### 2. Debounced Updates
+```javascript
+const createDebouncedUpdate = () => {
+ let timeout;
+ return async (fn) => {
+ if (timeout) clearTimeout(timeout);
+ timeout = setTimeout(async () => {
+ await fn();
+ }, 16);
+ };
+};
+```
+
+## State Management
+
+### 1. Component State
+```javascript
+class SimpleMode extends HTMLElement {
+ constructor() {
+ this.currentLevel = 0;
+ this.currentExceptions = new Set();
+ }
+
+ connectedCallback() {
+ // Restore from last saved state
+ this.currentLevel = state.filterLevel;
+ this.currentExceptions = new Set(state.selectedExceptions);
+ this.setupEventListeners();
+ }
+}
+```
+
+### 2. State Persistence Rules
+```javascript
+async function handleMuteSubmit() {
+ // Process changes
+ await processChanges();
+
+ // Save state including:
+ // - Filter level
+ // - Selected contexts
+ // - Active keywords
+ // - Exceptions
+ await saveState();
+}
+```
+
+## Event Handling
+
+### 1. Filter Level Changes
+```javascript
+handleFilterLevelChange(event) {
+ const level = parseInt(event.target.value);
+ this.updateLevel(level);
+ this.updateFilterUI();
+ notifyKeywordChanges();
+}
+```
+
+### 2. Context Changes
+```javascript
+handleContextChange(event) {
+ const contextId = event.target.dataset.context;
+ handleContextToggle(contextId);
+ this.updateExceptions(this.getActiveExceptions());
+}
+```
+
+## Best Practices
+
+### 1. State Updates
+```javascript
+// Always update UI immediately
+this.updateFilterUI();
+
+// But defer persistence
+debouncedUpdate(async () => {
+ renderInterface();
+ await saveState();
+});
+```
+
+### 2. Performance
+```javascript
+// Use cache for expensive operations
+const keywords = cache.getKeywords(category, true);
+
+// Throttle updates
+if (now - this.lastUpdate < 50) return;
+
+// Batch operations
+processBatchKeywords(keywords, operation);
+```
+
+### 3. Error Prevention
+```javascript
+// Validate all inputs
+if (level < 0 || level > 3) return;
+
+// Handle edge cases
+if (!context?.categories) return 'none';
+
+// Maintain consistency
+if (this.isProcessing) return;
+```
+
+## Testing Considerations
+
+### 1. State Transitions
+```javascript
+// Test level changes
+async function testLevelChange() {
+ const before = getCurrentState();
+ await updateLevel(newLevel);
+ const after = getCurrentState();
+ assert(validateStateTransition(before, after));
+}
+```
+
+### 2. Performance Monitoring
+```javascript
+// Monitor update timing
+const start = performance.now();
+await operation();
+const duration = performance.now() - start;
+console.debug(`Operation took ${duration}ms`);
+```
+
+### 3. Edge Cases
+```javascript
+// Test rapid updates
+async function testRapidUpdates() {
+ for (let i = 0; i < 100; i++) {
+ await handleContextToggle(contextId);
+ }
+}
+```
+
+This implementation ensures efficient performance while maintaining a clear and intuitive user interface.
diff --git a/docs/1-architecture/12-state-management.md b/docs/1-architecture/12-state-management.md
new file mode 100644
index 0000000..9e74cc0
--- /dev/null
+++ b/docs/1-architecture/12-state-management.md
@@ -0,0 +1,289 @@
+# Centralized State Management System
+
+## Overview
+
+MuteSky uses a centralized state management system to handle user preferences, filter levels, and keyword selections. The system is explicitly tied to user actions to ensure predictable behavior.
+
+## Core Components
+
+### 1. State Structure
+```javascript
+// state.js
+export const state = {
+ // Authentication
+ authenticated: false,
+ did: null,
+
+ // Mode
+ mode: 'simple',
+
+ // Keywords
+ activeKeywords: new Set(), // Currently checked keywords
+ originalMutedKeywords: new Set(), // All muted keywords (lowercase)
+ sessionMutedKeywords: new Set(), // New mutes this session
+ manuallyUnchecked: new Set(), // User's manual unchecks
+
+ // Selections
+ selectedContexts: new Set(),
+ selectedExceptions: new Set(),
+ selectedCategories: new Set(),
+
+ // Settings
+ filterLevel: 0,
+ targetKeywordCount: 100,
+ lastBulkAction: null
+};
+```
+
+### 2. State Persistence Rules
+
+#### When State Saves
+```javascript
+async function handleMuteSubmit() {
+ // Save complete state after mute/unmute
+ const saveData = {
+ activeKeywords: Array.from(state.activeKeywords),
+ selectedCategories: Array.from(state.selectedCategories),
+ selectedContexts: Array.from(state.selectedContexts),
+ selectedExceptions: Array.from(state.selectedExceptions),
+ mode: state.mode,
+ filterLevel: state.filterLevel,
+ targetKeywordCount: state.targetKeywordCount,
+ lastBulkAction: state.lastBulkAction
+ };
+
+ localStorage.setItem(getStorageKey(), JSON.stringify(saveData));
+}
+```
+
+#### What Doesn't Auto-Save
+- Filter level changes
+- Context toggles
+- Exception toggles
+- Mode switches
+- Individual keyword toggles
+- Enable/disable all actions
+- Logout operations
+
+### 3. State Restoration Flow
+
+#### Login Restoration
+```javascript
+async function restoreState() {
+ const savedState = localStorage.getItem(getStorageKey());
+ if (savedState) {
+ const data = JSON.parse(savedState);
+
+ // Restore with proper case handling
+ const keywordMap = getKeywordsWithCase();
+ state.activeKeywords = new Set(
+ data.activeKeywords.map(keyword =>
+ keywordMap.get(keyword.toLowerCase()) || keyword
+ )
+ );
+
+ // Restore selections
+ state.selectedCategories = new Set(data.selectedCategories);
+ state.selectedContexts = new Set(data.selectedContexts);
+ state.selectedExceptions = new Set(data.selectedExceptions);
+
+ // Restore settings
+ state.mode = data.mode || 'simple';
+ state.filterLevel = data.filterLevel || 0;
+ state.targetKeywordCount = data.targetKeywordCount || 100;
+ state.lastBulkAction = data.lastBulkAction || null;
+ }
+}
+```
+
+#### Session Management
+```javascript
+// During session
+function handleStateChange() {
+ // Update UI immediately
+ renderInterface();
+
+ // But don't save until mute/unmute
+ notifyKeywordChanges();
+}
+
+// On logout
+function handleLogout() {
+ // Preserve last saved state
+ // Don't auto-save current state
+ resetUIState();
+}
+```
+
+## Implementation Details
+
+### 1. Filter Level Management
+```javascript
+function updateFilterLevel(level) {
+ // Update immediately
+ state.filterLevel = level;
+ state.targetKeywordCount = getTargetCount(level);
+
+ // Update UI
+ updateFilterUI();
+
+ // Don't save until mute/unmute
+ notifyKeywordChanges();
+}
+
+function getTargetCount(level) {
+ const targets = {
+ 0: 100, // Minimal
+ 1: 300, // Moderate
+ 2: 500, // Extensive
+ 3: 2000 // Complete
+ };
+ return targets[level] || 100;
+}
+```
+
+### 2. State Change Handlers
+```javascript
+// Context changes
+function handleContextToggle(contextId) {
+ const isSelected = state.selectedContexts.has(contextId);
+ if (isSelected) {
+ state.selectedContexts.delete(contextId);
+ } else {
+ state.selectedContexts.add(contextId);
+ }
+
+ // Update UI only
+ renderInterface();
+}
+
+// Exception changes
+function handleExceptionToggle(category) {
+ const wasException = state.selectedExceptions.has(category);
+ if (wasException) {
+ state.selectedExceptions.delete(category);
+ } else {
+ state.selectedExceptions.add(category);
+ }
+
+ // Update UI only
+ renderInterface();
+}
+```
+
+### 3. Mode Transitions
+```javascript
+function switchMode(newMode) {
+ // Update mode
+ state.mode = newMode;
+
+ // Update UI
+ if (newMode === 'simple') {
+ initializeSimpleMode();
+ } else {
+ initializeAdvancedMode();
+ }
+
+ // Don't save until mute/unmute
+ renderInterface();
+}
+```
+
+## Best Practices
+
+### 1. State Updates
+```javascript
+// DO: Update UI immediately
+function handleChange() {
+ updateState();
+ renderInterface();
+}
+
+// DON'T: Save automatically
+function handleChange() {
+ updateState();
+ saveState(); // Wrong! Wait for mute/unmute
+}
+```
+
+### 2. User Actions
+```javascript
+// DO: Allow experimentation
+function handleFilterChange(level) {
+ updateFilterLevel(level);
+ // Don't save - let user experiment
+}
+
+// DO: Save on explicit action
+async function handleMuteClick() {
+ await processChanges();
+ await saveState(); // Correct! User explicitly acted
+}
+```
+
+### 3. State Restoration
+```javascript
+// DO: Restore from last save
+async function initializeState() {
+ await restoreState();
+ renderInterface();
+}
+
+// DON'T: Mix current and saved state
+function restoreState() {
+ const saved = loadSavedState();
+ // Wrong! Don't mix current and saved state
+ state.activeKeywords = new Set([
+ ...saved.activeKeywords,
+ ...state.activeKeywords
+ ]);
+}
+```
+
+## Error Prevention
+
+### 1. State Validation
+```javascript
+function validateState(state) {
+ if (!state.did) {
+ throw new Error('No DID set in state');
+ }
+
+ if (state.filterLevel < 0 || state.filterLevel > 3) {
+ state.filterLevel = 0; // Reset to safe default
+ }
+}
+```
+
+### 2. Safe State Updates
+```javascript
+function updateState(changes) {
+ // Create backup
+ const backup = { ...state };
+
+ try {
+ Object.assign(state, changes);
+ validateState(state);
+ } catch (error) {
+ // Restore from backup on error
+ Object.assign(state, backup);
+ throw error;
+ }
+}
+```
+
+### 3. Consistent State Loading
+```javascript
+async function loadState() {
+ try {
+ await restoreState();
+ } catch (error) {
+ // On error, reset to defaults but preserve DID
+ const did = state.did;
+ resetState();
+ state.did = did;
+ }
+}
+```
+
+This centralized approach ensures consistent state management while maintaining a clear and predictable persistence model.
diff --git a/docs/1-architecture/2-authentication.md b/docs/1-architecture/2-authentication.md
new file mode 100644
index 0000000..653dd6f
--- /dev/null
+++ b/docs/1-architecture/2-authentication.md
@@ -0,0 +1,188 @@
+# Authentication Architecture
+
+## Overview
+
+MuteSky uses Bluesky's OAuth implementation through the `@atproto/oauth-client-browser` library. The system ensures proper session management, token refresh, and state persistence across user sessions.
+
+## OAuth Flow
+
+1. Initial Setup
+```javascript
+this.client = await BrowserOAuthClient.load({
+ clientId: 'https://mutesky.app/client-metadata.json',
+ handleResolver: 'https://bsky.social/'
+});
+```
+
+2. Sign In Process
+ - User enters Bluesky handle
+ - App initiates OAuth flow with `client.signIn(handle)`
+ - User redirects to Bluesky for authorization
+ - Bluesky redirects to callback page
+ - Callback processes response and establishes session
+
+## Session Management
+
+### Components
+
+1. AuthService (js/auth.js)
+ - Handles OAuth client initialization
+ - Manages sign in/out operations
+ - Provides session refresh capabilities
+
+2. BlueskyService (js/bluesky.js)
+ - Coordinates between services
+ - Handles session state changes
+ - Manages session refresh events
+
+3. Individual Services
+ - Maintain own session references
+ - Handle API calls with current session
+ - Dispatch refresh events when needed
+
+### Session States
+
+1. No Session
+ - Initial app load
+ - After sign out
+ - After failed authentication
+
+2. Active Session
+ - After successful sign in
+ - After successful token refresh
+ - Contains valid access token
+
+3. Expired Session
+ - Token has expired
+ - Triggers refresh flow
+ - Temporary state during refresh
+
+## Token Refresh Mechanism
+
+1. Detection
+```javascript
+if (error.status === 401) {
+ // Dispatch event for session refresh
+ const refreshEvent = new CustomEvent('mutesky:session:refresh:needed');
+ window.dispatchEvent(refreshEvent);
+}
+```
+
+2. Handling
+```javascript
+setupSessionRefreshHandler() {
+ window.addEventListener('mutesky:session:refresh:needed', async () => {
+ if (this.isRefreshing) return; // Prevent multiple refreshes
+
+ try {
+ this.isRefreshing = true;
+ const result = await this.auth.refreshSession();
+ if (result.success) {
+ // Update services with new session
+ this.profile.setSession(result.session);
+ this.mute.setSession(result.session);
+ } else {
+ await this.signOut();
+ }
+ } finally {
+ this.isRefreshing = false;
+ }
+ });
+}
+```
+
+## Callback System
+
+### 1. Callback Page Structure
+```html
+
+
Authentication Successful
+
✨ Rendering keywords
+
+
+
Return to app
+
+```
+
+### 2. Event System
+- `mutesky:auth:complete`: Fired when OAuth callback processing finishes
+- `mutesky:setup:complete`: Fired when initial data loading finishes
+
+### 3. Error Handling
+```javascript
+showError(message) {
+ this.container.classList.add('error');
+ this.titleElement.textContent = 'Authentication Failed';
+ this.errorElement.textContent = message;
+ this.homeLink.style.display = 'block';
+}
+```
+
+## Error Handling
+
+1. Token Expiration
+ - Detected through 401 responses
+ - Triggers automatic refresh attempt
+ - Retries failed operation if refresh succeeds
+ - Signs out user if refresh fails
+
+2. Network Issues
+ - Services clear cached data on errors
+ - Errors are logged with context
+ - User-friendly error messages displayed
+
+3. Race Conditions
+ - Prevents multiple simultaneous refresh attempts
+ - Maintains consistent session state across services
+ - Cleans up properly on sign out
+
+## Implementation Details
+
+1. Session Storage
+ - OAuth client handles token storage
+ - Services maintain runtime session references
+ - No sensitive data stored in localStorage
+
+2. Service Coordination
+```javascript
+// Example: Updating services with new session
+this.profile.setSession(result.session);
+this.mute.setSession(result.session);
+this.ui.updateLoginState(true);
+```
+
+3. Error Recovery
+```javascript
+try {
+ await operation();
+} catch (error) {
+ if (error.status === 401) {
+ // Trigger refresh flow
+ window.dispatchEvent(new CustomEvent('mutesky:session:refresh:needed'));
+ }
+ // Clear any cached data
+ this.cachedData = null;
+}
+```
+
+## Best Practices
+
+1. Session Management
+ - Clear cached data when session changes
+ - Handle 401 errors at service level
+ - Use event system for refresh coordination
+ - Prevent multiple simultaneous refreshes
+
+2. Error Handling
+ - Provide clear user feedback
+ - Log errors with context
+ - Clean up state on failures
+ - Handle race conditions
+
+3. Security
+ - No sensitive data in localStorage
+ - Clear all state on logout
+ - Validate session before operations
+ - Handle token refresh securely
diff --git a/docs/1-architecture/3-muting-system.md b/docs/1-architecture/3-muting-system.md
new file mode 100644
index 0000000..d5a960d
--- /dev/null
+++ b/docs/1-architecture/3-muting-system.md
@@ -0,0 +1,223 @@
+# Muting System Architecture
+
+## Overview
+
+MuteSky provides a specialized keyword muting system that respects user's existing muted keywords while providing a curated list of additional keywords to mute. The system is non-destructive - it never removes keywords that users have muted outside of our curated list.
+
+## Core Components
+
+### 1. MuteService
+```javascript
+class MuteService {
+ constructor(session) {
+ this.agent = session ? new Agent(session) : null;
+ this.session = session;
+ this.cachedKeywords = null;
+ this.cachedPreferences = null;
+ }
+}
+```
+
+### 2. Mute Settings
+```javascript
+const settings = loadMuteSettings();
+{
+ scope: 'all' | 'tags-only', // Where to apply muting
+ duration: number, // Mute duration in days
+ excludeFollows: boolean // Whether to exclude followed users
+}
+```
+
+### 3. Keyword Types
+
+1. **Curated Keywords**
+ - From our predefined list
+ - Can be muted/unmuted through UI
+ - Case-insensitive matching
+ - Original case preserved when muting
+ - Support mute settings (scope, duration, excludes)
+
+2. **User's Custom Keywords**
+ - Muted outside our list
+ - Never shown in UI but tracked
+ - Preserved during all operations
+ - Stored lowercase for comparison
+ - Original settings preserved
+
+## Implementation Details
+
+### 1. Keyword Preservation
+```javascript
+// Separate user's custom keywords
+const userCustomKeywords = currentMutedPref.items
+ .filter(item => !ourKeywordsSet.has(item.value.toLowerCase()))
+ .map(item => ({
+ value: item.value,
+ targets: item.targets || ['content', 'tag']
+ }));
+
+// Create new items for selected keywords
+const newManagedItems = selectedKeywords
+ .filter(keyword => ourKeywordsSet.has(keyword.toLowerCase()))
+ .map(keyword => ({
+ value: keyword,
+ targets: settings.scope === 'tags-only' ? ['tag'] : ['content', 'tag'],
+ ...(settings.excludeFollows && { actorTarget: 'notFollowed' }),
+ ...(expiresAt && { expires: expiresAt.toISOString() })
+ }));
+
+// Combine preserving user's keywords
+const updatedItems = [
+ ...userCustomKeywords, // Preserve all user's custom keywords
+ ...newManagedItems // Only include selected keywords from our list
+];
+```
+
+### 2. Case Sensitivity Handling
+```javascript
+// Store all keywords in lowercase for comparison
+userKeywords.forEach(keyword => {
+ const lowerKeyword = keyword.toLowerCase();
+ state.originalMutedKeywords.add(lowerKeyword);
+
+ // If it's one of our managed keywords, add to activeKeywords with proper case
+ const originalCase = ourKeywordsMap.get(lowerKeyword);
+ if (originalCase) {
+ state.activeKeywords.add(originalCase);
+ cache.invalidateCategory(getKeywordCategory(originalCase));
+ }
+});
+```
+
+### 3. Caching System
+```javascript
+class MuteService {
+ async getMutedKeywords(forceRefresh = false) {
+ // Return cached keywords if available
+ if (!forceRefresh && this.cachedKeywords !== null) {
+ return this.cachedKeywords;
+ }
+
+ // Fetch and cache new keywords
+ const response = await agent.api.app.bsky.actor.getPreferences();
+ this.cachedPreferences = response.data.preferences;
+ const mutedWordsPref = this.cachedPreferences.find(
+ pref => pref.$type === 'app.bsky.actor.defs#mutedWordsPref'
+ );
+ this.cachedKeywords = mutedWordsPref?.items?.map(item => item.value) || [];
+ return this.cachedKeywords;
+ }
+}
+```
+
+## Muting Operations
+
+### 1. Initial Load
+```javascript
+async function initializeKeywordState() {
+ // Get user's currently muted keywords
+ const mutedKeywords = await muteService.getMutedKeywords();
+
+ // Store in lowercase for comparison
+ state.originalMutedKeywords = new Set(
+ mutedKeywords.map(k => k.toLowerCase())
+ );
+
+ // Show checkmarks for our keywords that user has muted
+ const ourKeywordsMap = getOurKeywordsMap();
+ mutedKeywords.forEach(keyword => {
+ const originalCase = ourKeywordsMap.get(keyword.toLowerCase());
+ if (originalCase) {
+ state.activeKeywords.add(originalCase);
+ }
+ });
+}
+```
+
+### 2. Muting Process
+```javascript
+async function handleMuteSubmit() {
+ // Get current mute settings
+ const settings = loadMuteSettings();
+
+ // Apply settings to keywords
+ const keywordsToMute = Array.from(state.activeKeywords)
+ .filter(k => !state.originalMutedKeywords.has(k.toLowerCase()));
+
+ // Update on Bluesky
+ await muteService.updateMutedKeywords(keywordsToMute, ourKeywordsList);
+
+ // Update local state
+ keywordsToMute.forEach(k => {
+ state.originalMutedKeywords.add(k.toLowerCase());
+ state.sessionMutedKeywords.add(k);
+ });
+}
+```
+
+### 3. Unmuting Process
+```javascript
+// Can only unmute our keywords
+function canUnmuteKeyword(keyword) {
+ return ourKeywordsMap.has(keyword.toLowerCase());
+}
+
+// Preserve user's custom keywords during unmute
+const userCustomKeywords = currentMutedPref.items
+ .filter(item => !ourKeywordsSet.has(item.value.toLowerCase()));
+```
+
+## Error Handling
+
+### 1. Session Errors
+```javascript
+if (error.status === 401) {
+ // Dispatch event for session refresh
+ const refreshEvent = new CustomEvent('mutesky:session:refresh:needed');
+ window.dispatchEvent(refreshEvent);
+ // Clear caches
+ this.cachedKeywords = null;
+ this.cachedPreferences = null;
+}
+```
+
+### 2. Cache Management
+```javascript
+setSession(session) {
+ this.agent = session ? new Agent(session) : null;
+ this.session = session;
+ // Clear caches when session changes
+ this.cachedKeywords = null;
+ this.cachedPreferences = null;
+}
+```
+
+## Best Practices
+
+1. Keyword Management
+ - Store keywords lowercase for comparison
+ - Preserve original case for display/muting
+ - Never modify user's custom keywords
+ - Track both custom and managed keywords
+ - Use caching for efficient lookups
+
+2. Error Prevention
+ - Validate session before operations
+ - Handle case sensitivity properly
+ - Verify keyword existence
+ - Maintain consistent state
+ - Clear caches on errors
+
+3. Performance
+ - Cache expensive API calls
+ - Batch keyword updates
+ - Throttle rapid operations
+ - Clear caches appropriately
+ - Use efficient data structures
+
+4. User Experience
+ - Preserve user preferences
+ - Provide clear feedback
+ - Handle errors gracefully
+ - Maintain responsive UI
+ - Show accurate mute counts
diff --git a/docs/1-architecture/4-mode-system.md b/docs/1-architecture/4-mode-system.md
new file mode 100644
index 0000000..008012c
--- /dev/null
+++ b/docs/1-architecture/4-mode-system.md
@@ -0,0 +1,294 @@
+# Mode System Architecture
+
+## Overview
+
+MuteSky operates in two distinct modes:
+- Simple Mode: Context-based filtering with filter levels (0-3)
+- Advanced Mode: Direct keyword management
+
+The system maintains consistency between these modes while preserving user preferences.
+
+## Weight System Implementation
+
+### 1. Filter Level System
+```javascript
+// Map filter levels to thresholds
+function getWeightThreshold(filterLevel) {
+ switch(filterLevel) {
+ case 0: return 3; // Minimal (most restrictive)
+ case 1: return 2; // Moderate
+ case 2: return 1; // Extensive
+ case 3: return 0; // Complete (most inclusive)
+ default: return 3;
+ }
+}
+```
+
+### 2. Filter Level Handler
+```javascript
+export function handleFilterLevelChange(event) {
+ const level = event.detail.level;
+ state.filterLevel = level;
+
+ // Store current exceptions
+ const currentExceptions = new Set(state.selectedExceptions);
+
+ // Clear and rebuild active keywords
+ state.activeKeywords.clear();
+ state.selectedContexts.forEach(contextId => {
+ const context = state.contextGroups[contextId];
+ if (context?.categories) {
+ context.categories.forEach(category => {
+ if (!currentExceptions.has(category)) {
+ const keywords = getAllKeywordsForCategory(category, true);
+ keywords.forEach(keyword => state.activeKeywords.add(keyword));
+ }
+ });
+ }
+ });
+
+ // Restore exceptions and update UI
+ state.selectedExceptions = currentExceptions;
+ renderInterface();
+}
+```
+
+## Context System Implementation
+
+### 1. Context Toggle Handler
+```javascript
+export async function handleContextToggle(contextId) {
+ // Store currently unchecked keywords
+ const uncheckedKeywords = new Set(state.manuallyUnchecked);
+
+ if (state.selectedContexts.has(contextId)) {
+ // Unchecking context
+ state.selectedContexts.delete(contextId);
+
+ // Remove exceptions for this context
+ context.categories.forEach(category => {
+ state.selectedExceptions.delete(category);
+ cache.invalidateCategory(category);
+ });
+
+ // Mark keywords for removal but keep temporarily for getMuteUnmuteCounts
+ const keywordsToRemove = new Set();
+ for (const category of context.categories) {
+ if (!state.selectedExceptions.has(category)) {
+ const keywords = cache.getKeywords(category, true);
+ for (const keyword of keywords) {
+ if (!uncheckedKeywords.has(keyword)) {
+ keywordsToRemove.add(keyword);
+ }
+ }
+ }
+ }
+
+ // Remove after counts are calculated
+ for (const keyword of keywordsToRemove) {
+ state.activeKeywords.delete(keyword);
+ }
+ } else {
+ // Checking context
+ state.selectedContexts.add(contextId);
+
+ // Add keywords while respecting unchecked state
+ for (const category of context.categories) {
+ if (!state.selectedExceptions.has(category)) {
+ const keywords = cache.getKeywords(category, true);
+ for (const keyword of keywords) {
+ if (!uncheckedKeywords.has(keyword)) {
+ state.activeKeywords.add(keyword);
+ }
+ }
+ }
+ }
+ }
+
+ // Update UI with debouncing
+ const debouncedUpdate = createDebouncedUpdate();
+ await debouncedUpdate(async () => {
+ renderInterface();
+ await saveState();
+ });
+}
+```
+
+### 2. Exception Handler
+```javascript
+export async function handleExceptionToggle(category) {
+ // Store unchecked state
+ const uncheckedKeywords = new Set(state.manuallyUnchecked);
+
+ const wasException = state.selectedExceptions.has(category);
+ if (wasException) {
+ state.selectedExceptions.delete(category);
+ } else {
+ state.selectedExceptions.add(category);
+ }
+
+ cache.invalidateCategory(category);
+
+ // Only rebuild keywords in simple mode
+ if (state.mode === 'simple') {
+ // Clear and rebuild active keywords
+ state.activeKeywords.clear();
+ for (const contextId of state.selectedContexts) {
+ activateContextKeywords(contextId, cache);
+ }
+
+ // Re-apply original muted keywords
+ for (const keyword of state.originalMutedKeywords) {
+ if (!state.activeKeywords.has(keyword)) {
+ state.activeKeywords.add(keyword);
+ }
+ }
+
+ // Re-apply unchecked status
+ for (const keyword of uncheckedKeywords) {
+ state.activeKeywords.delete(keyword);
+ state.manuallyUnchecked.add(keyword);
+ }
+ }
+}
+```
+
+## Mode-Specific State Management
+
+### 1. Simple Mode State Updates
+```javascript
+export async function updateSimpleModeState() {
+ if (!state.authenticated) return;
+
+ // Only rebuild keywords in simple mode
+ if (state.mode === 'simple') {
+ // Check contexts
+ for (const contextId of Array.from(state.selectedContexts)) {
+ const contextState = cache.getContextState(contextId);
+ if (contextState === 'none') {
+ state.selectedContexts.delete(contextId);
+ }
+ }
+
+ cache.clear();
+ rebuildActiveKeywords();
+ }
+
+ await debouncedUpdate(async () => {
+ renderInterface();
+ await saveState();
+ });
+}
+```
+
+### 2. Advanced Mode State
+```javascript
+export function handleKeywordToggle(keyword, enabled) {
+ if (enabled) {
+ state.activeKeywords.add(keyword);
+ state.manuallyUnchecked.delete(keyword);
+ } else {
+ state.activeKeywords.delete(keyword);
+ state.manuallyUnchecked.add(keyword);
+ }
+
+ cache.invalidateCategory(getKeywordCategory(keyword));
+ notifyKeywordChanges();
+}
+```
+
+## State Preservation
+
+### 1. Keyword State Preservation
+```javascript
+// Store unchecked state before changes
+const uncheckedKeywords = new Set(state.manuallyUnchecked);
+
+// Re-apply after changes
+for (const keyword of uncheckedKeywords) {
+ state.activeKeywords.delete(keyword);
+ state.manuallyUnchecked.add(keyword);
+}
+```
+
+### 2. Context State Tracking
+```javascript
+getContextState(contextId) {
+ const context = state.contextGroups[contextId];
+ if (!context?.categories) return 'none';
+
+ let allNone = true;
+ for (const category of context.categories) {
+ // Skip excepted categories
+ if (state.selectedExceptions.has(category)) continue;
+
+ const categoryState = this.getCategoryState(category);
+ if (categoryState !== 'none') {
+ allNone = false;
+ break;
+ }
+ }
+ return allNone ? 'none' : 'partial';
+}
+```
+
+## Performance Optimizations
+
+### 1. Cache System
+```javascript
+const cache = {
+ keywords: new Map(),
+ categoryStates: new Map(),
+ contextKeywords: new Map(),
+ activeKeywordsByCategory: new Map(),
+
+ invalidateCategory(category) {
+ const now = Date.now();
+ if (now - this.lastUpdate < 50) return;
+ this.lastUpdate = now;
+ this.keywords.delete(category);
+ this.categoryStates.delete(category);
+ this.contextKeywords.delete(category);
+ this.activeKeywordsByCategory.delete(category);
+ }
+};
+```
+
+### 2. Debounced Updates
+```javascript
+const createDebouncedUpdate = () => {
+ let timeout;
+ return async (fn) => {
+ if (timeout) clearTimeout(timeout);
+ timeout = setTimeout(async () => {
+ await fn();
+ }, 16);
+ };
+};
+```
+
+## Best Practices
+
+### 1. State Updates
+- Store unchecked state before changes
+- Re-apply unchecked state after changes
+- Use proper cache invalidation
+- Maintain mode-specific behavior
+
+### 2. Performance
+- Use caching for expensive operations
+- Debounce UI updates
+- Throttle cache invalidation
+- Batch related operations
+
+### 3. Mode Synchronization
+- Respect mode hierarchy
+- Preserve exceptions when valid
+- Update UI immediately
+- Defer persistence to mute/unmute
+
+### 4. Error Prevention
+- Validate state before changes
+- Handle edge cases properly
+- Maintain consistent state
+- Provide clear feedback
diff --git a/docs/1-architecture/5-performance.md b/docs/1-architecture/5-performance.md
new file mode 100644
index 0000000..d042ffa
--- /dev/null
+++ b/docs/1-architecture/5-performance.md
@@ -0,0 +1,254 @@
+# Performance Optimizations
+
+## Overview
+
+MuteSky implements sophisticated performance optimizations to ensure responsive UI and efficient data handling, particularly for operations involving large sets of keywords and frequent state updates.
+
+## Core Optimizations
+
+### 1. Set Operations
+```javascript
+// State implementation using Sets for O(1) operations
+export const state = {
+ activeKeywords: new Set(), // O(1) lookup
+ originalMutedKeywords: new Set(), // O(1) lookup
+ sessionMutedKeywords: new Set(), // O(1) lookup
+ manuallyUnchecked: new Set(), // O(1) lookup
+ selectedContexts: new Set(), // O(1) lookup
+ selectedExceptions: new Set(), // O(1) lookup
+ selectedCategories: new Set() // O(1) lookup
+};
+
+// Example usage
+if (state.activeKeywords.has(keyword)) { // O(1) instead of O(n)
+ state.activeKeywords.delete(keyword); // O(1) operation
+}
+```
+
+### 2. Multi-Level Caching System
+```javascript
+const cache = {
+ keywords: new Map(),
+ categoryStates: new Map(),
+ contextKeywords: new Map(),
+ activeKeywordsByCategory: new Map(),
+ lastUpdate: 0,
+
+ getKeywords(category, sortByWeight = false) {
+ const key = `${category}-${sortByWeight}`;
+ if (!this.keywords.has(key)) {
+ this.keywords.set(key, new Set(getAllKeywordsForCategory(category, sortByWeight)));
+ }
+ return this.keywords.get(key);
+ },
+
+ invalidateCategory(category) {
+ const now = Date.now();
+ if (now - this.lastUpdate < 50) return; // Throttle updates
+ this.lastUpdate = now;
+
+ const patterns = [`${category}-true`, `${category}-false`];
+ patterns.forEach(p => this.keywords.delete(p));
+ this.activeKeywordsByCategory.delete(category);
+ }
+};
+```
+
+### 3. Progressive Processing
+```javascript
+function processNextCategory() {
+ if (processedCount >= allCategories.length) return;
+
+ const category = allCategories[processedCount++];
+ const keywords = keywordCache.getKeywordsForCategory(category);
+
+ // Process in chunks
+ processBatchKeywords(keywords, keyword => {
+ state.activeKeywords.add(keyword);
+ });
+
+ // Schedule next chunk
+ requestAnimationFrame(processNextCategory);
+}
+
+function processBatchKeywords(keywords, operation) {
+ const chunkSize = 100;
+ const chunks = Array.from(keywords);
+
+ let index = 0;
+ function processNextChunk() {
+ if (index >= chunks.length) return;
+
+ const end = Math.min(index + chunkSize, chunks.length);
+ for (let i = index; i < end; i++) {
+ operation(chunks[i]);
+ }
+
+ index += chunkSize;
+ requestAnimationFrame(processNextChunk);
+ }
+
+ processNextChunk();
+}
+```
+
+### 4. Debounced Updates
+```javascript
+const debouncedUpdate = (() => {
+ let timeout;
+ let frameRequest;
+ return (fn) => {
+ if (timeout) clearTimeout(timeout);
+ if (frameRequest) cancelAnimationFrame(frameRequest);
+
+ timeout = setTimeout(() => {
+ frameRequest = requestAnimationFrame(() => {
+ fn();
+ notifyKeywordChanges();
+ });
+ }, 16); // One frame duration
+ };
+})();
+
+// Usage
+debouncedUpdate(() => {
+ renderInterface();
+ saveState();
+});
+```
+
+## State Update Optimizations
+
+### 1. Batched State Changes
+```javascript
+// Before: Multiple individual updates
+state.activeKeywords.add(keyword1);
+state.activeKeywords.add(keyword2);
+saveState();
+renderInterface();
+
+// After: Batched updates with debouncing
+keywords.forEach(k => state.activeKeywords.add(k));
+debouncedUpdate(() => {
+ saveState();
+ renderInterface();
+});
+```
+
+### 2. Efficient State Persistence
+```javascript
+const debouncedSave = (() => {
+ let timeout;
+ return () => {
+ if (timeout) clearTimeout(timeout);
+ timeout = setTimeout(() => {
+ const saveData = {
+ activeKeywords: Array.from(state.activeKeywords),
+ selectedCategories: Array.from(state.selectedCategories),
+ selectedContexts: Array.from(state.selectedContexts),
+ selectedExceptions: Array.from(state.selectedExceptions),
+ manuallyUnchecked: Array.from(state.manuallyUnchecked),
+ mode: state.mode,
+ lastModified: state.lastModified,
+ targetKeywordCount: state.targetKeywordCount,
+ filterLevel: state.filterLevel,
+ lastBulkAction: state.lastBulkAction
+ };
+ try {
+ localStorage.setItem(getStorageKey(), JSON.stringify(saveData));
+ } catch (error) {
+ console.error('Error saving state:', error);
+ }
+ }, 16);
+ };
+})();
+```
+
+## Memory Management
+
+### 1. Cache Size Control
+```javascript
+shouldInvalidate() {
+ const now = Date.now();
+ if (now - this.lastUpdate < 50) return false;
+ this.lastUpdate = now;
+ return true;
+}
+
+invalidateCategory(category) {
+ if (!this.shouldInvalidate()) return;
+ // Clear relevant caches
+ this.keywords.delete(category);
+ this.categoryStates.delete(category);
+}
+```
+
+### 2. Efficient Data Structures
+```javascript
+// Use Maps for key-value lookups
+const keywordCache = {
+ categoryKeywords: new Map(),
+ lastUpdate: 0,
+ updateThreshold: 16
+};
+
+// Use Sets for unique collections
+const uniqueKeywords = new Set(keywords);
+```
+
+## UI Optimizations
+
+### 1. Frame-Aligned Updates
+```javascript
+function updateUI() {
+ requestAnimationFrame(() => {
+ renderInterface();
+ updateMuteButton();
+ });
+}
+```
+
+### 2. Throttled Operations
+```javascript
+const throttledUIUpdate = (() => {
+ let lastUpdate = 0;
+ return (fn) => {
+ const now = Date.now();
+ if (now - lastUpdate < 16) return;
+ lastUpdate = now;
+ fn();
+ };
+})();
+```
+
+## Best Practices
+
+### 1. Data Structures
+- Use Sets for unique collections
+- Maps for key-value lookups
+- Batch array operations
+- Minimize object creation
+
+### 2. UI Updates
+- Debounce rapid changes
+- Use requestAnimationFrame
+- Batch DOM operations
+- Throttle expensive updates
+
+### 3. Memory Management
+- Clear unused caches
+- Limit cache sizes
+- Batch similar operations
+- Use efficient structures
+
+### 4. State Operations
+- Batch state changes
+- Debounce saves
+- Use Set operations
+- Implement proper throttling
+
+### 5. Error Prevention
+- Validate data before processing
+- Handle edge cases
+- Provide fallbacks
+- Monitor performance metrics
diff --git a/docs/1-architecture/6-click-performance.md b/docs/1-architecture/6-click-performance.md
new file mode 100644
index 0000000..7e0af49
--- /dev/null
+++ b/docs/1-architecture/6-click-performance.md
@@ -0,0 +1,174 @@
+# Click Performance Optimization
+
+## Overview
+
+This document details the performance optimizations implemented to reduce checkbox click response time from 700ms to near-instant operation. The optimizations focus on efficient data structures, caching, and UI updates while maintaining functionality between Simple and Advanced modes.
+
+## Key Optimizations
+
+### 1. Set Operations
+```javascript
+// Before: Array operations
+keywords.filter(k => activeKeywords.includes(k)) // O(n) lookup time
+
+// After: Set operations
+const activeKeywords = new Set();
+keywords.filter(k => activeKeywords.has(k)) // O(1) lookup time
+```
+- Replaced array operations with Set operations
+- O(1) lookups instead of O(n) array searches
+- Faster add/remove operations with Set.add() and Set.delete()
+- Eliminated duplicate handling automatically
+
+### 2. Enhanced Caching System
+```javascript
+const cache = {
+ keywords: new Map(),
+ categoryStates: new Map(),
+ contextKeywords: new Map(),
+ activeKeywordsByCategory: new Map(),
+ lastUpdate: 0,
+
+ getKeywords(category, sortByWeight = false) {
+ const key = `${category}-${sortByWeight}`;
+ if (!this.keywords.has(key)) {
+ this.keywords.set(key, new Set(getAllKeywordsForCategory(category, sortByWeight)));
+ }
+ return this.keywords.get(key);
+ }
+}
+```
+- Implemented memoization for expensive operations
+- Cached keyword sets by category
+- Cached active keywords by category
+- Cached context-specific keyword sets
+- Added intelligent cache invalidation
+
+### 3. Deferred UI Updates
+```javascript
+const debouncedUpdate = (() => {
+ let timeout;
+ let frameRequest;
+ return (fn) => {
+ if (timeout) clearTimeout(timeout);
+ if (frameRequest) cancelAnimationFrame(frameRequest);
+
+ timeout = setTimeout(() => {
+ frameRequest = requestAnimationFrame(() => {
+ fn();
+ notifyKeywordChanges();
+ });
+ }, 16);
+ };
+})();
+```
+- Batched UI updates using requestAnimationFrame
+- Debounced state saves
+- Prevented UI thrashing
+- Reduced unnecessary re-renders
+
+### 4. Optimized State Updates
+```javascript
+// Before: Multiple individual updates
+state.activeKeywords.add(keyword1);
+state.activeKeywords.add(keyword2);
+saveState();
+renderInterface();
+
+// After: Batched updates
+keywords.forEach(k => state.activeKeywords.add(k));
+debouncedUpdate(() => {
+ saveState();
+ renderInterface();
+});
+```
+- Batched state changes
+- Reduced number of save operations
+- Minimized localStorage writes
+- Optimized state synchronization
+
+### 5. Memory Management
+```javascript
+cache.invalidateCategory(category) {
+ if (!this.shouldInvalidate()) return;
+
+ const patterns = [`${category}-true`, `${category}-false`];
+ patterns.forEach(p => this.keywords.delete(p));
+ this.activeKeywordsByCategory.delete(category);
+}
+```
+- Improved cache size management
+- Selective cache clearing
+- Reduced memory footprint
+- Prevented memory leaks
+
+### 6. Throttled Operations
+```javascript
+shouldInvalidate() {
+ const now = Date.now();
+ if (now - this.lastUpdate < 50) return false;
+ this.lastUpdate = now;
+ return true;
+}
+```
+- Added throttling for cache invalidation
+- Prevented redundant operations
+- Reduced CPU usage
+- Improved responsiveness
+
+## Implementation Locations
+
+### 1. State Management (state.js)
+- Core Set operations for state tracking
+- Keyword caching system
+- Debounced state saves
+- Memory-efficient state updates
+
+### 2. Context Handling (contextHandlers.js)
+- Enhanced caching with memory management
+- Batch keyword operations
+- Optimized UI updates with requestAnimationFrame
+- Throttling for rapid operations
+
+### 3. Mute Operations (muteHandlers.js)
+- Mute-specific caching
+- Batch processing for keywords
+- Debounced UI updates
+- Optimized Set operations
+
+### 4. Keyword Operations (keywordHandlers.js)
+- Category keyword caching
+- Batch processing
+- Optimized DOM operations
+- Throttling for rapid toggles
+
+## Results
+
+- Reduced click response time from 700ms to near-instant
+- Maintained functionality between modes
+- Preserved state consistency
+- Improved overall responsiveness
+
+## Best Practices
+
+1. Use Set operations for collections that need fast lookups
+2. Implement caching for expensive operations
+3. Batch UI updates and state changes
+4. Use throttling for frequent operations
+5. Manage memory efficiently
+6. Maintain functionality while optimizing
+
+## Trade-offs
+
+1. Slightly increased memory usage for caching
+2. Added complexity in cache invalidation
+3. Delayed state persistence for performance
+4. Required careful management of cached data
+
+## Future Improvements
+
+1. Consider implementing Web Workers for heavy computations
+2. Add cache size limits
+3. Implement progressive loading for large datasets
+4. Add performance monitoring
+5. Optimize cache invalidation strategies
diff --git a/docs/1-architecture/7-bluesky-api.md b/docs/1-architecture/7-bluesky-api.md
new file mode 100644
index 0000000..89dbe47
--- /dev/null
+++ b/docs/1-architecture/7-bluesky-api.md
@@ -0,0 +1,255 @@
+# Bluesky API Integration
+
+## Overview
+
+This document details how MuteSky integrates with Bluesky's API, specifically focusing on the muting system implementation through the `app.bsky.actor.putPreferences` endpoint.
+
+## API Specifications
+
+### Endpoint
+```
+PUT https://[host]/xrpc/app.bsky.actor.putPreferences
+```
+
+### Authentication
+- Bearer token authentication required
+- Token obtained via OAuth flow using @atproto/oauth-client-browser
+
+### Data Structures
+
+```typescript
+interface MutedWord {
+ id?: string // Optional unique identifier
+ value: string // Word/phrase to mute
+ targets: ('content' | 'tag')[] // Where to apply muting
+ actorTarget: 'all' | 'exclude-following' // Who to apply muting to
+ expiresAt?: string // Optional expiration date
+}
+
+interface MutedWordsPref {
+ $type: 'app.bsky.actor.defs#mutedWordsPref'
+ items: MutedWord[]
+}
+```
+
+## Implementation
+
+### Required Dependencies
+```json
+{
+ "@atproto/api": "^0.13.18",
+ "@atproto/oauth-client-browser": "^0.3.2"
+}
+```
+
+### Core Service Implementation
+```javascript
+import { Agent } from '@atproto/api'
+
+class MuteService {
+ constructor(session) {
+ this.agent = session ? new Agent(session) : null;
+ this.session = session;
+ this.cachedKeywords = null;
+ this.cachedPreferences = null;
+ }
+
+ setSession(session) {
+ this.agent = session ? new Agent(session) : null;
+ this.session = session;
+ // Clear caches when session changes
+ this.cachedKeywords = null;
+ this.cachedPreferences = null;
+ }
+
+ async getMutedKeywords(forceRefresh = false) {
+ if (!this.session) {
+ throw new Error('Not logged in');
+ }
+
+ if (!forceRefresh && this.cachedKeywords !== null) {
+ return this.cachedKeywords;
+ }
+
+ const agent = new Agent(this.session);
+ const response = await agent.api.app.bsky.actor.getPreferences();
+ this.cachedPreferences = response.data.preferences;
+
+ const mutedWordsPref = this.cachedPreferences.find(
+ pref => pref.$type === 'app.bsky.actor.defs#mutedWordsPref'
+ );
+
+ this.cachedKeywords = mutedWordsPref?.items?.map(item => item.value) || [];
+ return this.cachedKeywords;
+ }
+
+ async updateMutedKeywords(selectedKeywords, ourKeywordsList) {
+ if (!this.session) {
+ throw new Error('Not logged in');
+ }
+
+ const agent = new Agent(this.session);
+ const response = await agent.api.app.bsky.actor.getPreferences();
+ this.cachedPreferences = response.data.preferences;
+
+ // Create efficient lookup
+ const ourKeywordsSet = new Set(ourKeywordsList.map(k => k.toLowerCase()));
+
+ // Find current muted words pref
+ const mutedWordsIndex = this.cachedPreferences.findIndex(
+ pref => pref.$type === 'app.bsky.actor.defs#mutedWordsPref'
+ );
+
+ // Get current or create new
+ const currentMutedPref = mutedWordsIndex >= 0 ?
+ this.cachedPreferences[mutedWordsIndex] : {
+ $type: 'app.bsky.actor.defs#mutedWordsPref',
+ items: []
+ };
+
+ // Preserve user's custom keywords
+ const userCustomKeywords = currentMutedPref.items
+ .filter(item => !ourKeywordsSet.has(item.value.toLowerCase()));
+
+ // Create new items with settings
+ const newManagedItems = selectedKeywords
+ .filter(keyword => ourKeywordsSet.has(keyword.toLowerCase()))
+ .map(keyword => ({
+ value: keyword,
+ targets: ['content', 'tag'],
+ actorTarget: 'all'
+ }));
+
+ // Combine preserving user's keywords
+ const updatedItems = [
+ ...userCustomKeywords,
+ ...newManagedItems
+ ];
+
+ // Update preferences
+ const updatedMutedPref = {
+ $type: 'app.bsky.actor.defs#mutedWordsPref',
+ items: updatedItems
+ };
+
+ if (mutedWordsIndex >= 0) {
+ this.cachedPreferences[mutedWordsIndex] = updatedMutedPref;
+ } else {
+ this.cachedPreferences.push(updatedMutedPref);
+ }
+
+ // Update on Bluesky
+ await agent.api.app.bsky.actor.putPreferences({
+ preferences: this.cachedPreferences
+ });
+
+ // Clear caches after successful update
+ this.cachedKeywords = null;
+ this.cachedPreferences = null;
+ }
+}
+```
+
+## Advanced Features
+
+### 1. Muting Options
+```javascript
+// Content type targeting
+const contentTypeOptions = {
+ contentOnly: ['content'],
+ tagsOnly: ['tag'],
+ both: ['content', 'tag']
+};
+
+// Actor targeting
+const actorTargetOptions = {
+ all: 'all',
+ excludeFollowing: 'exclude-following'
+};
+```
+
+### 2. Expiration Support
+```javascript
+const muteWithExpiration = {
+ value: keyword,
+ targets: ['content', 'tag'],
+ actorTarget: 'all',
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() // 7 days
+};
+```
+
+### 3. Batch Operations
+```javascript
+const batchMuteKeywords = async (keywords) => {
+ const items = keywords.map(keyword => ({
+ value: keyword,
+ targets: ['content', 'tag'],
+ actorTarget: 'all'
+ }));
+
+ const prefs = {
+ $type: 'app.bsky.actor.defs#mutedWordsPref',
+ items
+ };
+
+ await agent.api.app.bsky.actor.putPreferences({
+ preferences: [prefs]
+ });
+};
+```
+
+## Error Handling
+
+### 1. Session Errors
+```javascript
+try {
+ await operation();
+} catch (error) {
+ if (error.status === 401) {
+ // Trigger session refresh
+ window.dispatchEvent(new CustomEvent('mutesky:session:refresh:needed'));
+ }
+ // Clear caches
+ this.cachedKeywords = null;
+ this.cachedPreferences = null;
+ throw error;
+}
+```
+
+### 2. Network Issues
+```javascript
+try {
+ await agent.api.app.bsky.actor.putPreferences(/* ... */);
+} catch (error) {
+ console.error('API Error:', error);
+ // Extract API error message if available
+ const apiError = error.message || 'Failed to update muted keywords';
+ throw new Error(apiError);
+}
+```
+
+## Best Practices
+
+1. Error Handling
+ - Verify session before API calls
+ - Handle network errors gracefully
+ - Provide clear error messages
+ - Implement proper retry logic
+
+2. Performance
+ - Cache muted keywords list
+ - Batch multiple mute operations
+ - Clear caches on session change
+ - Update UI optimistically
+
+3. User Experience
+ - Preserve user's custom keywords
+ - Handle case sensitivity properly
+ - Provide clear feedback
+ - Support easy unmuting
+
+4. Security
+ - Always use fresh agent instances
+ - Clear sensitive data on logout
+ - Validate input before API calls
+ - Handle session expiration properly
diff --git a/docs/1-architecture/8-context-persistence.md b/docs/1-architecture/8-context-persistence.md
new file mode 100644
index 0000000..69c89e9
--- /dev/null
+++ b/docs/1-architecture/8-context-persistence.md
@@ -0,0 +1,220 @@
+# Context Persistence Architecture
+
+## Core Principle
+
+The fundamental principle is that **advanced mode is the source of truth**, not simple mode. This architectural decision ensures consistent state management and reliable user experience.
+
+## Implementation Architecture
+
+### 1. State Hierarchy
+```javascript
+// Advanced mode drives the system
+state.activeKeywords = new Set(); // Source of truth
+state.originalMutedKeywords = new Set(); // All muted keywords
+state.manuallyUnchecked = new Set(); // User preferences
+state.selectedContexts = new Set(); // UI state
+state.selectedExceptions = new Set(); // Exception tracking
+```
+
+### 2. Multi-User Support
+```javascript
+// Storage key format
+const storageKey = `muteskyState-${state.did}`;
+
+// User state isolation
+class StateManager {
+ getStorageKey() {
+ if (!state.did) {
+ throw new Error('No DID set in state');
+ }
+ return `muteskyState-${state.did}`;
+ }
+}
+```
+
+### 3. State Flow
+
+#### Initialization
+1. Set DID in state when user authenticates
+2. Load saved state from DID-specific localStorage key
+3. Restore advanced mode selections first
+4. If in simple mode:
+ - Derive context selections from advanced mode state
+ - Generate appropriate keywords from those contexts
+5. Maintain manually unchecked keywords across modes
+6. Sync with external services (e.g., Bluesky)
+
+#### Updates
+```javascript
+// Advanced Mode Updates
+function handleAdvancedModeUpdate() {
+ // Direct modifications preserved
+ state.activeKeywords.add(keyword);
+ // Changes saved to DID-specific storage
+ saveState();
+ // Simple mode respects these changes
+ updateSimpleModeState();
+}
+
+// Simple Mode Updates
+function handleSimpleModeUpdate() {
+ // Context selections generate keywords
+ const contextKeywords = getContextKeywords(contextId);
+ // But don't override advanced mode
+ contextKeywords.forEach(keyword => {
+ if (!state.manuallyUnchecked.has(keyword)) {
+ state.activeKeywords.add(keyword);
+ }
+ });
+}
+```
+
+## Case Sensitivity Handling
+
+### 1. Comparison Rules
+```javascript
+// Store lowercase for comparison
+const lowerKeyword = keyword.toLowerCase();
+state.originalMutedKeywords.add(lowerKeyword);
+
+// Preserve original case for display
+const originalCase = ourKeywordsMap.get(lowerKeyword);
+if (originalCase) {
+ state.activeKeywords.add(originalCase);
+}
+```
+
+### 2. Implementation Locations
+- **state.js**: Case-insensitive comparisons
+- **contextUtils.js**: Case-insensitive activation
+- **contextCache.js**: Case-insensitive lookups
+
+## Exception Handling
+
+### 1. Bulk Actions
+```javascript
+// Track bulk actions
+state.lastBulkAction = action; // 'enable' or 'disable'
+
+// Clear exceptions on commit
+if (state.lastBulkAction) {
+ clearExceptions();
+ state.lastBulkAction = null;
+}
+```
+
+### 2. Exception Rules
+- Only clear when mute/unmute follows bulk action
+- Regular operations preserve exceptions
+- Exceptions persist across sessions
+- Valid exceptions restored on login
+
+## Cache System
+
+### 1. Memory Management
+```javascript
+const cache = {
+ keywords: new Map(),
+ categoryStates: new Map(),
+ contextKeywords: new Map(),
+ activeKeywordsByCategory: new Map(),
+
+ shouldInvalidate() {
+ const now = Date.now();
+ if (now - this.lastUpdate < 50) return false;
+ this.lastUpdate = now;
+ return true;
+ }
+};
+```
+
+### 2. Performance Features
+- Frame-timed updates (16ms)
+- Batch processing
+- Smart invalidation
+- Category-specific caching
+
+## Error Recovery
+
+### 1. State Recovery
+```javascript
+try {
+ loadState();
+} catch (error) {
+ // Preserve unchecked keywords
+ const unchecked = new Set(state.manuallyUnchecked);
+ resetState();
+ state.manuallyUnchecked = unchecked;
+}
+```
+
+### 2. Cache Invalidation
+```javascript
+function invalidateCache() {
+ // Clear on mode transitions
+ cache.clear();
+ // Invalidate affected categories
+ affectedCategories.forEach(category => {
+ cache.invalidateCategory(category);
+ });
+}
+```
+
+## File Structure
+
+### 1. Core Files
+- **contextState.js**: State initialization and updates
+- **contextHandlers.js**: Context and exception management
+- **contextUtils.js**: Core utilities and processing
+- **contextCache.js**: Caching implementation
+- **keywordHandlers.js**: Bulk actions and toggles
+- **muteHandlers.js**: Mute operations and integration
+- **state.js**: Core state structure and persistence
+
+### 2. Key Functions
+```javascript
+// State Initialization
+export async function initializeState() {
+ const did = await auth.getDID();
+ state.did = did;
+ await loadSavedState();
+ await initializeUI();
+}
+
+// Context Management
+export async function handleContextToggle(contextId) {
+ const uncheckedKeywords = new Set(state.manuallyUnchecked);
+ // Update state while preserving preferences
+ updateContextState(contextId, uncheckedKeywords);
+ await debouncedUpdate(() => {
+ renderInterface();
+ saveState();
+ });
+}
+```
+
+## Best Practices
+
+1. State Management
+ - Use DID-specific storage keys
+ - Derive simple mode from advanced
+ - Sync external services when needed
+ - Preserve user preferences
+
+2. Error Handling
+ - Maintain unchecked keywords
+ - Handle mode transitions
+ - Implement error boundaries
+ - Clear caches appropriately
+
+3. Performance
+ - Use efficient data structures
+ - Implement proper caching
+ - Batch operations
+ - Debounce updates
+
+4. Multi-User Support
+ - Isolate user states
+ - Clean state switching
+ - Preserve preferences
+ - Handle edge cases
diff --git a/docs/1-architecture/9-mute-button.md b/docs/1-architecture/9-mute-button.md
new file mode 100644
index 0000000..4f8c671
--- /dev/null
+++ b/docs/1-architecture/9-mute-button.md
@@ -0,0 +1,220 @@
+# Mute Button Architecture
+
+## Overview
+
+The mute button is a critical UI component that appears in multiple locations and handles complex state synchronization and bulk operations. This document details its architecture and performance optimizations.
+
+## Core Architecture
+
+### 1. UI Components
+```javascript
+// Button locations
+const elements = {
+ muteButton: document.getElementById('muteButton'), // Main interface
+ navMuteButton: document.getElementById('navMuteButton') // Navigation bar
+};
+```
+
+### 2. State Synchronization
+```javascript
+export function updateMuteButton() {
+ const buttonText = getButtonText();
+ const hasChanges = buttonText !== 'No changes';
+
+ // Update main button
+ if (elements.muteButton) {
+ elements.muteButton.textContent = buttonText;
+ elements.muteButton.classList.toggle('visible', hasChanges);
+ }
+
+ // Sync nav button
+ if (elements.navMuteButton) {
+ elements.navMuteButton.textContent = buttonText;
+ elements.navMuteButton.classList.toggle('visible', hasChanges);
+ }
+}
+```
+
+## Bulk Operations
+
+### 1. Disable All Operation
+```javascript
+// Atomic operation for instant feedback
+function handleDisableAll() {
+ state.activeKeywords.clear();
+ state.lastBulkAction = 'disable';
+ updateMuteButton();
+}
+```
+
+### 2. Enable All Operation
+```javascript
+function handleEnableAll() {
+ // Gather categories
+ const allCategories = [
+ ...Object.keys(state.keywordGroups),
+ ...Object.keys(state.displayConfig.combinedCategories || {})
+ ];
+
+ let processedCount = 0;
+
+ // Progressive processing
+ function processNextCategory() {
+ if (processedCount >= allCategories.length) return;
+
+ const category = allCategories[processedCount++];
+ const keywords = keywordCache.getKeywordsForCategory(category);
+
+ // Process in batches
+ processBatchKeywords(keywords, keyword => {
+ state.activeKeywords.add(keyword);
+ });
+
+ // Schedule next batch
+ requestAnimationFrame(processNextCategory);
+ }
+
+ state.lastBulkAction = 'enable';
+ processNextCategory();
+}
+```
+
+## Performance Optimizations
+
+### 1. Keyword Caching
+```javascript
+const keywordCache = {
+ categoryKeywords: new Map(),
+ lastUpdate: 0,
+ updateThreshold: 16, // One frame duration
+
+ getKeywordsForCategory(category) {
+ const cached = this.categoryKeywords.get(category);
+ if (cached && Date.now() - this.lastUpdate < this.updateThreshold) {
+ return cached;
+ }
+
+ const keywords = getAllKeywordsForCategory(category);
+ this.categoryKeywords.set(category, keywords);
+ this.lastUpdate = Date.now();
+ return keywords;
+ }
+};
+```
+
+### 2. Batch Processing
+```javascript
+function processBatchKeywords(keywords, operation) {
+ const chunkSize = 100;
+ const chunks = Array.from(keywords);
+ let index = 0;
+
+ function processNextChunk() {
+ if (index >= chunks.length) return;
+
+ const end = Math.min(index + chunkSize, chunks.length);
+ for (let i = index; i < end; i++) {
+ operation(chunks[i]);
+ }
+
+ index += chunkSize;
+ requestAnimationFrame(processNextChunk);
+ }
+
+ processNextChunk();
+}
+```
+
+### 3. Debounced Updates
+```javascript
+const debouncedUpdate = (() => {
+ let timeout;
+ let frameRequest;
+
+ return (fn) => {
+ if (timeout) clearTimeout(timeout);
+ if (frameRequest) cancelAnimationFrame(frameRequest);
+
+ timeout = setTimeout(() => {
+ frameRequest = requestAnimationFrame(() => {
+ fn();
+ notifyKeywordChanges();
+ });
+ }, 16);
+ };
+})();
+```
+
+## Button States
+
+### 1. Text Calculation
+```javascript
+function getButtonText() {
+ const { toMute, toUnmute } = getMuteUnmuteCounts();
+
+ if (toMute === 0 && toUnmute === 0) {
+ return 'No changes';
+ }
+
+ const parts = [];
+ if (toMute > 0) parts.push(`Mute ${toMute} new`);
+ if (toUnmute > 0) parts.push(`Unmute ${toUnmute} existing`);
+
+ return parts.join(', ');
+}
+```
+
+### 2. Visibility Control
+```javascript
+function updateButtonVisibility() {
+ const hasChanges = getButtonText() !== 'No changes';
+ elements.muteButton?.classList.toggle('visible', hasChanges);
+ elements.navMuteButton?.classList.toggle('visible', hasChanges);
+}
+```
+
+## Implementation Benefits
+
+1. UI Consistency
+ - Synchronized state across button instances
+ - Clear visual feedback
+ - Consistent button text
+ - Proper visibility states
+
+2. Performance
+ - Responsive during bulk operations
+ - No UI blocking
+ - Smooth animations
+ - Efficient memory usage
+
+3. User Experience
+ - Progressive feedback
+ - Clear operation status
+ - Responsive interface
+ - Predictable behavior
+
+## Best Practices
+
+1. State Updates
+ - Use debounced updates
+ - Batch operations
+ - Cache calculations
+ - Maintain consistency
+
+2. Performance
+ - Process in chunks
+ - Use requestAnimationFrame
+ - Implement caching
+ - Optimize memory usage
+
+3. UI Feedback
+ - Show clear status
+ - Update progressively
+ - Maintain responsiveness
+ - Handle edge cases
+
+4. Error Prevention
+ - Validate state
+ - Handle missing elements
+ - Clear timeouts
+ - Cancel animations
diff --git a/docs/2-development/1-known-issues.md b/docs/2-development/1-known-issues.md
new file mode 100644
index 0000000..d1d7995
--- /dev/null
+++ b/docs/2-development/1-known-issues.md
@@ -0,0 +1,207 @@
+# Known Issues and Solutions
+
+## Mode System Issues
+
+### 1. Checkbox Persistence in Advanced Mode
+
+**Problem**: Checkboxes would visually check for half a second then uncheck themselves.
+
+**Root Cause**: State management conflict between modes where updateSimpleModeState() was rebuilding keywords and losing direct changes.
+
+**Original Code**:
+```javascript
+// Before the fix:
+export async function updateSimpleModeState() {
+ // Check contexts
+ for (const contextId of Array.from(state.selectedContexts)) {
+ const contextState = cache.getContextState(contextId);
+ if (contextState === 'none') {
+ state.selectedContexts.delete(contextId);
+ }
+ }
+
+ cache.clear();
+ rebuildActiveKeywords(); // <-- This was the problem
+}
+```
+
+**Flow of the Issue**:
+1. Click checkbox in advanced mode -> handleKeywordToggle adds/removes keyword
+2. updateSimpleModeState runs
+3. rebuildActiveKeywords clears all keywords
+4. Rebuilds only from contexts
+5. Loses direct checkbox changes
+
+**Solution**:
+```javascript
+export async function updateSimpleModeState() {
+ if (!state.authenticated) return;
+
+ // Only rebuild keywords in simple mode
+ if (state.mode === 'simple') { // <-- Added mode check
+ // Check contexts and rebuild keywords
+ for (const contextId of Array.from(state.selectedContexts)) {
+ const contextState = cache.getContextState(contextId);
+ if (contextState === 'none') {
+ state.selectedContexts.delete(contextId);
+ }
+ }
+
+ cache.clear();
+ rebuildActiveKeywords(); // <-- Only runs in simple mode now
+ }
+
+ // Maintain async performance optimizations
+ await debouncedUpdate(async () => {
+ renderInterface();
+ await saveState();
+ });
+}
+```
+
+**Result**:
+- Simple mode derives keywords from contexts
+- Advanced mode preserves direct modifications
+- Both modes maintain expected behavior
+- Performance optimizations preserved
+
+## Case Sensitivity Issues
+
+### 1. Duplicate Keywords
+
+**Problem**: Keywords like "Paris Agreement" appearing multiple times with different cases, particularly when switching between modes.
+
+**Root Cause**: Inconsistent case handling across different parts of the codebase, especially during mode transitions and context handling.
+
+**Solution**: Implemented comprehensive case-insensitive handling across all keyword operations:
+```javascript
+// Helper function for case-insensitive removal
+export function removeKeyword(keyword) {
+ const lowerKeyword = keyword.toLowerCase();
+ for (const activeKeyword of state.activeKeywords) {
+ if (activeKeyword.toLowerCase() === lowerKeyword) {
+ state.activeKeywords.delete(activeKeyword);
+ break;
+ }
+ }
+}
+
+// Helper function for case-sensitive addition with deduplication
+function addKeywordWithCase(keyword) {
+ // First remove any existing case variations
+ removeKeyword(keyword);
+ // Then add with original case
+ state.activeKeywords.add(keyword);
+}
+```
+
+**Implementation Details**:
+- Case-insensitive checks using isKeywordActive()
+- Case-insensitive removal using removeKeyword()
+- Case-preserving addition using addKeywordWithCase()
+- Consistent handling across mode switches and context changes
+
+**Result**:
+- No more duplicate keywords with different cases
+- Maintains proper keyword counts during mode switches
+- Preserves original case for display purposes
+- Consistent behavior across all operations
+
+### 2. Payload Size Issues
+
+**Problem**: "413 Payload Too Large" error when sending to Bluesky.
+
+**Root Cause**: Duplicate keywords with different cases inflating payload size.
+
+**Solution**: Case-insensitive deduplication is now handled consistently across all operations, preventing duplicates from being added in the first place.
+
+## Authentication Issues
+
+### 1. Missing DID in State
+
+**Problem**: "No DID set in state" error during state saves.
+
+**Root Cause**: Inconsistent DID handling across components.
+
+**Solution**: Centralized DID management:
+```javascript
+class AuthService {
+ verifyDID() {
+ if (!state.did) {
+ throw new Error('No DID set in state');
+ }
+ return state.did;
+ }
+}
+```
+
+### 2. Session State Inconsistency
+
+**Problem**: Different components using different auth state checks.
+
+**Solution**: Standardized auth checking:
+```javascript
+export function isAuthenticated() {
+ return state.authenticated && state.did;
+}
+```
+
+## State Persistence Issues
+
+### 1. Lost State After Login
+
+**Problem**: User preferences not persisting across sessions.
+
+**Solution**: Implemented proper state restoration flow:
+```javascript
+async function handleLogin() {
+ const did = await auth.getDID();
+ state.did = did;
+ await loadSavedState();
+ await initializeUI();
+}
+```
+
+### 2. Premature State Saves
+
+**Problem**: State saving before user confirms changes.
+
+**Solution**: Tied state persistence to explicit user actions:
+```javascript
+async function handleMuteUnmute() {
+ await processChanges();
+ await saveState();
+}
+```
+
+## Best Practices for Prevention
+
+### 1. Mode Management
+- Verify mode before state rebuilds
+- Respect mode hierarchy
+- Test mode transitions
+- Document mode-specific flows
+
+### 2. State Management
+- Centralize DID handling
+- Standardize auth checks
+- Defer state saves to user actions
+- Implement proper error recovery
+
+### 3. Case Sensitivity
+- Use case-insensitive storage
+- Preserve original case
+- Implement deduplication
+- Verify payload sizes
+
+### 4. Testing
+- Test mode transitions
+- Verify state persistence
+- Check case handling
+- Monitor performance
+
+### 5. Error Handling
+- Implement proper error boundaries
+- Provide clear error messages
+- Handle edge cases
+- Log relevant context
diff --git a/docs/2-development/2-troubleshooting-guide.md b/docs/2-development/2-troubleshooting-guide.md
new file mode 100644
index 0000000..c577ebf
--- /dev/null
+++ b/docs/2-development/2-troubleshooting-guide.md
@@ -0,0 +1,291 @@
+# Troubleshooting Guide
+
+This guide provides a systematic approach to diagnosing and fixing common issues in MuteSky.
+
+## Problem Categories
+
+### 1. Mode Switching Issues
+
+**Symptoms**:
+- Keywords muted in advanced mode show as "to unmute" in simple mode
+- Checkboxes in advanced mode check then uncheck after half second
+- State inconsistency between modes
+
+**Diagnostic Steps**:
+1. Check mode state:
+```javascript
+console.debug('[Mode] Current mode:', state.mode);
+console.debug('[Mode] Active keywords:', state.activeKeywords.size);
+console.debug('[Mode] Original muted:', state.originalMutedKeywords.size);
+```
+
+2. Verify state hierarchy:
+```javascript
+// Advanced mode should be source of truth
+console.debug('[State] Advanced selections:', {
+ activeKeywords: Array.from(state.activeKeywords),
+ manuallyUnchecked: Array.from(state.manuallyUnchecked)
+});
+
+// Simple mode should derive from this
+console.debug('[State] Simple mode state:', {
+ selectedContexts: Array.from(state.selectedContexts),
+ selectedExceptions: Array.from(state.selectedExceptions)
+});
+```
+
+### 2. State Management Issues
+
+**Symptoms**:
+- Inconsistent state across mode switches
+- Lost preferences after operations
+- Unexpected state changes
+
+**Diagnostic Steps**:
+1. Check state relationships:
+```javascript
+console.debug('[State] State relationships:', {
+ activeKeywords: state.activeKeywords.size,
+ originalMuted: state.originalMutedKeywords.size,
+ sessionMuted: state.sessionMutedKeywords.size,
+ manuallyUnchecked: state.manuallyUnchecked.size
+});
+```
+
+2. Verify state persistence:
+```javascript
+// Check storage key
+const storageKey = getStorageKey();
+console.debug('[Storage] Current key:', storageKey);
+
+// Check saved state
+const savedState = localStorage.getItem(storageKey);
+console.debug('[Storage] Saved state exists:', !!savedState);
+```
+
+### 3. Case Sensitivity Issues
+
+**Symptoms**:
+- Keywords not matching case variants
+- Duplicate keywords with different cases
+- Large payload sizes to Bluesky
+
+**Diagnostic Steps**:
+1. Check case handling:
+```javascript
+// Log case variants
+const keyword = 'Example';
+console.debug('[Case] Variants:', {
+ original: keyword,
+ lower: keyword.toLowerCase(),
+ stored: state.originalMutedKeywords.has(keyword.toLowerCase()),
+ active: state.activeKeywords.has(keyword)
+});
+```
+
+2. Check payload size:
+```javascript
+// Log unique keywords
+const uniqueKeywords = new Set(
+ Array.from(state.activeKeywords).map(k => k.toLowerCase())
+);
+console.debug('[Payload] Unique keywords:', uniqueKeywords.size);
+```
+
+### 4. Authentication Issues
+
+**Symptoms**:
+- "No DID set in state" errors
+- Inconsistent auth state
+- Session refresh problems
+
+**Diagnostic Steps**:
+1. Check auth state:
+```javascript
+console.debug('[Auth] State:', {
+ authenticated: state.authenticated,
+ did: state.did,
+ hasSession: !!session
+});
+```
+
+2. Verify DID consistency:
+```javascript
+// Check DID across components
+console.debug('[DID] Storage key:', getStorageKey());
+console.debug('[DID] State DID:', state.did);
+console.debug('[DID] Session DID:', session?.did);
+```
+
+## Common Fixes
+
+### 1. Mode Switching Fix
+```javascript
+export async function updateSimpleModeState() {
+ if (!state.authenticated) return;
+
+ // Only rebuild in simple mode
+ if (state.mode === 'simple') {
+ // Your mode-specific logic here
+ }
+
+ await debouncedUpdate(async () => {
+ renderInterface();
+ await saveState();
+ });
+}
+```
+
+### 2. State Persistence Fix
+```javascript
+function saveState() {
+ if (!state.did) {
+ console.error('[State] Cannot save - no DID');
+ return;
+ }
+
+ const key = getStorageKey();
+ try {
+ localStorage.setItem(key, JSON.stringify({
+ // Your state here
+ }));
+ } catch (error) {
+ console.error('[State] Save failed:', error);
+ }
+}
+```
+
+### 3. Case Sensitivity Fix
+```javascript
+// Store lowercase for comparison
+const lowerKeyword = keyword.toLowerCase();
+state.originalMutedKeywords.add(lowerKeyword);
+
+// Preserve original case for display
+const originalCase = ourKeywordsMap.get(lowerKeyword);
+if (originalCase) {
+ state.activeKeywords.add(originalCase);
+}
+```
+
+### 4. Auth State Fix
+```javascript
+function isAuthenticated() {
+ return state.authenticated && state.did;
+}
+
+// Use consistently across all files
+if (!isAuthenticated()) {
+ console.error('[Auth] Not authenticated');
+ return;
+}
+```
+
+## Prevention Checklist
+
+### 1. Mode Switching
+- [ ] Verify mode before state rebuilds
+- [ ] Respect mode hierarchy
+- [ ] Test mode transitions
+- [ ] Log state changes
+
+### 2. State Management
+- [ ] Check DID before operations
+- [ ] Verify state relationships
+- [ ] Test persistence
+- [ ] Handle errors gracefully
+
+### 3. Case Sensitivity
+- [ ] Use lowercase for comparisons
+- [ ] Preserve original case
+- [ ] Check payload sizes
+- [ ] Implement deduplication
+
+### 4. Authentication
+- [ ] Verify DID consistency
+- [ ] Handle session refresh
+- [ ] Test auth flows
+- [ ] Log auth state changes
+
+## Debugging Tools
+
+### 1. State Inspector
+```javascript
+function inspectState() {
+ return {
+ mode: state.mode,
+ auth: {
+ authenticated: state.authenticated,
+ did: state.did
+ },
+ keywords: {
+ active: state.activeKeywords.size,
+ original: state.originalMutedKeywords.size,
+ session: state.sessionMutedKeywords.size,
+ unchecked: state.manuallyUnchecked.size
+ },
+ contexts: {
+ selected: state.selectedContexts.size,
+ exceptions: state.selectedExceptions.size
+ }
+ };
+}
+```
+
+### 2. Storage Validator
+```javascript
+function validateStorage() {
+ const key = getStorageKey();
+ try {
+ const saved = localStorage.getItem(key);
+ const parsed = JSON.parse(saved);
+ return {
+ valid: true,
+ data: parsed
+ };
+ } catch (error) {
+ return {
+ valid: false,
+ error: error.message
+ };
+ }
+}
+```
+
+## Best Practices
+
+1. Always log state changes:
+```javascript
+console.debug('[State] Before:', inspectState());
+// Make changes
+console.debug('[State] After:', inspectState());
+```
+
+2. Verify auth state consistently:
+```javascript
+if (!isAuthenticated()) {
+ console.error('[Auth] Operation failed - not authenticated');
+ return;
+}
+```
+
+3. Handle errors gracefully:
+```javascript
+try {
+ await operation();
+} catch (error) {
+ console.error('[Error]', {
+ operation: 'name',
+ error: error.message,
+ state: inspectState()
+ });
+}
+```
+
+4. Test mode transitions thoroughly:
+```javascript
+async function testModeTransition() {
+ console.debug('[Test] Before switch:', inspectState());
+ await switchMode(newMode);
+ console.debug('[Test] After switch:', inspectState());
+}
diff --git a/docs/3-guides/1-understanding-modes.md b/docs/3-guides/1-understanding-modes.md
new file mode 100644
index 0000000..410d360
--- /dev/null
+++ b/docs/3-guides/1-understanding-modes.md
@@ -0,0 +1,101 @@
+# Understanding MuteSky's Modes
+
+## The Big Picture
+
+MuteSky has two ways of working - Simple Mode and Advanced Mode. Think of it like having both an automatic and manual transmission in your car. Simple Mode is like automatic - it handles a lot of the complexity for you. Advanced Mode is like manual - you get complete control over everything.
+
+## Simple Mode: The Easy Way
+
+Imagine you're organizing your closet. Instead of dealing with individual pieces of clothing, you might say "I want to organize my work clothes" or "I want to sort my gym wear". This is how Simple Mode works:
+
+1. **Contexts Instead of Keywords**
+ - Rather than managing individual keywords, you select broader contexts like "Political Content" or "Sports Drama"
+ - Each context automatically handles related keywords for you
+ - It's like having a professional organizer who knows what goes where
+
+2. **Filter Levels**
+ - Level 0 (Minimal): Just the essentials, like having a basic wardrobe
+ - Level 1 (Moderate): A balanced approach
+ - Level 2 (Extensive): More comprehensive filtering
+ - Level 3 (Complete): Maximum filtering, like organizing everything down to the last sock
+
+3. **Smart Exceptions**
+ - Want to keep track of your favorite team but filter other sports? Use exceptions
+ - It's like saying "I want to organize all my clothes, but leave my favorite jacket where it is"
+
+## Advanced Mode: Complete Control
+
+Advanced Mode is for when you want to manage everything yourself. It's like deciding exactly where each piece of clothing goes in your closet:
+
+1. **Direct Keyword Management**
+ - See every keyword available
+ - Choose exactly what to mute or unmute
+ - Perfect for fine-tuning your filters
+
+2. **Category Organization**
+ - Keywords are grouped into categories
+ - Easy to find related terms
+ - Toggle entire categories at once
+
+## How They Work Together
+
+Here's the clever part - these modes work together seamlessly:
+
+1. **Advanced Mode is the Boss**
+ - Any changes you make in Advanced Mode stick
+ - Simple Mode respects these changes
+ - It's like having your manual organization take priority over the automatic system
+
+2. **Simple Mode is Smart**
+ - Derives its actions from Advanced Mode's state
+ - Updates automatically when Advanced Mode changes
+ - Maintains a user-friendly interface while respecting your detailed preferences
+
+## Real-World Example
+
+Let's say you're managing political content:
+
+1. **In Simple Mode:**
+ - Select the "Political Content" context
+ - Choose a filter level
+ - Maybe add an exception for your favorite topic
+ - The system handles all the keywords automatically
+
+2. **In Advanced Mode:**
+ - See all political keywords
+ - Choose specific terms to mute
+ - Fine-tune exactly what you want to see or hide
+ - Your choices are remembered and respected by Simple Mode
+
+## When to Use Each Mode
+
+### Use Simple Mode When:
+- You want quick, easy content management
+- You prefer broader categories over specific terms
+- You're just getting started with content filtering
+- You want a "set it and forget it" approach
+
+### Use Advanced Mode When:
+- You need precise control
+- You want to see exactly what's being filtered
+- You have specific terms you want to manage
+- You're comfortable with more detailed configuration
+
+## Pro Tips
+
+1. **Start Simple**
+ - Begin with Simple Mode to get a feel for the system
+ - Use filter levels to find your comfort zone
+ - Add exceptions as needed
+
+2. **Graduate to Advanced**
+ - Switch to Advanced Mode when you want more control
+ - Your Simple Mode settings are preserved
+ - Make fine-tuned adjustments without losing your base configuration
+
+3. **Mix and Match**
+ - Use Simple Mode for broad categories
+ - Switch to Advanced for specific adjustments
+ - The system maintains consistency between both
+
+Remember: There's no "right" way to use MuteSky. Choose the mode that works best for you, and feel free to switch between them as your needs change.
diff --git a/docs/3-guides/2-muting-explained.md b/docs/3-guides/2-muting-explained.md
new file mode 100644
index 0000000..f349217
--- /dev/null
+++ b/docs/3-guides/2-muting-explained.md
@@ -0,0 +1,119 @@
+# Understanding MuteSky's Muting System
+
+## The Basics: How Muting Works
+
+Think of MuteSky's muting system like a smart content filter for your Bluesky feed. It's designed to work alongside any muting you've already set up, not replace it.
+
+## Your Keywords Are Safe
+
+One of the most important things to understand is that MuteSky is very careful with your existing muted keywords:
+
+1. **We Never Delete Your Keywords**
+ - If you've muted "cats" outside of MuteSky, we'll never remove it
+ - Think of it like having a personal list that we promise never to erase
+ - We only manage the keywords from our curated list
+
+2. **Case Doesn't Matter**
+ - Whether you muted "Bitcoin", "bitcoin", or "BITCOIN", we handle it
+ - It's like having a smart assistant who understands these are the same thing
+ - We preserve the original case when displaying keywords
+
+## How It All Works Together
+
+### When You First Log In
+
+1. **We Check Your Existing Mutes**
+ - MuteSky looks at what you've already muted on Bluesky
+ - Like taking inventory of your current setup
+ - We note these down to make sure we don't mess with them
+
+2. **We Show What's Already Muted**
+ - Keywords from our list that you've already muted show up as checked
+ - Your custom keywords stay in the background, safely preserved
+ - It's like having two lists: our suggestions and your personal choices
+
+### When You Make Changes
+
+1. **Adding New Mutes**
+ - Select keywords from our list
+ - Click the mute button
+ - We add these to your Bluesky mutes without touching your existing ones
+
+2. **Removing Mutes**
+ - You can only unmute keywords from our list
+ - Your personal muted keywords stay untouched
+ - It's like having a guest who can only move their own things
+
+## Muting Settings
+
+You have control over how muting works:
+
+1. **Scope Options**
+ - "All Content": Mutes keywords in posts and tags
+ - "Tags Only": Only mutes hashtags
+ - Like choosing whether to filter just headlines or entire articles
+
+2. **Duration**
+ - Choose how long mutes should last
+ - Set them to expire after a certain time
+ - Or keep them permanent
+
+3. **Following Exception**
+ - Option to not mute content from people you follow
+ - Like saying "I trust these sources, show me their content anyway"
+
+## Real-World Examples
+
+### Scenario 1: Mixed Keywords
+```
+Your existing mutes: "bitcoin", "kitty", "ELON"
+Our list includes: "Bitcoin", "DeSantis", "Pence"
+
+What you'll see:
+✓ Bitcoin (checkmark, can unmute - matches your "bitcoin")
+□ DeSantis (no checkmark, can mute)
+□ Pence (no checkmark, can mute)
+("kitty" and "ELON" are preserved but not shown)
+```
+
+### Scenario 2: Making Changes
+```
+You mute "DeSantis" through MuteSky:
+- "DeSantis" gets added to your Bluesky mutes
+- Your original mutes ("bitcoin", "kitty", "ELON") stay exactly as they are
+- The checkbox for "DeSantis" shows as checked
+```
+
+## Pro Tips
+
+1. **Start Small**
+ - Begin with a few keywords
+ - See how they affect your feed
+ - Add more as needed
+
+2. **Use the Preview**
+ - The mute button shows exactly what will change
+ - "Mute 5 new" means adding 5 new keywords
+ - "Unmute 3 existing" means removing 3 from our list
+
+3. **Check Your Settings**
+ - Review your muting settings periodically
+ - Adjust scope and duration as needed
+ - Consider the following exception for trusted sources
+
+## Common Questions
+
+### "What happens to my existing mutes?"
+They stay exactly as they are. MuteSky never removes mutes you've set up outside our system.
+
+### "Can I unmute everything?"
+You can unmute any keywords from our list, but your personal muted keywords will stay muted.
+
+### "Why do some keywords show as already muted?"
+These are keywords from our list that match ones you've already muted on Bluesky (regardless of case).
+
+### "What's the difference between muting all content vs tags only?"
+- All content: Filters the keyword everywhere (posts and tags)
+- Tags only: Only filters when the keyword is used as a hashtag
+
+Remember: MuteSky is designed to enhance your Bluesky experience, not take control of it. Your preferences always come first, and we're just here to help make content filtering easier.
diff --git a/docs/3-guides/3-state-persistence.md b/docs/3-guides/3-state-persistence.md
new file mode 100644
index 0000000..11d1147
--- /dev/null
+++ b/docs/3-guides/3-state-persistence.md
@@ -0,0 +1,139 @@
+# Understanding How MuteSky Saves Your Changes
+
+## The Big Picture
+
+MuteSky is designed to let you experiment freely with different settings and keywords before committing your changes. Think of it like online shopping - you can add items to your cart (make changes), but nothing is final until you click "checkout" (click the mute/unmute button).
+
+## When Changes Are Saved
+
+### The Golden Rule: Mute Button is King
+The most important thing to understand is that changes are only saved when you click the mute/unmute button. This is intentional and here's why:
+
+1. **Freedom to Experiment**
+ - Try different filter levels
+ - Toggle contexts on and off
+ - Check and uncheck keywords
+ - Nothing is permanent until you're ready
+
+2. **Preview Your Changes**
+ - The mute button shows exactly what will happen
+ - "Mute 5 new" means adding 5 new keywords
+ - "Unmute 3 existing" means removing 3 keywords
+ - You can see the impact before committing
+
+## What Gets Saved
+
+When you do click the mute/unmute button, here's what's saved:
+
+1. **Active Keywords**
+ - Which keywords are checked/unchecked
+ - Your manually unchecked preferences
+ - Context selections in Simple mode
+
+2. **Mode Settings**
+ - Whether you're in Simple or Advanced mode
+ - Your selected filter level
+ - Which contexts are selected
+ - Any exceptions you've set
+
+3. **User Preferences**
+ - Target keyword count
+ - Last bulk action (enable/disable all)
+ - Interface settings
+
+## Real-World Examples
+
+### Scenario 1: Experimenting with Filters
+```
+1. You're in Simple mode
+2. Change filter level from 0 to 2
+3. UI updates immediately
+4. BUT changes aren't saved yet
+5. Click mute button to make it permanent
+```
+
+### Scenario 2: Context Changes
+```
+1. Select "Political Content" context
+2. UI shows new keywords to be muted
+3. Add an exception for a specific category
+4. Nothing is muted yet
+5. Click mute button to apply changes
+```
+
+### Scenario 3: Logging Back In
+```
+1. Last session: clicked mute button after setting filter level 2
+2. Log out, then log back in
+3. Filter level 2 is restored
+4. All your selections are exactly as you left them
+```
+
+## What's Not Automatically Saved
+
+1. **Filter Level Changes**
+ - Changing levels updates the UI
+ - But doesn't save until mute/unmute
+
+2. **Context Toggles**
+ - Selecting/deselecting contexts
+ - Adding/removing exceptions
+ - All temporary until mute/unmute
+
+3. **Keyword Toggles**
+ - Checking/unchecking keywords
+ - Enable/disable all actions
+ - Need mute/unmute to persist
+
+## Why This Design?
+
+1. **Safety First**
+ - No accidental mass changes
+ - Clear preview of what will happen
+ - Easy to experiment without fear
+
+2. **Clear Intent**
+ - Clicking mute/unmute shows clear intention
+ - No surprise changes to your Bluesky mutes
+ - You're always in control
+
+3. **Better Experience**
+ - Free to try different combinations
+ - See the effects before committing
+ - Easy to back out of changes
+
+## Pro Tips
+
+1. **Experiment Freely**
+ - Try different filter levels
+ - Toggle contexts on and off
+ - The mute button will show the outcome
+
+2. **Watch the Mute Button**
+ - It's your guide to what will change
+ - Shows exactly what will be muted/unmuted
+ - Clear indication of pending changes
+
+3. **Understanding Persistence**
+ - Changes aren't lost on mode switch
+ - But they're not saved until mute/unmute
+ - Your last saved state is restored on login
+
+## Common Questions
+
+### "Why didn't my filter level save?"
+You need to click the mute/unmute button after changing the filter level. This lets you preview the effect before committing.
+
+### "Will I lose my changes if I switch modes?"
+No! Changes stay in memory until you either:
+- Click mute/unmute to save them
+- Log out without saving
+- Refresh the page without saving
+
+### "What happens if I log out without saving?"
+You'll return to your last saved state - the last time you clicked mute/unmute.
+
+### "Do I need to save after every small change?"
+No! Make all your changes, then save once with mute/unmute when you're happy with everything.
+
+Remember: The mute/unmute button is your "save" button. Feel free to experiment - nothing is permanent until you click it!
diff --git a/docs/3-guides/4-authentication-guide.md b/docs/3-guides/4-authentication-guide.md
new file mode 100644
index 0000000..5e89f57
--- /dev/null
+++ b/docs/3-guides/4-authentication-guide.md
@@ -0,0 +1,100 @@
+# Understanding MuteSky's Authentication
+
+## How Authentication Works
+
+When you use MuteSky, you're actually connecting it to your Bluesky account. Think of it like giving a trusted assistant permission to help manage your muted keywords. Here's how it works:
+
+## The Login Process
+
+1. **Enter Your Handle**
+ - Type in your Bluesky handle (like @you.bsky.social)
+ - MuteSky uses this to start the connection process
+ - It's like telling the system "This is who I am"
+
+2. **Authorize with Bluesky**
+ - You'll be taken to Bluesky's website
+ - Bluesky asks "Do you want to let MuteSky help manage your mutes?"
+ - You approve the connection
+ - It's like giving MuteSky a special key to help manage your mutes
+
+3. **Back to MuteSky**
+ - After approval, you return to MuteSky
+ - You'll see a loading screen while we:
+ * Set up your connection
+ * Load your current muted keywords
+ * Prepare your personalized interface
+
+## What's Happening Behind the Scenes
+
+1. **Secure Connection**
+ - MuteSky gets a secure token from Bluesky
+ - Like a special pass that proves you gave permission
+ - This token is handled securely and never stored permanently
+
+2. **Session Management**
+ - Your session stays active while you use MuteSky
+ - If it expires, we automatically refresh it
+ - You don't need to keep logging in
+
+3. **Error Handling**
+ - If something goes wrong, you'll see clear error messages
+ - We automatically try to fix connection issues
+ - You can always try logging in again if needed
+
+## Common Questions
+
+### "Do I need to log in every time?"
+No! Your session persists until you:
+- Click "Sign Out"
+- Clear your browser data
+- The session naturally expires (we handle renewal automatically)
+
+### "Is it safe to authorize MuteSky?"
+Yes! MuteSky:
+- Only asks for permissions it needs
+- Never stores your Bluesky password
+- Uses secure OAuth (the same system many apps use)
+- Can only help manage mutes, nothing else
+
+### "What if I see an error?"
+Most errors can be fixed by:
+1. Refreshing the page
+2. Signing out and back in
+3. Clearing browser cache if needed
+
+### "What happens if I lose connection?"
+- MuteSky tries to reconnect automatically
+- Your changes are preserved
+- You might need to log in again if reconnection fails
+
+## Pro Tips
+
+1. **Stay Signed In**
+ - Don't clear browser data if you want to stay logged in
+ - MuteSky handles session renewal automatically
+ - Your preferences are preserved between sessions
+
+2. **Watch for Status Messages**
+ - The app shows clear status updates
+ - You'll know exactly what's happening
+ - Error messages explain any issues
+
+3. **Handle Errors Gracefully**
+ - If you see an error, try refreshing first
+ - Sign out and back in if refresh doesn't work
+ - Contact support if problems persist
+
+## Security Notes
+
+1. **What MuteSky Can Do**
+ - View your current muted keywords
+ - Add or remove muted keywords
+ - That's it! No posting, following, or other actions
+
+2. **What MuteSky Can't Do**
+ - Post on your behalf
+ - Follow/unfollow accounts
+ - Access your private messages
+ - Change your account settings
+
+Remember: MuteSky is designed to be a helpful tool for managing your muted keywords while respecting your privacy and security. You're always in control and can revoke access at any time through your Bluesky settings.
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..157e806
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,154 @@
+# MuteSky Documentation
+
+## Overview
+
+This documentation is organized into three main sections:
+1. User Guides - Human-friendly explanations of how MuteSky works
+2. Architecture - Technical documentation of system design and implementation
+3. Development - Implementation details and troubleshooting guides
+
+## Documentation Structure
+
+### 1. User Guides
+Easy-to-understand explanations for users and developers.
+
+1. [Understanding Modes](3-guides/1-understanding-modes.md)
+ - Simple vs Advanced Mode explained
+ - When to use each mode
+ - Real-world examples
+ - Pro tips and best practices
+
+2. [Muting Explained](3-guides/2-muting-explained.md)
+ - How muting works with Bluesky
+ - Keyword preservation
+ - Settings and options
+ - Common questions answered
+
+3. [State Persistence](3-guides/3-state-persistence.md)
+ - When changes are saved
+ - What gets saved
+ - Real-world examples
+ - Troubleshooting guide
+
+### 2. Architecture
+Technical documentation for developers.
+
+1. [Core Concepts](1-architecture/1-core-concepts.md)
+ - Two-mode system overview
+ - State management hierarchy
+ - Persistence model
+ - Multi-user support
+
+2. [Authentication](1-architecture/2-authentication.md)
+ - OAuth implementation
+ - Session management
+ - Token refresh mechanism
+ - Callback system
+
+3. [Muting System](1-architecture/3-muting-system.md)
+ - Keyword types and management
+ - Muting behavior
+ - Case sensitivity handling
+ - API integration
+
+4. [Mode System](1-architecture/4-mode-system.md)
+ - Simple mode components
+ - Advanced mode components
+ - Mode synchronization
+ - State management
+
+5. [Performance](1-architecture/5-performance.md)
+ - Core optimizations
+ - Bulk operations
+ - State updates
+ - Memory management
+
+6. [Click Performance](1-architecture/6-click-performance.md)
+ - Set operations optimization
+ - Enhanced caching system
+ - Deferred UI updates
+ - Response time improvements
+
+### 3. Development
+Implementation details and troubleshooting.
+
+1. [Known Issues](2-development/1-known-issues.md)
+ - Mode-related issues
+ - Case sensitivity problems
+ - Authentication issues
+ - Performance concerns
+
+## Key Concepts
+
+### State Management
+- Advanced mode is source of truth
+- State only saves on mute/unmute
+- DID-specific storage keys
+- Case-insensitive comparisons
+
+### Performance
+- Set operations for O(1) lookups
+- Debounced UI updates
+- Progressive bulk operations
+- Efficient caching system
+
+### Mode System
+- Simple mode: Context-based filtering
+- Advanced mode: Direct keyword management
+- Synchronized state between modes
+- Exception handling for granular control
+
+### Authentication
+- Bluesky OAuth integration
+- Token refresh mechanism
+- Session state management
+- Multi-user support
+
+## Contributing
+
+When working with this codebase:
+
+1. State Management
+ - Follow the established hierarchy
+ - Respect the persistence model
+ - Maintain case sensitivity rules
+ - Handle exceptions properly
+
+2. Performance
+ - Use provided optimization patterns
+ - Implement proper caching
+ - Follow bulk operation patterns
+ - Monitor memory usage
+
+3. Error Handling
+ - Follow established patterns
+ - Provide clear error messages
+ - Implement proper recovery
+ - Log relevant context
+
+4. Testing
+ - Verify mode transitions
+ - Test case sensitivity
+ - Check state persistence
+ - Monitor performance
+
+## Documentation Updates
+
+When updating documentation:
+
+1. Consider Both Audiences
+ - Add/update technical docs in Architecture
+ - Add/update user guides in Guides
+ - Keep explanations appropriate for each audience
+
+2. Maintain Structure
+ - Keep sections focused and concise
+ - Include relevant code examples
+ - Update the README.md index
+ - Cross-reference related sections
+
+3. Include Real-World Context
+ - Add practical examples
+ - Explain the "why" not just the "how"
+ - Address common questions
+ - Provide troubleshooting tips
diff --git a/docs/migration.md b/docs/migration.md
new file mode 100644
index 0000000..d46fb09
--- /dev/null
+++ b/docs/migration.md
@@ -0,0 +1,31 @@
+# Migration Notes
+
+## Weight System Simplification (January 2024)
+
+### Changes Made
+1. Simplified weight system from complex thresholds to 0-3 scale:
+ - Level 0 (Minimal) = threshold 3 (most restrictive)
+ - Level 1 (Moderate) = threshold 2
+ - Level 2 (Extensive) = threshold 1
+ - Level 3 (Complete) = threshold 0 (most inclusive)
+
+2. Removed targetKeywordCount:
+ - Removed from state
+ - Removed setTargetKeywordCount function
+ - Updated state persistence
+ - Simplified filterLevel handling
+
+3. Removed category weights:
+ - Weight thresholds now based only on keyword weights
+ - Simplified filtering logic
+ - Maintained case sensitivity handling
+
+### Future Considerations
+1. Categories will be removed in a future update
+2. Current category-related code is maintained for backwards compatibility
+3. New features should use filterLevel and keyword weights only
+
+### Migration Path
+- Old state using targetKeywordCount will default to filterLevel 0
+- Existing keyword weights (0-3) work directly with new thresholds
+- Category weights are ignored but preserved in data structure for now
diff --git a/favicon.ico b/favicon.ico
new file mode 100644
index 0000000..26fec60
Binary files /dev/null and b/favicon.ico differ
diff --git a/images/logo.png b/images/logo.png
new file mode 100644
index 0000000..d5f9150
Binary files /dev/null and b/images/logo.png differ
diff --git a/images/screenshots/dark-advanced-mode.png b/images/screenshots/dark-advanced-mode.png
new file mode 100644
index 0000000..2e59244
Binary files /dev/null and b/images/screenshots/dark-advanced-mode.png differ
diff --git a/images/screenshots/dark-search.png b/images/screenshots/dark-search.png
new file mode 100644
index 0000000..67b8f97
Binary files /dev/null and b/images/screenshots/dark-search.png differ
diff --git a/images/screenshots/dark-simple-mode.png b/images/screenshots/dark-simple-mode.png
new file mode 100644
index 0000000..3954523
Binary files /dev/null and b/images/screenshots/dark-simple-mode.png differ
diff --git a/images/screenshots/light-advanced-mode.png b/images/screenshots/light-advanced-mode.png
new file mode 100644
index 0000000..8f8cb26
Binary files /dev/null and b/images/screenshots/light-advanced-mode.png differ
diff --git a/images/screenshots/light-search.png b/images/screenshots/light-search.png
new file mode 100644
index 0000000..e2c8e6a
Binary files /dev/null and b/images/screenshots/light-search.png differ
diff --git a/images/screenshots/light-simple-mode.png b/images/screenshots/light-simple-mode.png
new file mode 100644
index 0000000..0545148
Binary files /dev/null and b/images/screenshots/light-simple-mode.png differ
diff --git a/images/sponsor.svg b/images/sponsor.svg
new file mode 100644
index 0000000..4f1e3f3
--- /dev/null
+++ b/images/sponsor.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..cc975d4
--- /dev/null
+++ b/index.html
@@ -0,0 +1,107 @@
+
+
+
+
+
+ MuteSky — Mute in Bulk on Bluesky
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/js/api.js b/js/api.js
new file mode 100644
index 0000000..8459324
--- /dev/null
+++ b/js/api.js
@@ -0,0 +1,4 @@
+// Re-export everything from the new modular structure
+export { cache } from './api/cache.js';
+export { getLastModifiedDate, listCategoryFiles } from './api/github.js';
+export { fetchKeywordGroups, fetchContextGroups, fetchDisplayConfig, refreshAllData } from './api/index.js';
diff --git a/js/api/cache.js b/js/api/cache.js
new file mode 100644
index 0000000..61c3a70
--- /dev/null
+++ b/js/api/cache.js
@@ -0,0 +1,17 @@
+// Cache implementation
+export const cache = {
+ data: new Map(),
+ getItem: function(key) {
+ const item = this.data.get(key);
+ if (!item) return null;
+ if (Date.now() > item.expiry) {
+ this.data.delete(key);
+ return null;
+ }
+ return item.value;
+ },
+ setItem: function(key, value, ttl = 3600000) { // 1 hour default TTL
+ const expiry = Date.now() + ttl;
+ this.data.set(key, { value, expiry });
+ }
+};
diff --git a/js/api/fetchers.js b/js/api/fetchers.js
new file mode 100644
index 0000000..361dafc
--- /dev/null
+++ b/js/api/fetchers.js
@@ -0,0 +1,79 @@
+import { KEYWORDS_BASE_URL, CONTEXT_GROUPS_URL, DISPLAY_CONFIG_URL } from '../config.js';
+import { state, forceRefresh } from '../state.js';
+import { listCategoryFiles, getLastModifiedDate } from './github.js';
+
+export async function fetchKeywordGroups(forceFresh = false) {
+ try {
+ // Get list of category files
+ const categoryFiles = await listCategoryFiles();
+ console.debug('Found category files:', categoryFiles);
+
+ // Fetch and process each category file
+ const keywordGroups = {};
+ const results = await Promise.allSettled(categoryFiles.map(async (fileName) => {
+ try {
+ const url = `${KEYWORDS_BASE_URL}/${fileName}`;
+ const response = await fetch(url, { cache: 'no-store' });
+ if (!response.ok) return;
+
+ const categoryData = await response.json();
+ const categoryName = Object.keys(categoryData)[0];
+
+ // Store the entire category data structure
+ keywordGroups[categoryName] = categoryData;
+
+ console.debug(`Loaded ${categoryName} with ${Object.keys(categoryData[categoryName].keywords).length} keywords`);
+ } catch (error) {
+ console.error(`Failed to load category ${fileName}:`, error);
+ }
+ }));
+
+ // Sort categories alphabetically and create a new ordered object
+ const orderedKeywordGroups = {};
+ Object.keys(keywordGroups)
+ .sort((a, b) => a.localeCompare(b))
+ .forEach(key => {
+ orderedKeywordGroups[key] = keywordGroups[key];
+ });
+
+ // Update state with ordered groups
+ state.lastModified = await getLastModifiedDate();
+ state.keywordGroups = orderedKeywordGroups;
+
+ // Initialize selected categories if empty
+ if (state.selectedCategories.size === 0) {
+ Object.keys(orderedKeywordGroups).forEach(category => {
+ state.selectedCategories.add(category);
+ });
+ }
+
+ console.debug('Keyword groups loaded:', Object.keys(orderedKeywordGroups).length, 'categories');
+ } catch (error) {
+ console.error('Error fetching keyword groups:', error);
+ throw error;
+ }
+}
+
+export async function fetchContextGroups(forceFresh = false) {
+ try {
+ const url = forceFresh ? forceRefresh().contextGroupsUrl : CONTEXT_GROUPS_URL;
+ const response = await fetch(url, { cache: 'no-store' });
+ if (!response.ok) throw new Error('Failed to fetch context groups');
+ state.contextGroups = await response.json();
+ } catch (error) {
+ console.error('Error fetching context groups:', error);
+ throw error;
+ }
+}
+
+export async function fetchDisplayConfig(forceFresh = false) {
+ try {
+ const url = forceFresh ? forceRefresh().displayConfigUrl : DISPLAY_CONFIG_URL;
+ const response = await fetch(url, { cache: 'no-store' });
+ if (!response.ok) throw new Error('Failed to fetch display config');
+ state.displayConfig = await response.json();
+ } catch (error) {
+ console.error('Error fetching display config:', error);
+ throw error;
+ }
+}
diff --git a/js/api/github.js b/js/api/github.js
new file mode 100644
index 0000000..e52df9b
--- /dev/null
+++ b/js/api/github.js
@@ -0,0 +1,102 @@
+import { cache } from './cache.js';
+
+// Backup category files list
+const BACKUP_CATEGORY_FILES = [
+ 'climate-and-environment.json',
+ 'economic-policy.json',
+ 'education.json',
+ 'gun-policy.json',
+ 'healthcare-and-public-health.json',
+ 'immigration.json',
+ 'international-coverage.json',
+ 'lgbtq.json',
+ 'media-personalities.json',
+ 'military-and-defense.json',
+ 'new-developments.json',
+ 'political-organizations.json',
+ 'political-rhetoric.json',
+ 'political-violence-and-security-threats.json',
+ 'race-relations.json',
+ 'relational-violence.json',
+ 'religion.json',
+ 'reproductive-health.json',
+ 'social-policy.json',
+ 'us-government-institutions.json',
+ 'us-political-figures-full-name.json',
+ 'us-political-figures-single-name.json',
+ 'vaccine-policy.json',
+ 'world-leaders.json'
+];
+
+const BACKUP_LAST_MODIFIED = 'Dec 1, 2023 9:00 PM';
+
+export async function getLastModifiedDate() {
+ const repoOwner = 'potatoqualitee';
+ const repoName = 'calm-the-chaos';
+ const filePath = 'keywords/categories';
+ const cacheKey = `lastModified_${repoOwner}_${repoName}_${filePath}`;
+
+ try {
+ // Check cache first
+ const cachedDate = cache.getItem(cacheKey);
+ if (cachedDate) return cachedDate;
+
+ const apiUrl = `https://api.github.com/repos/${repoOwner}/${repoName}/commits?path=${filePath}&per_page=1`;
+ const response = await fetch(apiUrl, {
+ headers: {
+ 'User-Agent': 'MuteSky-App'
+ }
+ });
+ const data = await response.json();
+
+ if (data && data[0] && data[0].commit && data[0].commit.committer.date) {
+ const date = new Date(data[0].commit.committer.date);
+ const formattedDate = date.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true
+ });
+ cache.setItem(cacheKey, formattedDate);
+ return formattedDate;
+ }
+ } catch (error) {
+ console.error('Failed to fetch last modified date:', error);
+ }
+ return BACKUP_LAST_MODIFIED;
+}
+
+export async function listCategoryFiles() {
+ const repoOwner = 'potatoqualitee';
+ const repoName = 'calm-the-chaos';
+ const path = 'keywords/categories';
+ const cacheKey = `categoryFiles_${repoOwner}_${repoName}_${path}`;
+
+ try {
+ // Check cache first
+ const cachedFiles = cache.getItem(cacheKey);
+ if (cachedFiles) return cachedFiles;
+
+ const apiUrl = `https://api.github.com/repos/${repoOwner}/${repoName}/contents/${path}`;
+ const response = await fetch(apiUrl, {
+ headers: {
+ 'User-Agent': 'MuteSky-App'
+ }
+ });
+
+ if (response.status === 403) {
+ console.debug('GitHub API rate limit reached, using backup files');
+ return BACKUP_CATEGORY_FILES;
+ }
+
+ const data = await response.json();
+ const files = data.filter(file => file.name.endsWith('.json')).map(file => file.name);
+ cache.setItem(cacheKey, files);
+ return files;
+ } catch (error) {
+ console.error('Failed to list category files:', error);
+ return BACKUP_CATEGORY_FILES;
+ }
+}
diff --git a/js/api/index.js b/js/api/index.js
new file mode 100644
index 0000000..ef946a2
--- /dev/null
+++ b/js/api/index.js
@@ -0,0 +1,50 @@
+import { state, forceRefresh } from '../state.js';
+import { fetchKeywordGroups, fetchContextGroups, fetchDisplayConfig } from './fetchers.js';
+
+export { fetchKeywordGroups, fetchContextGroups, fetchDisplayConfig };
+
+export async function refreshAllData() {
+ try {
+ // Store current state before refresh
+ const activeKeywords = new Set(state.activeKeywords);
+ const selectedContexts = new Set(state.selectedContexts);
+ const selectedExceptions = new Set(state.selectedExceptions);
+ const selectedCategories = new Set(state.selectedCategories);
+ const currentMode = state.mode;
+ const menuOpen = state.menuOpen;
+ const filterLevel = state.filterLevel;
+ // Preserve auth state
+ const did = state.did;
+ const authenticated = state.authenticated;
+ // Preserve mute state
+ const originalMutedKeywords = new Set(state.originalMutedKeywords);
+ const sessionMutedKeywords = new Set(state.sessionMutedKeywords);
+
+ // Fetch fresh data
+ await Promise.all([
+ fetchKeywordGroups(true),
+ fetchContextGroups(true),
+ fetchDisplayConfig(true)
+ ]);
+
+ // Restore previous state
+ state.activeKeywords = activeKeywords;
+ state.selectedContexts = selectedContexts;
+ state.selectedExceptions = selectedExceptions;
+ state.selectedCategories = selectedCategories;
+ state.mode = currentMode;
+ state.menuOpen = menuOpen;
+ state.filterLevel = filterLevel;
+ // Restore auth state
+ state.did = did;
+ state.authenticated = authenticated;
+ // Restore mute state
+ state.originalMutedKeywords = originalMutedKeywords;
+ state.sessionMutedKeywords = sessionMutedKeywords;
+
+ console.debug('Data refreshed successfully');
+ } catch (error) {
+ console.error('Failed to refresh data:', error);
+ throw error;
+ }
+}
diff --git a/js/auth.js b/js/auth.js
new file mode 100644
index 0000000..201f1d5
--- /dev/null
+++ b/js/auth.js
@@ -0,0 +1,121 @@
+import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
+
+export class AuthService {
+ constructor() {
+ this.client = null;
+ this.session = null;
+ }
+
+ async setup() {
+ try {
+ // Initialize the OAuth client with production configuration
+ this.client = await BrowserOAuthClient.load({
+ clientId: 'https://mutesky.app/client-metadata.json',
+ handleResolver: 'https://bsky.social/'
+ });
+
+ // Let the client handle initialization and callback processing
+ const result = await this.client.init();
+
+ if (result?.session) {
+ this.session = result.session;
+ if (result.state) {
+ console.debug('[Auth] Session established from callback');
+ // Dispatch event for callback page
+ window.dispatchEvent(new CustomEvent('mutesky:auth:complete', {
+ detail: { success: true }
+ }));
+ } else {
+ console.debug('[Auth] Session restored from last active session');
+ }
+ return { success: true, session: this.session };
+ }
+
+ // Dispatch event for callback page if we're on the callback page
+ if (window.location.pathname.endsWith('callback.html')) {
+ window.dispatchEvent(new CustomEvent('mutesky:auth:complete', {
+ detail: { success: false }
+ }));
+ }
+ return { success: false, reason: 'no_session' };
+ } catch (error) {
+ console.error('[Auth] Failed to initialize Bluesky client', error);
+ this.session = null;
+ // Dispatch event for callback page if we're on the callback page
+ if (window.location.pathname.endsWith('callback.html')) {
+ window.dispatchEvent(new CustomEvent('mutesky:auth:complete', {
+ detail: { success: false, error }
+ }));
+ }
+ return { success: false, error, reason: 'error' };
+ }
+ }
+
+ async signIn(handle) {
+ try {
+ console.debug('[Auth] Starting sign in for handle:', handle);
+ if (!this.client) {
+ throw new Error('Client not initialized. Call setup() first.');
+ }
+
+ if (!handle?.trim()) {
+ throw new Error('Please enter your Bluesky handle');
+ }
+
+ // Initiate the OAuth flow
+ await this.client.signIn(handle, {
+ scope: 'atproto transition:generic'
+ });
+ // Note: The above line will redirect the user, so we won't reach here
+ // unless there's an error or the user navigates back
+ } catch (error) {
+ console.error('[Auth] Sign in failed:', error);
+ throw error;
+ }
+ }
+
+ async signOut() {
+ if (this.session) {
+ try {
+ console.debug('[Auth] Starting sign out...');
+ await this.session.signOut();
+ this.session = null;
+ console.debug('[Auth] Sign out complete');
+ return true;
+ } catch (error) {
+ console.error('[Auth] Sign out failed:', error);
+ throw error;
+ }
+ }
+ }
+
+ async refreshSession() {
+ try {
+ if (!this.client) {
+ throw new Error('Client not initialized');
+ }
+
+ // Attempt to refresh the session
+ const result = await this.client.init();
+ if (result?.session) {
+ this.session = result.session;
+ console.debug('[Auth] Session refreshed successfully');
+ return { success: true, session: this.session };
+ }
+
+ return { success: false };
+ } catch (error) {
+ console.error('[Auth] Session refresh failed:', error);
+ return { success: false, error };
+ }
+ }
+
+ // Add event listener for session invalidation
+ onSessionInvalidated(callback) {
+ this.client?.addEventListener('deleted', (event) => {
+ const { sub, cause } = event.detail;
+ console.error(`[Auth] Session for ${sub} is no longer available (cause: ${cause})`);
+ callback(sub, cause);
+ });
+ }
+}
diff --git a/js/bluesky.js b/js/bluesky.js
new file mode 100644
index 0000000..cb240af
--- /dev/null
+++ b/js/bluesky.js
@@ -0,0 +1,185 @@
+import { AuthService } from './auth.js'
+import { ProfileService } from './profile.js'
+import { MuteService } from './mute.js'
+import { UIService } from './ui.js'
+
+class BlueskyService {
+ constructor() {
+ this.auth = new AuthService();
+ this.profile = new ProfileService(null);
+ this.mute = new MuteService(null);
+ this.ui = new UIService();
+ this.setupPromise = null;
+ this.isRefreshing = false;
+ }
+
+ async setup() {
+ // Return existing setup promise if it exists
+ if (this.setupPromise) {
+ return this.setupPromise;
+ }
+
+ // Create new setup promise
+ this.setupPromise = (async () => {
+ try {
+ const result = await this.auth.setup();
+
+ if (result.success && result.session) {
+ // Update services with active session
+ this.profile.setSession(result.session);
+ this.mute.setSession(result.session);
+ this.ui.updateLoginState(true);
+
+ // Set up session refresh handler
+ this.setupSessionRefreshHandler();
+
+ // Only fetch profile initially
+ await this.updateProfile();
+
+ // Start mute count update
+ await this.updateMuteCount();
+
+ // Dispatch setup complete event
+ window.dispatchEvent(new CustomEvent('mutesky:setup:complete'));
+
+ return result;
+ } else {
+ // Handle different reasons for no session
+ if (result.reason === 'no_session') {
+ this.ui.updateLoginState(false);
+ } else if (result.error?.name === 'OAuthCallbackError') {
+ this.ui.updateLoginState(false, `Failed to connect to Bluesky: ${result.error.message}`);
+ } else if (result.error) {
+ this.ui.updateLoginState(false, `Failed to connect to Bluesky: ${result.error.message || 'Unknown error'}`);
+ }
+ return result;
+ }
+ } catch (error) {
+ console.error('[Bluesky] Setup failed:', error);
+ this.ui.updateLoginState(false, `Setup failed: ${error.message || 'Unknown error'}`);
+ throw error;
+ }
+ })();
+
+ return this.setupPromise;
+ }
+
+ setupSessionRefreshHandler() {
+ window.addEventListener('mutesky:session:refresh:needed', async () => {
+ if (this.isRefreshing) return; // Prevent multiple simultaneous refreshes
+
+ try {
+ this.isRefreshing = true;
+ console.debug('[Bluesky] Attempting to refresh session...');
+
+ const result = await this.auth.refreshSession();
+
+ if (result.success && result.session) {
+ console.debug('[Bluesky] Session refreshed successfully');
+ // Update services with new session
+ this.profile.setSession(result.session);
+ this.mute.setSession(result.session);
+
+ // Retry the failed operations
+ await this.updateProfile();
+ await this.updateMuteCount();
+ } else {
+ console.error('[Bluesky] Session refresh failed');
+ // If refresh fails, sign out user
+ await this.signOut();
+ }
+ } catch (error) {
+ console.error('[Bluesky] Session refresh error:', error);
+ await this.signOut();
+ } finally {
+ this.isRefreshing = false;
+ }
+ });
+ }
+
+ async updateProfile() {
+ try {
+ const profile = await this.profile.getProfile();
+ if (profile) {
+ this.profile.updateUI(profile);
+ }
+ } catch (error) {
+ console.error('[Bluesky] Profile update failed:', error);
+ }
+ }
+
+ async updateMuteCount() {
+ try {
+ const keywords = await this.mute.getMutedKeywords();
+ this.profile.updateMuteCount(keywords.length);
+ } catch (error) {
+ console.error('[Bluesky] Mute count update failed:', error);
+ }
+ }
+
+ async signIn() {
+ try {
+ const handle = this.ui.getHandleInput();
+ if (!handle) {
+ this.ui.showError('Please enter your Bluesky handle');
+ return;
+ }
+ await this.auth.signIn(handle);
+ } catch (error) {
+ console.error('[Bluesky] Sign in failed:', error);
+
+ // Check for common service availability errors
+ if (error.message && (
+ error.message.includes('invalid_client_metadata') ||
+ error.message.includes('Failed to resolve OAuth server metadata for issuer: bsky.social')
+ )) {
+ this.ui.updateLoginState(false, 'Bluesky service appears to be down. Please try again in a few minutes.');
+ } else {
+ this.ui.updateLoginState(false, `Sign in failed: ${error.message || 'Please try again'}`);
+ }
+ }
+ }
+
+ async signOut() {
+ try {
+ await this.auth.signOut();
+
+ // Update services for sign out
+ this.profile.setSession(null);
+ this.mute.setSession(null);
+ this.profile.resetUI();
+ this.ui.updateLoginState(false);
+
+ // Clear setup promise on sign out
+ this.setupPromise = null;
+ } catch (error) {
+ console.error('[Bluesky] Sign out failed:', error);
+ this.ui.updateLoginState(false, `Sign out failed: ${error.message || 'Please try again'}`);
+ }
+ }
+
+ // Mute operations
+ async muteKeyword(keyword) {
+ return this.mute.muteKeyword(keyword);
+ }
+
+ async unmuteKeyword(keyword) {
+ return this.mute.unmuteKeyword(keyword);
+ }
+
+ async muteActor(actor) {
+ return this.mute.muteActor(actor);
+ }
+
+ async unmuteActor(actor) {
+ return this.mute.unmuteActor(actor);
+ }
+}
+
+// Export singleton instance
+const blueskyService = new BlueskyService();
+
+// Initialize the service when the module loads
+blueskyService.setup().catch(console.error);
+
+export { blueskyService };
diff --git a/js/callback.js b/js/callback.js
new file mode 100644
index 0000000..2e410b7
--- /dev/null
+++ b/js/callback.js
@@ -0,0 +1,66 @@
+class CallbackHandler {
+ constructor() {
+ console.debug('[Callback] Initializing callback handler...');
+ this.container = document.querySelector('.callback-container');
+ this.errorElement = document.getElementById('error');
+ this.titleElement = document.querySelector('h2');
+ this.statusElement = document.querySelector('.status-text');
+ this.homeLink = document.querySelector('.home-link');
+
+ // Hide the home link initially
+ if (this.homeLink) {
+ this.homeLink.style.display = 'none';
+ }
+ }
+
+ init() {
+ console.debug('[Callback] Starting callback processing...');
+ this.showLoading();
+
+ // Listen for auth completion
+ window.addEventListener('mutesky:auth:complete', (event) => {
+ const { success } = event.detail || {};
+ if (success) {
+ this.showKeywordLoading();
+ } else {
+ // Show error and manual return link on failure
+ this.showError('Authentication failed. Please try again.');
+ if (this.homeLink) {
+ this.homeLink.style.display = 'block';
+ }
+ }
+ });
+
+ // Listen for setup completion
+ window.addEventListener('mutesky:setup:complete', () => {
+ // Redirect back to app
+ window.location.href = '/';
+ });
+ }
+
+ showLoading() {
+ console.debug('[Callback] Processing auth callback...');
+ this.titleElement.textContent = 'Authentication Successful';
+ this.statusElement.textContent = 'Verifying credentials';
+ }
+
+ showKeywordLoading() {
+ console.debug('[Callback] Showing keyword loading state');
+ this.titleElement.textContent = 'Loading Keywords';
+ this.statusElement.textContent = 'This may take a moment';
+ }
+
+ showError(message) {
+ console.debug('[Callback] Showing error:', message);
+ this.container.classList.add('error');
+ this.titleElement.textContent = 'Authentication Failed';
+ this.errorElement.textContent = message;
+ }
+}
+
+// Initialize when page loads
+window.addEventListener('load', () => {
+ console.debug('[Callback] Page loaded, initializing handler...');
+ const handler = new CallbackHandler();
+ handler.init();
+});
diff --git a/js/categoryManager.js b/js/categoryManager.js
new file mode 100644
index 0000000..4ab2ee4
--- /dev/null
+++ b/js/categoryManager.js
@@ -0,0 +1,41 @@
+import { state } from './state.js';
+import { getDisplayName, getCategoryState, getCheckboxClass, getAllKeywordsForCategory } from './utils/categoryUtils.js';
+import { filterKeywordGroups } from './utils/keywordFilters.js';
+
+function calculateKeywordsToMute() {
+ const keywordsToMute = new Set();
+
+ if (state.mode === 'simple') {
+ state.selectedContexts.forEach(contextId => {
+ const context = state.contextGroups[contextId];
+ if (context && context.categories) {
+ context.categories.forEach(category => {
+ if (!state.selectedExceptions.has(category)) {
+ // Get keywords sorted and filtered by weight based on current filter level
+ const keywords = getAllKeywordsForCategory(category, true);
+ console.debug(`Adding ${keywords.length} keywords from ${category} to mute list`);
+ keywords.forEach(keyword => keywordsToMute.add(keyword));
+ }
+ });
+ }
+ });
+ } else {
+ state.activeKeywords.forEach(keyword => keywordsToMute.add(keyword));
+ }
+
+ return keywordsToMute;
+}
+
+function calculateKeywordCount() {
+ return calculateKeywordsToMute().size;
+}
+
+export {
+ getDisplayName,
+ getCategoryState,
+ getCheckboxClass,
+ filterKeywordGroups,
+ getAllKeywordsForCategory,
+ calculateKeywordsToMute,
+ calculateKeywordCount
+};
diff --git a/js/components/advanced-mode.js b/js/components/advanced-mode.js
new file mode 100644
index 0000000..f99fa79
--- /dev/null
+++ b/js/components/advanced-mode.js
@@ -0,0 +1,38 @@
+class AdvancedMode extends HTMLElement {
+ constructor() {
+ super();
+ }
+
+ connectedCallback() {
+ this.innerHTML = `
+
+ `;
+ }
+}
+
+customElements.define('advanced-mode', AdvancedMode);
+
+export default AdvancedMode;
diff --git a/js/components/app-intro.js b/js/components/app-intro.js
new file mode 100644
index 0000000..deaa110
--- /dev/null
+++ b/js/components/app-intro.js
@@ -0,0 +1,17 @@
+class AppIntro extends HTMLElement {
+ constructor() {
+ super();
+ }
+
+ connectedCallback() {
+ this.innerHTML = `
+
+
Mutesky helps you manage and filter unwanted content on your Bluesky feed using curated keyword groups and smart filtering. Choose Simple Mode for easy context-based filtering with exceptions, or Advanced Mode for detailed control over individual keywords and categories.
+
+ `;
+ }
+}
+
+customElements.define('app-intro', AppIntro);
+
+export default AppIntro;
diff --git a/js/components/footer.js b/js/components/footer.js
new file mode 100644
index 0000000..f494b94
--- /dev/null
+++ b/js/components/footer.js
@@ -0,0 +1,28 @@
+class AppFooter extends HTMLElement {
+ constructor() {
+ super();
+ }
+
+ connectedCallback() {
+ this.innerHTML = `
+
+ `;
+ }
+}
+
+customElements.define('app-footer', AppFooter);
+
+export default AppFooter;
diff --git a/js/components/index.js b/js/components/index.js
new file mode 100644
index 0000000..b70dab4
--- /dev/null
+++ b/js/components/index.js
@@ -0,0 +1,18 @@
+import LandingPage from './landing-page.js';
+import TopNav from './top-nav.js';
+import SimpleMode from './simple-mode.js';
+import AdvancedMode from './advanced-mode.js';
+import { SettingsModal } from './modals.js';
+import AppFooter from './footer.js';
+import AppIntro from './app-intro.js';
+
+// Export all components
+export {
+ LandingPage,
+ TopNav,
+ SimpleMode,
+ AdvancedMode,
+ SettingsModal,
+ AppFooter,
+ AppIntro
+};
diff --git a/js/components/landing-page.js b/js/components/landing-page.js
new file mode 100644
index 0000000..0b01c63
--- /dev/null
+++ b/js/components/landing-page.js
@@ -0,0 +1 @@
+export { default } from './landing/landing-page.js';
diff --git a/js/components/landing/auth-handler.js b/js/components/landing/auth-handler.js
new file mode 100644
index 0000000..a18d002
--- /dev/null
+++ b/js/components/landing/auth-handler.js
@@ -0,0 +1,24 @@
+export class AuthHandler {
+ static checkAuthErrors() {
+ const error = sessionStorage.getItem('auth_error');
+ const errorDescription = sessionStorage.getItem('auth_error_description');
+
+ if (error) {
+ const messageEl = document.getElementById('bsky-auth-message');
+ const errorText = errorDescription || error;
+
+ messageEl.innerHTML = `
+
+ Authentication failed: ${errorText}
+
+ Please try again.
+
+ `;
+ messageEl.classList.add('error');
+
+ // Clear error state
+ sessionStorage.removeItem('auth_error');
+ sessionStorage.removeItem('auth_error_description');
+ }
+ }
+}
diff --git a/js/components/landing/image-handler.js b/js/components/landing/image-handler.js
new file mode 100644
index 0000000..55a851e
--- /dev/null
+++ b/js/components/landing/image-handler.js
@@ -0,0 +1,80 @@
+export class ImageHandler {
+ constructor() {
+ this.imageCache = new Map();
+ this.themeObserver = null;
+ }
+
+ async initThemeAwareImages(component) {
+ const images = component.querySelectorAll('.theme-aware-image');
+ const preloadPromises = [];
+
+ // Preload all images
+ images.forEach(img => {
+ const lightSrc = img.dataset.lightSrc;
+ const darkSrc = img.dataset.darkSrc;
+
+ if (lightSrc && !this.imageCache.has(lightSrc)) {
+ preloadPromises.push(this.preloadImage(lightSrc));
+ }
+ if (darkSrc && !this.imageCache.has(darkSrc)) {
+ preloadPromises.push(this.preloadImage(darkSrc));
+ }
+ });
+
+ try {
+ await Promise.all(preloadPromises);
+ this.updateThemeAwareImages(component);
+ } catch (error) {
+ console.error('Error preloading images:', error);
+ }
+ }
+
+ async preloadImage(src) {
+ if (!src || this.imageCache.has(src)) return;
+
+ try {
+ const img = new Image();
+ const loadPromise = new Promise((resolve, reject) => {
+ img.onload = () => resolve(src);
+ img.onerror = () => reject(new Error(`Failed to load image: ${src}`));
+ });
+
+ img.src = src;
+ await loadPromise;
+ this.imageCache.set(src, true);
+ } catch (error) {
+ console.error(`Error preloading image ${src}:`, error);
+ // Cache the failure to avoid repeated attempts
+ this.imageCache.set(src, false);
+ }
+ }
+
+ updateThemeAwareImages(component, theme = null) {
+ if (!theme) {
+ theme = document.documentElement.getAttribute('data-theme');
+ }
+ const isDarkMode = theme === 'dark';
+
+ requestAnimationFrame(() => {
+ component.querySelectorAll('.theme-aware-image').forEach(async (img) => {
+ const src = isDarkMode ? img.dataset.darkSrc : img.dataset.lightSrc;
+
+ // Skip if image hasn't been preloaded or failed to preload
+ if (!this.imageCache.has(src)) {
+ await this.preloadImage(src);
+ }
+
+ if (this.imageCache.get(src)) {
+ img.style.backgroundImage = `url('${src}')`;
+ } else {
+ // Use fallback image or add error class
+ img.classList.add('image-load-error');
+ }
+ });
+ });
+ }
+
+ cleanup() {
+ this.imageCache.clear();
+ }
+}
diff --git a/js/components/landing/landing-page.js b/js/components/landing/landing-page.js
new file mode 100644
index 0000000..e56b2e8
--- /dev/null
+++ b/js/components/landing/landing-page.js
@@ -0,0 +1,37 @@
+import { ImageHandler } from './image-handler.js';
+import { AuthHandler } from './auth-handler.js';
+import { landingPageTemplate } from './template.js';
+
+class LandingPage extends HTMLElement {
+ constructor() {
+ super();
+ this.imageHandler = new ImageHandler();
+ this.themeObserver = null;
+ }
+
+ connectedCallback() {
+ this.innerHTML = landingPageTemplate;
+
+ // Initialize theme-aware images after component is mounted
+ this.imageHandler.initThemeAwareImages(this);
+
+ // Listen for theme changes
+ this.themeObserver = (event) => this.imageHandler.updateThemeAwareImages(this, event?.detail?.theme);
+ document.addEventListener('themeChanged', this.themeObserver);
+
+ // Check for auth errors after component is mounted
+ AuthHandler.checkAuthErrors();
+ }
+
+ disconnectedCallback() {
+ // Clean up event listeners and cache
+ if (this.themeObserver) {
+ document.removeEventListener('themeChanged', this.themeObserver);
+ }
+ this.imageHandler.cleanup();
+ }
+}
+
+customElements.define('landing-page', LandingPage);
+
+export default LandingPage;
diff --git a/js/components/landing/template.js b/js/components/landing/template.js
new file mode 100644
index 0000000..5d247bb
--- /dev/null
+++ b/js/components/landing/template.js
@@ -0,0 +1,131 @@
+export const landingPageTemplate = `
+
+
+
+
+
+
Mutesky
+
Bulk manage Bluesky mutes with pre-populated keyword lists
+
+
+
+
+
+
+
+
+
+
✨
+
+
1,400+ Keywords
+
Continuously updated by AI to reflect current events
+
+
+
+
🎯
+
+
20+ Categories
+
From politics to climate, choose what you want to see
+
+
+
+
🎚️
+
+
Easy Management
+
Simple toggles or advanced keyword controls
+
+
+
+
⚡
+
+
Instant Updates
+
Changes take effect immediately on your feed
+
+
+
+
+
+
+
Sign in
+
+
+
+
The next page will prompt for your username and Bluesky account password, not your app password. Your credentials are securely handled by Bluesky's official authentication service.
+
+
+
+
+
+
+ Connect to Bluesky
+
+
+
+
+
+
+
+
How It Works
+
Take control of your Bluesky experience with Mutesky's intuitive filtering system
+
+
+
+
+
+
+
Start with Simple Mode
+
Quickly filter content across major topics like politics, healthcare, and global affairs. Choose what you don't want to see with just a few clicks.
+
+
+
+
+
+
+
Extensive Categories
+
Select from over 20 content categories, from climate to international coverage. Each category comes pre-populated with carefully curated keywords, continuously updated to reflect current events.
+
+
+
+
+
+
+
Advanced Control
+
Need more control? Switch to Advanced Mode for direct access to over 1,400 keywords. Fine-tune your filters with individual toggles or bulk actions.
+
+
+
+
+
+
Perfect Balance
+
Choose your perfect balance with four filtering levels:
+
+ Minimal for light touch filtering
+ Moderate for balanced content management
+ Extensive for comprehensive filtering
+ Complete for maximum control
+
+
Changes take effect instantly on your feed, and you can adjust your settings anytime.
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/js/components/modals.js b/js/components/modals.js
new file mode 100644
index 0000000..e18ff0a
--- /dev/null
+++ b/js/components/modals.js
@@ -0,0 +1 @@
+export { SettingsModal } from './modals/index.js';
diff --git a/js/components/modals/index.js b/js/components/modals/index.js
new file mode 100644
index 0000000..5c400d0
--- /dev/null
+++ b/js/components/modals/index.js
@@ -0,0 +1 @@
+export { SettingsModal } from './settings-modal.js';
diff --git a/js/components/modals/settings-appearance.js b/js/components/modals/settings-appearance.js
new file mode 100644
index 0000000..7195787
--- /dev/null
+++ b/js/components/modals/settings-appearance.js
@@ -0,0 +1,41 @@
+import { loadAppearanceSettings, saveAppearanceSettings } from '../../settings/appearanceSettings.js';
+
+export function setupAppearanceHandlers() {
+ // Load current settings from localStorage
+ const settings = loadAppearanceSettings();
+
+ // Set initial active states
+ this.querySelector(`.theme-mode-switch[data-theme="${settings.colorMode}"]`)?.classList.add('active');
+ this.querySelector(`.font-switch[data-font="${settings.font}"]`)?.classList.add('active');
+ this.querySelector(`.font-switch[data-size="${settings.fontSize}"]`)?.classList.add('active');
+
+ // Theme buttons
+ this.querySelectorAll('.theme-mode-switch[data-theme]').forEach(button => {
+ button.addEventListener('click', () => {
+ this.querySelectorAll('.theme-mode-switch[data-theme]').forEach(btn => btn.classList.remove('active'));
+ button.classList.add('active');
+ settings.colorMode = button.dataset.theme;
+ saveAppearanceSettings(settings);
+ });
+ });
+
+ // Font buttons
+ this.querySelectorAll('.font-switch[data-font]').forEach(button => {
+ button.addEventListener('click', () => {
+ this.querySelectorAll('.font-switch[data-font]').forEach(btn => btn.classList.remove('active'));
+ button.classList.add('active');
+ settings.font = button.dataset.font;
+ saveAppearanceSettings(settings);
+ });
+ });
+
+ // Font size buttons
+ this.querySelectorAll('.font-switch[data-size]').forEach(button => {
+ button.addEventListener('click', () => {
+ this.querySelectorAll('.font-switch[data-size]').forEach(btn => btn.classList.remove('active'));
+ button.classList.add('active');
+ settings.fontSize = button.dataset.size;
+ saveAppearanceSettings(settings);
+ });
+ });
+}
diff --git a/js/components/modals/settings-modal.js b/js/components/modals/settings-modal.js
new file mode 100644
index 0000000..50aec15
--- /dev/null
+++ b/js/components/modals/settings-modal.js
@@ -0,0 +1,24 @@
+import { updateWarningVisibility } from '../../handlers/modalHandlers.js';
+import { loadAppearanceSettings, saveAppearanceSettings } from '../../settings/appearanceSettings.js';
+import { settingsTemplate } from './settings-template.js';
+import { setupAppearanceHandlers } from './settings-appearance.js';
+import { setupTabHandlers } from './settings-tabs.js';
+
+class SettingsModal extends HTMLElement {
+ constructor() {
+ super();
+ }
+
+ connectedCallback() {
+ this.innerHTML = settingsTemplate;
+
+ // Add tab switching functionality
+ setupTabHandlers.call(this);
+ // Add appearance settings handlers
+ setupAppearanceHandlers.call(this);
+ }
+}
+
+customElements.define('settings-modal', SettingsModal);
+
+export { SettingsModal };
diff --git a/js/components/modals/settings-tabs.js b/js/components/modals/settings-tabs.js
new file mode 100644
index 0000000..243c172
--- /dev/null
+++ b/js/components/modals/settings-tabs.js
@@ -0,0 +1,32 @@
+export function setupTabHandlers() {
+ const tabs = this.querySelectorAll('.settings-tab');
+ tabs.forEach(tab => {
+ tab.addEventListener('click', () => {
+ // Remove active class from all tabs and contents
+ this.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active'));
+ this.querySelectorAll('.settings-content').forEach(c => c.classList.remove('active'));
+
+ // Add active class to clicked tab and corresponding content
+ tab.classList.add('active');
+ const content = this.querySelector(`[data-content="${tab.dataset.tab}"]`);
+ content.classList.add('active');
+
+ // Show/hide warning based on active tab and duration
+ const warningElement = this.querySelector('.settings-warning');
+ if (tab.dataset.tab === 'muting') {
+ const duration = document.querySelector('input[name="duration"]:checked')?.value;
+ warningElement.style.display = duration && duration !== 'forever' ? 'flex' : 'none';
+ } else {
+ warningElement.style.display = 'none';
+ }
+
+ // Lazy load the creator image when about tab is clicked
+ if (tab.dataset.tab === 'about') {
+ const img = this.querySelector('.creator-image');
+ if (img) {
+ img.loading = 'eager'; // Switch to eager loading when tab is active
+ }
+ }
+ });
+ });
+}
diff --git a/js/components/modals/settings-template.js b/js/components/modals/settings-template.js
new file mode 100644
index 0000000..37ad62c
--- /dev/null
+++ b/js/components/modals/settings-template.js
@@ -0,0 +1,137 @@
+export const settingsTemplate = `
+
+
+
×
+
+
+ Muting
+ Appearance
+ About
+
+
+
+
+
Mute Duration
+
+
+
+
+
+
+
+
+
+
Exceptions
+
+
+
+
Don't mute people I follow
+
+
+
+
+
+
+
Color mode
+
+ System
+ Light
+ Dark
+
+
+
+
+
Font
+
+ System font
+ Theme font
+
+
+
+
+
Font size
+
+ Smaller
+ Default
+ Larger
+
+
+
+
+
+
+
+
+
Mutesky is based off of my old Twitter mental health mute list.
+
This project was built with Cline and used $300 in Openrouter.ai and Anthropic API credits. My wife says I can't get any more so pls help me keep this project going 😅
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/js/components/simple-mode.js b/js/components/simple-mode.js
new file mode 100644
index 0000000..9c167ee
--- /dev/null
+++ b/js/components/simple-mode.js
@@ -0,0 +1,177 @@
+import { state } from '../state.js';
+
+class SimpleMode extends HTMLElement {
+ constructor() {
+ super();
+ this.currentLevel = 0;
+ this.currentExceptions = new Set();
+ this.activeKeywordCount = 0;
+ this.handleKeywordsUpdated = this.handleKeywordsUpdated.bind(this);
+ }
+
+ handleKeywordsUpdated(event) {
+ this.activeKeywordCount = event.detail.count;
+ this.updateFilterUI();
+ }
+
+ connectedCallback() {
+ this.innerHTML = `
+
+
+
+
Select the content types you want to filter, choose your filtering strength, and set any exceptions. Click the blue "Mute" button at the top right to apply your changes. For more detailed control, try Advanced Mode in the top menu.
+
+
+
I want to avoid content about...
+
+
+
+
+
+
+
Choose your filtering level
+
+ Adding more words to your mute list can make Bluesky run more slowly, especially when reading posts with many comments. You may notice the Bluesky becomes slower when you have more than 215 muted keywords, especially on mobile devices.
+
+
+
+
Minimal
+
Focus on highest impact content
+
+
+
Moderate
+
Balanced content management
+
+
+
Extensive
+
Comprehensive filtering
+
+
+
Complete
+
Maximum filtering capability
+
+
+
+
+
+
Keep showing me content about...
+
+
+
+
+
+
+
+
+
+ `;
+
+ // Initialize from saved state
+ this.currentLevel = state.filterLevel;
+ this.currentExceptions = new Set(state.selectedExceptions);
+
+ // Start observing state changes
+ document.addEventListener('keywordsUpdated', this.handleKeywordsUpdated);
+
+ // Initial UI update
+ this.activeKeywordCount = state.activeKeywords.size;
+ this.updateFilterUI();
+ this.setupEventListeners();
+ }
+
+ setupEventListeners() {
+ const levels = this.querySelectorAll('.filter-card');
+
+ levels.forEach(level => {
+ // Click handler
+ level.addEventListener('click', (e) => {
+ this.setActiveLevel(parseInt(level.dataset.level));
+ });
+
+ // Keyboard handler
+ level.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ this.setActiveLevel(parseInt(level.dataset.level));
+ }
+ });
+ });
+ }
+
+ updateFilterUI() {
+ const levels = this.querySelectorAll('.filter-card');
+ const warningNote = this.querySelector('.filter-note');
+ const keywordCount = this.querySelector('.keyword-count');
+
+ levels.forEach(el => {
+ const isActive = parseInt(el.dataset.level) === this.currentLevel;
+ el.classList.toggle('active', isActive);
+ el.setAttribute('aria-checked', isActive);
+ });
+
+ if (warningNote) {
+ // Show warning if active keywords exceed 215
+ warningNote.style.display = this.activeKeywordCount > 215 ? 'block' : 'none';
+ // Update the keyword count
+ if (keywordCount) {
+ keywordCount.textContent = this.activeKeywordCount;
+ }
+ }
+ }
+
+ setActiveLevel(level) {
+ if (level === this.currentLevel) return;
+ this.currentLevel = level;
+ state.filterLevel = level;
+ this.updateFilterUI();
+
+ // Dispatch custom event for level change
+ this.dispatchEvent(new CustomEvent('filterLevelChange', {
+ detail: { level },
+ bubbles: true
+ }));
+ }
+
+ // Method to update level from outside
+ updateLevel(level) {
+ if (level === this.currentLevel) return;
+ this.currentLevel = level;
+ this.updateFilterUI();
+ }
+
+ // Method to update exceptions from outside
+ updateExceptions(exceptions) {
+ const newExceptions = new Set(exceptions);
+ if (this.areExceptionsEqual(this.currentExceptions, newExceptions)) return;
+
+ this.currentExceptions = newExceptions;
+
+ // Update exception tags UI if needed
+ const exceptionTags = this.querySelector('#exception-tags');
+ if (exceptionTags) {
+ // Let the contextRenderer handle the actual UI update
+ this.dispatchEvent(new CustomEvent('exceptionsUpdated', {
+ detail: { exceptions: Array.from(newExceptions) },
+ bubbles: true
+ }));
+ }
+ }
+
+ // Helper to compare exception sets
+ areExceptionsEqual(set1, set2) {
+ if (set1.size !== set2.size) return false;
+ for (const item of set1) {
+ if (!set2.has(item)) return false;
+ }
+ return true;
+ }
+
+ disconnectedCallback() {
+ // Clean up event listeners
+ document.removeEventListener('keywordsUpdated', this.handleKeywordsUpdated);
+ }
+}
+
+customElements.define('simple-mode', SimpleMode);
+
+export default SimpleMode;
diff --git a/js/components/top-nav.js b/js/components/top-nav.js
new file mode 100644
index 0000000..c39bc1c
--- /dev/null
+++ b/js/components/top-nav.js
@@ -0,0 +1,125 @@
+class TopNav extends HTMLElement {
+ constructor() {
+ super();
+ }
+
+ connectedCallback() {
+ this.innerHTML = `
+
+
+
Mutesky
+
+
+ Simple Mode
+ Advanced Mode
+
+
+ Mute 0 keywords
+
+
+
+
+ `;
+
+ // Add click handler for hamburger menu
+ const hamburgerMenu = this.querySelector('.hamburger-menu');
+ const userMenu = this.querySelector('.user-menu');
+
+ // Handle hamburger menu clicks
+ hamburgerMenu?.addEventListener('click', () => {
+ const isActive = hamburgerMenu.classList.contains('active');
+ if (isActive) {
+ hamburgerMenu.classList.remove('active');
+ userMenu.classList.remove('active');
+ } else {
+ hamburgerMenu.classList.add('active');
+ userMenu.classList.add('active');
+ }
+ });
+
+ // Handle clicks outside menu
+ document.addEventListener('click', (e) => {
+ // Only handle clicks outside both hamburger and menu
+ if (e.target !== hamburgerMenu &&
+ !hamburgerMenu?.contains(e.target) &&
+ e.target !== userMenu &&
+ !userMenu?.contains(e.target)) {
+ hamburgerMenu?.classList.remove('active');
+ userMenu?.classList.remove('active');
+ }
+ });
+
+ // Handle all interface mode switches (both desktop and mobile) with the same logic
+ this.querySelectorAll('.interface-mode-switch').forEach(button => {
+ button.addEventListener('click', () => {
+ const mode = button.dataset.mode;
+ // Use the centralized switchMode function
+ window.switchMode(mode);
+ // Close menu if it's in the mobile dropdown
+ if (button.closest('.mobile-mode-switches')) {
+ hamburgerMenu?.classList.remove('active');
+ userMenu.classList.remove('active');
+ }
+ });
+ });
+ }
+}
+
+customElements.define('top-nav', TopNav);
+
+export default TopNav;
diff --git a/js/config.js b/js/config.js
new file mode 100644
index 0000000..d75a7e2
--- /dev/null
+++ b/js/config.js
@@ -0,0 +1,3 @@
+export const KEYWORDS_BASE_URL = 'https://raw.githubusercontent.com/potatoqualitee/calm-the-chaos/main/keywords/categories';
+export const CONTEXT_GROUPS_URL = 'https://raw.githubusercontent.com/potatoqualitee/calm-the-chaos/main/keywords/context-groups.json';
+export const DISPLAY_CONFIG_URL = 'https://raw.githubusercontent.com/potatoqualitee/calm-the-chaos/main/keywords/display-config.json';
diff --git a/js/dom.js b/js/dom.js
new file mode 100644
index 0000000..bd8ee7d
--- /dev/null
+++ b/js/dom.js
@@ -0,0 +1,42 @@
+export const elements = {
+ landingPage: document.getElementById('landing-page'),
+ appInterface: document.getElementById('app-interface'),
+ authButton: document.getElementById('bsky-login-btn'),
+ handleInput: document.getElementById('bsky-handle-input'),
+ logoutButton: document.getElementById('bsky-logout-btn'),
+ modeToggles: document.querySelectorAll('.mode-switch'),
+ simpleMode: document.getElementById('simple-mode'),
+ advancedMode: document.getElementById('advanced-mode'),
+ contextOptions: document.getElementById('context-options'),
+ exceptionsPanel: document.querySelector('.exceptions-panel'),
+ exceptionTags: document.getElementById('exception-tags'),
+ searchInput: document.getElementById('keyword-search'),
+ sidebarSearch: document.getElementById('sidebar-search'),
+ categoriesGrid: document.getElementById('categories-grid'),
+ categoryList: document.getElementById('category-list'),
+ sidebarLastUpdate: document.getElementById('sidebar-last-update'),
+ activeCount: document.getElementById('active-count'),
+ lastUpdate: document.getElementById('last-update'),
+ muteButton: document.querySelector('.btn-mute-keywords'),
+ navMuteButton: document.querySelector('.nav-mute-button'),
+ profileButton: document.querySelector('.profile-button'),
+ userMenuDropdown: document.querySelector('.user-menu-dropdown'),
+ enableAllBtn: document.getElementById('enable-all'),
+ disableAllBtn: document.getElementById('disable-all'),
+ refreshButton: document.getElementById('refresh-data'),
+
+ // Settings modal elements
+ settingsButton: document.getElementById('muting-settings'),
+ settingsModal: document.getElementById('settings-modal'),
+
+ // Appearance modal elements
+ appearanceButton: document.getElementById('appearance-settings'),
+ appearanceModal: document.getElementById('appearance-modal'),
+ colorModeToggles: document.querySelectorAll('.mode-switch[data-theme]'),
+ darkThemeToggles: document.querySelectorAll('.theme-switch[data-dark-theme]'),
+ fontToggles: document.querySelectorAll('.font-switch[data-font]'),
+ fontSizeToggles: document.querySelectorAll('.font-switch[data-size]'),
+
+ // Bluesky specific elements
+ bskyHandle: document.getElementById('bsky-handle')
+};
diff --git a/js/events.js b/js/events.js
new file mode 100644
index 0000000..4cacb53
--- /dev/null
+++ b/js/events.js
@@ -0,0 +1,170 @@
+import { elements } from './dom.js';
+import { state, loadState } from './state.js';
+import { renderInterface } from './renderer.js';
+import { debounce } from './utils.js';
+import { getAllKeywordsForCategory } from './categoryManager.js';
+import { blueskyService } from './bluesky.js';
+import {
+ handleAuth,
+ handleLogout,
+ handleMuteSubmit,
+ switchMode,
+ handleEnableAll,
+ handleDisableAll,
+ handleRefreshData,
+ showApp,
+ initializeKeywordState,
+ applyAppearanceSettings
+} from './handlers/index.js';
+
+// Event Listeners
+export function setupEventListeners() {
+ elements.authButton?.addEventListener('click', handleAuth);
+ elements.logoutButton?.addEventListener('click', handleLogout);
+ elements.muteButton?.addEventListener('click', handleMuteSubmit);
+ elements.navMuteButton?.addEventListener('click', handleMuteSubmit);
+ elements.enableAllBtn?.addEventListener('click', handleEnableAll);
+ elements.disableAllBtn?.addEventListener('click', handleDisableAll);
+ elements.refreshButton?.addEventListener('click', handleRefreshData);
+
+ // Add Enter key handler for login input
+ const handleInput = document.getElementById('bsky-handle-input');
+ if (handleInput) {
+ handleInput.addEventListener('keypress', (event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ handleAuth();
+ }
+ });
+ }
+
+ // Set up intersection observer for auth button visibility
+ if (elements.authButton) {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach(entry => {
+ // Check if the button is being intersected (covered) by other elements
+ const isVisible = entry.intersectionRatio === 1.0;
+ elements.authButton.style.visibility = isVisible ? 'visible' : 'hidden';
+ });
+ },
+ {
+ threshold: 1.0, // Only trigger when button is fully visible/invisible
+ root: null // Use viewport as root
+ }
+ );
+
+ observer.observe(elements.authButton);
+ }
+
+ // Helper function to notify keyword changes
+ function notifyKeywordChanges() {
+ document.dispatchEvent(new CustomEvent('keywordsUpdated', {
+ detail: { count: state.activeKeywords.size }
+ }));
+ }
+
+ // Handle filter level changes from simple mode
+ document.addEventListener('filterLevelChange', (event) => {
+ const level = event.detail.level;
+
+ // Update filter level in state
+ state.filterLevel = level;
+
+ // Store current exceptions
+ const currentExceptions = new Set(state.selectedExceptions);
+
+ // Clear and rebuild active keywords while preserving exceptions
+ state.activeKeywords.clear();
+ state.selectedContexts.forEach(contextId => {
+ const context = state.contextGroups[contextId];
+ if (context && context.categories) {
+ context.categories.forEach(category => {
+ if (!currentExceptions.has(category)) {
+ // Get keywords sorted by weight
+ const keywords = getAllKeywordsForCategory(category, true);
+ keywords.forEach(keyword => state.activeKeywords.add(keyword));
+ }
+ });
+ }
+ });
+
+ // Notify about keyword changes
+ notifyKeywordChanges();
+
+ // Restore exceptions
+ state.selectedExceptions = currentExceptions;
+
+ // Update interface with new filtered keywords
+ renderInterface();
+ });
+
+ elements.profileButton?.addEventListener('click', () => {
+ state.menuOpen = !state.menuOpen;
+ elements.userMenuDropdown?.classList.toggle('visible', state.menuOpen);
+ });
+
+ document.addEventListener('click', (event) => {
+ if (!event.target.closest('.user-menu') && state.menuOpen && elements.userMenuDropdown) {
+ state.menuOpen = false;
+ elements.userMenuDropdown.classList.remove('visible');
+ }
+ });
+
+ elements.sidebarSearch?.addEventListener('input', debounce((e) => {
+ state.searchTerm = e.target.value.toLowerCase();
+ renderInterface();
+ }, 300));
+
+ // Listen for system theme changes
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
+ applyAppearanceSettings();
+ });
+
+ // Handle visibility change to restore state when page becomes visible
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'visible' && state.did) {
+ loadState();
+
+ // Re-render interface with restored state
+ renderInterface();
+ // Re-apply mode
+ switchMode(state.mode);
+
+ // Update SimpleMode component with current state
+ const simpleMode = document.querySelector('simple-mode');
+ if (simpleMode) {
+ simpleMode.updateLevel(state.filterLevel);
+ simpleMode.updateExceptions(state.selectedExceptions);
+ }
+ }
+ });
+
+ // Listen for Bluesky login state changes
+ window.addEventListener('blueskyLoginStateChanged', async (event) => {
+ state.authenticated = event.detail.isLoggedIn;
+ if (state.authenticated) {
+ // Set DID in state when user logs in
+ state.did = blueskyService.auth.session?.did;
+ await showApp();
+ // Initialize keyword state after authentication
+ await initializeKeywordState();
+ // Re-render interface to show checked keywords
+ renderInterface();
+
+ // Update SimpleMode component with current state
+ const simpleMode = document.querySelector('simple-mode');
+ if (simpleMode) {
+ simpleMode.updateLevel(state.filterLevel);
+ simpleMode.updateExceptions(state.selectedExceptions);
+ }
+ } else {
+ // Clear DID when user logs out
+ state.did = null;
+ if (elements.landingPage && elements.appInterface) {
+ elements.landingPage.classList.remove('hidden');
+ elements.appInterface.classList.add('hidden');
+ }
+ }
+ });
+}
diff --git a/js/handlers/authHandlers.js b/js/handlers/authHandlers.js
new file mode 100644
index 0000000..f51af48
--- /dev/null
+++ b/js/handlers/authHandlers.js
@@ -0,0 +1,140 @@
+import { state, loadState } from '../state.js';
+import { elements } from '../dom.js';
+import { blueskyService } from '../bluesky.js';
+import { initializeState } from './contextHandlers.js';
+
+export async function handleAuth() {
+ try {
+ // Clear any previous error states first
+ if (elements.handleInput) {
+ elements.handleInput.classList.remove('error');
+ }
+ const messageEl = document.getElementById('bsky-auth-message');
+ if (messageEl) {
+ messageEl.classList.remove('error');
+ messageEl.textContent = 'The next page will prompt for your username and Bluesky account password, not your app password. Your credentials are securely handled by Bluesky\'s official authentication service.';
+ }
+
+ // Validate handle before attempting auth
+ const handle = elements.handleInput?.value?.trim();
+ if (!handle) {
+ if (elements.handleInput) {
+ elements.handleInput.classList.add('error');
+ }
+ throw new Error('Please enter your Bluesky handle');
+ }
+
+ // Disable input and button during authentication
+ if (elements.handleInput) {
+ elements.handleInput.disabled = true;
+ }
+ if (elements.authButton) {
+ elements.authButton.disabled = true;
+ elements.authButton.textContent = 'Connecting...';
+ }
+
+ // Store current state before clearing
+ const savedContexts = new Set(state.selectedContexts);
+ const savedExceptions = new Set(state.selectedExceptions);
+ const filterLevel = state.filterLevel;
+
+ // Clear active state
+ state.activeKeywords.clear();
+ state.selectedContexts.clear();
+ state.selectedExceptions.clear();
+ state.selectedCategories.clear();
+
+ // Initiate Bluesky login
+ await blueskyService.signIn();
+
+ // Restore saved state after login
+ state.selectedContexts = savedContexts;
+ state.selectedExceptions = savedExceptions;
+ state.filterLevel = filterLevel;
+
+ // Initialize state to restore context keywords
+ initializeState();
+
+ // The rest will be handled by the OAuth callback and blueskyService's setup
+ } catch (error) {
+ console.error('Authentication failed:', error);
+
+ // Re-enable input and button on error
+ if (elements.handleInput) {
+ elements.handleInput.disabled = false;
+ elements.handleInput.classList.add('error');
+ }
+ if (elements.authButton) {
+ elements.authButton.disabled = false;
+ elements.authButton.textContent = 'Connect to Bluesky';
+ }
+
+ // Update auth message with error
+ const messageEl = document.getElementById('bsky-auth-message');
+ if (messageEl) {
+ messageEl.textContent = error.message || 'Authentication failed. Please try again.';
+ messageEl.classList.add('error');
+ }
+
+ // Ensure UI service is updated
+ blueskyService.ui.updateLoginState(false, error.message || 'Authentication failed. Please try again.');
+ }
+}
+
+export async function handleLogout() {
+ try {
+ console.debug('[Auth] Starting logout, current exceptions:', Array.from(state.selectedExceptions));
+ await blueskyService.signOut();
+
+ // Store exceptions and contexts before clearing state
+ const exceptions = new Set(state.selectedExceptions);
+ const contexts = new Set(state.selectedContexts);
+ const filterLevel = state.filterLevel;
+ console.debug('[Auth] Preserved exceptions for logout:', Array.from(exceptions));
+
+ // Clear state but preserve mode
+ state.authenticated = false;
+ state.activeKeywords.clear();
+ state.selectedContexts.clear();
+ state.selectedCategories.clear();
+ state.mode = 'simple';
+ state.menuOpen = false;
+
+ // Restore preserved values
+ state.selectedExceptions = exceptions;
+ state.selectedContexts = contexts;
+ state.filterLevel = filterLevel;
+ console.debug('[Auth] Restored exceptions after state clear:', Array.from(state.selectedExceptions));
+
+ // Initialize state to restore context keywords
+ initializeState();
+
+ elements.landingPage.classList.remove('hidden');
+ elements.appInterface.classList.add('hidden');
+ elements.userMenuDropdown.classList.remove('visible');
+
+ // Reset UI elements to initial state
+ if (elements.handleInput) {
+ elements.handleInput.disabled = false;
+ elements.handleInput.classList.remove('error');
+ elements.handleInput.value = '';
+ }
+ if (elements.authButton) {
+ elements.authButton.disabled = false;
+ elements.authButton.textContent = 'Connect to Bluesky';
+ }
+
+ // Reset auth message
+ const messageEl = document.getElementById('bsky-auth-message');
+ if (messageEl) {
+ messageEl.classList.remove('error');
+ messageEl.textContent = 'The next page will prompt for your username and Bluesky account password, not your app password. Your credentials are securely handled by Bluesky\'s official authentication service.';
+ }
+
+ // Removed saveState() call since we only want to save during mute/unmute
+ console.debug('[Auth] Logout complete with exceptions:', Array.from(state.selectedExceptions));
+ } catch (error) {
+ console.error('Logout failed:', error);
+ blueskyService.ui.updateLoginState(false, `Logout failed: ${error.message || 'Please try again'}`);
+ }
+}
diff --git a/js/handlers/context/contextCache.js b/js/handlers/context/contextCache.js
new file mode 100644
index 0000000..d29d1b9
--- /dev/null
+++ b/js/handlers/context/contextCache.js
@@ -0,0 +1,124 @@
+import { state } from '../../state.js';
+import { getAllKeywordsForCategory } from '../../categoryManager.js';
+import { isKeywordActive } from '../keywordHandlers.js';
+
+// Enhanced cache with memory management and performance optimizations
+export const cache = {
+ keywords: new Map(),
+ categoryStates: new Map(),
+ contextKeywords: new Map(),
+ activeKeywordsByCategory: new Map(),
+ lastUpdate: 0,
+ maxCacheSize: 100,
+ updateThreshold: 16,
+
+ getKeywords(category, sortByWeight = false) {
+ const key = `${category}-${sortByWeight}-${state.filterLevel}`;
+ if (!this.keywords.has(key)) {
+ this.manageCache(this.keywords);
+ const keywords = getAllKeywordsForCategory(category, sortByWeight);
+ this.keywords.set(key, new Set(keywords));
+ }
+ return this.keywords.get(key);
+ },
+
+ getActiveKeywordsForCategory(category) {
+ const key = `active-${category}-${state.filterLevel}`;
+ if (!this.activeKeywordsByCategory.has(key)) {
+ this.manageCache(this.activeKeywordsByCategory);
+ const keywords = this.getKeywords(category, true);
+ const active = new Set();
+ for (const k of keywords) {
+ if (isKeywordActive(k)) active.add(k);
+ }
+ this.activeKeywordsByCategory.set(key, active);
+ }
+ return this.activeKeywordsByCategory.get(key);
+ },
+
+ getCategoryState(category) {
+ const keywords = this.getKeywords(category, true);
+ const activeKeywords = this.getActiveKeywordsForCategory(category);
+
+ if (activeKeywords.size === 0) return 'none';
+ if (activeKeywords.size === keywords.size) return 'all';
+ return 'partial';
+ },
+
+ getContextKeywords(contextId) {
+ const key = `${contextId}-${state.filterLevel}`;
+ if (!this.contextKeywords.has(key)) {
+ this.manageCache(this.contextKeywords);
+ const context = state.contextGroups[contextId];
+ const keywordSet = new Set();
+
+ if (context?.categories) {
+ const nonExceptedCategories = context.categories.filter(
+ category => !state.selectedExceptions.has(category)
+ );
+
+ for (const category of nonExceptedCategories) {
+ const keywords = this.getKeywords(category, true);
+ for (const k of keywords) keywordSet.add(k);
+ }
+ }
+ this.contextKeywords.set(key, keywordSet);
+ }
+ return this.contextKeywords.get(key);
+ },
+
+ getContextState(contextId) {
+ const context = state.contextGroups[contextId];
+ if (!context?.categories) return 'none';
+
+ let allNone = true;
+ for (const category of context.categories) {
+ if (state.selectedExceptions.has(category)) continue;
+
+ const categoryState = this.getCategoryState(category);
+ if (categoryState !== 'none') {
+ allNone = false;
+ break;
+ }
+ }
+ return allNone ? 'none' : 'partial';
+ },
+
+ manageCache(cacheMap) {
+ if (cacheMap.size >= this.maxCacheSize) {
+ const entriesToRemove = Math.ceil(this.maxCacheSize * 0.2);
+ const keys = Array.from(cacheMap.keys());
+ for (let i = 0; i < entriesToRemove; i++) {
+ cacheMap.delete(keys[i]);
+ }
+ }
+ },
+
+ shouldUpdate() {
+ const now = Date.now();
+ if (now - this.lastUpdate < this.updateThreshold) return false;
+ this.lastUpdate = now;
+ return true;
+ },
+
+ invalidateCategory(category) {
+ if (!this.shouldUpdate()) return;
+
+ const keywordKeys = Array.from(this.keywords.keys())
+ .filter(key => key.startsWith(`${category}-`));
+ const activeKeys = Array.from(this.activeKeywordsByCategory.keys())
+ .filter(key => key.startsWith(`active-${category}-`));
+
+ keywordKeys.forEach(key => this.keywords.delete(key));
+ activeKeys.forEach(key => this.activeKeywordsByCategory.delete(key));
+ this.contextKeywords.clear();
+ },
+
+ clear() {
+ this.keywords.clear();
+ this.categoryStates.clear();
+ this.contextKeywords.clear();
+ this.activeKeywordsByCategory.clear();
+ this.lastUpdate = 0;
+ }
+};
diff --git a/js/handlers/context/contextHandlers.js b/js/handlers/context/contextHandlers.js
new file mode 100644
index 0000000..a1461d5
--- /dev/null
+++ b/js/handlers/context/contextHandlers.js
@@ -0,0 +1,10 @@
+import { handleContextToggle } from './contextToggleHandler.js';
+import { handleExceptionToggle } from './exceptionToggleHandler.js';
+import { updateSimpleModeState, initializeState } from './contextState.js';
+
+export {
+ handleContextToggle,
+ handleExceptionToggle,
+ updateSimpleModeState,
+ initializeState
+};
diff --git a/js/handlers/context/contextState.js b/js/handlers/context/contextState.js
new file mode 100644
index 0000000..777b9ae
--- /dev/null
+++ b/js/handlers/context/contextState.js
@@ -0,0 +1,4 @@
+// Re-export functionality from split files
+export { addKeywordWithCase } from './keywordManager.js';
+export { updateSimpleModeState } from './simpleModeManager.js';
+export { initializeState } from './stateInitializer.js';
diff --git a/js/handlers/context/contextToggleHandler.js b/js/handlers/context/contextToggleHandler.js
new file mode 100644
index 0000000..d9078d0
--- /dev/null
+++ b/js/handlers/context/contextToggleHandler.js
@@ -0,0 +1,132 @@
+import { renderInterface } from '../../renderer.js';
+import { state, saveState } from '../../state.js';
+import { cache } from './contextCache.js';
+import {
+ createDebouncedUpdate,
+ notifyKeywordChanges
+} from './contextUtils.js';
+
+export async function handleContextToggle(contextId) {
+ console.debug('[handleContextToggle] Starting toggle for context:', contextId);
+ console.debug('[handleContextToggle] Initial state:', {
+ isAuthenticated: state.authenticated,
+ mode: state.mode,
+ selectedContextsCount: state.selectedContexts.size,
+ activeKeywordsCount: state.activeKeywords.size,
+ manuallyUncheckedCount: state.manuallyUnchecked.size
+ });
+
+ if (!state.authenticated) {
+ console.debug('[handleContextToggle] Not authenticated, returning');
+ return;
+ }
+
+ const isSelected = state.selectedContexts.has(contextId);
+ console.debug('[handleContextToggle] Context currently selected:', isSelected);
+
+ const context = state.contextGroups[contextId];
+ console.debug('[handleContextToggle] Context categories:', context?.categories);
+
+ // Store currently unchecked keywords before context change
+ const uncheckedKeywords = new Set(state.manuallyUnchecked);
+ console.debug('[handleContextToggle] Stored unchecked keywords count:', uncheckedKeywords.size);
+
+ if (isSelected) {
+ console.debug('[handleContextToggle] Unchecking context');
+
+ // 1. Update UI state first
+ state.selectedContexts.delete(contextId);
+ console.debug('[handleContextToggle] Removed context from selectedContexts');
+
+ if (context?.categories) {
+ context.categories.forEach(category => {
+ state.selectedExceptions.delete(category);
+ cache.invalidateCategory(category);
+ console.debug('[handleContextToggle] Removed exception and invalidated cache for category:', category);
+ });
+ }
+
+ // 2. Keep keywords in activeKeywords temporarily so getMuteUnmuteCounts works
+ const keywordsToRemove = new Set();
+ if (context?.categories) {
+ for (const category of context.categories) {
+ if (!state.selectedExceptions.has(category)) {
+ const keywords = cache.getKeywords(category, true);
+ console.debug(`[handleContextToggle] Found ${keywords.size} keywords in category:`, category);
+
+ for (const keyword of keywords) {
+ if (!uncheckedKeywords.has(keyword)) {
+ keywordsToRemove.add(keyword);
+ console.debug('[handleContextToggle] Marking keyword for removal:', keyword);
+ }
+ }
+ }
+ }
+ }
+
+ console.debug('[handleContextToggle] Total keywords marked for removal:', keywordsToRemove.size);
+ console.debug('[handleContextToggle] Active keywords before removal:', state.activeKeywords.size);
+
+ // 3. Now remove from activeKeywords after getMuteUnmuteCounts has run
+ for (const keyword of keywordsToRemove) {
+ state.activeKeywords.delete(keyword);
+ console.debug('[handleContextToggle] Removed keyword from activeKeywords:', keyword);
+ }
+
+ console.debug('[handleContextToggle] Active keywords after removal:', state.activeKeywords.size);
+
+ } else {
+ console.debug('[handleContextToggle] Checking context');
+
+ // 1. Update UI state
+ state.selectedContexts.add(contextId);
+ console.debug('[handleContextToggle] Added context to selectedContexts');
+
+ if (context?.categories) {
+ context.categories.forEach(category => {
+ cache.invalidateCategory(category);
+ console.debug('[handleContextToggle] Invalidated cache for category:', category);
+ });
+ }
+
+ // 2. Add keywords to activeKeywords
+ if (context?.categories) {
+ for (const category of context.categories) {
+ if (!state.selectedExceptions.has(category)) {
+ const keywords = cache.getKeywords(category, true);
+ console.debug(`[handleContextToggle] Found ${keywords.size} keywords in category:`, category);
+
+ for (const keyword of keywords) {
+ if (!uncheckedKeywords.has(keyword)) {
+ state.activeKeywords.add(keyword);
+ console.debug('[handleContextToggle] Added keyword to activeKeywords:', keyword);
+ }
+ }
+ }
+ }
+ }
+
+ console.debug('[handleContextToggle] Active keywords after additions:', state.activeKeywords.size);
+ }
+
+ // Notify of keyword changes to update mute button
+ console.debug('[handleContextToggle] Notifying of keyword changes');
+ notifyKeywordChanges();
+
+ // Create a new debounced update for this call
+ console.debug('[handleContextToggle] Creating debounced update');
+ const debouncedUpdate = createDebouncedUpdate();
+ await debouncedUpdate(async () => {
+ console.debug('[handleContextToggle] Executing debounced update');
+ console.debug('[handleContextToggle] Final state:', {
+ selectedContextsCount: state.selectedContexts.size,
+ activeKeywordsCount: state.activeKeywords.size,
+ manuallyUncheckedCount: state.manuallyUnchecked.size
+ });
+ renderInterface();
+ await saveState();
+ console.debug('[handleContextToggle] Completed interface render and state save');
+ });
+
+ console.debug('[handleContextToggle] Toggle operation complete');
+}
diff --git a/js/handlers/context/contextUtils.js b/js/handlers/context/contextUtils.js
new file mode 100644
index 0000000..4486eab
--- /dev/null
+++ b/js/handlers/context/contextUtils.js
@@ -0,0 +1,101 @@
+import { state } from '../../state.js';
+import { isKeywordActive, removeKeyword } from '../keywordHandlers.js';
+
+// Helper function to notify keyword changes
+export function notifyKeywordChanges() {
+ document.dispatchEvent(new CustomEvent('keywordsUpdated', {
+ detail: { count: state.activeKeywords.size }
+ }));
+}
+
+// Enhanced debounced UI updates with frame timing
+export const createDebouncedUpdate = () => {
+ let timeout;
+ let frameRequest;
+ return async (fn) => {
+ if (timeout) clearTimeout(timeout);
+ if (frameRequest) cancelAnimationFrame(frameRequest);
+
+ timeout = setTimeout(() => {
+ frameRequest = requestAnimationFrame(async () => {
+ await fn();
+ notifyKeywordChanges();
+ });
+ }, 16);
+ };
+};
+
+// Batch process keywords
+export function processBatchKeywords(keywords, operation) {
+ const chunkSize = 100;
+ const chunks = Array.from(keywords);
+
+ let index = 0;
+ function processChunk() {
+ const chunk = chunks.slice(index, index + chunkSize);
+ if (chunk.length === 0) return;
+
+ chunk.forEach(operation);
+ index += chunkSize;
+
+ if (index < chunks.length) {
+ requestAnimationFrame(processChunk);
+ }
+ }
+
+ processChunk();
+}
+
+// Helper function to add keyword with case handling
+function addKeywordWithCase(keyword) {
+ // First remove any existing case variations
+ removeKeyword(keyword);
+ // Then add with original case
+ state.activeKeywords.add(keyword);
+}
+
+// Helper function to activate context keywords
+export function activateContextKeywords(contextId, cache) {
+ const context = state.contextGroups[contextId];
+ if (!context?.categories) return;
+
+ for (const category of context.categories) {
+ if (state.selectedExceptions.has(category)) continue;
+ // Get keywords considering filter level (sortByWeight = true)
+ const keywords = cache.getKeywords(category, true);
+ processBatchKeywords(keywords, keyword => {
+ // Only activate if not manually unchecked
+ if (!state.manuallyUnchecked.has(keyword)) {
+ addKeywordWithCase(keyword);
+ }
+ });
+ }
+}
+
+// Helper function to rebuild active keywords
+export function rebuildActiveKeywords(cache) {
+ // Only rebuild keywords in simple mode
+ if (state.mode === 'simple') {
+ // Store currently unchecked keywords
+ const uncheckedKeywords = new Set(state.manuallyUnchecked);
+
+ // Clear and rebuild active keywords
+ state.activeKeywords.clear();
+ for (const contextId of state.selectedContexts) {
+ activateContextKeywords(contextId, cache);
+ }
+
+ // Add only original muted keywords that aren't already active and weren't manually unchecked
+ for (const keyword of state.originalMutedKeywords) {
+ if (!isKeywordActive(keyword) && !state.manuallyUnchecked.has(keyword)) {
+ addKeywordWithCase(keyword);
+ }
+ }
+
+ // Re-apply unchecked status
+ for (const keyword of uncheckedKeywords) {
+ removeKeyword(keyword);
+ state.manuallyUnchecked.add(keyword);
+ }
+ }
+}
diff --git a/js/handlers/context/exceptionToggleHandler.js b/js/handlers/context/exceptionToggleHandler.js
new file mode 100644
index 0000000..57c9d05
--- /dev/null
+++ b/js/handlers/context/exceptionToggleHandler.js
@@ -0,0 +1,91 @@
+import { renderInterface } from '../../renderer.js';
+import { state, saveState } from '../../state.js';
+import { cache } from './contextCache.js';
+import {
+ activateContextKeywords,
+ createDebouncedUpdate,
+ notifyKeywordChanges
+} from './contextUtils.js';
+
+export async function handleExceptionToggle(category) {
+ console.debug('[handleExceptionToggle] Starting toggle for category:', category);
+ if (!state.authenticated) return;
+
+ // Store currently unchecked keywords before exception change
+ const uncheckedKeywords = new Set(state.manuallyUnchecked);
+
+ const wasException = state.selectedExceptions.has(category);
+ console.debug('[handleExceptionToggle] Was exception:', wasException);
+
+ if (wasException) {
+ state.selectedExceptions.delete(category);
+ console.debug('[handleExceptionToggle] Removed exception');
+ } else {
+ state.selectedExceptions.add(category);
+ console.debug('[handleExceptionToggle] Added exception');
+
+ // Check if any keywords in this category are currently muted
+ if (state.mode === 'simple') {
+ const categoryKeywords = cache.getKeywords(category, true);
+ for (const keyword of categoryKeywords) {
+ if (state.originalMutedKeywords.has(keyword)) {
+ state.activeKeywords.delete(keyword);
+ }
+ }
+ // Notify immediately of keyword changes to update mute button
+ notifyKeywordChanges();
+ }
+ }
+
+ cache.invalidateCategory(category);
+ console.debug('[handleExceptionToggle] Invalidated category cache');
+
+ // Only rebuild keywords in simple mode
+ if (state.mode === 'simple') {
+ console.debug('[handleExceptionToggle] Rebuilding keywords in simple mode');
+
+ // Clear and rebuild active keywords
+ state.activeKeywords.clear();
+ for (const contextId of state.selectedContexts) {
+ activateContextKeywords(contextId, cache);
+ }
+
+ // Add only original muted keywords that aren't in excepted categories
+ for (const keyword of state.originalMutedKeywords) {
+ if (!state.activeKeywords.has(keyword)) {
+ let isExcepted = false;
+ for (const exceptedCategory of state.selectedExceptions) {
+ const exceptedKeywords = cache.getKeywords(exceptedCategory, true);
+ if (exceptedKeywords.has(keyword)) {
+ isExcepted = true;
+ break;
+ }
+ }
+ if (!isExcepted) {
+ state.activeKeywords.add(keyword);
+ }
+ }
+ }
+
+ // Re-apply unchecked status
+ for (const keyword of uncheckedKeywords) {
+ state.activeKeywords.delete(keyword);
+ state.manuallyUnchecked.add(keyword);
+ }
+
+ console.debug('[handleExceptionToggle] Keyword counts after rebuild:', {
+ activeKeywords: state.activeKeywords.size,
+ manuallyUnchecked: state.manuallyUnchecked.size
+ });
+ }
+
+ // Create a new debounced update for this call
+ console.debug('[handleExceptionToggle] Creating debounced update');
+ const debouncedUpdate = createDebouncedUpdate();
+ await debouncedUpdate(async () => {
+ console.debug('[handleExceptionToggle] Executing debounced update');
+ renderInterface();
+ await saveState();
+ console.debug('[handleExceptionToggle] Completed interface render and state save');
+ });
+}
diff --git a/js/handlers/context/keywordManager.js b/js/handlers/context/keywordManager.js
new file mode 100644
index 0000000..ed94680
--- /dev/null
+++ b/js/handlers/context/keywordManager.js
@@ -0,0 +1,10 @@
+import { state } from '../../state.js';
+import { removeKeyword } from '../keywordHandlers.js';
+
+// Helper function to add keyword with case handling
+export function addKeywordWithCase(keyword) {
+ // First remove any existing case variations
+ removeKeyword(keyword);
+ // Then add with original case
+ state.activeKeywords.add(keyword);
+}
diff --git a/js/handlers/context/simpleModeManager.js b/js/handlers/context/simpleModeManager.js
new file mode 100644
index 0000000..4592407
--- /dev/null
+++ b/js/handlers/context/simpleModeManager.js
@@ -0,0 +1,90 @@
+import { state, saveState } from '../../state.js';
+import { renderInterface } from '../../renderer.js';
+import { cache } from './contextCache.js';
+import { isKeywordActive, removeKeyword } from '../keywordHandlers.js';
+import {
+ createDebouncedUpdate,
+ activateContextKeywords
+} from './contextUtils.js';
+import { addKeywordWithCase } from './keywordManager.js';
+
+export async function updateSimpleModeState() {
+ if (!state.authenticated) return;
+
+ // Store currently unchecked keywords
+ const uncheckedKeywords = new Set(state.manuallyUnchecked);
+
+ if (state.mode === 'simple') {
+ // First derive context selections from advanced mode state
+ for (const contextId in state.contextGroups) {
+ const context = state.contextGroups[contextId];
+ if (!context?.categories) continue;
+
+ // Check if all non-excepted categories in this context are fully selected
+ let allCategoriesActive = true;
+ for (const category of context.categories) {
+ if (state.selectedExceptions.has(category)) continue;
+
+ // Get keywords considering filter level
+ const keywords = cache.getKeywords(category, true);
+ let allActive = true;
+
+ // Check if all keywords at current filter level are active
+ for (const keyword of keywords) {
+ if (!isKeywordActive(keyword)) {
+ allActive = false;
+ break;
+ }
+ }
+
+ if (!allActive) {
+ allCategoriesActive = false;
+ break;
+ }
+ }
+
+ // Update context selection based on category states
+ if (allCategoriesActive) {
+ state.selectedContexts.add(contextId);
+ } else {
+ state.selectedContexts.delete(contextId);
+ }
+ }
+
+ // Then check if any selected contexts should be deselected
+ for (const contextId of Array.from(state.selectedContexts)) {
+ const contextState = cache.getContextState(contextId);
+ if (contextState === 'none') {
+ state.selectedContexts.delete(contextId);
+ }
+ }
+
+ cache.clear();
+
+ // Clear and rebuild active keywords from derived contexts
+ state.activeKeywords.clear();
+ for (const contextId of state.selectedContexts) {
+ activateContextKeywords(contextId, cache);
+ }
+
+ // Add only original muted keywords that aren't already active and weren't manually unchecked
+ for (const keyword of state.originalMutedKeywords) {
+ if (!isKeywordActive(keyword) && !state.manuallyUnchecked.has(keyword)) {
+ addKeywordWithCase(keyword);
+ }
+ }
+
+ // Re-apply unchecked status
+ for (const keyword of uncheckedKeywords) {
+ removeKeyword(keyword);
+ state.manuallyUnchecked.add(keyword);
+ }
+ }
+
+ // Create a new debounced update for this call with state
+ const debouncedUpdate = createDebouncedUpdate();
+ await debouncedUpdate(async () => {
+ renderInterface();
+ await saveState();
+ });
+}
diff --git a/js/handlers/context/stateInitializer.js b/js/handlers/context/stateInitializer.js
new file mode 100644
index 0000000..8984acc
--- /dev/null
+++ b/js/handlers/context/stateInitializer.js
@@ -0,0 +1,118 @@
+import { state, saveState, getStorageKey } from '../../state.js';
+import { renderInterface } from '../../renderer.js';
+import { cache } from './contextCache.js';
+import { isKeywordActive, removeKeyword } from '../keywordHandlers.js';
+import {
+ createDebouncedUpdate,
+ activateContextKeywords
+} from './contextUtils.js';
+import { addKeywordWithCase } from './keywordManager.js';
+
+export async function initializeState() {
+ if (!state.authenticated) return;
+
+ state.selectedContexts.clear();
+ state.selectedExceptions.clear();
+ state.activeKeywords.clear();
+ cache.clear();
+
+ const saved = localStorage.getItem(getStorageKey());
+ if (saved) {
+ try {
+ const data = JSON.parse(saved);
+
+ if (data.selectedContexts) {
+ state.selectedContexts = new Set(data.selectedContexts);
+ }
+
+ if (data.selectedExceptions) {
+ const validExceptions = new Set();
+ for (const contextId of state.selectedContexts) {
+ const context = state.contextGroups[contextId];
+ if (context?.categories) {
+ context.categories.forEach(category => {
+ if (data.selectedExceptions.includes(category)) {
+ validExceptions.add(category);
+ }
+ });
+ }
+ }
+ state.selectedExceptions = validExceptions;
+ }
+
+ if (data.manuallyUnchecked) {
+ state.manuallyUnchecked = new Set(data.manuallyUnchecked);
+ }
+
+ if (state.mode === 'simple') {
+ // First derive context selections from advanced mode state
+ for (const contextId in state.contextGroups) {
+ const context = state.contextGroups[contextId];
+ if (!context?.categories) continue;
+
+ // Check if all non-excepted categories in this context are fully selected
+ let allCategoriesActive = true;
+ for (const category of context.categories) {
+ if (state.selectedExceptions.has(category)) continue;
+
+ // Get keywords considering filter level
+ const keywords = cache.getKeywords(category, true);
+ let allActive = true;
+
+ // Check if all keywords at current filter level are active
+ for (const keyword of keywords) {
+ if (!isKeywordActive(keyword)) {
+ allActive = false;
+ break;
+ }
+ }
+
+ if (!allActive) {
+ allCategoriesActive = false;
+ break;
+ }
+ }
+
+ // Update context selection based on category states
+ if (allCategoriesActive) {
+ state.selectedContexts.add(contextId);
+ } else {
+ state.selectedContexts.delete(contextId);
+ }
+ }
+
+ // Clear and rebuild active keywords from derived contexts
+ state.activeKeywords.clear();
+ for (const contextId of state.selectedContexts) {
+ activateContextKeywords(contextId, cache);
+ }
+
+ // Add only original muted keywords that aren't already active and weren't manually unchecked
+ for (const keyword of state.originalMutedKeywords) {
+ if (!isKeywordActive(keyword) && !state.manuallyUnchecked.has(keyword)) {
+ addKeywordWithCase(keyword);
+ }
+ }
+
+ // Re-apply unchecked status
+ for (const keyword of Array.from(state.manuallyUnchecked)) {
+ removeKeyword(keyword);
+ }
+ }
+
+ // Create a new debounced update for this call with state
+ const debouncedUpdate = createDebouncedUpdate();
+ await debouncedUpdate(async () => {
+ renderInterface();
+ await saveState();
+ });
+ } catch (error) {
+ console.error('Error initializing state:', error);
+ state.selectedContexts.clear();
+ state.selectedExceptions.clear();
+ state.activeKeywords.clear();
+ // Don't clear manuallyUnchecked on error
+ await saveState();
+ }
+ }
+}
diff --git a/js/handlers/contextHandlers.js b/js/handlers/contextHandlers.js
new file mode 100644
index 0000000..31200b1
--- /dev/null
+++ b/js/handlers/contextHandlers.js
@@ -0,0 +1,25 @@
+// Import state and internal handlers
+import { state } from '../state.js';
+import {
+ handleContextToggle as _handleContextToggle,
+ handleExceptionToggle as _handleExceptionToggle,
+ updateSimpleModeState as _updateSimpleModeState,
+ initializeState as _initializeState
+} from './context/contextHandlers.js';
+
+// Wrap the imported functions to automatically pass state
+export async function handleContextToggle(contextId) {
+ return await _handleContextToggle(state, contextId);
+}
+
+export async function handleExceptionToggle(category) {
+ return await _handleExceptionToggle(state, category);
+}
+
+export async function updateSimpleModeState() {
+ return await _updateSimpleModeState(state);
+}
+
+export async function initializeState() {
+ return await _initializeState(state);
+}
diff --git a/js/handlers/index.js b/js/handlers/index.js
new file mode 100644
index 0000000..b15e220
--- /dev/null
+++ b/js/handlers/index.js
@@ -0,0 +1,15 @@
+export { handleAuth, handleLogout } from './authHandlers.js';
+export { handleContextToggle, handleExceptionToggle, updateSimpleModeState } from './context/contextHandlers.js';
+export { handleKeywordToggle, handleCategoryToggle, handleEnableAll, handleDisableAll } from './keywordHandlers.js';
+export { handleMuteSubmit, initializeKeywordState } from './muteHandlers.js';
+export { switchMode, handleRefreshData, showApp } from './uiHandlers.js';
+export { handleFooterThemeToggle } from './themeHandlers.js';
+export {
+ handleSettingsModalToggle,
+ applyAppearanceSettings,
+ loadAppearanceSettings,
+ saveAppearanceSettings,
+ loadMuteSettings,
+ saveMuteSettings,
+ getExpirationDate
+} from './settingsHandlers.js';
diff --git a/js/handlers/keywordHandlers.js b/js/handlers/keywordHandlers.js
new file mode 100644
index 0000000..94374b6
--- /dev/null
+++ b/js/handlers/keywordHandlers.js
@@ -0,0 +1,15 @@
+// Re-export everything from the new modular structure
+export {
+ keywordCache,
+ debouncedUpdate,
+ notifyKeywordChanges,
+ updateCheckboxes,
+ standardUpdate,
+ isKeywordActive,
+ removeKeyword,
+ processBatchKeywords,
+ handleKeywordToggle,
+ handleCategoryToggle,
+ handleEnableAll,
+ handleDisableAll
+} from './keywords/index.js';
diff --git a/js/handlers/keywords/bulk-handlers.js b/js/handlers/keywords/bulk-handlers.js
new file mode 100644
index 0000000..eb6437d
--- /dev/null
+++ b/js/handlers/keywords/bulk-handlers.js
@@ -0,0 +1,101 @@
+import { state, saveState } from '../../state.js';
+import { filterKeywordGroups } from '../../categoryManager.js';
+import { debouncedUpdate } from './ui-utils.js';
+import { keywordCache } from './cache.js';
+import { removeKeyword, isKeywordActive, processBatchKeywords } from './keyword-utils.js';
+import { updateSimpleModeState } from '../contextHandlers.js';
+import { renderInterface } from '../../renderer.js';
+
+export function handleEnableAll() {
+ // Clear manually unchecked since this is an explicit enable all
+ state.manuallyUnchecked.clear();
+ // Set flag to indicate enable all was used
+ state.lastBulkAction = 'enable';
+
+ if (state.searchTerm) {
+ // When searching, only enable filtered keywords
+ const filteredGroups = filterKeywordGroups();
+ processBatchKeywords(Object.values(filteredGroups).flat(), keyword => {
+ // First remove any existing case variations
+ removeKeyword(keyword);
+ // Then add with original case if not already active
+ if (!isKeywordActive(keyword)) {
+ state.activeKeywords.add(keyword);
+ }
+ });
+ } else {
+ // When not searching, enable all keywords from all categories
+ const allCategories = [
+ ...Object.keys(state.keywordGroups),
+ ...Object.keys(state.displayConfig.combinedCategories || {})
+ ];
+
+ // Enable all contexts first
+ Object.keys(state.contextGroups).forEach(contextId => {
+ state.selectedContexts.add(contextId);
+ });
+
+ let processedCount = 0;
+ function processNextCategory() {
+ if (processedCount >= allCategories.length) {
+ debouncedUpdate(() => {
+ updateSimpleModeState();
+ renderInterface();
+ saveState();
+ });
+ return;
+ }
+
+ const category = allCategories[processedCount++];
+ const keywords = keywordCache.getKeywordsForCategory(category);
+ processBatchKeywords(keywords, keyword => {
+ // First remove any existing case variations
+ removeKeyword(keyword);
+ // Then add with original case if not already active
+ if (!isKeywordActive(keyword)) {
+ state.activeKeywords.add(keyword);
+ }
+ });
+
+ requestAnimationFrame(processNextCategory);
+ }
+
+ processNextCategory();
+ return; // Early return since updates are handled in processNextCategory
+ }
+
+ debouncedUpdate(() => {
+ updateSimpleModeState();
+ renderInterface();
+ saveState();
+ });
+}
+
+export function handleDisableAll() {
+ // Clear manually unchecked since this is an explicit disable all
+ state.manuallyUnchecked.clear();
+ // Set flag to indicate disable all was used
+ state.lastBulkAction = 'disable';
+
+ if (state.searchTerm) {
+ // When searching, only disable filtered keywords
+ const filteredGroups = filterKeywordGroups();
+ processBatchKeywords(Object.values(filteredGroups).flat(), keyword => {
+ removeKeyword(keyword);
+ });
+ } else {
+ // Clear all contexts first
+ state.selectedContexts.clear();
+ state.selectedExceptions.clear();
+
+ // When not searching, disable all keywords
+ state.activeKeywords.clear();
+ keywordCache.clear();
+ }
+
+ debouncedUpdate(() => {
+ updateSimpleModeState();
+ renderInterface();
+ saveState();
+ });
+}
diff --git a/js/handlers/keywords/cache.js b/js/handlers/keywords/cache.js
new file mode 100644
index 0000000..485c28e
--- /dev/null
+++ b/js/handlers/keywords/cache.js
@@ -0,0 +1,27 @@
+import { getAllKeywordsForCategory } from '../../categoryManager.js';
+
+// Enhanced keyword cache with shorter timeout
+export const keywordCache = {
+ categoryKeywords: new Map(),
+ lastUpdate: 0,
+ updateThreshold: 16, // Reduced to one frame to match state.js
+
+ shouldUpdate() {
+ const now = Date.now();
+ if (now - this.lastUpdate < this.updateThreshold) return false;
+ this.lastUpdate = now;
+ return true;
+ },
+
+ getKeywordsForCategory(category) {
+ if (!this.categoryKeywords.has(category) || this.shouldUpdate()) {
+ this.categoryKeywords.set(category, new Set(getAllKeywordsForCategory(category)));
+ }
+ return this.categoryKeywords.get(category);
+ },
+
+ clear() {
+ this.categoryKeywords.clear();
+ this.lastUpdate = 0;
+ }
+};
diff --git a/js/handlers/keywords/core-handlers.js b/js/handlers/keywords/core-handlers.js
new file mode 100644
index 0000000..3ca50e0
--- /dev/null
+++ b/js/handlers/keywords/core-handlers.js
@@ -0,0 +1,57 @@
+import { state, saveState } from '../../state.js';
+import { debouncedUpdate, updateCheckboxes } from './ui-utils.js';
+import { keywordCache } from './cache.js';
+import { removeKeyword, isKeywordActive, processBatchKeywords } from './keyword-utils.js';
+import { updateSimpleModeState } from '../contextHandlers.js';
+import { renderInterface } from '../../renderer.js';
+
+export function handleKeywordToggle(keyword, enabled) {
+ if (enabled) {
+ // If manually checking, remove from unchecked list
+ state.manuallyUnchecked.delete(keyword);
+ // First remove any existing case variations
+ removeKeyword(keyword);
+ // Then add with original case
+ state.activeKeywords.add(keyword);
+ } else {
+ // If manually unchecking, add to unchecked list
+ state.manuallyUnchecked.add(keyword);
+ removeKeyword(keyword);
+ }
+
+ debouncedUpdate(() => {
+ updateSimpleModeState();
+ renderInterface();
+ saveState();
+ });
+}
+
+export function handleCategoryToggle(category, currentState) {
+ const keywords = keywordCache.getKeywordsForCategory(category);
+ const shouldEnable = currentState !== 'all';
+
+ processBatchKeywords(keywords, keyword => {
+ if (shouldEnable) {
+ // If enabling category, remove keywords from unchecked list
+ state.manuallyUnchecked.delete(keyword);
+ // First remove any existing case variations
+ removeKeyword(keyword);
+ // Then add with original case if not already active
+ if (!isKeywordActive(keyword)) {
+ state.activeKeywords.add(keyword);
+ }
+ } else {
+ // If disabling category, add keywords to unchecked list
+ state.manuallyUnchecked.add(keyword);
+ removeKeyword(keyword);
+ }
+ });
+
+ updateCheckboxes(category, shouldEnable);
+
+ debouncedUpdate(() => {
+ updateSimpleModeState();
+ renderInterface();
+ saveState();
+ });
+}
diff --git a/js/handlers/keywords/index.js b/js/handlers/keywords/index.js
new file mode 100644
index 0000000..160e63a
--- /dev/null
+++ b/js/handlers/keywords/index.js
@@ -0,0 +1,20 @@
+export { keywordCache } from './cache.js';
+export {
+ debouncedUpdate,
+ notifyKeywordChanges,
+ updateCheckboxes,
+ standardUpdate
+} from './ui-utils.js';
+export {
+ isKeywordActive,
+ removeKeyword,
+ processBatchKeywords
+} from './keyword-utils.js';
+export {
+ handleKeywordToggle,
+ handleCategoryToggle
+} from './core-handlers.js';
+export {
+ handleEnableAll,
+ handleDisableAll
+} from './bulk-handlers.js';
diff --git a/js/handlers/keywords/keyword-utils.js b/js/handlers/keywords/keyword-utils.js
new file mode 100644
index 0000000..bcd56ea
--- /dev/null
+++ b/js/handlers/keywords/keyword-utils.js
@@ -0,0 +1,51 @@
+import { state, saveState } from '../../state.js';
+
+// Helper to check if keyword is active (case-insensitive)
+export function isKeywordActive(keyword) {
+ const lowerKeyword = keyword.toLowerCase();
+ for (const activeKeyword of state.activeKeywords) {
+ if (activeKeyword.toLowerCase() === lowerKeyword) {
+ return true;
+ }
+ }
+ return false;
+}
+
+// Helper to remove keyword (case-insensitive)
+export function removeKeyword(keyword) {
+ const lowerKeyword = keyword.toLowerCase();
+ for (const activeKeyword of state.activeKeywords) {
+ if (activeKeyword.toLowerCase() === lowerKeyword) {
+ state.activeKeywords.delete(activeKeyword);
+ break;
+ }
+ }
+}
+
+// Batch process keywords
+export function processBatchKeywords(keywords, operation) {
+ const chunkSize = 100;
+ const chunks = Array.from(keywords);
+
+ let index = 0;
+ function processChunk() {
+ const chunk = chunks.slice(index, index + chunkSize);
+ if (chunk.length === 0) {
+ // Save state after all chunks are processed
+ saveState();
+ return;
+ }
+
+ chunk.forEach(operation);
+ index += chunkSize;
+
+ if (index < chunks.length) {
+ requestAnimationFrame(processChunk);
+ } else {
+ // Save state after final chunk
+ saveState();
+ }
+ }
+
+ processChunk();
+}
diff --git a/js/handlers/keywords/ui-utils.js b/js/handlers/keywords/ui-utils.js
new file mode 100644
index 0000000..2d05c89
--- /dev/null
+++ b/js/handlers/keywords/ui-utils.js
@@ -0,0 +1,57 @@
+import { state, saveState } from '../../state.js';
+import { updateSimpleModeState } from '../contextHandlers.js';
+import { renderInterface } from '../../renderer.js';
+
+// Debounced UI updates with frame timing
+export const debouncedUpdate = (() => {
+ let timeout;
+ let frameRequest;
+ return (fn) => {
+ if (timeout) clearTimeout(timeout);
+ if (frameRequest) cancelAnimationFrame(frameRequest);
+
+ timeout = setTimeout(() => {
+ frameRequest = requestAnimationFrame(() => {
+ fn();
+ notifyKeywordChanges();
+ });
+ }, 16);
+ };
+})();
+
+// Helper function to notify keyword changes
+export function notifyKeywordChanges() {
+ document.dispatchEvent(new CustomEvent('keywordsUpdated', {
+ detail: { count: state.activeKeywords.size }
+ }));
+}
+
+// Optimized checkbox update with proper CSS escaping
+export function updateCheckboxes(category, enabled) {
+ requestAnimationFrame(() => {
+ const escapedCategory = CSS.escape(category.replace(/\s+/g, '-').toLowerCase());
+ // Use more specific selectors for better performance
+ const sidebarCheckbox = document.querySelector(`.category-item[data-category="${CSS.escape(category)}"] > input[type="checkbox"]`);
+ const mainCheckbox = document.querySelector(`#category-${escapedCategory} > input[type="checkbox"]`);
+ const keywordCheckboxes = document.querySelectorAll(`#category-${escapedCategory} .keywords-container input[type="checkbox"]`);
+
+ if (sidebarCheckbox) {
+ sidebarCheckbox.checked = enabled;
+ sidebarCheckbox.indeterminate = false;
+ }
+ if (mainCheckbox) {
+ mainCheckbox.checked = enabled;
+ mainCheckbox.indeterminate = false;
+ }
+ keywordCheckboxes.forEach(checkbox => {
+ checkbox.checked = enabled;
+ });
+ });
+}
+
+// Standard update function used by handlers
+export function standardUpdate() {
+ updateSimpleModeState();
+ renderInterface();
+ saveState();
+}
diff --git a/js/handlers/modalHandlers.js b/js/handlers/modalHandlers.js
new file mode 100644
index 0000000..6317875
--- /dev/null
+++ b/js/handlers/modalHandlers.js
@@ -0,0 +1,107 @@
+import { loadMuteSettings, saveMuteSettings } from '../settings/muteSettings.js';
+import { loadAppearanceSettings, saveAppearanceSettings, updateAppearanceUI } from '../settings/appearanceSettings.js';
+import { getStorageKey } from '../state.js';
+
+export function updateWarningVisibility() {
+ const duration = document.querySelector('input[name="duration"]:checked')?.value;
+ const warningElement = document.getElementById('settings-warning');
+ if (duration) {
+ warningElement.classList.toggle('visible', duration !== 'forever');
+ }
+}
+
+function setupMuteSettingsListeners() {
+ // Duration radio buttons
+ document.querySelectorAll('input[name="duration"]').forEach(radio => {
+ radio.addEventListener('change', () => {
+ const settings = {
+ duration: document.querySelector('input[name="duration"]:checked').value,
+ scope: document.querySelector('input[name="scope"]:checked').value,
+ excludeFollows: document.getElementById('exclude-follows').checked
+ };
+ saveMuteSettings(settings);
+ updateWarningVisibility();
+ });
+ });
+
+ // Scope radio buttons
+ document.querySelectorAll('input[name="scope"]').forEach(radio => {
+ radio.addEventListener('change', () => {
+ const settings = {
+ duration: document.querySelector('input[name="duration"]:checked').value,
+ scope: document.querySelector('input[name="scope"]:checked').value,
+ excludeFollows: document.getElementById('exclude-follows').checked
+ };
+ saveMuteSettings(settings);
+ });
+ });
+
+ // Exclude follows checkbox
+ const excludeFollows = document.getElementById('exclude-follows');
+ excludeFollows.addEventListener('change', () => {
+ const settings = {
+ duration: document.querySelector('input[name="duration"]:checked').value,
+ scope: document.querySelector('input[name="scope"]:checked').value,
+ excludeFollows: excludeFollows.checked
+ };
+ saveMuteSettings(settings);
+ });
+}
+
+function setupAppearanceSettingsListeners() {
+ // Theme mode switches
+ document.querySelectorAll('.theme-mode-switch').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const settings = window.appearanceSettings || loadAppearanceSettings();
+ settings.colorMode = btn.dataset.theme;
+ saveAppearanceSettings(settings);
+ });
+ });
+
+ // Font switches
+ document.querySelectorAll('.font-switch[data-font]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const settings = window.appearanceSettings || loadAppearanceSettings();
+ settings.font = btn.dataset.font;
+ saveAppearanceSettings(settings);
+ });
+ });
+
+ // Font size switches
+ document.querySelectorAll('.font-switch[data-size]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const settings = window.appearanceSettings || loadAppearanceSettings();
+ settings.fontSize = btn.dataset.size;
+ saveAppearanceSettings(settings);
+ });
+ });
+}
+
+export function handleSettingsModalToggle() {
+ const modal = document.getElementById('settings-modal');
+ modal.classList.toggle('visible');
+
+ if (modal.classList.contains('visible')) {
+ // Load mute settings
+ const muteSettings = loadMuteSettings();
+ document.querySelector(`input[name="duration"][value="${muteSettings.duration}"]`).checked = true;
+ document.querySelector(`input[name="scope"][value="${muteSettings.scope}"]`).checked = true;
+ document.getElementById('exclude-follows').checked = muteSettings.excludeFollows;
+
+ // Load appearance settings
+ const appearanceSettings = loadAppearanceSettings();
+ updateAppearanceUI(appearanceSettings);
+ window.appearanceSettings = appearanceSettings;
+
+ // Setup all change listeners
+ setupMuteSettingsListeners();
+ setupAppearanceSettingsListeners();
+ updateWarningVisibility();
+ } else {
+ // When modal is closed, use the centralized switchMode function
+ // to ensure consistent state management
+ const savedState = localStorage.getItem(getStorageKey());
+ const currentMode = savedState ? JSON.parse(savedState).mode || 'simple' : 'simple';
+ window.switchMode(currentMode);
+ }
+}
diff --git a/js/handlers/mute/index.js b/js/handlers/mute/index.js
new file mode 100644
index 0000000..6e433df
--- /dev/null
+++ b/js/handlers/mute/index.js
@@ -0,0 +1,10 @@
+import { handleMuteSubmit, initializeKeywordState } from './muteOperations.js';
+import { getButtonText } from './muteUIUtils.js';
+import { muteCache } from './muteCache.js';
+
+export {
+ handleMuteSubmit,
+ initializeKeywordState,
+ getButtonText,
+ muteCache
+};
diff --git a/js/handlers/mute/muteCache.js b/js/handlers/mute/muteCache.js
new file mode 100644
index 0000000..e4103c4
--- /dev/null
+++ b/js/handlers/mute/muteCache.js
@@ -0,0 +1,44 @@
+// Enhanced keyword cache for mute operations
+const muteCache = {
+ ourKeywordsMap: null,
+ lastUpdate: 0,
+ updateThreshold: 50,
+
+ shouldUpdate() {
+ const now = Date.now();
+ if (now - this.lastUpdate < this.updateThreshold) return false;
+ this.lastUpdate = now;
+ return true;
+ },
+
+ getOurKeywordsMap() {
+ if (this.ourKeywordsMap && !this.shouldUpdate()) {
+ console.debug('[muteCache] Returning cached keyword map');
+ return this.ourKeywordsMap;
+ }
+
+ console.debug('[muteCache] Building new keyword map');
+ const map = new Map();
+ Object.entries(state.keywordGroups).forEach(([category, categoryData]) => {
+ const categoryInfo = categoryData[category];
+ if (categoryInfo?.keywords) {
+ Object.keys(categoryInfo.keywords).forEach(keyword => {
+ map.set(keyword.toLowerCase(), keyword);
+ });
+ }
+ });
+ this.ourKeywordsMap = map;
+ console.debug('[muteCache] New keyword map size:', map.size);
+ return map;
+ },
+
+ clear() {
+ console.debug('[muteCache] Clearing cache');
+ this.ourKeywordsMap = null;
+ this.lastUpdate = 0;
+ }
+};
+
+import { state } from '../../state.js';
+
+export { muteCache };
diff --git a/js/handlers/mute/muteOperations.js b/js/handlers/mute/muteOperations.js
new file mode 100644
index 0000000..9d2ac8e
--- /dev/null
+++ b/js/handlers/mute/muteOperations.js
@@ -0,0 +1,123 @@
+import { state, saveState, getMuteUnmuteCounts } from '../../state.js';
+import { blueskyService } from '../../bluesky.js';
+import { renderInterface } from '../../renderer.js';
+import { showNotification } from '../../utils/notifications.js';
+import { muteCache } from './muteCache.js';
+import { debouncedUpdate } from './muteUIUtils.js';
+
+// Process all keywords immediately without batching
+function processKeywords(keywords, operation) {
+ console.debug('[processKeywords] Processing', keywords.length, 'keywords');
+ keywords.forEach(operation);
+ console.debug('[processKeywords] Finished processing all keywords');
+}
+
+export async function handleMuteSubmit() {
+ try {
+ console.debug('[handleMuteSubmit] Starting mute operation');
+
+ // Get selected keywords efficiently
+ const selectedKeywords = Array.from(state.activeKeywords);
+ console.debug('[handleMuteSubmit] Selected keywords:', selectedKeywords.length);
+
+ // Use cached keyword map
+ const ourKeywordsMap = muteCache.getOurKeywordsMap();
+ const ourKeywords = new Set(Array.from(ourKeywordsMap.keys()));
+ console.debug('[handleMuteSubmit] Our keywords total:', ourKeywords.size);
+
+ // Get the counts before update
+ const { toMute, toUnmute } = getMuteUnmuteCounts();
+ console.debug('[handleMuteSubmit] To mute:', toMute, 'To unmute:', toUnmute);
+
+ // Update muted keywords
+ console.debug('[handleMuteSubmit] Updating keywords on Bluesky');
+ await blueskyService.mute.updateMutedKeywords(selectedKeywords, Array.from(ourKeywords));
+ console.debug('[handleMuteSubmit] Bluesky update complete');
+
+ // If this mute/unmute follows an enable/disable all action, clear exceptions
+ if (state.lastBulkAction) {
+ console.debug('[handleMuteSubmit] Clearing exceptions after bulk action');
+ state.selectedExceptions.clear();
+ state.lastBulkAction = null; // Reset the flag
+ }
+
+ // Clear all caches and update counts
+ console.debug('[handleMuteSubmit] Clearing caches');
+ muteCache.clear();
+ console.debug('[handleMuteSubmit] Updating mute count in BlueskyService');
+ await blueskyService.updateMuteCount();
+
+ // Get fresh muted keywords from Bluesky
+ console.debug('[handleMuteSubmit] Reinitializing keyword state');
+ await initializeKeywordState();
+
+ // Save state after successful mute/unmute
+ console.debug('[handleMuteSubmit] Saving state');
+ await saveState();
+
+ // Update UI with debouncing
+ console.debug('[handleMuteSubmit] Scheduling UI update');
+ debouncedUpdate(async () => {
+ console.debug('[handleMuteSubmit] Rendering interface');
+ renderInterface();
+
+ // Show appropriate notification
+ if (toMute > 0 && toUnmute > 0) {
+ showNotification(`Successfully muted ${toMute} and unmuted ${toUnmute} keywords`);
+ } else if (toMute > 0) {
+ showNotification(`Successfully muted ${toMute} ${toMute === 1 ? 'keyword' : 'keywords'}`);
+ } else if (toUnmute > 0) {
+ showNotification(`Successfully unmuted ${toUnmute} ${toUnmute === 1 ? 'keyword' : 'keywords'}`);
+ }
+ console.debug('[handleMuteSubmit] UI update complete');
+ });
+ } catch (error) {
+ console.error('[handleMuteSubmit] Failed to process mutes:', error);
+
+ // Convert technical errors into user-friendly messages
+ let userMessage = 'Failed to update mutes. ';
+ if (error.message.includes('not logged in')) {
+ userMessage += 'Please log in and try again.';
+ } else if (error.message.includes('401')) {
+ userMessage += 'Your session has expired. Please log in again.';
+ } else if (error.message.includes('429')) {
+ userMessage += 'Too many requests. Please wait a moment and try again.';
+ } else if (error.message.includes('503')) {
+ userMessage += 'Bluesky service is temporarily unavailable. Please try again later.';
+ } else {
+ userMessage += error.message;
+ }
+
+ showNotification(userMessage, 'error');
+ }
+}
+
+export async function initializeKeywordState() {
+ try {
+ console.debug('[initializeKeywordState] Starting initialization');
+
+ // Get user's muted keywords from Bluesky with force refresh
+ const userKeywords = await blueskyService.mute.getMutedKeywords(true);
+ console.debug('[initializeKeywordState] Fetched', userKeywords.length, 'keywords from Bluesky');
+
+ // Only clear mute tracking state, leave contexts alone
+ console.debug('[initializeKeywordState] Clearing state');
+ const beforeOriginal = state.originalMutedKeywords.size;
+ const beforeSession = state.sessionMutedKeywords.size;
+ state.originalMutedKeywords.clear();
+ state.sessionMutedKeywords.clear();
+ console.debug('[initializeKeywordState] Cleared originalMutedKeywords (was:', beforeOriginal, ') and sessionMutedKeywords (was:', beforeSession, ')');
+
+ // Track which keywords are muted in Bluesky
+ console.debug('[initializeKeywordState] Processing user keywords');
+ processKeywords(userKeywords, keyword => {
+ const lowerKeyword = keyword.toLowerCase();
+ state.originalMutedKeywords.add(lowerKeyword);
+ });
+ console.debug('[initializeKeywordState] Final originalMutedKeywords size:', state.originalMutedKeywords.size);
+
+ } catch (error) {
+ console.error('[initializeKeywordState] Failed to initialize keyword state:', error);
+ showNotification('Failed to load your muted keywords. Please refresh the page.', 'error');
+ }
+}
diff --git a/js/handlers/mute/muteUIUtils.js b/js/handlers/mute/muteUIUtils.js
new file mode 100644
index 0000000..02091a3
--- /dev/null
+++ b/js/handlers/mute/muteUIUtils.js
@@ -0,0 +1,39 @@
+import { getMuteUnmuteCounts } from '../../state.js';
+import { renderInterface } from '../../renderer.js';
+
+// Debounced UI updates with frame timing
+const debouncedUpdate = (() => {
+ let timeout;
+ let frameRequest;
+ return (fn) => {
+ if (timeout) clearTimeout(timeout);
+ if (frameRequest) cancelAnimationFrame(frameRequest);
+
+ timeout = setTimeout(() => {
+ frameRequest = requestAnimationFrame(() => {
+ console.debug('[debouncedUpdate] Executing update');
+ fn();
+ });
+ }, 16);
+ };
+})();
+
+// Helper to update button text
+function getButtonText() {
+ const { toMute, toUnmute } = getMuteUnmuteCounts();
+ console.debug('[getButtonText] To mute:', toMute, 'To unmute:', toUnmute);
+ const parts = [];
+
+ if (toMute > 0) {
+ parts.push(`Mute ${toMute} new`);
+ }
+ if (toUnmute > 0) {
+ parts.push(`Unmute ${toUnmute} existing`);
+ }
+
+ const text = parts.length > 0 ? parts.join(', ') : 'No changes';
+ console.debug('[getButtonText] Button text:', text);
+ return text;
+}
+
+export { debouncedUpdate, getButtonText };
diff --git a/js/handlers/muteHandlers.js b/js/handlers/muteHandlers.js
new file mode 100644
index 0000000..56c4390
--- /dev/null
+++ b/js/handlers/muteHandlers.js
@@ -0,0 +1,2 @@
+// Re-export everything from the new mute module
+export { handleMuteSubmit, initializeKeywordState, getButtonText, muteCache } from './mute/index.js';
diff --git a/js/handlers/settingsHandlers.js b/js/handlers/settingsHandlers.js
new file mode 100644
index 0000000..8d4e75c
--- /dev/null
+++ b/js/handlers/settingsHandlers.js
@@ -0,0 +1,29 @@
+// Re-export all settings-related functionality from their new modules
+import { loadMuteSettings, saveMuteSettings, getExpirationDate } from '../settings/muteSettings.js';
+import { loadAppearanceSettings, saveAppearanceSettings, applyAppearanceSettings } from '../settings/appearanceSettings.js';
+import { handleSettingsModalToggle } from './modalHandlers.js';
+import { handleFooterThemeToggle } from './themeHandlers.js';
+import { initializeSettings } from '../settings/init.js';
+
+// Expose handlers to window for HTML onclick handlers
+if (typeof window !== 'undefined') {
+ window.settingsHandlers = {
+ handleSettingsModalToggle,
+ handleFooterThemeToggle,
+ saveAppearanceSettings,
+ saveMuteSettings
+ };
+}
+
+// Export for module usage
+export {
+ loadMuteSettings,
+ saveMuteSettings,
+ getExpirationDate,
+ loadAppearanceSettings,
+ saveAppearanceSettings,
+ applyAppearanceSettings,
+ handleSettingsModalToggle,
+ handleFooterThemeToggle,
+ initializeSettings
+};
diff --git a/js/handlers/themeHandlers.js b/js/handlers/themeHandlers.js
new file mode 100644
index 0000000..f6522cc
--- /dev/null
+++ b/js/handlers/themeHandlers.js
@@ -0,0 +1,64 @@
+import { loadAppearanceSettings, saveAppearanceSettings } from '../settings/appearanceSettings.js';
+
+export function handleFooterThemeToggle() {
+ const settings = loadAppearanceSettings();
+ const html = document.documentElement;
+ const currentTheme = html.getAttribute('data-theme');
+
+ // Toggle between light and dark themes
+ const newTheme = currentTheme === 'light' ? 'dark' : 'light';
+ settings.colorMode = newTheme;
+
+ // Save and apply the new settings
+ saveAppearanceSettings(settings);
+
+ // Apply theme immediately
+ html.setAttribute('data-theme', newTheme);
+
+ // Update all theme toggles
+ const toggles = document.querySelectorAll('.theme-toggle');
+ toggles.forEach(toggle => {
+ toggle.classList.toggle('dark', newTheme === 'dark');
+ });
+
+ // Dispatch theme change event
+ document.dispatchEvent(new CustomEvent('themeChanged', {
+ detail: { theme: newTheme }
+ }));
+}
+
+// Initialize theme on page load
+document.addEventListener('DOMContentLoaded', () => {
+ const settings = loadAppearanceSettings();
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
+ const theme = settings.colorMode === 'dark' || (settings.colorMode === 'system' && prefersDark) ? 'dark' : 'light';
+
+ document.documentElement.setAttribute('data-theme', theme);
+
+ // Update toggle states
+ const toggles = document.querySelectorAll('.theme-toggle');
+ toggles.forEach(toggle => {
+ toggle.classList.toggle('dark', theme === 'dark');
+ });
+});
+
+// Add system theme change listener
+const systemThemeQuery = window.matchMedia('(prefers-color-scheme: dark)');
+systemThemeQuery.addEventListener('change', (e) => {
+ const settings = loadAppearanceSettings();
+ if (settings.colorMode === 'system') {
+ const theme = e.matches ? 'dark' : 'light';
+ document.documentElement.setAttribute('data-theme', theme);
+
+ // Update toggle states
+ const toggles = document.querySelectorAll('.theme-toggle');
+ toggles.forEach(toggle => {
+ toggle.classList.toggle('dark', theme === 'dark');
+ });
+
+ // Dispatch theme change event
+ document.dispatchEvent(new CustomEvent('themeChanged', {
+ detail: { theme }
+ }));
+ }
+});
diff --git a/js/handlers/uiHandlers.js b/js/handlers/uiHandlers.js
new file mode 100644
index 0000000..e84b8c8
--- /dev/null
+++ b/js/handlers/uiHandlers.js
@@ -0,0 +1,117 @@
+import { elements } from '../dom.js';
+import { state, saveState } from '../state.js';
+import { renderInterface } from '../renderer.js';
+import { refreshAllData } from '../api.js';
+import { updateSimpleModeState } from './contextHandlers.js';
+import { updateStatusCounts, updateMuteButton, updateEnableDisableButtons, updateLastUpdate } from '../renderers/uiRenderer.js';
+import { isKeywordActive } from './keywordHandlers.js';
+
+// Function to ensure mode toggles always reflect current state
+export function updateModeToggles() {
+ document.querySelectorAll('.interface-mode-switch').forEach(toggle => {
+ toggle.classList.toggle('active', toggle.dataset.mode === state.mode);
+ });
+}
+
+// Single source of truth for mode management
+export function switchMode(mode) {
+ if (mode !== 'simple' && mode !== 'advanced') {
+ mode = 'simple'; // Default to simple mode if invalid
+ }
+
+ // Update state
+ const previousMode = state.mode;
+ state.mode = mode;
+
+ // Ensure mode toggles reflect current state
+ updateModeToggles();
+
+ // Update mode visibility
+ const simpleMode = document.getElementById('simple-mode');
+ const advancedMode = document.getElementById('advanced-mode');
+ if (simpleMode) simpleMode.classList.toggle('hidden', mode !== 'simple');
+ if (advancedMode) advancedMode.classList.toggle('hidden', mode !== 'advanced');
+
+ // Only update state when switching TO simple mode
+ // This ensures contexts drive keyword selection in simple mode
+ // But allows direct keyword selection in advanced mode
+ if (mode === 'simple' && previousMode === 'advanced') {
+ updateSimpleModeState();
+ }
+
+ // Always save state and update interface
+ saveState();
+ renderInterface();
+}
+
+export async function handleRefreshData() {
+ const refreshButton = document.getElementById('refresh-data');
+ if (!refreshButton) return;
+
+ // Store the original SVG content
+ const svgIcon = `
+
+ `;
+
+ const updateButtonContent = (svg, text) => {
+ refreshButton.innerHTML = `${svg}${text} `;
+ };
+
+ try {
+ // Add spinning animation class
+ refreshButton.classList.add('spinning');
+ updateButtonContent(svgIcon, 'Refreshing...');
+ refreshButton.disabled = true;
+
+ await refreshAllData();
+
+ // Instead of full renderInterface, do targeted updates
+ // Update checkbox states without full redraw
+ document.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
+ if (checkbox.hasAttribute('onchange')) {
+ const keyword = checkbox.parentElement.textContent.trim();
+ checkbox.checked = isKeywordActive(keyword);
+ }
+ });
+
+ // Update counts and status
+ updateStatusCounts();
+ updateMuteButton();
+ updateEnableDisableButtons();
+ updateLastUpdate();
+ // Ensure mode toggles stay in sync
+ updateModeToggles();
+
+ // Show success state briefly
+ refreshButton.classList.remove('spinning');
+ updateButtonContent(svgIcon, 'Updated!');
+
+ // Reset button after a delay
+ setTimeout(() => {
+ updateButtonContent(svgIcon, 'Refresh Data');
+ refreshButton.disabled = false;
+ }, 1000);
+
+ } catch (error) {
+ console.error('Failed to refresh data:', error);
+ refreshButton.classList.remove('spinning');
+ updateButtonContent(svgIcon, 'Refresh Failed');
+
+ // Reset button after a delay
+ setTimeout(() => {
+ updateButtonContent(svgIcon, 'Refresh Data');
+ refreshButton.disabled = false;
+ }, 2000);
+ }
+}
+
+export function showApp() {
+ elements.landingPage.classList.add('hidden');
+ elements.appInterface.classList.remove('hidden');
+
+ // Ensure mode is set properly when showing app
+ switchMode(state.mode);
+}
+
+// Expose refreshData function to window object for use in settings modal
+window.refreshData = handleRefreshData;
diff --git a/js/initialization.js b/js/initialization.js
new file mode 100644
index 0000000..ed853e3
--- /dev/null
+++ b/js/initialization.js
@@ -0,0 +1,93 @@
+import { elements } from './dom.js';
+import { state, loadState } from './state.js';
+import { fetchKeywordGroups, fetchContextGroups, fetchDisplayConfig } from './api.js';
+import { renderInterface } from './renderer.js';
+import { blueskyService } from './bluesky.js';
+import {
+ showApp,
+ updateSimpleModeState,
+ initializeKeywordState,
+ switchMode,
+ applyAppearanceSettings
+} from './handlers/index.js';
+
+// Initialize Application
+export async function init() {
+ try {
+ // Show loading state
+ const loadingOverlay = document.getElementById('loading-state');
+
+ // Apply appearance settings first
+ applyAppearanceSettings();
+
+ // Check if we're on the callback page
+ const isCallbackPage = window.location.pathname.includes('callback.html');
+ if (isCallbackPage) {
+ // Only do auth setup on callback page
+ await blueskyService.setup();
+ return;
+ }
+
+ // Initialize Bluesky service and handle auth first
+ const result = await blueskyService.setup();
+ if (result?.session) {
+ // Set DID in state before loading saved state
+ state.did = result.session.did;
+ state.authenticated = true;
+
+ // Now load saved state
+ loadState();
+
+ // Load all required data
+ await Promise.all([
+ fetchDisplayConfig(),
+ fetchKeywordGroups(),
+ fetchContextGroups()
+ ]);
+
+ await showApp();
+ // Initialize keyword state after authentication
+ await initializeKeywordState();
+ }
+
+ // Now that all data is loaded, initialize the UI
+ if (state.authenticated) {
+ // First update simple mode state if needed
+ if (state.mode === 'simple') {
+ updateSimpleModeState();
+ }
+ // Then switch to the correct mode
+ switchMode(state.mode);
+ // Finally render the interface
+ renderInterface();
+
+ // Update SimpleMode component with loaded state
+ const simpleMode = document.querySelector('simple-mode');
+ if (simpleMode) {
+ simpleMode.updateLevel(state.filterLevel);
+ simpleMode.updateExceptions(state.selectedExceptions);
+ }
+ } else if (elements.landingPage && elements.appInterface) {
+ elements.landingPage.classList.remove('hidden');
+ elements.appInterface.classList.add('hidden');
+ }
+
+ // Hide loading state
+ if (loadingOverlay) {
+ loadingOverlay.classList.add('hidden');
+ // Remove from DOM after transition
+ setTimeout(() => loadingOverlay.remove(), 300);
+ }
+
+ // Add js-loaded class to body to show content
+ document.body.classList.add('js-loaded');
+ } catch (error) {
+ console.error('Initialization failed:', error);
+ // Hide loading state even on error
+ const loadingOverlay = document.getElementById('loading-state');
+ if (loadingOverlay) {
+ loadingOverlay.classList.add('hidden');
+ setTimeout(() => loadingOverlay.remove(), 300);
+ }
+ }
+}
diff --git a/js/keywordState.js b/js/keywordState.js
new file mode 100644
index 0000000..a490566
--- /dev/null
+++ b/js/keywordState.js
@@ -0,0 +1,86 @@
+import { state } from './state.js';
+import { keywordCache } from './stateCache.js';
+
+// Helper to get all keywords from our list with their original case
+export function getKeywordsWithCase() {
+ const keywordMap = new Map();
+ Object.entries(state.keywordGroups).forEach(([category, categoryData]) => {
+ const categoryInfo = categoryData[category];
+ if (categoryInfo?.keywords) {
+ Object.keys(categoryInfo.keywords).forEach(keyword => {
+ keywordMap.set(keyword.toLowerCase(), keyword);
+ });
+ }
+ });
+ return keywordMap;
+}
+
+// Helper to get all keywords from our list
+export function getOurKeywords() {
+ // Return cached version if valid
+ if (keywordCache.ourKeywords && !keywordCache.shouldUpdate()) {
+ return keywordCache.ourKeywords;
+ }
+
+ const ourKeywords = new Set();
+ Object.entries(state.keywordGroups).forEach(([category, categoryData]) => {
+ // Get the category info which contains the keywords
+ const categoryInfo = categoryData[category];
+ if (categoryInfo?.keywords) {
+ // Add each keyword to our set
+ Object.keys(categoryInfo.keywords).forEach(keyword => {
+ ourKeywords.add(keyword.toLowerCase());
+ });
+ }
+ });
+
+ // Cache the result
+ keywordCache.ourKeywords = ourKeywords;
+ return ourKeywords;
+}
+
+// Helper to determine if a keyword can be unmuted
+export function canUnmuteKeyword(keyword) {
+ // Only allow unmuting if:
+ // 1. It's in our list of keywords (case-insensitive)
+ // 2. It was previously muted (either originally or this session)
+ const ourKeywords = getOurKeywords();
+ const lowerKeyword = keyword.toLowerCase();
+ return ourKeywords.has(lowerKeyword) &&
+ (state.originalMutedKeywords.has(lowerKeyword) || state.sessionMutedKeywords.has(lowerKeyword));
+}
+
+// Optimized helper to get mute/unmute counts
+export function getMuteUnmuteCounts() {
+ const ourKeywords = getOurKeywords();
+ let toMute = 0;
+ let toUnmute = 0;
+
+ // Create lowercase lookup Set for active keywords
+ const activeLowerKeywords = new Set();
+ for (const keyword of state.activeKeywords) {
+ activeLowerKeywords.add(keyword.toLowerCase());
+ }
+
+ // Create lowercase lookup Set for originally muted keywords
+ const originalLowerKeywords = new Set();
+ for (const keyword of state.originalMutedKeywords) {
+ originalLowerKeywords.add(keyword.toLowerCase());
+ }
+
+ // Only count keywords from our list
+ for (const keyword of ourKeywords) {
+ const isActive = activeLowerKeywords.has(keyword);
+ const wasOriginallyMuted = originalLowerKeywords.has(keyword);
+
+ if (isActive && !wasOriginallyMuted) {
+ // New keyword to mute
+ toMute++;
+ } else if (!isActive && wasOriginallyMuted) {
+ // Existing keyword to unmute
+ toUnmute++;
+ }
+ }
+
+ return { toMute, toUnmute };
+}
diff --git a/js/main.js b/js/main.js
new file mode 100644
index 0000000..4b241b0
--- /dev/null
+++ b/js/main.js
@@ -0,0 +1,28 @@
+import { init } from './initialization.js';
+import { setupEventListeners } from './events.js';
+import {
+ handleContextToggle,
+ handleExceptionToggle,
+ handleCategoryToggle,
+ handleKeywordToggle,
+ handleSettingsModalToggle,
+ handleFooterThemeToggle,
+ switchMode
+} from './handlers/index.js';
+
+// Make handlers available globally
+window.handleContextToggle = handleContextToggle;
+window.handleExceptionToggle = handleExceptionToggle;
+window.handleCategoryToggle = handleCategoryToggle;
+window.handleKeywordToggle = handleKeywordToggle;
+window.settingsHandlers = {
+ handleSettingsModalToggle,
+ handleFooterThemeToggle
+};
+window.switchMode = switchMode;
+
+// Initialize app
+document.addEventListener('DOMContentLoaded', async () => {
+ await init();
+ setupEventListeners();
+});
diff --git a/js/mute.js b/js/mute.js
new file mode 100644
index 0000000..6877ba5
--- /dev/null
+++ b/js/mute.js
@@ -0,0 +1,186 @@
+import { Agent } from '@atproto/api'
+import { loadMuteSettings, getExpirationDate } from './handlers/settingsHandlers.js'
+
+export class MuteService {
+ constructor(session) {
+ this.agent = session ? new Agent(session) : null;
+ this.session = session;
+ this.cachedKeywords = null;
+ this.cachedPreferences = null;
+ console.debug('[MuteService] MuteService initialized, has session:', !!session);
+ }
+
+ setSession(session) {
+ console.debug('[MuteService] Setting new session in MuteService:', !!session);
+ this.agent = session ? new Agent(session) : null;
+ this.session = session;
+ // Clear caches when session changes
+ this.cachedKeywords = null;
+ this.cachedPreferences = null;
+ }
+
+ async getMutedKeywords(forceRefresh = false) {
+ if (!this.session) {
+ console.debug('[MuteService] Cannot get muted keywords - not logged in');
+ return [];
+ }
+
+ // Return cached keywords if available and not forcing refresh
+ if (!forceRefresh && this.cachedKeywords !== null) {
+ console.debug('[MuteService] Returning cached muted keywords');
+ return this.cachedKeywords;
+ }
+
+ try {
+ // Create fresh agent instance to ensure latest session
+ const agent = new Agent(this.session);
+
+ // Get user's preferences from Bluesky
+ console.debug('[MuteService] Fetching user preferences...');
+ const response = await agent.api.app.bsky.actor.getPreferences();
+ this.cachedPreferences = response.data.preferences;
+
+ // Find the muted words preference
+ const mutedWordsPref = this.cachedPreferences.find(
+ pref => pref.$type === 'app.bsky.actor.defs#mutedWordsPref'
+ );
+
+ // Extract just the values from the muted words
+ const mutedKeywords = mutedWordsPref?.items?.map(item => item.value) || [];
+
+ // Cache the result
+ this.cachedKeywords = mutedKeywords;
+
+ // Log the counts
+ console.debug('[MuteService] User muted keywords:', mutedKeywords);
+
+ return mutedKeywords;
+ } catch (error) {
+ console.error('[MuteService] Failed to get muted keywords:', error);
+ // Try to refresh session if we got a 401
+ if (error.status === 401) {
+ // Dispatch event for session refresh
+ const refreshEvent = new CustomEvent('mutesky:session:refresh:needed');
+ window.dispatchEvent(refreshEvent);
+ }
+ // Clear caches on error
+ this.cachedKeywords = null;
+ this.cachedPreferences = null;
+ throw new Error('Failed to fetch muted keywords from Bluesky');
+ }
+ }
+
+ async updateMutedKeywords(selectedKeywords, ourKeywordsList) {
+ // Early validation
+ if (!this.session) {
+ throw new Error('Cannot update keywords - not logged in');
+ }
+
+ if (!Array.isArray(selectedKeywords) || !Array.isArray(ourKeywordsList)) {
+ throw new Error('Invalid input: selected keywords must be provided as arrays');
+ }
+
+ try {
+ // Create fresh agent instance to ensure latest session
+ const agent = new Agent(this.session);
+
+ // Always get fresh preferences for updates
+ console.debug('[MuteService] Getting current preferences...');
+ const response = await agent.api.app.bsky.actor.getPreferences();
+ this.cachedPreferences = response.data.preferences;
+
+ // Find current muted words pref
+ const mutedWordsIndex = this.cachedPreferences.findIndex(
+ pref => pref.$type === 'app.bsky.actor.defs#mutedWordsPref'
+ );
+
+ // Create efficient lookup Set for our keywords
+ const ourKeywordsSet = new Set(ourKeywordsList.map(k => k.toLowerCase()));
+
+ // Get current muted words or initialize empty
+ const currentMutedPref = mutedWordsIndex >= 0 ? this.cachedPreferences[mutedWordsIndex] : {
+ $type: 'app.bsky.actor.defs#mutedWordsPref',
+ items: []
+ };
+
+ // Separate user's custom keywords (those not in our list)
+ const userCustomKeywords = currentMutedPref.items
+ .filter(item => !ourKeywordsSet.has(item.value.toLowerCase()))
+ .map(item => ({
+ value: item.value,
+ targets: item.targets || ['content', 'tag']
+ }));
+
+ // Load mute settings
+ const settings = loadMuteSettings();
+ const expiresAt = getExpirationDate(settings.duration);
+
+ // Create new items for selected keywords with settings applied
+ const newManagedItems = selectedKeywords
+ .filter(keyword => ourKeywordsSet.has(keyword.toLowerCase()))
+ .map(keyword => ({
+ value: keyword,
+ targets: settings.scope === 'tags-only' ? ['tag'] : ['content', 'tag'],
+ ...(settings.excludeFollows && { actorTarget: 'notFollowed' }),
+ ...(expiresAt && { expires: expiresAt.toISOString() })
+ }));
+
+ // Log operations for verification
+ console.debug('[MuteService] Applied mute settings:', settings);
+ console.debug('[MuteService] User custom keywords (will be preserved):', userCustomKeywords.map(i => i.value));
+ console.debug('[MuteService] New managed keywords to be set:', newManagedItems.map(i => i.value));
+
+ // Combine user's custom keywords with selected managed keywords
+ const updatedItems = [
+ ...userCustomKeywords, // Preserve all user's custom keywords
+ ...newManagedItems // Only include selected keywords from our list
+ ];
+
+ // Create updated preference
+ const updatedMutedPref = {
+ $type: 'app.bsky.actor.defs#mutedWordsPref',
+ items: updatedItems
+ };
+
+ // Update preferences array
+ if (mutedWordsIndex >= 0) {
+ this.cachedPreferences[mutedWordsIndex] = updatedMutedPref;
+ } else {
+ this.cachedPreferences.push(updatedMutedPref);
+ }
+
+ // Log final state
+ console.debug('[MuteService] Total keywords after update:', updatedItems.length);
+
+ try {
+ // Update preferences using fresh agent
+ await agent.api.app.bsky.actor.putPreferences({
+ preferences: this.cachedPreferences
+ });
+ } catch (error) {
+ if (error.status === 401) {
+ // Dispatch event for session refresh
+ const refreshEvent = new CustomEvent('mutesky:session:refresh:needed');
+ window.dispatchEvent(refreshEvent);
+ throw error; // Let BlueskyService handle the retry
+ }
+ throw error;
+ }
+
+ // Clear caches after successful update
+ this.cachedKeywords = null;
+ this.cachedPreferences = null;
+
+ console.debug('[MuteService] Successfully updated muted keywords');
+ return true;
+ } catch (error) {
+ console.error('[MuteService] Failed to update muted keywords:', error);
+ // Clear caches on error
+ this.cachedKeywords = null;
+ this.cachedPreferences = null;
+ // Extract API error message if available
+ const apiError = error.message || 'Failed to update muted keywords';
+ throw new Error(apiError);
+ }
+ }
+}
diff --git a/js/profile.js b/js/profile.js
new file mode 100644
index 0000000..4a2e21d
--- /dev/null
+++ b/js/profile.js
@@ -0,0 +1,112 @@
+import { Agent } from '@atproto/api'
+
+export class ProfileService {
+ constructor(session) {
+ this.agent = session ? new Agent(session) : null;
+ this.session = session;
+ this.handle = null; // Store handle for mute count updates
+ }
+
+ // Made synchronous - just sets properties
+ setSession(session) {
+ this.agent = session ? new Agent(session) : null;
+ this.session = session;
+ }
+
+ async getProfile() {
+ // Ensure we're using the latest session
+ if (!this.agent || !this.session) throw new Error('Not logged in');
+
+ // Create a fresh agent instance to ensure we're using the latest session
+ const agent = new Agent(this.session);
+
+ try {
+ const response = await agent.getProfile({
+ actor: this.session.did
+ });
+ return response.data;
+ } catch (error) {
+ // Check if it's an unauthorized error (401)
+ if (error.status === 401) {
+ // Dispatch event for session refresh
+ const refreshEvent = new CustomEvent('mutesky:session:refresh:needed');
+ window.dispatchEvent(refreshEvent);
+ }
+ console.error('Failed to get profile:', error);
+ return null;
+ }
+ }
+
+ updateUI(profile) {
+ if (!profile) return;
+
+ const handleEl = document.getElementById('bsky-handle');
+ const dropdownHandleEl = document.getElementById('dropdown-handle');
+ const displayNameEl = document.getElementById('user-display-name');
+ const profilePic = document.querySelector('.profile-pic');
+
+ if (handleEl) {
+ // Store handle for mute count updates
+ this.handle = profile.handle;
+ handleEl.textContent = `@${profile.handle}`;
+ }
+
+ if (dropdownHandleEl) {
+ dropdownHandleEl.textContent = `@${profile.handle}`;
+ }
+
+ if (displayNameEl) {
+ displayNameEl.textContent = profile.displayName || profile.handle;
+ }
+
+ if (profilePic && profile.avatar) {
+ profilePic.style.backgroundImage = `url(${profile.avatar})`;
+ profilePic.style.backgroundSize = 'cover';
+ profilePic.style.backgroundPosition = 'center';
+ }
+ }
+
+ // Updated method to show mute count in both places
+ updateMuteCount(count) {
+ const muteCountEl = document.getElementById('total-mute-count');
+ const handleEl = document.getElementById('bsky-handle');
+
+ if (muteCountEl) {
+ muteCountEl.textContent = `${count} muted`;
+ }
+
+ if (handleEl && this.handle) {
+ handleEl.textContent = `@${this.handle} - ${count} mutes`;
+ }
+ }
+
+ resetUI() {
+ const profilePic = document.querySelector('.profile-pic');
+ const handleEl = document.getElementById('bsky-handle');
+ const dropdownHandleEl = document.getElementById('dropdown-handle');
+ const displayNameEl = document.getElementById('user-display-name');
+ const muteCountEl = document.getElementById('total-mute-count');
+
+ if (profilePic) {
+ profilePic.style.backgroundImage = 'none';
+ }
+
+ if (handleEl) {
+ handleEl.textContent = '';
+ }
+
+ if (dropdownHandleEl) {
+ dropdownHandleEl.textContent = '';
+ }
+
+ if (displayNameEl) {
+ displayNameEl.textContent = '';
+ }
+
+ if (muteCountEl) {
+ muteCountEl.textContent = '0 muted';
+ }
+
+ this.handle = null; // Clear stored handle
+ }
+}
diff --git a/js/renderer.js b/js/renderer.js
new file mode 100644
index 0000000..167e1b5
--- /dev/null
+++ b/js/renderer.js
@@ -0,0 +1 @@
+export { renderInterface } from './renderers/index.js';
diff --git a/js/renderers/categoryRenderer.js b/js/renderers/categoryRenderer.js
new file mode 100644
index 0000000..d3a6a71
--- /dev/null
+++ b/js/renderers/categoryRenderer.js
@@ -0,0 +1,129 @@
+import { elements } from '../dom.js';
+import { state } from '../state.js';
+import { getDisplayName, getCategoryState, getCheckboxClass, filterKeywordGroups, getAllKeywordsForCategory } from '../categoryManager.js';
+import { isKeywordActive } from '../handlers/keywordHandlers.js';
+
+export function renderAdvancedMode() {
+ if (!elements.categoriesGrid) return;
+
+ const filteredGroups = filterKeywordGroups(true); // Pass true for right panel
+ elements.categoriesGrid.innerHTML = Object.entries(filteredGroups)
+ .map(([category, keywords]) => {
+ if (keywords.length === 0) return '';
+
+ const activeCount = keywords.filter(k => isKeywordActive(k)).length;
+ const displayName = category;
+ const categoryState = getCategoryState(category);
+
+ // Special case: give US Political Figures the ID that matches the politicians link
+ const sectionId = category === 'US Political Figures - Full Name' ? 'politicians' : category.replace(/\s+/g, '-').toLowerCase();
+
+ return `
+
+
+
+ ${keywords.map(keyword => `
+
+
+ ${keyword}
+
+ `).join('')}
+
+
+ `;
+ })
+ .join('');
+
+ // Set indeterminate state after rendering
+ document.querySelectorAll('.category-checkbox').forEach(checkbox => {
+ const category = checkbox.dataset.category;
+ const state = checkbox.dataset.state;
+ if (state === 'partial') {
+ checkbox.indeterminate = true;
+ checkbox.checked = false;
+ }
+ });
+}
+
+export function renderCategoryList() {
+ if (!elements.categoryList) return;
+
+ // Get all categories including combined ones but excluding source categories
+ const allCategories = new Set([
+ ...Object.keys(state.keywordGroups).filter(category => {
+ // Filter out categories that are part of combined categories
+ return !Object.values(state.displayConfig.combinedCategories || {})
+ .some(sources => sources.includes(category));
+ }),
+ ...Object.keys(state.displayConfig.combinedCategories || {})
+ ]);
+
+ const categories = Array.from(allCategories)
+ .map(category => {
+ const keywords = getAllKeywordsForCategory(category);
+ const totalKeywords = keywords.length;
+ const activeKeywords = keywords.filter(k => isKeywordActive(k)).length;
+ const displayName = getDisplayName(category);
+ const categoryState = getCategoryState(category);
+
+ return {
+ category,
+ displayName,
+ activeKeywords,
+ totalKeywords,
+ state: categoryState
+ };
+ })
+ .sort((a, b) => a.displayName.localeCompare(b.displayName));
+
+ const html = categories.map(({ category, displayName, activeKeywords, totalKeywords, state }) => `
+
+ `).join('');
+
+ elements.categoryList.innerHTML = html;
+
+ // Set indeterminate state after rendering
+ document.querySelectorAll('.category-checkbox').forEach(checkbox => {
+ const state = checkbox.dataset.state;
+ if (state === 'partial') {
+ checkbox.indeterminate = true;
+ checkbox.checked = false;
+ }
+ });
+}
diff --git a/js/renderers/contextRenderer.js b/js/renderers/contextRenderer.js
new file mode 100644
index 0000000..9362e6c
--- /dev/null
+++ b/js/renderers/contextRenderer.js
@@ -0,0 +1,58 @@
+import { elements } from '../dom.js';
+import { state } from '../state.js';
+
+export function renderContextCards() {
+ if (!elements.contextOptions) return;
+
+ elements.contextOptions.innerHTML = Object.entries(state.contextGroups)
+ .map(([id, context]) => {
+ // Only check if the context is in selectedContexts
+ const isSelected = state.selectedContexts.has(id);
+ return `
+
+
${context.title}
+
${context.description}
+
+ `;
+ }).join('');
+}
+
+export function renderExceptions() {
+ if (!elements.exceptionsPanel || !elements.exceptionTags) return;
+
+ // Show/hide panel based on context selection without clearing exceptions
+ if (state.selectedContexts.size > 0) {
+ elements.exceptionsPanel.classList.add('visible');
+ } else {
+ elements.exceptionsPanel.classList.remove('visible');
+ elements.muteButton?.classList.remove('visible');
+ return;
+ }
+
+ // Get categories only from contexts that are actually selected
+ const selectedCategories = new Set();
+ for (const contextId of state.selectedContexts) {
+ const context = state.contextGroups[contextId];
+ if (context?.categories) {
+ context.categories.forEach(category => {
+ // Only add categories from selected contexts
+ if (state.selectedContexts.has(contextId)) {
+ selectedCategories.add(category);
+ }
+ });
+ }
+ }
+
+ // Render exception tags, preserving selected state
+ elements.exceptionTags.innerHTML = Array.from(selectedCategories)
+ .map(category => {
+ return `
+
+ ${category}
+
+ `;
+ }).join('');
+}
diff --git a/js/renderers/index.js b/js/renderers/index.js
new file mode 100644
index 0000000..cce8f8b
--- /dev/null
+++ b/js/renderers/index.js
@@ -0,0 +1,28 @@
+import { updateBlueskyUI, updateEnableDisableButtons, updateLastUpdate, updateStatusCounts, updateMuteButton } from './uiRenderer.js';
+import { renderContextCards, renderExceptions } from './contextRenderer.js';
+import { renderAdvancedMode, renderCategoryList } from './categoryRenderer.js';
+import { state } from '../state.js';
+
+// Import the updateModeToggles function from uiHandlers
+import { updateModeToggles } from '../handlers/uiHandlers.js';
+
+export function renderInterface() {
+ // Update Bluesky-specific UI elements
+ updateBlueskyUI();
+
+ if (state.mode === 'simple') {
+ renderContextCards();
+ renderExceptions();
+ } else {
+ renderAdvancedMode();
+ renderCategoryList();
+ }
+
+ // Ensure mode toggles always reflect current state
+ updateModeToggles();
+
+ updateStatusCounts();
+ updateMuteButton();
+ updateEnableDisableButtons();
+ updateLastUpdate();
+}
diff --git a/js/renderers/uiRenderer.js b/js/renderers/uiRenderer.js
new file mode 100644
index 0000000..a94c87a
--- /dev/null
+++ b/js/renderers/uiRenderer.js
@@ -0,0 +1,84 @@
+import { elements } from '../dom.js';
+import { state } from '../state.js';
+import { blueskyService } from '../bluesky.js';
+import { filterKeywordGroups } from '../categoryManager.js';
+import { getButtonText } from '../handlers/muteHandlers.js';
+
+export function updateBlueskyUI() {
+ // Update handle display if user is logged in
+ if (state.authenticated && blueskyService.session) {
+ const handle = blueskyService.session.handle || blueskyService.session.sub;
+ if (elements.bskyHandle) {
+ elements.bskyHandle.textContent = `@${handle}`;
+ }
+ }
+
+ // Update auth button visibility
+ if (elements.authButton) {
+ elements.authButton.style.display = state.authenticated ? 'none' : 'block';
+ }
+
+ // Update user menu visibility
+ if (elements.userMenuDropdown) {
+ elements.userMenuDropdown.classList.toggle('visible', state.menuOpen && state.authenticated);
+ }
+}
+
+export function updateEnableDisableButtons() {
+ const searchTerm = state.searchTerm.toLowerCase();
+ if (searchTerm) {
+ const filteredCount = Object.values(filterKeywordGroups(false))
+ .reduce((count, keywords) => count + keywords.length, 0);
+ if (elements.enableAllBtn) {
+ elements.enableAllBtn.textContent = `Enable (${filteredCount})`;
+ }
+ if (elements.disableAllBtn) {
+ elements.disableAllBtn.textContent = `Disable (${filteredCount})`;
+ }
+ } else {
+ if (elements.enableAllBtn) {
+ elements.enableAllBtn.textContent = 'Enable All';
+ }
+ if (elements.disableAllBtn) {
+ elements.disableAllBtn.textContent = 'Disable All';
+ }
+ }
+}
+
+export function updateLastUpdate() {
+ if (elements.sidebarLastUpdate) {
+ if (state.lastModified) {
+ elements.sidebarLastUpdate.textContent = state.lastModified;
+ } else {
+ elements.sidebarLastUpdate.textContent = 'checking...';
+ }
+ }
+}
+
+export function updateStatusCounts() {
+ const total = Object.values(state.keywordGroups).flat().length;
+ const active = calculateKeywordCount();
+
+ if (elements.activeCount) {
+ elements.activeCount.textContent = `${active}/${total} terms`;
+ }
+}
+
+export function updateMuteButton() {
+ const buttonText = getButtonText();
+ const hasChanges = buttonText !== 'No changes';
+
+ if (elements.muteButton) {
+ elements.muteButton.textContent = buttonText;
+ elements.muteButton.classList.toggle('visible', hasChanges);
+ }
+
+ if (elements.navMuteButton) {
+ elements.navMuteButton.textContent = buttonText;
+ elements.navMuteButton.classList.toggle('visible', hasChanges);
+ }
+}
+
+function calculateKeywordCount() {
+ return state.activeKeywords.size;
+}
diff --git a/js/settings/appearanceSettings.js b/js/settings/appearanceSettings.js
new file mode 100644
index 0000000..c045e4e
--- /dev/null
+++ b/js/settings/appearanceSettings.js
@@ -0,0 +1,91 @@
+const DEFAULT_APPEARANCE = {
+ colorMode: 'system',
+ font: 'system',
+ fontSize: 'default'
+};
+
+const FONT_SCALES = {
+ 'smaller': 0.867, // 13px equivalent
+ 'default': 1, // 15px base
+ 'larger': 1.133 // 17px equivalent
+};
+
+export function loadAppearanceSettings() {
+ try {
+ const saved = localStorage.getItem('appearanceSettings');
+ if (saved) {
+ const settings = JSON.parse(saved);
+ return { ...DEFAULT_APPEARANCE, ...settings };
+ }
+ } catch (error) {
+ console.error('Error loading appearance settings:', error);
+ }
+ return { ...DEFAULT_APPEARANCE };
+}
+
+export function saveAppearanceSettings(settings) {
+ try {
+ const newSettings = {
+ ...DEFAULT_APPEARANCE,
+ ...settings
+ };
+ localStorage.setItem('appearanceSettings', JSON.stringify(newSettings));
+ applyAppearanceSettings(newSettings);
+ } catch (error) {
+ console.error('Error saving appearance settings:', error);
+ }
+}
+
+export function applyAppearanceSettings(settings = null) {
+ if (!settings) {
+ settings = loadAppearanceSettings();
+ }
+
+ const html = document.documentElement;
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
+
+ // Apply theme
+ const theme = settings.colorMode === 'dark' || (settings.colorMode === 'system' && prefersDark) ? 'dark' : 'light';
+
+ // Apply theme immediately
+ html.setAttribute('data-theme', theme);
+
+ // Update footer toggle state
+ const footerToggle = document.getElementById('footer-theme-toggle');
+ if (footerToggle) {
+ footerToggle.classList.toggle('dark', theme === 'dark');
+ }
+
+ // Update landing page toggle state
+ const landingToggle = document.getElementById('landing-theme-toggle');
+ if (landingToggle) {
+ landingToggle.classList.toggle('dark', theme === 'dark');
+ }
+
+ // Apply font settings
+ html.style.fontFamily = settings.font === 'theme'
+ ? 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'
+ : '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
+
+ // Apply font scale using CSS variable
+ html.style.setProperty('--font-scale', FONT_SCALES[settings.fontSize]);
+
+ // Dispatch theme change event
+ document.dispatchEvent(new CustomEvent('themeChanged', {
+ detail: { theme }
+ }));
+
+ updateAppearanceUI(settings);
+}
+
+export function updateAppearanceUI(settings) {
+ requestAnimationFrame(() => {
+ document.querySelectorAll('.mode-switch, .theme-switch, .font-switch').forEach(btn => {
+ btn.classList.remove('active');
+ });
+
+ document.querySelector(`.mode-switch[data-theme="${settings.colorMode}"]`)?.classList.add('active');
+ document.querySelector(`.font-switch[data-font="${settings.font}"]`)?.classList.add('active');
+ document.querySelector(`.font-switch[data-size="${settings.fontSize}"]`)?.classList.add('active');
+ });
+}
diff --git a/js/settings/init.js b/js/settings/init.js
new file mode 100644
index 0000000..f5295c7
--- /dev/null
+++ b/js/settings/init.js
@@ -0,0 +1,44 @@
+import { applyAppearanceSettings, loadAppearanceSettings, saveAppearanceSettings } from './appearanceSettings.js';
+import { handleSettingsModalToggle } from '../handlers/modalHandlers.js';
+import { handleFooterThemeToggle } from '../handlers/themeHandlers.js';
+
+export function initializeSettings() {
+ // Apply initial appearance settings
+ applyAppearanceSettings();
+
+ // Add click handlers for appearance settings
+ document.querySelectorAll('.mode-switch[data-theme]').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ const settings = loadAppearanceSettings();
+ settings.colorMode = e.target.dataset.theme;
+ saveAppearanceSettings(settings);
+ });
+ });
+
+ document.querySelectorAll('.font-switch[data-font]').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ const settings = loadAppearanceSettings();
+ settings.font = e.target.dataset.font;
+ saveAppearanceSettings(settings);
+ });
+ });
+
+ document.querySelectorAll('.font-switch[data-size]').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ const settings = loadAppearanceSettings();
+ settings.fontSize = e.target.dataset.size;
+ saveAppearanceSettings(settings);
+ });
+ });
+
+ // Add system theme change listener
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
+ const settings = loadAppearanceSettings();
+ if (settings.colorMode === 'system') {
+ applyAppearanceSettings(settings);
+ }
+ });
+
+ // Add footer theme toggle handler
+ document.getElementById('footer-theme-toggle')?.addEventListener('click', handleFooterThemeToggle);
+}
diff --git a/js/settings/muteSettings.js b/js/settings/muteSettings.js
new file mode 100644
index 0000000..a5968bc
--- /dev/null
+++ b/js/settings/muteSettings.js
@@ -0,0 +1,44 @@
+const DEFAULT_SETTINGS = {
+ duration: 'forever',
+ scope: 'text-and-tags',
+ excludeFollows: true
+};
+
+export function loadMuteSettings() {
+ try {
+ const saved = localStorage.getItem('muteSettings');
+ if (saved) {
+ return { ...DEFAULT_SETTINGS, ...JSON.parse(saved) };
+ }
+ } catch (error) {
+ console.error('Error loading mute settings:', error);
+ }
+ return { ...DEFAULT_SETTINGS };
+}
+
+export function saveMuteSettings(settings) {
+ try {
+ localStorage.setItem('muteSettings', JSON.stringify({
+ ...DEFAULT_SETTINGS,
+ ...settings
+ }));
+ } catch (error) {
+ console.error('Error saving mute settings:', error);
+ }
+}
+
+export function getExpirationDate(duration) {
+ if (duration === 'forever') return null;
+
+ const now = new Date();
+ switch (duration) {
+ case '24h':
+ return new Date(now.getTime() + 24 * 60 * 60 * 1000);
+ case '7d':
+ return new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
+ case '30d':
+ return new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
+ default:
+ return null;
+ }
+}
diff --git a/js/state.js b/js/state.js
new file mode 100644
index 0000000..dcc3664
--- /dev/null
+++ b/js/state.js
@@ -0,0 +1,38 @@
+import { loadState, saveState, resetState, forceRefresh, getStorageKey } from './statePersistence.js';
+import { setUser } from './userState.js';
+import { canUnmuteKeyword, getMuteUnmuteCounts } from './keywordState.js';
+
+// Core state object
+export const state = {
+ authenticated: false,
+ did: null, // Track current user's DID
+ mode: 'simple',
+ keywordGroups: {},
+ contextGroups: {},
+ displayConfig: {},
+ activeKeywords: new Set(), // Currently checked keywords (only from our list)
+ originalMutedKeywords: new Set(), // All user's muted keywords (for safety check)
+ sessionMutedKeywords: new Set(), // New keywords muted this session
+ manuallyUnchecked: new Set(), // Keywords that user has manually unchecked
+ selectedContexts: new Set(),
+ selectedExceptions: new Set(),
+ selectedCategories: new Set(),
+ searchTerm: '',
+ filterMode: 'all',
+ menuOpen: false,
+ lastModified: null, // Last-Modified header from keywords file
+ filterLevel: 0, // Track current filter level (0=Minimal to 3=Complete)
+ lastBulkAction: null // Track when enable/disable all is used
+};
+
+// Re-export core functionality
+export {
+ loadState,
+ saveState,
+ resetState,
+ forceRefresh,
+ setUser,
+ canUnmuteKeyword,
+ getMuteUnmuteCounts,
+ getStorageKey
+};
diff --git a/js/stateCache.js b/js/stateCache.js
new file mode 100644
index 0000000..c78eb5e
--- /dev/null
+++ b/js/stateCache.js
@@ -0,0 +1,18 @@
+// Optimized keyword cache with shorter timeout
+export const keywordCache = {
+ ourKeywords: null,
+ lastUpdate: 0,
+
+ clear() {
+ this.ourKeywords = null;
+ this.lastUpdate = 0;
+ },
+
+ shouldUpdate() {
+ // Reduced throttle time to 16ms (one frame) for more responsive updates
+ const now = Date.now();
+ if (now - this.lastUpdate < 16) return false;
+ this.lastUpdate = now;
+ return true;
+ }
+};
diff --git a/js/statePersistence.js b/js/statePersistence.js
new file mode 100644
index 0000000..aaad525
--- /dev/null
+++ b/js/statePersistence.js
@@ -0,0 +1,160 @@
+import { KEYWORDS_BASE_URL, CONTEXT_GROUPS_URL, DISPLAY_CONFIG_URL } from './config.js';
+import { state } from './state.js';
+import { keywordCache } from './stateCache.js';
+import { getKeywordsWithCase } from './keywordState.js';
+
+// Helper to get storage key for current user
+export function getStorageKey() {
+ if (!state.did) {
+ throw new Error('No DID set in state');
+ }
+ return `muteskyState-${state.did}`;
+}
+
+// Debounced save state with shorter delay
+const debouncedSave = (() => {
+ let timeout;
+ return () => {
+ if (timeout) clearTimeout(timeout);
+ timeout = setTimeout(() => {
+ const saveData = {
+ activeKeywords: Array.from(state.activeKeywords),
+ selectedCategories: Array.from(state.selectedCategories),
+ selectedContexts: Array.from(state.selectedContexts),
+ selectedExceptions: Array.from(state.selectedExceptions),
+ manuallyUnchecked: Array.from(state.manuallyUnchecked),
+ mode: state.mode,
+ lastModified: state.lastModified,
+ filterLevel: state.filterLevel,
+ lastBulkAction: state.lastBulkAction
+ };
+ try {
+ localStorage.setItem(getStorageKey(), JSON.stringify(saveData));
+ } catch (error) {
+ console.error('Error saving state:', error);
+ }
+ }, 16); // One frame delay for more responsive saves
+ };
+})();
+
+export function saveState() {
+ debouncedSave();
+}
+
+export function loadState() {
+ try {
+ // Clear all selections first
+ state.activeKeywords.clear();
+ state.selectedContexts.clear();
+ state.selectedExceptions.clear();
+ state.selectedCategories.clear();
+ // Don't clear manuallyUnchecked - let it persist
+
+ const saved = localStorage.getItem(getStorageKey());
+ if (saved) {
+ const data = JSON.parse(saved);
+
+ // Get map of lowercase -> original case keywords
+ const keywordMap = getKeywordsWithCase();
+
+ // Ensure we're working with Sets and proper case
+ state.activeKeywords = new Set(
+ (data.activeKeywords || []).map(keyword => {
+ // Use original case from keyword map if available
+ return keywordMap.get(keyword.toLowerCase()) || keyword;
+ })
+ );
+ state.selectedCategories = new Set(data.selectedCategories || []);
+ state.selectedContexts = new Set(data.selectedContexts || []);
+ state.selectedExceptions = new Set(data.selectedExceptions || []);
+ state.manuallyUnchecked = new Set(
+ (data.manuallyUnchecked || []).map(keyword => {
+ // Use original case from keyword map if available
+ return keywordMap.get(keyword.toLowerCase()) || keyword;
+ })
+ );
+
+ // Load other state properties
+ state.mode = data.mode || 'simple';
+ state.lastModified = data.lastModified || null;
+ state.filterLevel = typeof data.filterLevel === 'number' ? data.filterLevel : 0;
+ state.lastBulkAction = data.lastBulkAction || null;
+
+ // Force cache refresh
+ keywordCache.clear();
+ }
+ } catch (error) {
+ console.error('Error loading saved state:', error);
+ // If there's an error, ensure state is clean but preserve manuallyUnchecked
+ const unchecked = new Set(state.manuallyUnchecked);
+ resetState();
+ state.manuallyUnchecked = unchecked;
+ }
+}
+
+export function resetState() {
+ // Preserve auth state
+ const did = state.did;
+ const authenticated = state.authenticated;
+ // Preserve mute state
+ const originalMutedKeywords = new Set(state.originalMutedKeywords);
+ const sessionMutedKeywords = new Set(state.sessionMutedKeywords);
+
+ // Reset all other state
+ state.mode = 'simple';
+ state.activeKeywords.clear();
+ state.originalMutedKeywords.clear();
+ state.sessionMutedKeywords.clear();
+ // Don't clear manuallyUnchecked - let it persist
+ state.selectedContexts.clear();
+ state.selectedExceptions.clear();
+ state.selectedCategories.clear();
+ state.searchTerm = '';
+ state.filterMode = 'all';
+ state.menuOpen = false;
+ state.lastModified = null;
+ state.filterLevel = 0;
+ state.lastBulkAction = null;
+ keywordCache.clear();
+
+ // Restore auth state
+ state.did = did;
+ state.authenticated = authenticated;
+ // Restore mute state
+ state.originalMutedKeywords = originalMutedKeywords;
+ state.sessionMutedKeywords = sessionMutedKeywords;
+
+ saveState();
+}
+
+export function forceRefresh() {
+ // Preserve auth state
+ const did = state.did;
+ const authenticated = state.authenticated;
+ // Preserve mute state
+ const originalMutedKeywords = new Set(state.originalMutedKeywords);
+ const sessionMutedKeywords = new Set(state.sessionMutedKeywords);
+
+ // Clear all cached data
+ localStorage.removeItem(getStorageKey());
+ state.keywordGroups = {};
+ state.contextGroups = {};
+ state.displayConfig = {};
+ keywordCache.clear();
+ resetState();
+
+ // Restore auth state
+ state.did = did;
+ state.authenticated = authenticated;
+ // Restore mute state
+ state.originalMutedKeywords = originalMutedKeywords;
+ state.sessionMutedKeywords = sessionMutedKeywords;
+
+ // Force browser to skip cache when fetching
+ const cacheBuster = `?t=${new Date().getTime()}`;
+ return {
+ keywordsBaseUrl: `${KEYWORDS_BASE_URL}${cacheBuster}`,
+ contextGroupsUrl: `${CONTEXT_GROUPS_URL}${cacheBuster}`,
+ displayConfigUrl: `${DISPLAY_CONFIG_URL}${cacheBuster}`
+ };
+}
diff --git a/js/themeInit.js b/js/themeInit.js
new file mode 100644
index 0000000..4f19f90
--- /dev/null
+++ b/js/themeInit.js
@@ -0,0 +1,81 @@
+// Early theme initialization to prevent flash
+(function() {
+ try {
+ const html = document.documentElement;
+ let theme = 'light';
+
+ // Try to load saved settings
+ const savedSettings = localStorage.getItem('appearanceSettings');
+ if (savedSettings) {
+ const settings = JSON.parse(savedSettings);
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
+
+ if (settings.colorMode === 'system') {
+ theme = prefersDark ? 'dark' : 'light';
+ } else if (settings.colorMode === 'dark') {
+ theme = 'dark';
+ }
+ } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
+ theme = 'dark';
+ }
+
+ // Apply theme immediately
+ html.setAttribute('data-theme', theme);
+
+ // Show content only after theme is set
+ window.addEventListener('DOMContentLoaded', () => {
+ document.body.classList.add('js-loaded');
+
+ // Set initial footer toggle state
+ const footerToggle = document.getElementById('footer-theme-toggle');
+ if (footerToggle) {
+ footerToggle.classList.toggle('dark', theme === 'dark');
+ }
+ });
+
+ // Listen for theme changes from system
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
+ const currentSettings = localStorage.getItem('appearanceSettings');
+ if (currentSettings) {
+ const settings = JSON.parse(currentSettings);
+ if (settings.colorMode === 'system') {
+ const newTheme = e.matches ? 'dark' : 'light';
+ html.setAttribute('data-theme', newTheme);
+
+ // Update footer toggle
+ const footerToggle = document.getElementById('footer-theme-toggle');
+ if (footerToggle) {
+ footerToggle.classList.toggle('dark', e.matches);
+ }
+ }
+ }
+ });
+
+ // Listen for storage changes (theme updates from other tabs)
+ window.addEventListener('storage', (e) => {
+ if (e.key === 'appearanceSettings' && e.newValue) {
+ const settings = JSON.parse(e.newValue);
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
+ let newTheme = 'light';
+
+ if (settings.colorMode === 'system') {
+ newTheme = prefersDark ? 'dark' : 'light';
+ } else if (settings.colorMode === 'dark') {
+ newTheme = 'dark';
+ }
+
+ html.setAttribute('data-theme', newTheme);
+
+ // Update footer toggle
+ const footerToggle = document.getElementById('footer-theme-toggle');
+ if (footerToggle) {
+ footerToggle.classList.toggle('dark', newTheme === 'dark');
+ }
+ }
+ });
+ } catch (error) {
+ console.error('Error in early theme initialization:', error);
+ // Fallback to light theme if something goes wrong
+ document.documentElement.setAttribute('data-theme', 'light');
+ }
+})();
diff --git a/js/ui.js b/js/ui.js
new file mode 100644
index 0000000..078c666
--- /dev/null
+++ b/js/ui.js
@@ -0,0 +1,80 @@
+export class UIService {
+ updateLoginState(isLoggedIn, message = '') {
+ // Update DOM synchronously
+ this.updateDOMElements(isLoggedIn, message);
+
+ // Dispatch event in background
+ setTimeout(() => {
+ window.dispatchEvent(new CustomEvent('blueskyLoginStateChanged', {
+ detail: { isLoggedIn, message }
+ }));
+ }, 0);
+ }
+
+ updateDOMElements(isLoggedIn, message) {
+ const loginBtn = document.getElementById('bsky-login-btn');
+ const logoutBtn = document.getElementById('bsky-logout-btn');
+ const handleInput = document.getElementById('bsky-handle-input');
+ const authMessage = document.getElementById('bsky-auth-message');
+
+ // Clear error states if logging in successfully
+ if (isLoggedIn) {
+ if (handleInput) {
+ handleInput.classList.remove('error');
+ }
+ if (authMessage) {
+ authMessage.classList.remove('error');
+ }
+ }
+
+ // Batch DOM updates
+ if (loginBtn) {
+ loginBtn.style.display = isLoggedIn ? 'none' : 'block';
+ loginBtn.disabled = false;
+ loginBtn.textContent = 'Connect to Bluesky';
+ }
+ if (logoutBtn) logoutBtn.style.display = isLoggedIn ? 'block' : 'none';
+
+ if (handleInput) {
+ handleInput.style.display = isLoggedIn ? 'none' : 'block';
+ handleInput.disabled = false;
+ handleInput.classList.toggle('error', !isLoggedIn && !!message);
+ }
+
+ if (authMessage) {
+ if (message) {
+ authMessage.textContent = message;
+ authMessage.classList.toggle('error', !isLoggedIn && !!message);
+ } else if (isLoggedIn) {
+ // Clear error message on successful login
+ authMessage.textContent = 'The next page will prompt for your username and Bluesky account password, not your app password. Your credentials are securely handled by Bluesky\'s official authentication service.';
+ authMessage.classList.remove('error');
+ }
+ }
+ }
+
+ getHandleInput() {
+ const handleInput = document.getElementById('bsky-handle-input');
+ // Strip @ symbol if present and trim whitespace
+ return (handleInput?.value?.replace('@', '') || '').trim();
+ }
+
+ showError(message) {
+ const handleInput = document.getElementById('bsky-handle-input');
+ const authMessage = document.getElementById('bsky-auth-message');
+ const loginBtn = document.getElementById('bsky-login-btn');
+
+ if (handleInput) {
+ handleInput.classList.add('error');
+ handleInput.disabled = false;
+ }
+ if (loginBtn) {
+ loginBtn.disabled = false;
+ loginBtn.textContent = 'Connect to Bluesky';
+ }
+ if (authMessage) {
+ authMessage.textContent = message;
+ authMessage.classList.add('error');
+ }
+ }
+}
diff --git a/js/userState.js b/js/userState.js
new file mode 100644
index 0000000..ac14896
--- /dev/null
+++ b/js/userState.js
@@ -0,0 +1,13 @@
+import { state } from './state.js';
+import { resetState, loadState } from './statePersistence.js';
+
+// Helper to set current user and load their state
+export function setUser(did) {
+ // Clear current state first
+ resetState();
+ // Set new user info
+ state.did = did;
+ state.authenticated = true;
+ // Load state for this user
+ loadState();
+}
diff --git a/js/utils.js b/js/utils.js
new file mode 100644
index 0000000..22022cd
--- /dev/null
+++ b/js/utils.js
@@ -0,0 +1,11 @@
+export function debounce(func, wait) {
+ let timeout;
+ return function executedFunction(...args) {
+ const later = () => {
+ clearTimeout(timeout);
+ func(...args);
+ };
+ clearTimeout(timeout);
+ timeout = setTimeout(later, wait);
+ };
+}
diff --git a/js/utils/categoryUtils.js b/js/utils/categoryUtils.js
new file mode 100644
index 0000000..87d7e52
--- /dev/null
+++ b/js/utils/categoryUtils.js
@@ -0,0 +1,90 @@
+import { state } from '../state.js';
+import { getWeightThreshold } from './weightManager.js';
+import { isKeywordActive } from '../handlers/keywordHandlers.js';
+
+export function getDisplayName(category) {
+ return state.displayConfig.displayNames[category] || category;
+}
+
+export function getCategoryState(category) {
+ const keywords = getAllKeywordsForCategory(category);
+ const activeCount = keywords.filter(k => isKeywordActive(k)).length;
+
+ if (activeCount === 0) return 'none';
+ if (activeCount === keywords.length) return 'all';
+ return 'partial';
+}
+
+export function getCheckboxClass(state) {
+ switch (state) {
+ case 'all': return 'checked';
+ case 'partial': return 'partial';
+ default: return '';
+ }
+}
+
+export function extractKeywordsFromCategory(category, categoryData) {
+ if (!categoryData?.[category]?.keywords) return [];
+
+ const categoryInfo = categoryData[category];
+ return Object.entries(categoryInfo.keywords).map(([keyword, data]) => ({
+ keyword,
+ weight: data.weight || 0,
+ category
+ }));
+}
+
+export function extractKeywordsFromCombinedSources(combinedSources, keywordGroups) {
+ return combinedSources.flatMap(source => {
+ const categoryData = keywordGroups[source];
+ if (!categoryData?.[source]) return [];
+ return extractKeywordsFromCategory(source, categoryData);
+ });
+}
+
+export function getAllKeywordsForCategory(category, sortByWeight = false) {
+ let keywords = [];
+
+ // Check if this is a combined category
+ const combinedSources = state.displayConfig.combinedCategories?.[category];
+ if (combinedSources) {
+ keywords = extractKeywordsFromCombinedSources(combinedSources, state.keywordGroups);
+ } else {
+ // Regular category
+ const categoryData = state.keywordGroups[category];
+ keywords = extractKeywordsFromCategory(category, categoryData);
+ }
+
+ // Sort and filter by weight if requested
+ if (sortByWeight) {
+ keywords.sort((a, b) => b.weight - a.weight);
+
+ if (state.filterLevel !== undefined) {
+ const before = keywords.length;
+ keywords = filterByWeight(keywords, category);
+ logFilterResults(category, keywords, before);
+ }
+ }
+
+ // Return just the keyword strings
+ return keywords.map(k => k.keyword);
+}
+
+function filterByWeight(keywords, category) {
+ return keywords.filter(k => {
+ const threshold = getWeightThreshold(state.filterLevel);
+ const passes = k.weight >= threshold;
+ if (passes) {
+ console.debug(`Including ${k.keyword} (weight: ${k.weight}) from ${k.category}`);
+ }
+ return passes;
+ });
+}
+
+function logFilterResults(category, keywords, beforeCount) {
+ console.debug(`Category ${category}:
+ - Filter level: ${state.filterLevel}
+ - Threshold: ${getWeightThreshold(state.filterLevel)}
+ - Filtered from ${beforeCount} to ${keywords.length} keywords
+ - Remaining keywords: ${keywords.map(k => `${k.keyword} (${k.weight})`).join(', ')}`);
+}
diff --git a/js/utils/keywordFilters.js b/js/utils/keywordFilters.js
new file mode 100644
index 0000000..c5e10e4
--- /dev/null
+++ b/js/utils/keywordFilters.js
@@ -0,0 +1,85 @@
+import { state } from '../state.js';
+import { getAllKeywordsForCategory } from './categoryUtils.js';
+import { isKeywordActive } from '../handlers/keywordHandlers.js';
+
+export function filterKeywordGroups(isRightPanel = false) {
+ const filtered = {};
+ const searchTerm = state.searchTerm.toLowerCase();
+ const categoriesToShow = state.selectedCategories.size > 0
+ ? state.selectedCategories
+ : new Set(Object.keys(state.keywordGroups));
+
+ if (isRightPanel) {
+ filterRightPanel(filtered, categoriesToShow, searchTerm);
+ } else {
+ filterLeftPanel(filtered, categoriesToShow, searchTerm);
+ }
+
+ return filtered;
+}
+
+function filterRightPanel(filtered, categoriesToShow, searchTerm) {
+ Object.entries(state.keywordGroups).forEach(([category, categoryData]) => {
+ if (!categoriesToShow.has(category)) return;
+
+ const categoryInfo = categoryData[category];
+ if (!categoryInfo?.keywords) return;
+
+ const keywords = Object.keys(categoryInfo.keywords);
+ const filteredKeywords = filterKeywords(keywords, category, searchTerm);
+
+ if (filteredKeywords.length > 0) {
+ filtered[category] = filteredKeywords;
+ }
+ });
+}
+
+function filterLeftPanel(filtered, categoriesToShow, searchTerm) {
+ // Handle regular categories
+ Object.entries(state.keywordGroups).forEach(([category, categoryData]) => {
+ const isCombined = Object.values(state.displayConfig.combinedCategories || {})
+ .some(sources => sources.includes(category));
+ if (isCombined) return;
+
+ if (!categoriesToShow.has(category)) return;
+
+ const categoryInfo = categoryData[category];
+ if (!categoryInfo?.keywords) return;
+
+ const keywords = Object.keys(categoryInfo.keywords);
+ const filteredKeywords = filterKeywords(keywords, category, searchTerm);
+
+ if (filteredKeywords.length > 0) {
+ filtered[category] = filteredKeywords;
+ }
+ });
+
+ // Handle combined categories
+ Object.entries(state.displayConfig.combinedCategories || {}).forEach(([combinedCategory, sourceCategories]) => {
+ const allKeywords = sourceCategories.flatMap(category => {
+ const categoryData = state.keywordGroups[category];
+ if (!categoryData?.[category]?.keywords) return [];
+ return Object.keys(categoryData[category].keywords);
+ });
+
+ const filteredKeywords = filterKeywords(allKeywords, combinedCategory, searchTerm);
+
+ if (filteredKeywords.length > 0) {
+ filtered[combinedCategory] = filteredKeywords;
+ }
+ });
+}
+
+function filterKeywords(keywords, category, searchTerm) {
+ return keywords.filter(keyword => {
+ const matchesSearch = !searchTerm ||
+ keyword.toLowerCase().includes(searchTerm) ||
+ category.toLowerCase().includes(searchTerm);
+
+ const matchesFilter = state.filterMode === 'all' ||
+ (state.filterMode === 'active' && isKeywordActive(keyword)) ||
+ (state.filterMode === 'disabled' && !isKeywordActive(keyword));
+
+ return matchesSearch && matchesFilter;
+ });
+}
diff --git a/js/utils/notifications.js b/js/utils/notifications.js
new file mode 100644
index 0000000..ba8f8d2
--- /dev/null
+++ b/js/utils/notifications.js
@@ -0,0 +1,22 @@
+export function showNotification(message, type = 'success') {
+ // Remove any existing notifications
+ const existingNotification = document.querySelector('.notification');
+ if (existingNotification) {
+ existingNotification.remove();
+ }
+
+ // Create notification element
+ const notification = document.createElement('div');
+ notification.className = `notification ${type}`;
+ notification.textContent = message;
+ document.body.appendChild(notification);
+
+ // Trigger animation
+ setTimeout(() => notification.classList.add('show'), 10);
+
+ // Remove notification after delay
+ setTimeout(() => {
+ notification.classList.add('hide');
+ setTimeout(() => notification.remove(), 300);
+ }, 3000);
+}
diff --git a/js/utils/weightManager.js b/js/utils/weightManager.js
new file mode 100644
index 0000000..850fb11
--- /dev/null
+++ b/js/utils/weightManager.js
@@ -0,0 +1,22 @@
+import { state } from '../state.js';
+
+function getWeightThreshold(filterLevel) {
+ // Get filter level from state if not provided
+ const level = filterLevel ?? state?.filterLevel ?? 0;
+
+ // Map levels to thresholds based on keyword weight of 3
+ switch(level) {
+ case 0: // Minimal (most restrictive)
+ return 3;
+ case 1: // Moderate
+ return 2;
+ case 2: // Extensive
+ return 1;
+ case 3: // Complete (most inclusive)
+ return 0;
+ default:
+ return 3; // Default to most restrictive
+ }
+}
+
+export { getWeightThreshold };
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..750264e
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,5066 @@
+{
+ "name": "mutesky",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "mutesky",
+ "version": "1.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/api": "^0.13.18",
+ "@atproto/oauth-client-browser": "^0.3.2"
+ },
+ "devDependencies": {
+ "buffer": "6.0.3",
+ "copy-webpack-plugin": "11.0.0",
+ "crypto-browserify": "3.12.1",
+ "http-server": "^14.1.1",
+ "process": "0.11.10",
+ "stream-browserify": "3.0.0",
+ "util": "0.12.5",
+ "webpack": "5.96.1",
+ "webpack-cli": "5.1.4",
+ "webpack-dev-server": "^4.15.2"
+ }
+ },
+ "node_modules/@atproto-labs/did-resolver": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.1.5.tgz",
+ "integrity": "sha512-uoCb+P0N4du5NiZt6ohVEbSDdijXBJlQwSlWLHX0rUDtEVV+g3aEGe7jUW94lWpqQmRlQ5xcyd9owleMibNxZw==",
+ "dependencies": {
+ "@atproto-labs/fetch": "0.1.1",
+ "@atproto-labs/pipe": "0.1.0",
+ "@atproto-labs/simple-store": "0.1.1",
+ "@atproto-labs/simple-store-memory": "0.1.1",
+ "@atproto/did": "0.1.3",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto-labs/fetch": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.1.1.tgz",
+ "integrity": "sha512-X1zO1MDoJzEurbWXMAe1H8EZ995Xam/aXdxhGVrXmOMyPDuvBa1oxwh/kQNZRCKcMQUbiwkk+Jfq6ZkTuvGbww==",
+ "dependencies": {
+ "@atproto-labs/pipe": "0.1.0"
+ },
+ "optionalDependencies": {
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto-labs/handle-resolver": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.1.4.tgz",
+ "integrity": "sha512-tnGUD2mQ6c8xHs3eeVJgwYqM3FHoTZZbOcOGKqO1A5cuIG+gElwEhpWwpwX5LI7FF4J8eS9BOHLl3NFS7Q8QXg==",
+ "dependencies": {
+ "@atproto-labs/simple-store": "0.1.1",
+ "@atproto-labs/simple-store-memory": "0.1.1",
+ "@atproto/did": "0.1.3",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto-labs/identity-resolver": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.1.6.tgz",
+ "integrity": "sha512-kq1yhpImGG1IUE8QEKj2IjSfNrkG2VailZRuiFLYdcszDEBDzr9HN3ElV42ebxhofuSFgKOCrYWJIUiLuXo6Uw==",
+ "dependencies": {
+ "@atproto-labs/did-resolver": "0.1.5",
+ "@atproto-labs/handle-resolver": "0.1.4",
+ "@atproto/syntax": "0.3.1"
+ }
+ },
+ "node_modules/@atproto-labs/pipe": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.1.0.tgz",
+ "integrity": "sha512-ghOqHFyJlQVFPESzlVHjKroP0tPzbmG5Jms0dNI9yLDEfL8xp4OFPWLX4f6T8mRq69wWs4nIDM3sSsFbFqLa1w=="
+ },
+ "node_modules/@atproto-labs/simple-store": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz",
+ "integrity": "sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg=="
+ },
+ "node_modules/@atproto-labs/simple-store-memory": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store-memory/-/simple-store-memory-0.1.1.tgz",
+ "integrity": "sha512-PCRqhnZ8NBNBvLku53O56T0lsVOtclfIrQU/rwLCc4+p45/SBPrRYNBi6YFq5rxZbK6Njos9MCmILV/KLQxrWA==",
+ "dependencies": {
+ "@atproto-labs/simple-store": "0.1.1",
+ "lru-cache": "^10.2.0"
+ }
+ },
+ "node_modules/@atproto/api": {
+ "version": "0.13.18",
+ "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.13.18.tgz",
+ "integrity": "sha512-rrl5HhzGYWZ7fiC965TPBUOVItq9M4dxMb6qz8IvAVQliSkrJrKc7UD0QWL89QiiXaOBuX8w+4i5r4wrfBGddg==",
+ "dependencies": {
+ "@atproto/common-web": "^0.3.1",
+ "@atproto/lexicon": "^0.4.3",
+ "@atproto/syntax": "^0.3.1",
+ "@atproto/xrpc": "^0.6.4",
+ "await-lock": "^2.2.2",
+ "multiformats": "^9.9.0",
+ "tlds": "^1.234.0",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto/common-web": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.3.1.tgz",
+ "integrity": "sha512-N7wiTnus5vAr+lT//0y8m/FaHHLJ9LpGuEwkwDAeV3LCiPif4m/FS8x/QOYrx1PdZQwKso95RAPzCGWQBH5j6Q==",
+ "dependencies": {
+ "graphemer": "^1.4.0",
+ "multiformats": "^9.9.0",
+ "uint8arrays": "3.0.0",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto/did": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.1.3.tgz",
+ "integrity": "sha512-ULD8Gw/KRRwLFZ2Z2L4DjmdOMrg8IYYlcjdSc+GQ2/QJSVnD2zaJJVTLd3vls121wGt/583rNaiZTT2DpBze4w==",
+ "dependencies": {
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto/jwk": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.1.1.tgz",
+ "integrity": "sha512-6h/bj1APUk7QcV9t/oA6+9DB5NZx9SZru9x+/pV5oHFI9Xz4ZuM5+dq1PfsJV54pZyqdnZ6W6M717cxoC7q7og==",
+ "dependencies": {
+ "multiformats": "^9.9.0",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto/jwk-jose": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.1.2.tgz",
+ "integrity": "sha512-lDwc/6lLn2aZ/JpyyggyjLFsJPMntrVzryyGUx5aNpuTS8SIuc4Ky0REhxqfLopQXJJZCuRRjagHG3uP05/moQ==",
+ "dependencies": {
+ "@atproto/jwk": "0.1.1",
+ "jose": "^5.2.0"
+ }
+ },
+ "node_modules/@atproto/jwk-webcrypto": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.1.2.tgz",
+ "integrity": "sha512-vTBUbUZXh0GI+6KJiPGukmI4BQEHFAij8fJJ4WnReF/hefAs3ISZtrWZHGBebz+q2EcExYlnhhlmxvDzV7veGw==",
+ "dependencies": {
+ "@atproto/jwk": "0.1.1",
+ "@atproto/jwk-jose": "0.1.2"
+ }
+ },
+ "node_modules/@atproto/lexicon": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.4.3.tgz",
+ "integrity": "sha512-lFVZXe1S1pJP0dcxvJuHP3r/a+EAIBwwU7jUK+r8iLhIja+ml6NmYv8KeFHmIJATh03spEQ9s02duDmFVdCoXg==",
+ "dependencies": {
+ "@atproto/common-web": "^0.3.1",
+ "@atproto/syntax": "^0.3.1",
+ "iso-datestring-validator": "^2.2.2",
+ "multiformats": "^9.9.0",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto/oauth-client": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.3.2.tgz",
+ "integrity": "sha512-/HUlv5dnR1am4BQlVYSuevGf4mKJ5RMkElnum8lbwRDewKyzqHwdtJWeNcfcPFtDhUKg0U2pWfRv8ZZd6kk9dQ==",
+ "dependencies": {
+ "@atproto-labs/did-resolver": "0.1.5",
+ "@atproto-labs/fetch": "0.1.1",
+ "@atproto-labs/handle-resolver": "0.1.4",
+ "@atproto-labs/identity-resolver": "0.1.6",
+ "@atproto-labs/simple-store": "0.1.1",
+ "@atproto-labs/simple-store-memory": "0.1.1",
+ "@atproto/did": "0.1.3",
+ "@atproto/jwk": "0.1.1",
+ "@atproto/oauth-types": "0.2.1",
+ "@atproto/xrpc": "0.6.4",
+ "multiformats": "^9.9.0",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto/oauth-client-browser": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/@atproto/oauth-client-browser/-/oauth-client-browser-0.3.2.tgz",
+ "integrity": "sha512-Nt9tPxeJTwsX8i6du0dSMonymHHpOVnt67bfA49LpwAS39nNd9zY6yjOrqj0suRwFhoGpvO2e+I35lqe30L+Ig==",
+ "dependencies": {
+ "@atproto-labs/did-resolver": "0.1.5",
+ "@atproto-labs/handle-resolver": "0.1.4",
+ "@atproto-labs/simple-store": "0.1.1",
+ "@atproto/did": "0.1.3",
+ "@atproto/jwk": "0.1.1",
+ "@atproto/jwk-webcrypto": "0.1.2",
+ "@atproto/oauth-client": "0.3.2",
+ "@atproto/oauth-types": "0.2.1"
+ }
+ },
+ "node_modules/@atproto/oauth-types": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.2.1.tgz",
+ "integrity": "sha512-hDisUXzcq5KU1HMuCYZ8Kcz7BePl7V11bFjjgZvND3mdSphiyBpJ8MCNn3QzAa6cXpFo0w9PDcYMAlCCRZHdVw==",
+ "dependencies": {
+ "@atproto/jwk": "0.1.1",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto/syntax": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.3.1.tgz",
+ "integrity": "sha512-fzW0Mg1QUOVCWUD3RgEsDt6d1OZ6DdFmbKcDdbzUfh0t4rhtRAC05KbZYmxuMPWDAiJ4BbbQ5dkAc/mNypMXkw=="
+ },
+ "node_modules/@atproto/xrpc": {
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.6.4.tgz",
+ "integrity": "sha512-9ZAJ8nsXTqC4XFyS0E1Wlg7bAvonhXQNQ3Ocs1L1LIwFLXvsw/4fNpIHXxvXvqTCVeyHLbImOnE9UiO1c/qIYA==",
+ "dependencies": {
+ "@atproto/lexicon": "^0.4.3",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@discoveryjs/json-ext": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
+ "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==",
+ "dev": true,
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
+ "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/set-array": "^1.2.1",
+ "@jridgewell/sourcemap-codec": "^1.4.10",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/set-array": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
+ "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/source-map": {
+ "version": "0.3.6",
+ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz",
+ "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.25"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
+ "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
+ "dev": true
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.25",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
+ "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@leichtgewicht/ip-codec": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
+ "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
+ "dev": true
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@types/body-parser": {
+ "version": "1.19.5",
+ "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
+ "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==",
+ "dev": true,
+ "dependencies": {
+ "@types/connect": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/bonjour": {
+ "version": "3.5.13",
+ "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz",
+ "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/connect": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
+ "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/connect-history-api-fallback": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz",
+ "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==",
+ "dev": true,
+ "dependencies": {
+ "@types/express-serve-static-core": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/eslint": {
+ "version": "9.6.1",
+ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
+ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
+ "dev": true,
+ "dependencies": {
+ "@types/estree": "*",
+ "@types/json-schema": "*"
+ }
+ },
+ "node_modules/@types/eslint-scope": {
+ "version": "3.7.7",
+ "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz",
+ "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==",
+ "dev": true,
+ "dependencies": {
+ "@types/eslint": "*",
+ "@types/estree": "*"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
+ "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
+ "dev": true
+ },
+ "node_modules/@types/express": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz",
+ "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/body-parser": "*",
+ "@types/express-serve-static-core": "^4.17.33",
+ "@types/qs": "*",
+ "@types/serve-static": "*"
+ }
+ },
+ "node_modules/@types/express-serve-static-core": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.2.tgz",
+ "integrity": "sha512-vluaspfvWEtE4vcSDlKRNer52DvOGrB2xv6diXy6UKyKW0lqZiWHGNApSyxOv+8DE5Z27IzVvE7hNkxg7EXIcg==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*",
+ "@types/qs": "*",
+ "@types/range-parser": "*",
+ "@types/send": "*"
+ }
+ },
+ "node_modules/@types/express/node_modules/@types/express-serve-static-core": {
+ "version": "4.19.6",
+ "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz",
+ "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*",
+ "@types/qs": "*",
+ "@types/range-parser": "*",
+ "@types/send": "*"
+ }
+ },
+ "node_modules/@types/http-errors": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz",
+ "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==",
+ "dev": true
+ },
+ "node_modules/@types/http-proxy": {
+ "version": "1.17.15",
+ "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.15.tgz",
+ "integrity": "sha512-25g5atgiVNTIv0LBDTg1H74Hvayx0ajtJPLLcYE3whFv75J0pWNtOBzaXJQgDTmrX1bx5U9YC2w/n65BN1HwRQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true
+ },
+ "node_modules/@types/mime": {
+ "version": "1.3.5",
+ "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
+ "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
+ "dev": true
+ },
+ "node_modules/@types/node": {
+ "version": "22.10.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.1.tgz",
+ "integrity": "sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==",
+ "dev": true,
+ "dependencies": {
+ "undici-types": "~6.20.0"
+ }
+ },
+ "node_modules/@types/node-forge": {
+ "version": "1.3.11",
+ "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.11.tgz",
+ "integrity": "sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/qs": {
+ "version": "6.9.17",
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.17.tgz",
+ "integrity": "sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ==",
+ "dev": true
+ },
+ "node_modules/@types/range-parser": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
+ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
+ "dev": true
+ },
+ "node_modules/@types/retry": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
+ "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==",
+ "dev": true
+ },
+ "node_modules/@types/send": {
+ "version": "0.17.4",
+ "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz",
+ "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==",
+ "dev": true,
+ "dependencies": {
+ "@types/mime": "^1",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/serve-index": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz",
+ "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==",
+ "dev": true,
+ "dependencies": {
+ "@types/express": "*"
+ }
+ },
+ "node_modules/@types/serve-static": {
+ "version": "1.15.7",
+ "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz",
+ "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==",
+ "dev": true,
+ "dependencies": {
+ "@types/http-errors": "*",
+ "@types/node": "*",
+ "@types/send": "*"
+ }
+ },
+ "node_modules/@types/sockjs": {
+ "version": "0.3.36",
+ "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz",
+ "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/ws": {
+ "version": "8.5.13",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz",
+ "integrity": "sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@webassemblyjs/ast": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
+ "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/helper-numbers": "1.13.2",
+ "@webassemblyjs/helper-wasm-bytecode": "1.13.2"
+ }
+ },
+ "node_modules/@webassemblyjs/floating-point-hex-parser": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz",
+ "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/helper-api-error": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz",
+ "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/helper-buffer": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz",
+ "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/helper-numbers": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz",
+ "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/floating-point-hex-parser": "1.13.2",
+ "@webassemblyjs/helper-api-error": "1.13.2",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@webassemblyjs/helper-wasm-bytecode": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz",
+ "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/helper-wasm-section": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz",
+ "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.14.1",
+ "@webassemblyjs/helper-buffer": "1.14.1",
+ "@webassemblyjs/helper-wasm-bytecode": "1.13.2",
+ "@webassemblyjs/wasm-gen": "1.14.1"
+ }
+ },
+ "node_modules/@webassemblyjs/ieee754": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz",
+ "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==",
+ "dev": true,
+ "dependencies": {
+ "@xtuc/ieee754": "^1.2.0"
+ }
+ },
+ "node_modules/@webassemblyjs/leb128": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz",
+ "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==",
+ "dev": true,
+ "dependencies": {
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@webassemblyjs/utf8": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz",
+ "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==",
+ "dev": true
+ },
+ "node_modules/@webassemblyjs/wasm-edit": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz",
+ "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.14.1",
+ "@webassemblyjs/helper-buffer": "1.14.1",
+ "@webassemblyjs/helper-wasm-bytecode": "1.13.2",
+ "@webassemblyjs/helper-wasm-section": "1.14.1",
+ "@webassemblyjs/wasm-gen": "1.14.1",
+ "@webassemblyjs/wasm-opt": "1.14.1",
+ "@webassemblyjs/wasm-parser": "1.14.1",
+ "@webassemblyjs/wast-printer": "1.14.1"
+ }
+ },
+ "node_modules/@webassemblyjs/wasm-gen": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz",
+ "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.14.1",
+ "@webassemblyjs/helper-wasm-bytecode": "1.13.2",
+ "@webassemblyjs/ieee754": "1.13.2",
+ "@webassemblyjs/leb128": "1.13.2",
+ "@webassemblyjs/utf8": "1.13.2"
+ }
+ },
+ "node_modules/@webassemblyjs/wasm-opt": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz",
+ "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.14.1",
+ "@webassemblyjs/helper-buffer": "1.14.1",
+ "@webassemblyjs/wasm-gen": "1.14.1",
+ "@webassemblyjs/wasm-parser": "1.14.1"
+ }
+ },
+ "node_modules/@webassemblyjs/wasm-parser": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz",
+ "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.14.1",
+ "@webassemblyjs/helper-api-error": "1.13.2",
+ "@webassemblyjs/helper-wasm-bytecode": "1.13.2",
+ "@webassemblyjs/ieee754": "1.13.2",
+ "@webassemblyjs/leb128": "1.13.2",
+ "@webassemblyjs/utf8": "1.13.2"
+ }
+ },
+ "node_modules/@webassemblyjs/wast-printer": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz",
+ "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==",
+ "dev": true,
+ "dependencies": {
+ "@webassemblyjs/ast": "1.14.1",
+ "@xtuc/long": "4.2.2"
+ }
+ },
+ "node_modules/@webpack-cli/configtest": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-2.1.1.tgz",
+ "integrity": "sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw==",
+ "dev": true,
+ "engines": {
+ "node": ">=14.15.0"
+ },
+ "peerDependencies": {
+ "webpack": "5.x.x",
+ "webpack-cli": "5.x.x"
+ }
+ },
+ "node_modules/@webpack-cli/info": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-2.0.2.tgz",
+ "integrity": "sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A==",
+ "dev": true,
+ "engines": {
+ "node": ">=14.15.0"
+ },
+ "peerDependencies": {
+ "webpack": "5.x.x",
+ "webpack-cli": "5.x.x"
+ }
+ },
+ "node_modules/@webpack-cli/serve": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-2.0.5.tgz",
+ "integrity": "sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=14.15.0"
+ },
+ "peerDependencies": {
+ "webpack": "5.x.x",
+ "webpack-cli": "5.x.x"
+ },
+ "peerDependenciesMeta": {
+ "webpack-dev-server": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@xtuc/ieee754": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
+ "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==",
+ "dev": true
+ },
+ "node_modules/@xtuc/long": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
+ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
+ "dev": true
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "dev": true,
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/accepts/node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.14.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
+ "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
+ "dev": true,
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.17.1",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
+ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "dev": true,
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
+ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
+ "dev": true,
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ajv-keywords": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
+ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
+ "dev": true,
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3"
+ },
+ "peerDependencies": {
+ "ajv": "^8.8.2"
+ }
+ },
+ "node_modules/ansi-html-community": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz",
+ "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==",
+ "dev": true,
+ "engines": [
+ "node >= 0.8.0"
+ ],
+ "bin": {
+ "ansi-html": "bin/ansi-html"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "dev": true
+ },
+ "node_modules/asn1.js": {
+ "version": "4.10.1",
+ "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz",
+ "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==",
+ "dev": true,
+ "dependencies": {
+ "bn.js": "^4.0.0",
+ "inherits": "^2.0.1",
+ "minimalistic-assert": "^1.0.0"
+ }
+ },
+ "node_modules/asn1.js/node_modules/bn.js": {
+ "version": "4.12.1",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz",
+ "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==",
+ "dev": true
+ },
+ "node_modules/async": {
+ "version": "2.6.4",
+ "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz",
+ "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==",
+ "dev": true,
+ "dependencies": {
+ "lodash": "^4.17.14"
+ }
+ },
+ "node_modules/available-typed-arrays": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
+ "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
+ "dev": true,
+ "dependencies": {
+ "possible-typed-array-names": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/await-lock": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz",
+ "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/basic-auth": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
+ "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "5.1.2"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/basic-auth/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "dev": true
+ },
+ "node_modules/batch": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz",
+ "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==",
+ "dev": true
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/bn.js": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz",
+ "integrity": "sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==",
+ "dev": true
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.3",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
+ "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
+ "dev": true,
+ "dependencies": {
+ "bytes": "3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "http-errors": "2.0.0",
+ "iconv-lite": "0.4.24",
+ "on-finished": "2.4.1",
+ "qs": "6.13.0",
+ "raw-body": "2.5.2",
+ "type-is": "~1.6.18",
+ "unpipe": "1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/body-parser/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/body-parser/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ },
+ "node_modules/body-parser/node_modules/qs": {
+ "version": "6.13.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
+ "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
+ "dev": true,
+ "dependencies": {
+ "side-channel": "^1.0.6"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/bonjour-service": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz",
+ "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==",
+ "dev": true,
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "multicast-dns": "^7.2.5"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "dev": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/brorand": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
+ "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==",
+ "dev": true
+ },
+ "node_modules/browserify-aes": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz",
+ "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==",
+ "dev": true,
+ "dependencies": {
+ "buffer-xor": "^1.0.3",
+ "cipher-base": "^1.0.0",
+ "create-hash": "^1.1.0",
+ "evp_bytestokey": "^1.0.3",
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/browserify-cipher": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz",
+ "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==",
+ "dev": true,
+ "dependencies": {
+ "browserify-aes": "^1.0.4",
+ "browserify-des": "^1.0.0",
+ "evp_bytestokey": "^1.0.0"
+ }
+ },
+ "node_modules/browserify-des": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz",
+ "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==",
+ "dev": true,
+ "dependencies": {
+ "cipher-base": "^1.0.1",
+ "des.js": "^1.0.0",
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.1.2"
+ }
+ },
+ "node_modules/browserify-rsa": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.1.tgz",
+ "integrity": "sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ==",
+ "dev": true,
+ "dependencies": {
+ "bn.js": "^5.2.1",
+ "randombytes": "^2.1.0",
+ "safe-buffer": "^5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/browserify-sign": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.3.tgz",
+ "integrity": "sha512-JWCZW6SKhfhjJxO8Tyiiy+XYB7cqd2S5/+WeYHsKdNKFlCBhKbblba1A/HN/90YwtxKc8tCErjffZl++UNmGiw==",
+ "dev": true,
+ "dependencies": {
+ "bn.js": "^5.2.1",
+ "browserify-rsa": "^4.1.0",
+ "create-hash": "^1.2.0",
+ "create-hmac": "^1.1.7",
+ "elliptic": "^6.5.5",
+ "hash-base": "~3.0",
+ "inherits": "^2.0.4",
+ "parse-asn1": "^5.1.7",
+ "readable-stream": "^2.3.8",
+ "safe-buffer": "^5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.12"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.24.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz",
+ "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "caniuse-lite": "^1.0.30001669",
+ "electron-to-chromium": "^1.5.41",
+ "node-releases": "^2.0.18",
+ "update-browserslist-db": "^1.1.1"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.2.1"
+ }
+ },
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "dev": true
+ },
+ "node_modules/buffer-xor": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz",
+ "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==",
+ "dev": true
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
+ "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==",
+ "dev": true,
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "set-function-length": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001685",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001685.tgz",
+ "integrity": "sha512-e/kJN1EMyHQzgcMEEgoo+YTCO1NGCmIYHk5Qk8jT6AazWemS5QFKJ5ShCJlH3GZrNIdZofcNCEwZqbMjjKzmnA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ]
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/chrome-trace-event": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
+ "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.0"
+ }
+ },
+ "node_modules/cipher-base": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.6.tgz",
+ "integrity": "sha512-3Ek9H3X6pj5TgenXYtNWdaBon1tgYCaebd+XPg0keyjEbEfkD4KkmAxkQ/i1vYvxdcT5nscLBfq9VJRmCBcFSw==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.4",
+ "safe-buffer": "^5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/clone-deep": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz",
+ "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==",
+ "dev": true,
+ "dependencies": {
+ "is-plain-object": "^2.0.4",
+ "kind-of": "^6.0.2",
+ "shallow-clone": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/colorette": {
+ "version": "2.0.20",
+ "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
+ "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
+ "dev": true
+ },
+ "node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true
+ },
+ "node_modules/compressible": {
+ "version": "2.0.18",
+ "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
+ "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
+ "dev": true,
+ "dependencies": {
+ "mime-db": ">= 1.43.0 < 2"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/compression": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.5.tgz",
+ "integrity": "sha512-bQJ0YRck5ak3LgtnpKkiabX5pNF7tMUh1BSy2ZBOTh0Dim0BUu6aPPwByIns6/A5Prh8PufSPerMDUklpzes2Q==",
+ "dev": true,
+ "dependencies": {
+ "bytes": "3.1.2",
+ "compressible": "~2.0.18",
+ "debug": "2.6.9",
+ "negotiator": "~0.6.4",
+ "on-headers": "~1.0.2",
+ "safe-buffer": "5.2.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/compression/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/compression/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true
+ },
+ "node_modules/connect-history-api-fallback": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz",
+ "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
+ "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "dev": true
+ },
+ "node_modules/copy-webpack-plugin": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz",
+ "integrity": "sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==",
+ "dev": true,
+ "dependencies": {
+ "fast-glob": "^3.2.11",
+ "glob-parent": "^6.0.1",
+ "globby": "^13.1.1",
+ "normalize-path": "^3.0.0",
+ "schema-utils": "^4.0.0",
+ "serialize-javascript": "^6.0.0"
+ },
+ "engines": {
+ "node": ">= 14.15.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^5.1.0"
+ }
+ },
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "dev": true
+ },
+ "node_modules/corser": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz",
+ "integrity": "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/create-ecdh": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz",
+ "integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==",
+ "dev": true,
+ "dependencies": {
+ "bn.js": "^4.1.0",
+ "elliptic": "^6.5.3"
+ }
+ },
+ "node_modules/create-ecdh/node_modules/bn.js": {
+ "version": "4.12.1",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz",
+ "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==",
+ "dev": true
+ },
+ "node_modules/create-hash": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
+ "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==",
+ "dev": true,
+ "dependencies": {
+ "cipher-base": "^1.0.1",
+ "inherits": "^2.0.1",
+ "md5.js": "^1.3.4",
+ "ripemd160": "^2.0.1",
+ "sha.js": "^2.4.0"
+ }
+ },
+ "node_modules/create-hmac": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz",
+ "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==",
+ "dev": true,
+ "dependencies": {
+ "cipher-base": "^1.0.3",
+ "create-hash": "^1.1.0",
+ "inherits": "^2.0.1",
+ "ripemd160": "^2.0.0",
+ "safe-buffer": "^5.0.1",
+ "sha.js": "^2.4.8"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/crypto-browserify": {
+ "version": "3.12.1",
+ "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.1.tgz",
+ "integrity": "sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==",
+ "dev": true,
+ "dependencies": {
+ "browserify-cipher": "^1.0.1",
+ "browserify-sign": "^4.2.3",
+ "create-ecdh": "^4.0.4",
+ "create-hash": "^1.2.0",
+ "create-hmac": "^1.1.7",
+ "diffie-hellman": "^5.0.3",
+ "hash-base": "~3.0.4",
+ "inherits": "^2.0.4",
+ "pbkdf2": "^3.1.2",
+ "public-encrypt": "^4.0.3",
+ "randombytes": "^2.1.0",
+ "randomfill": "^1.0.4"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "dev": true,
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/default-gateway": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz",
+ "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==",
+ "dev": true,
+ "dependencies": {
+ "execa": "^5.0.0"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "dev": true,
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-lazy-prop": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
+ "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/des.js": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz",
+ "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.1",
+ "minimalistic-assert": "^1.0.0"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/detect-node": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
+ "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
+ "dev": true
+ },
+ "node_modules/diffie-hellman": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz",
+ "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==",
+ "dev": true,
+ "dependencies": {
+ "bn.js": "^4.1.0",
+ "miller-rabin": "^4.0.0",
+ "randombytes": "^2.0.0"
+ }
+ },
+ "node_modules/diffie-hellman/node_modules/bn.js": {
+ "version": "4.12.1",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz",
+ "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==",
+ "dev": true
+ },
+ "node_modules/dir-glob": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
+ "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
+ "dev": true,
+ "dependencies": {
+ "path-type": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dns-packet": {
+ "version": "5.6.1",
+ "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
+ "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==",
+ "dev": true,
+ "dependencies": {
+ "@leichtgewicht/ip-codec": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "dev": true
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.67",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.67.tgz",
+ "integrity": "sha512-nz88NNBsD7kQSAGGJyp8hS6xSPtWwqNogA0mjtc2nUYeEf3nURK9qpV18TuBdDmEDgVWotS8Wkzf+V52dSQ/LQ==",
+ "dev": true
+ },
+ "node_modules/elliptic": {
+ "version": "6.6.1",
+ "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz",
+ "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==",
+ "dev": true,
+ "dependencies": {
+ "bn.js": "^4.11.9",
+ "brorand": "^1.1.0",
+ "hash.js": "^1.0.0",
+ "hmac-drbg": "^1.0.1",
+ "inherits": "^2.0.4",
+ "minimalistic-assert": "^1.0.1",
+ "minimalistic-crypto-utils": "^1.0.1"
+ }
+ },
+ "node_modules/elliptic/node_modules/bn.js": {
+ "version": "4.12.1",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz",
+ "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==",
+ "dev": true
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.17.1",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz",
+ "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/envinfo": {
+ "version": "7.14.0",
+ "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.14.0.tgz",
+ "integrity": "sha512-CO40UI41xDQzhLB1hWyqUKgFhs250pNcGbyGKe1l/e4FSaI/+YE4IMG76GDt0In67WLPACIITC+sOi08x4wIvg==",
+ "dev": true,
+ "bin": {
+ "envinfo": "dist/cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz",
+ "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==",
+ "dev": true,
+ "dependencies": {
+ "get-intrinsic": "^1.2.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz",
+ "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==",
+ "dev": true
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "dev": true
+ },
+ "node_modules/eslint-scope": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
+ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
+ "dev": true,
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^4.1.1"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esrecurse/node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
+ "dev": true
+ },
+ "node_modules/events": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
+ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.8.x"
+ }
+ },
+ "node_modules/evp_bytestokey": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz",
+ "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==",
+ "dev": true,
+ "dependencies": {
+ "md5.js": "^1.3.4",
+ "safe-buffer": "^5.1.1"
+ }
+ },
+ "node_modules/execa": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
+ "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
+ "dev": true,
+ "dependencies": {
+ "cross-spawn": "^7.0.3",
+ "get-stream": "^6.0.0",
+ "human-signals": "^2.1.0",
+ "is-stream": "^2.0.0",
+ "merge-stream": "^2.0.0",
+ "npm-run-path": "^4.0.1",
+ "onetime": "^5.1.2",
+ "signal-exit": "^3.0.3",
+ "strip-final-newline": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/execa?sponsor=1"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.21.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz",
+ "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==",
+ "dev": true,
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "1.20.3",
+ "content-disposition": "0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "0.7.1",
+ "cookie-signature": "1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "1.3.1",
+ "fresh": "0.5.2",
+ "http-errors": "2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "0.1.10",
+ "proxy-addr": "~2.0.7",
+ "qs": "6.13.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "0.19.0",
+ "serve-static": "1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ }
+ },
+ "node_modules/express/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/express/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ },
+ "node_modules/express/node_modules/qs": {
+ "version": "6.13.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
+ "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
+ "dev": true,
+ "dependencies": {
+ "side-channel": "^1.0.6"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
+ "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true
+ },
+ "node_modules/fast-uri": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz",
+ "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==",
+ "dev": true
+ },
+ "node_modules/fastest-levenshtein": {
+ "version": "1.0.16",
+ "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
+ "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 4.9.1"
+ }
+ },
+ "node_modules/fastq": {
+ "version": "1.17.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
+ "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==",
+ "dev": true,
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/faye-websocket": {
+ "version": "0.11.4",
+ "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz",
+ "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==",
+ "dev": true,
+ "dependencies": {
+ "websocket-driver": ">=0.5.1"
+ },
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
+ "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
+ "dev": true,
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "2.0.1",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/finalhandler/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/finalhandler/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ },
+ "node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/flat": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz",
+ "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==",
+ "dev": true,
+ "bin": {
+ "flat": "cli.js"
+ }
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.15.9",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
+ "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/for-each": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
+ "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==",
+ "dev": true,
+ "dependencies": {
+ "is-callable": "^1.1.3"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fs-monkey": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.6.tgz",
+ "integrity": "sha512-b1FMfwetIKymC0eioW7mTywihSQE4oLzQn1dB6rZB5fx/3NpNEdAWeCSMB+60/AeT0TCXsxzAlcYVEFCTAksWg==",
+ "dev": true
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+ "dev": true
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
+ "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==",
+ "dev": true,
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "has-proto": "^1.0.1",
+ "has-symbols": "^1.0.3",
+ "hasown": "^2.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-stream": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
+ "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "deprecated": "Glob versions prior to v9 are no longer supported",
+ "dev": true,
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/glob-to-regexp": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
+ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
+ "dev": true
+ },
+ "node_modules/globby": {
+ "version": "13.2.2",
+ "resolved": "https://registry.npmjs.org/globby/-/globby-13.2.2.tgz",
+ "integrity": "sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==",
+ "dev": true,
+ "dependencies": {
+ "dir-glob": "^3.0.1",
+ "fast-glob": "^3.3.0",
+ "ignore": "^5.2.4",
+ "merge2": "^1.4.1",
+ "slash": "^4.0.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.1.0.tgz",
+ "integrity": "sha512-FQoVQnqcdk4hVM4JN1eromaun4iuS34oStkdlLENLdpULsuQcTyXj8w7ayhuUfPwEYZ1ZOooOTT6fdA9Vmx/RA==",
+ "dev": true,
+ "dependencies": {
+ "get-intrinsic": "^1.2.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true
+ },
+ "node_modules/graphemer": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
+ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="
+ },
+ "node_modules/handle-thing": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz",
+ "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==",
+ "dev": true
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "dev": true,
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-proto": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.1.0.tgz",
+ "integrity": "sha512-QLdzI9IIO1Jg7f9GT1gXpPpXArAn6cS31R1eEZqz08Gc+uQ8/XiqHWt17Fiw+2p6oTTIq5GXEpQkAlA88YRl/Q==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
+ "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "dev": true,
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hash-base": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.5.tgz",
+ "integrity": "sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.4",
+ "safe-buffer": "^5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/hash.js": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
+ "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "minimalistic-assert": "^1.0.1"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dev": true,
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "dev": true,
+ "bin": {
+ "he": "bin/he"
+ }
+ },
+ "node_modules/hmac-drbg": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
+ "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==",
+ "dev": true,
+ "dependencies": {
+ "hash.js": "^1.0.3",
+ "minimalistic-assert": "^1.0.0",
+ "minimalistic-crypto-utils": "^1.0.1"
+ }
+ },
+ "node_modules/hpack.js": {
+ "version": "2.1.6",
+ "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz",
+ "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.1",
+ "obuf": "^1.0.0",
+ "readable-stream": "^2.0.1",
+ "wbuf": "^1.1.0"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
+ "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==",
+ "dev": true,
+ "dependencies": {
+ "whatwg-encoding": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/html-entities": {
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.2.tgz",
+ "integrity": "sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/mdevils"
+ },
+ {
+ "type": "patreon",
+ "url": "https://patreon.com/mdevils"
+ }
+ ]
+ },
+ "node_modules/http-deceiver": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz",
+ "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==",
+ "dev": true
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
+ "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
+ "dev": true,
+ "dependencies": {
+ "depd": "2.0.0",
+ "inherits": "2.0.4",
+ "setprototypeof": "1.2.0",
+ "statuses": "2.0.1",
+ "toidentifier": "1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/http-parser-js": {
+ "version": "0.5.8",
+ "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz",
+ "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==",
+ "dev": true
+ },
+ "node_modules/http-proxy": {
+ "version": "1.18.1",
+ "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
+ "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
+ "dev": true,
+ "dependencies": {
+ "eventemitter3": "^4.0.0",
+ "follow-redirects": "^1.0.0",
+ "requires-port": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/http-proxy-middleware": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz",
+ "integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==",
+ "dev": true,
+ "dependencies": {
+ "@types/http-proxy": "^1.17.8",
+ "http-proxy": "^1.18.1",
+ "is-glob": "^4.0.1",
+ "is-plain-obj": "^3.0.0",
+ "micromatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "@types/express": "^4.17.13"
+ },
+ "peerDependenciesMeta": {
+ "@types/express": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/http-server": {
+ "version": "14.1.1",
+ "resolved": "https://registry.npmjs.org/http-server/-/http-server-14.1.1.tgz",
+ "integrity": "sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==",
+ "dev": true,
+ "dependencies": {
+ "basic-auth": "^2.0.1",
+ "chalk": "^4.1.2",
+ "corser": "^2.0.1",
+ "he": "^1.2.0",
+ "html-encoding-sniffer": "^3.0.0",
+ "http-proxy": "^1.18.1",
+ "mime": "^1.6.0",
+ "minimist": "^1.2.6",
+ "opener": "^1.5.1",
+ "portfinder": "^1.0.28",
+ "secure-compare": "3.0.1",
+ "union": "~0.5.0",
+ "url-join": "^4.0.1"
+ },
+ "bin": {
+ "http-server": "bin/http-server"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/human-signals": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
+ "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=10.17.0"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "dev": true,
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-local": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
+ "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==",
+ "dev": true,
+ "dependencies": {
+ "pkg-dir": "^4.2.0",
+ "resolve-cwd": "^3.0.0"
+ },
+ "bin": {
+ "import-local-fixture": "fixtures/cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
+ "dev": true,
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "dev": true
+ },
+ "node_modules/interpret": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz",
+ "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/ipaddr.js": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
+ "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/is-arguments": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
+ "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "has-tostringtag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-callable": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.15.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz",
+ "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==",
+ "dev": true,
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-docker": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
+ "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
+ "dev": true,
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-generator-function": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz",
+ "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==",
+ "dev": true,
+ "dependencies": {
+ "has-tostringtag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-plain-obj": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz",
+ "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-plain-object": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
+ "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
+ "dev": true,
+ "dependencies": {
+ "isobject": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-stream": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-typed-array": {
+ "version": "1.1.13",
+ "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz",
+ "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==",
+ "dev": true,
+ "dependencies": {
+ "which-typed-array": "^1.1.14"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-wsl": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
+ "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+ "dev": true,
+ "dependencies": {
+ "is-docker": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true
+ },
+ "node_modules/iso-datestring-validator": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz",
+ "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="
+ },
+ "node_modules/isobject": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
+ "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/jest-worker": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
+ "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*",
+ "merge-stream": "^2.0.0",
+ "supports-color": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ }
+ },
+ "node_modules/jest-worker/node_modules/supports-color": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/jose": {
+ "version": "5.9.6",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz",
+ "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/json-parse-even-better-errors": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
+ "dev": true
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "dev": true
+ },
+ "node_modules/kind-of": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
+ "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/launch-editor": {
+ "version": "2.9.1",
+ "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.9.1.tgz",
+ "integrity": "sha512-Gcnl4Bd+hRO9P9icCP/RVVT2o8SFlPXofuCxvA2SaZuH45whSvf5p8x5oih5ftLiVhEI4sp5xDY+R+b3zJBh5w==",
+ "dev": true,
+ "dependencies": {
+ "picocolors": "^1.0.0",
+ "shell-quote": "^1.8.1"
+ }
+ },
+ "node_modules/loader-runner": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
+ "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.11.5"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "dev": true
+ },
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
+ },
+ "node_modules/md5.js": {
+ "version": "1.3.5",
+ "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
+ "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==",
+ "dev": true,
+ "dependencies": {
+ "hash-base": "^3.0.0",
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.1.2"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/memfs": {
+ "version": "3.5.3",
+ "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz",
+ "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==",
+ "dev": true,
+ "dependencies": {
+ "fs-monkey": "^1.0.4"
+ },
+ "engines": {
+ "node": ">= 4.0.0"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/merge-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
+ "dev": true
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/miller-rabin": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz",
+ "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==",
+ "dev": true,
+ "dependencies": {
+ "bn.js": "^4.0.0",
+ "brorand": "^1.0.1"
+ },
+ "bin": {
+ "miller-rabin": "bin/miller-rabin"
+ }
+ },
+ "node_modules/miller-rabin/node_modules/bn.js": {
+ "version": "4.12.1",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz",
+ "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==",
+ "dev": true
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "dev": true,
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dev": true,
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mimic-fn": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
+ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/minimalistic-assert": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
+ "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
+ "dev": true
+ },
+ "node_modules/minimalistic-crypto-utils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz",
+ "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==",
+ "dev": true
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "dependencies": {
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true
+ },
+ "node_modules/multicast-dns": {
+ "version": "7.2.5",
+ "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz",
+ "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==",
+ "dev": true,
+ "dependencies": {
+ "dns-packet": "^5.2.2",
+ "thunky": "^1.0.2"
+ },
+ "bin": {
+ "multicast-dns": "cli.js"
+ }
+ },
+ "node_modules/multiformats": {
+ "version": "9.9.0",
+ "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz",
+ "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
+ "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/neo-async": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
+ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
+ "dev": true
+ },
+ "node_modules/node-forge": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
+ "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6.13.0"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.18",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz",
+ "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==",
+ "dev": true
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/npm-run-path": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
+ "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
+ "dev": true,
+ "dependencies": {
+ "path-key": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.3",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz",
+ "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/obuf": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
+ "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
+ "dev": true
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "dev": true,
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/on-headers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
+ "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dev": true,
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/onetime": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
+ "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+ "dev": true,
+ "dependencies": {
+ "mimic-fn": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/open": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz",
+ "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==",
+ "dev": true,
+ "dependencies": {
+ "define-lazy-prop": "^2.0.0",
+ "is-docker": "^2.1.1",
+ "is-wsl": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/opener": {
+ "version": "1.5.2",
+ "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
+ "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==",
+ "dev": true,
+ "bin": {
+ "opener": "bin/opener-bin.js"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-retry": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
+ "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/retry": "0.12.0",
+ "retry": "^0.13.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parse-asn1": {
+ "version": "5.1.7",
+ "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.7.tgz",
+ "integrity": "sha512-CTM5kuWR3sx9IFamcl5ErfPl6ea/N8IYwiJ+vpeB2g+1iknv7zBl5uPwbMbRVznRVbrNY6lGuDoE5b30grmbqg==",
+ "dev": true,
+ "dependencies": {
+ "asn1.js": "^4.10.1",
+ "browserify-aes": "^1.2.0",
+ "evp_bytestokey": "^1.0.3",
+ "hash-base": "~3.0",
+ "pbkdf2": "^3.1.2",
+ "safe-buffer": "^5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.10",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz",
+ "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==",
+ "dev": true
+ },
+ "node_modules/path-type": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
+ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pbkdf2": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz",
+ "integrity": "sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==",
+ "dev": true,
+ "dependencies": {
+ "create-hash": "^1.1.2",
+ "create-hmac": "^1.1.4",
+ "ripemd160": "^2.0.1",
+ "safe-buffer": "^5.0.1",
+ "sha.js": "^2.4.8"
+ },
+ "engines": {
+ "node": ">=0.12"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pkg-dir": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
+ "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
+ "dev": true,
+ "dependencies": {
+ "find-up": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/portfinder": {
+ "version": "1.0.32",
+ "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.32.tgz",
+ "integrity": "sha512-on2ZJVVDXRADWE6jnQaX0ioEylzgBpQk8r55NE4wjXW1ZxO+BgDlY6DXwj20i0V8eB4SenDQ00WEaxfiIQPcxg==",
+ "dev": true,
+ "dependencies": {
+ "async": "^2.6.4",
+ "debug": "^3.2.7",
+ "mkdirp": "^0.5.6"
+ },
+ "engines": {
+ "node": ">= 0.12.0"
+ }
+ },
+ "node_modules/possible-typed-array-names": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
+ "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/process": {
+ "version": "0.11.10",
+ "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
+ "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "dev": true
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "dev": true,
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/proxy-addr/node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/public-encrypt": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz",
+ "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==",
+ "dev": true,
+ "dependencies": {
+ "bn.js": "^4.1.0",
+ "browserify-rsa": "^4.0.0",
+ "create-hash": "^1.1.0",
+ "parse-asn1": "^5.0.0",
+ "randombytes": "^2.0.1",
+ "safe-buffer": "^5.1.2"
+ }
+ },
+ "node_modules/public-encrypt/node_modules/bn.js": {
+ "version": "4.12.1",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz",
+ "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==",
+ "dev": true
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.13.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.1.tgz",
+ "integrity": "sha512-EJPeIn0CYrGu+hli1xilKAPXODtJ12T0sP63Ijx2/khC2JtuaN3JyNIpvmnkmaEtha9ocbG4A4cMcr+TvqvwQg==",
+ "dev": true,
+ "dependencies": {
+ "side-channel": "^1.0.6"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/randombytes": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
+ "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "^5.1.0"
+ }
+ },
+ "node_modules/randomfill": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz",
+ "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==",
+ "dev": true,
+ "dependencies": {
+ "randombytes": "^2.0.5",
+ "safe-buffer": "^5.1.0"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
+ "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
+ "dev": true,
+ "dependencies": {
+ "bytes": "3.1.2",
+ "http-errors": "2.0.0",
+ "iconv-lite": "0.4.24",
+ "unpipe": "1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/readable-stream/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "dev": true
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/rechoir": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz",
+ "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==",
+ "dev": true,
+ "dependencies": {
+ "resolve": "^1.20.0"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/requires-port": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
+ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
+ "dev": true
+ },
+ "node_modules/resolve": {
+ "version": "1.22.8",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
+ "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==",
+ "dev": true,
+ "dependencies": {
+ "is-core-module": "^2.13.0",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve-cwd": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz",
+ "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==",
+ "dev": true,
+ "dependencies": {
+ "resolve-from": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+ "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/retry": {
+ "version": "0.13.1",
+ "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
+ "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+ "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+ "dev": true,
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "deprecated": "Rimraf versions prior to v4 are no longer supported",
+ "dev": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/ripemd160": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz",
+ "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==",
+ "dev": true,
+ "dependencies": {
+ "hash-base": "^3.0.0",
+ "inherits": "^2.0.1"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true
+ },
+ "node_modules/schema-utils": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz",
+ "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.9",
+ "ajv": "^8.9.0",
+ "ajv-formats": "^2.1.1",
+ "ajv-keywords": "^5.1.0"
+ },
+ "engines": {
+ "node": ">= 12.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/secure-compare": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz",
+ "integrity": "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==",
+ "dev": true
+ },
+ "node_modules/select-hose": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
+ "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==",
+ "dev": true
+ },
+ "node_modules/selfsigned": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz",
+ "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==",
+ "dev": true,
+ "dependencies": {
+ "@types/node-forge": "^1.3.0",
+ "node-forge": "^1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.0",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
+ "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
+ "dev": true,
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "0.5.2",
+ "http-errors": "2.0.0",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "2.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/send/node_modules/debug/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ },
+ "node_modules/send/node_modules/encodeurl": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
+ "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/serialize-javascript": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
+ "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
+ "dev": true,
+ "dependencies": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "node_modules/serve-index": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz",
+ "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==",
+ "dev": true,
+ "dependencies": {
+ "accepts": "~1.3.4",
+ "batch": "0.6.1",
+ "debug": "2.6.9",
+ "escape-html": "~1.0.3",
+ "http-errors": "~1.6.2",
+ "mime-types": "~2.1.17",
+ "parseurl": "~1.3.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/serve-index/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/serve-index/node_modules/depd": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
+ "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/serve-index/node_modules/http-errors": {
+ "version": "1.6.3",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz",
+ "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==",
+ "dev": true,
+ "dependencies": {
+ "depd": "~1.1.2",
+ "inherits": "2.0.3",
+ "setprototypeof": "1.1.0",
+ "statuses": ">= 1.4.0 < 2"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/serve-index/node_modules/inherits": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
+ "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
+ "dev": true
+ },
+ "node_modules/serve-index/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "dev": true
+ },
+ "node_modules/serve-index/node_modules/setprototypeof": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz",
+ "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==",
+ "dev": true
+ },
+ "node_modules/serve-index/node_modules/statuses": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
+ "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.2",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
+ "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
+ "dev": true,
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "0.19.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/set-function-length": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+ "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+ "dev": true,
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "dev": true
+ },
+ "node_modules/sha.js": {
+ "version": "2.4.11",
+ "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
+ "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ },
+ "bin": {
+ "sha.js": "bin.js"
+ }
+ },
+ "node_modules/shallow-clone": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz",
+ "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==",
+ "dev": true,
+ "dependencies": {
+ "kind-of": "^6.0.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shell-quote": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz",
+ "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz",
+ "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.4",
+ "object-inspect": "^1.13.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "dev": true
+ },
+ "node_modules/slash": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz",
+ "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/sockjs": {
+ "version": "0.3.24",
+ "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz",
+ "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==",
+ "dev": true,
+ "dependencies": {
+ "faye-websocket": "^0.11.3",
+ "uuid": "^8.3.2",
+ "websocket-driver": "^0.7.4"
+ }
+ },
+ "node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-map-support": {
+ "version": "0.5.21",
+ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+ "dev": true,
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
+ "node_modules/spdy": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz",
+ "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==",
+ "dev": true,
+ "dependencies": {
+ "debug": "^4.1.0",
+ "handle-thing": "^2.0.0",
+ "http-deceiver": "^1.2.7",
+ "select-hose": "^2.0.0",
+ "spdy-transport": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/spdy-transport": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz",
+ "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==",
+ "dev": true,
+ "dependencies": {
+ "debug": "^4.1.0",
+ "detect-node": "^2.0.4",
+ "hpack.js": "^2.1.6",
+ "obuf": "^1.1.2",
+ "readable-stream": "^3.0.6",
+ "wbuf": "^1.7.3"
+ }
+ },
+ "node_modules/spdy-transport/node_modules/debug": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+ "dev": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/spdy-transport/node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/spdy/node_modules/debug": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+ "dev": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
+ "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/stream-browserify": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz",
+ "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "~2.0.4",
+ "readable-stream": "^3.5.0"
+ }
+ },
+ "node_modules/stream-browserify/node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/string_decoder/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "dev": true
+ },
+ "node_modules/strip-final-newline": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
+ "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/tapable": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
+ "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/terser": {
+ "version": "5.36.0",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-5.36.0.tgz",
+ "integrity": "sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/source-map": "^0.3.3",
+ "acorn": "^8.8.2",
+ "commander": "^2.20.0",
+ "source-map-support": "~0.5.20"
+ },
+ "bin": {
+ "terser": "bin/terser"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/terser-webpack-plugin": {
+ "version": "5.3.10",
+ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz",
+ "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.20",
+ "jest-worker": "^27.4.5",
+ "schema-utils": "^3.1.1",
+ "serialize-javascript": "^6.0.1",
+ "terser": "^5.26.0"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^5.1.0"
+ },
+ "peerDependenciesMeta": {
+ "@swc/core": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "uglify-js": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/terser-webpack-plugin/node_modules/ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "dev": true,
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
+ "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
+ "dev": true,
+ "peerDependencies": {
+ "ajv": "^6.9.1"
+ }
+ },
+ "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true
+ },
+ "node_modules/terser-webpack-plugin/node_modules/schema-utils": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
+ "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/thunky": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
+ "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
+ "dev": true
+ },
+ "node_modules/tlds": {
+ "version": "1.255.0",
+ "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.255.0.tgz",
+ "integrity": "sha512-tcwMRIioTcF/FcxLev8MJWxCp+GUALRhFEqbDoZrnowmKSGqPrl5pqS+Sut2m8BgJ6S4FExCSSpGffZ0Tks6Aw==",
+ "bin": {
+ "tlds": "bin.js"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "dev": true,
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/uint8arrays": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz",
+ "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==",
+ "dependencies": {
+ "multiformats": "^9.4.2"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.20.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
+ "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
+ "dev": true
+ },
+ "node_modules/union": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz",
+ "integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==",
+ "dev": true,
+ "dependencies": {
+ "qs": "^6.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz",
+ "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.0"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/url-join": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz",
+ "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==",
+ "dev": true
+ },
+ "node_modules/util": {
+ "version": "0.12.5",
+ "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
+ "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "is-arguments": "^1.0.4",
+ "is-generator-function": "^1.0.7",
+ "is-typed-array": "^1.1.3",
+ "which-typed-array": "^1.1.2"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/uuid": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+ "dev": true,
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/watchpack": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz",
+ "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==",
+ "dev": true,
+ "dependencies": {
+ "glob-to-regexp": "^0.4.1",
+ "graceful-fs": "^4.1.2"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/wbuf": {
+ "version": "1.7.3",
+ "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz",
+ "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==",
+ "dev": true,
+ "dependencies": {
+ "minimalistic-assert": "^1.0.0"
+ }
+ },
+ "node_modules/webpack": {
+ "version": "5.96.1",
+ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.96.1.tgz",
+ "integrity": "sha512-l2LlBSvVZGhL4ZrPwyr8+37AunkcYj5qh8o6u2/2rzoPc8gxFJkLj1WxNgooi9pnoc06jh0BjuXnamM4qlujZA==",
+ "dev": true,
+ "dependencies": {
+ "@types/eslint-scope": "^3.7.7",
+ "@types/estree": "^1.0.6",
+ "@webassemblyjs/ast": "^1.12.1",
+ "@webassemblyjs/wasm-edit": "^1.12.1",
+ "@webassemblyjs/wasm-parser": "^1.12.1",
+ "acorn": "^8.14.0",
+ "browserslist": "^4.24.0",
+ "chrome-trace-event": "^1.0.2",
+ "enhanced-resolve": "^5.17.1",
+ "es-module-lexer": "^1.2.1",
+ "eslint-scope": "5.1.1",
+ "events": "^3.2.0",
+ "glob-to-regexp": "^0.4.1",
+ "graceful-fs": "^4.2.11",
+ "json-parse-even-better-errors": "^2.3.1",
+ "loader-runner": "^4.2.0",
+ "mime-types": "^2.1.27",
+ "neo-async": "^2.6.2",
+ "schema-utils": "^3.2.0",
+ "tapable": "^2.1.1",
+ "terser-webpack-plugin": "^5.3.10",
+ "watchpack": "^2.4.1",
+ "webpack-sources": "^3.2.3"
+ },
+ "bin": {
+ "webpack": "bin/webpack.js"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependenciesMeta": {
+ "webpack-cli": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/webpack-cli": {
+ "version": "5.1.4",
+ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz",
+ "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==",
+ "dev": true,
+ "dependencies": {
+ "@discoveryjs/json-ext": "^0.5.0",
+ "@webpack-cli/configtest": "^2.1.1",
+ "@webpack-cli/info": "^2.0.2",
+ "@webpack-cli/serve": "^2.0.5",
+ "colorette": "^2.0.14",
+ "commander": "^10.0.1",
+ "cross-spawn": "^7.0.3",
+ "envinfo": "^7.7.3",
+ "fastest-levenshtein": "^1.0.12",
+ "import-local": "^3.0.2",
+ "interpret": "^3.1.1",
+ "rechoir": "^0.8.0",
+ "webpack-merge": "^5.7.3"
+ },
+ "bin": {
+ "webpack-cli": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=14.15.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "5.x.x"
+ },
+ "peerDependenciesMeta": {
+ "@webpack-cli/generators": {
+ "optional": true
+ },
+ "webpack-bundle-analyzer": {
+ "optional": true
+ },
+ "webpack-dev-server": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/webpack-cli/node_modules/commander": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
+ "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
+ "dev": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/webpack-dev-middleware": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz",
+ "integrity": "sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==",
+ "dev": true,
+ "dependencies": {
+ "colorette": "^2.0.10",
+ "memfs": "^3.4.3",
+ "mime-types": "^2.1.31",
+ "range-parser": "^1.2.1",
+ "schema-utils": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 12.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/webpack-dev-server": {
+ "version": "4.15.2",
+ "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz",
+ "integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==",
+ "dev": true,
+ "dependencies": {
+ "@types/bonjour": "^3.5.9",
+ "@types/connect-history-api-fallback": "^1.3.5",
+ "@types/express": "^4.17.13",
+ "@types/serve-index": "^1.9.1",
+ "@types/serve-static": "^1.13.10",
+ "@types/sockjs": "^0.3.33",
+ "@types/ws": "^8.5.5",
+ "ansi-html-community": "^0.0.8",
+ "bonjour-service": "^1.0.11",
+ "chokidar": "^3.5.3",
+ "colorette": "^2.0.10",
+ "compression": "^1.7.4",
+ "connect-history-api-fallback": "^2.0.0",
+ "default-gateway": "^6.0.3",
+ "express": "^4.17.3",
+ "graceful-fs": "^4.2.6",
+ "html-entities": "^2.3.2",
+ "http-proxy-middleware": "^2.0.3",
+ "ipaddr.js": "^2.0.1",
+ "launch-editor": "^2.6.0",
+ "open": "^8.0.9",
+ "p-retry": "^4.5.0",
+ "rimraf": "^3.0.2",
+ "schema-utils": "^4.0.0",
+ "selfsigned": "^2.1.1",
+ "serve-index": "^1.9.1",
+ "sockjs": "^0.3.24",
+ "spdy": "^4.0.2",
+ "webpack-dev-middleware": "^5.3.4",
+ "ws": "^8.13.0"
+ },
+ "bin": {
+ "webpack-dev-server": "bin/webpack-dev-server.js"
+ },
+ "engines": {
+ "node": ">= 12.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^4.37.0 || ^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "webpack": {
+ "optional": true
+ },
+ "webpack-cli": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/webpack-merge": {
+ "version": "5.10.0",
+ "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz",
+ "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==",
+ "dev": true,
+ "dependencies": {
+ "clone-deep": "^4.0.1",
+ "flat": "^5.0.2",
+ "wildcard": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/webpack-sources": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz",
+ "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==",
+ "dev": true,
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/webpack/node_modules/ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "dev": true,
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/webpack/node_modules/ajv-keywords": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
+ "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
+ "dev": true,
+ "peerDependencies": {
+ "ajv": "^6.9.1"
+ }
+ },
+ "node_modules/webpack/node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true
+ },
+ "node_modules/webpack/node_modules/schema-utils": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
+ "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/websocket-driver": {
+ "version": "0.7.4",
+ "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz",
+ "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==",
+ "dev": true,
+ "dependencies": {
+ "http-parser-js": ">=0.5.1",
+ "safe-buffer": ">=5.1.0",
+ "websocket-extensions": ">=0.1.1"
+ },
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/websocket-extensions": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz",
+ "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz",
+ "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==",
+ "dev": true,
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-encoding/node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/which-typed-array": {
+ "version": "1.1.16",
+ "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.16.tgz",
+ "integrity": "sha512-g+N+GAWiRj66DngFwHvISJd+ITsyphZvD1vChfVg6cEdnzy53GzB3oy0fUNlvhz7H7+MiqhYr26qxQShCpKTTQ==",
+ "dev": true,
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.7",
+ "for-each": "^0.3.3",
+ "gopd": "^1.0.1",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/wildcard": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz",
+ "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==",
+ "dev": true
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "dev": true
+ },
+ "node_modules/ws": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
+ "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
+ "dev": true,
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/zod": {
+ "version": "3.23.8",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
+ "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..446bc2f
--- /dev/null
+++ b/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "mutesky",
+ "version": "1.0.0",
+ "description": "A web app for managing and filtering unwanted content on Bluesky, featuring curated keyword groups and context-based filtering.",
+ "main": "index.js",
+ "scripts": {
+ "dev": "npx webpack serve --open",
+ "build": "npx webpack",
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "keywords": [
+ "bluesky",
+ "content-filter",
+ "muting",
+ "social-media",
+ "moderation",
+ "keyword-filtering",
+ "atproto"
+ ],
+ "author": "Chrissy LeMaire",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/api": "^0.13.18",
+ "@atproto/oauth-client-browser": "^0.3.2"
+ },
+ "devDependencies": {
+ "buffer": "6.0.3",
+ "copy-webpack-plugin": "11.0.0",
+ "crypto-browserify": "3.12.1",
+ "http-server": "^14.1.1",
+ "process": "0.11.10",
+ "stream-browserify": "3.0.0",
+ "util": "0.12.5",
+ "webpack": "5.96.1",
+ "webpack-cli": "5.1.4",
+ "webpack-dev-server": "^4.15.2"
+ }
+}
diff --git a/webpack.config.js b/webpack.config.js
new file mode 100644
index 0000000..716893b
--- /dev/null
+++ b/webpack.config.js
@@ -0,0 +1,80 @@
+const path = require('path');
+const webpack = require('webpack');
+const CopyPlugin = require('copy-webpack-plugin');
+const fs = require('fs');
+
+const isDevelopment = process.env.NODE_ENV !== 'production';
+
+const devServerConfig = {
+ static: {
+ directory: path.join(__dirname, '/'),
+ publicPath: '/'
+ },
+ port: 443,
+ hot: true,
+ host: 'mutesky.app',
+ open: {
+ target: ['https://mutesky.app']
+ },
+ devMiddleware: {
+ publicPath: '/'
+ },
+ historyApiFallback: true
+};
+
+// Only add HTTPS configuration in development mode
+if (isDevelopment) {
+ devServerConfig.server = {
+ type: 'https',
+ options: {
+ key: fs.readFileSync('mutesky.app+3-key.pem'),
+ cert: fs.readFileSync('mutesky.app+3.pem')
+ }
+ };
+}
+
+module.exports = {
+ mode: isDevelopment ? 'development' : 'production',
+ entry: {
+ main: './js/main.js'
+ },
+ output: {
+ filename: 'js/bundle.js',
+ path: path.resolve(__dirname, 'dist'),
+ publicPath: '/',
+ clean: {
+ keep: /\.git/
+ }
+ },
+ devServer: devServerConfig,
+ resolve: {
+ extensions: ['.js'],
+ fallback: {
+ "crypto": require.resolve("crypto-browserify"),
+ "stream": require.resolve("stream-browserify"),
+ "buffer": require.resolve("buffer/"),
+ "util": require.resolve("util/"),
+ "path": false,
+ "fs": false
+ }
+ },
+ plugins: [
+ new webpack.ProvidePlugin({
+ Buffer: ['buffer', 'Buffer'],
+ process: 'process/browser',
+ blueskyService: ['./js/bluesky.js', 'blueskyService']
+ }),
+ new CopyPlugin({
+ patterns: [
+ { from: "index.html" },
+ { from: "css", to: "css" },
+ { from: "js", to: "js", globOptions: { ignore: ['**/main.js'] } },
+ { from: "CNAME" },
+ { from: "favicon.ico" },
+ { from: "images", to: "images" },
+ { from: "client-metadata.json" },
+ { from: "callback.html" }
+ ]
+ })
+ ]
+}