diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 3877c13..a08f766 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -22,6 +22,7 @@ What would you like to happen? - [ ] Flashcards - [ ] New module - [ ] UI / Popup +- [ ] Server (grammar proxy) - [ ] Docs / Translations ## Alternatives considered diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 0660dee..962537e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -14,18 +14,23 @@ The motivation or issue this addresses (link an issue if one exists). - [ ] Flashcards (`content_scripts/flashcard.js`) - [ ] Config files (`config/`) - [ ] UI (`ui/`) +- [ ] Server (`server/`) +- [ ] Tests (`tests/`) - [ ] Docs / Translations (`sources/`, `README.md`) - [ ] GitHub / repo files ## Testing done +- [ ] `npm test` passes - [ ] Tested in Firefox - [ ] Tested in Chrome - Tested on: (list the sites or scenarios you checked) ## Checklist +- [ ] PR title follows `type: description` format (feat / fix / chore / ci / docs / refactor / test / security) - [ ] No new external dependencies added - [ ] No user data sent anywhere without opt-in - [ ] Config changes are backwards-compatible (old keys still work) - [ ] If CSS selectors changed: verified they work on the live site today +- [ ] CHANGELOG.md updated if this is a user-facing change diff --git a/.github/labeler.yml b/.github/labeler.yml index 44ed939..369c826 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -29,3 +29,11 @@ docs: config: - changed-files: - any-glob-to-any-file: ['config/**'] + +server: + - changed-files: + - any-glob-to-any-file: ['server/**'] + +tests: + - changed-files: + - any-glob-to-any-file: ['tests/**'] diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index 5c01fc5..be4ca26 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -12,6 +12,6 @@ jobs: label: runs-on: ubuntu-latest steps: - - uses: actions/labeler@v5 + - uses: actions/labeler@v6 with: repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..c4a3234 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,33 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + # Weekly scan every Monday at 08:00 UTC + - cron: '0 8 * * 1' + +jobs: + analyze: + name: Analyze JavaScript + runs-on: ubuntu-latest + permissions: + security-events: write + actions: read + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: javascript + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: /language:javascript diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 06aab1a..5eab535 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,4 +1,4 @@ -name: Lint +name: Lint & Test on: pull_request: @@ -10,17 +10,33 @@ permissions: jobs: web-ext-lint: + name: web-ext lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version: '20' - - run: npm install -g web-ext + cache: 'npm' + - run: npm ci - run: | - web-ext lint \ + npx web-ext lint \ --source-dir . \ --ignore-files "server/**" \ --ignore-files "sources/**" \ --ignore-files ".github/**" \ + --ignore-files "tests/**" \ + --ignore-files "node_modules/**" \ --ignore-files "*.md" + + test: + name: Vitest unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'npm' + - run: npm ci + - run: npm test diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index e5fcea5..3d173c7 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -9,11 +9,28 @@ permissions: pull-requests: read jobs: + title-check: + name: PR title follows conventional commits + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v9 + with: + script: | + const title = context.payload.pull_request.title || ''; + const valid = /^(feat|fix|chore|ci|docs|refactor|test|security)(\(.+\))?!?:\s.+/.test(title); + if (!valid) { + core.setFailed( + `PR title "${title}" does not follow the conventional commit format.\n` + + 'Expected: : (e.g. "fix: handle empty filter list")\n' + + 'Valid types: feat, fix, chore, ci, docs, refactor, test, security' + ); + } + description-check: name: PR has description runs-on: ubuntu-latest steps: - - uses: actions/github-script@v7 + - uses: actions/github-script@v9 with: script: | const body = context.payload.pull_request.body || ''; @@ -28,11 +45,11 @@ jobs: name: Version bump has changelog entry runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - - uses: actions/github-script@v7 + - uses: actions/github-script@v9 with: script: | const { execSync } = require('child_process'); diff --git a/.github/workflows/selector-check.yml b/.github/workflows/selector-check.yml index a314bc2..3732c8b 100644 --- a/.github/workflows/selector-check.yml +++ b/.github/workflows/selector-check.yml @@ -14,9 +14,10 @@ jobs: name: Validate CSS selectors runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version: '20' - - run: npm install --no-save jsdom + cache: 'npm' + - run: npm ci - run: node .github/scripts/check-selectors.js diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 86a8172..4f03a32 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -2,7 +2,8 @@ name: Stale on: schedule: - - cron: '0 0 * * *' + # Weekly on Mondays at 00:00 UTC + - cron: '0 0 * * 1' workflow_dispatch: permissions: @@ -13,7 +14,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v9 + - uses: actions/stale@v10 with: days-before-stale: 60 days-before-close: 7 @@ -28,4 +29,4 @@ jobs: close-issue-message: Closed due to inactivity. Feel free to reopen if this is still relevant. close-pr-message: Closed due to inactivity. Feel free to reopen if still relevant. exempt-issue-labels: 'pinned,security' - exempt-pr-labels: 'pinned' + exempt-pr-labels: 'pinned,security' diff --git a/.github/workflows/sync-labels.yml b/.github/workflows/sync-labels.yml index 804cc99..f86cf35 100644 --- a/.github/workflows/sync-labels.yml +++ b/.github/workflows/sync-labels.yml @@ -14,7 +14,7 @@ jobs: sync: runs-on: ubuntu-latest steps: - - uses: actions/github-script@v7 + - uses: actions/github-script@v9 with: script: | const labels = [ @@ -34,6 +34,12 @@ jobs: { name: 'background', color: '1abc9c', description: 'background.js or utils.js changes' }, { name: 'config', color: 'f0932b', description: 'Changes to config/ JSON files' }, { name: 'docs', color: '95a5a6', description: 'Documentation or translation changes' }, + { name: 'server', color: '2980b9', description: 'Changes to the LanguageTool proxy server' }, + { name: 'tests', color: '27ae60', description: 'Changes to the test suite' }, + // Type (mirrors conventional commit prefixes) + { name: 'ci', color: '0075ca', description: 'CI/CD workflow changes' }, + { name: 'refactor', color: 'e2e2e2', description: 'Code restructuring, no behaviour change' }, + { name: 'test', color: 'c5def5', description: 'New or updated tests only' }, ]; for (const label of labels) { diff --git a/.gitignore b/.gitignore index 9795b06..e75ff70 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,6 @@ web-ext-artifacts/ # Node (if build tooling is added later) node_modules/ npm-debug.log* -package-lock.json # Safari Xcode conversion output *.xcodeproj/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 902c4ef..51da0e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ MindTab uses [Semantic Versioning](https://semver.org/). --- +## [1.2.0] — 2026-05-16 + +### Added + +- **Spaced repetition (SM-2)** — Flashcards now use a lightweight SM-2 algorithm. "Got it" increases a card's interval; "Skip" or timeout resets it. Due cards are shown first, and a badge on the overlay shows how many cards are currently due +- **Flashcard export / import** — Custom cards can now be exported as JSON and imported back (or shared as a deck). Import merges into existing cards, with schema validation +- **Writing check controls** — Settings page now lets you toggle each Tone Translator check independently (passive voice, hedge words, long sentences, filler words, repeated words) and configure the long-sentence word threshold (15–50 words) +- **Manual theme override** — Settings page has a System / Light / Dark segmented control that overrides `prefers-color-scheme`; preference persisted in sync storage +- **Custom filter list sources** — Settings page exposes the three filter list URLs with add/remove UI; previously only configurable programmatically. Changes take effect on next update +- **CodeQL security scanning** — Added `.github/workflows/codeql.yml` for automated JavaScript SAST on every PR and weekly +- **CONTRIBUTING.md** — Architecture overview, local dev setup, code style and security guidelines, PR checklist +- **Vitest test suite** — 43 unit tests across tone analysis (`mtDetectTone`, `mtAnalyzeLocally`, `mtReadability`, `mtSyllables`), filter list parser (`parseFilterList`), and CORS origin validation + +### Changed + +- **Keyboard shortcuts** — `Esc` closes the Tone Translator panel; `Alt+Shift+F` triggers a flashcard on demand +- **ARIA improvements** — Tone panel has `role="complementary"` + `aria-label`; flashcard overlay has `role="dialog"` + `aria-modal="true"`; flashcard buttons receive focus on card show +- **Grammar server cooldown** — Added a 750 ms post-analysis cooldown to prevent hammering the grammar server during rapid typing (on top of the existing debounce) +- **Filter list integrity check** — If a fetched update drops the total selector count by more than 30% vs. the cached set, the update is rejected and the cache is preserved +- **Branding** — Credit updated to AetherAssembly across all UI pages, linking to `https://aetherassembly.org/about` +- Bump version to 1.2.0 + +--- + ## [1.1.2] — 2026-05-12 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..093ba07 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,140 @@ +# Contributing to MindTab + +Thanks for your interest in improving MindTab! This guide covers everything you need to get started. + +## Architecture overview + +MindTab is a **Manifest V3 browser extension** with no build step — all files are loaded directly by the browser. + +``` +background.js Service worker: filter list fetching, badge counter, alarm scheduling +utils.js Shared utility (escHtml) +content_scripts/ + controller.js Loads config + state, dispatches mindtab:ready event + feedSanitizer.js Removes Shorts/Reels via CSS selector matching + MutationObserver + maliciousAdBlocker.js Hides scam ads by keyword/href pattern + toneTranslator.js Writing assistant panel — local analysis + optional grammar server + flashcard.js Flashcard overlay with SM-2 spaced repetition +config/ + filters.json Bundled CSS selectors + filter list URLs + toneConfig.json Tone keyword definitions + flashcards.json Default flashcard deck + display settings +ui/ + popup.html/js/css Main popup (feature toggles) + settings.html/js/css Settings page (server URL, theme, writing checks, filter sources) + cards.html/js/css Flashcard manager (add/delete/export/import) +server/ + index.js Optional CORS proxy for LanguageTool (Node.js/Express) +tests/ + toneAnalysis.test.js Vitest tests for tone detection and analysis + filterParser.test.js Vitest tests for ABP/uBlock filter list parser + corsOrigin.test.js Vitest tests for server CORS origin validation +``` + +## Loading the extension locally + +**Chrome / Edge** +1. Open `chrome://extensions` +2. Enable **Developer mode** (top-right toggle) +3. Click **Load unpacked** and select this repository folder + +**Firefox** +1. Open `about:debugging#/runtime/this-firefox` +2. Click **Load Temporary Add-on** +3. Select `manifest.json` + +No build step needed — changes to any file take effect after reloading the extension. + +## Running tests + +```sh +npm install +npm test # run once +npm run test:watch # watch mode +``` + +Tests use [Vitest](https://vitest.dev/) and cover the pure logic functions in `toneTranslator.js`, `background.js`, and `server/index.js`. They run in Node without a browser environment. + +## How the filter list parser works + +`background.js` fetches three community-maintained ABP/uBlock cosmetic filter lists daily and parses them into plain CSS selectors per target domain. The parser (`parseFilterList`) processes lines like: + +``` +youtube.com##ytd-rich-shelf-renderer[is-shorts] +``` + +It skips: +- Lines starting with `!` (comments), `[` (metadata), or `@@` (exception rules) +- Rules with no domain prefix (global rules — too broad) +- Procedural filters (`:matches-css`, `:upward(`, etc.) — not valid as `querySelectorAll` selectors +- Exclusion domain rules (`~domain`) + +Parsed selectors are stored in `chrome.storage.local` and merged with the bundled fallback selectors in `config/filters.json` on each page load. + +## Adding or editing tone keywords + +Tone keywords live in [`config/toneConfig.json`](config/toneConfig.json). Each tone has a `keywords` array of lowercase strings. The detector picks whichever tone has the most keyword hits in the lowercased input text. + +To add a keyword, add it to the relevant array — no code changes needed. To add a new tone, add a new entry with `label`, `emoji`, `color`, and `keywords`, then reference it in the tone panel CSS if you want a custom colour. + +## Default flashcards + +Default cards live in [`config/flashcards.json`](config/flashcards.json) under `defaultCards`. Each card is `{ "q": "Question?", "a": "Answer" }`. They are read-only from the user's perspective (custom cards are separate). + +## Code style + +- Vanilla JavaScript only — no frameworks, no bundler, no TypeScript +- No external dependencies in extension code (only `server/` uses npm packages) +- Prefer `document.createElement` + `textContent`/`appendChild` over `innerHTML` with dynamic data +- Use `chrome.storage.sync` for user preferences (syncs across devices), `chrome.storage.local` for cached/transient data + +## Security guidelines + +- Never use `innerHTML`, `insertAdjacentHTML`, or `eval` with any user-controlled or external data +- Validate origin strictly in the server (see `server/index.js`) — do not use `*` for CORS +- Use exact match or `.endsWith('.' + domain)` for domain checks — never `.includes()` +- All filter list selectors are used only as `querySelectorAll` arguments, never injected as HTML + +## Commit message prefixes + +MindTab uses [Conventional Commits](https://www.conventionalcommits.org/) style. Start every commit message with one of these prefixes so the history stays scannable and the changelog easy to write. + +| Prefix | When to use | Example | +|--------|-------------|---------| +| `feat:` | A new user-facing feature or behaviour | `feat: add SM-2 spaced repetition to flashcards` | +| `fix:` | A bug fix | `fix: passive voice regex missing plural forms` | +| `chore:` | Maintenance that isn't a feature or bug fix — dependency bumps, version bumps, file moves, deleting dead code | `chore: bump version to 1.2.0` | +| `ci:` | Changes to GitHub Actions workflows or CI config only | `ci: add weekly CodeQL scan` | +| `docs:` | Documentation only — README, CHANGELOG, CONTRIBUTING, code comments | `docs: add architecture overview to CONTRIBUTING` | +| `refactor:` | Code restructuring with no behaviour change | `refactor: extract mtAnalyzeLocally into its own module` | +| `test:` | Adding or updating tests, no production code change | `test: add filter parser edge-case coverage` | +| `security:` | Security-focused patches — CORS, input validation, XSS hardening | `security: reject spoofed extension origins in CORS check` | + +**Tips:** +- Keep the subject line under 72 characters. +- Use the imperative mood: "add X", "fix Y", not "added X" or "fixes Y". +- If a commit touches multiple concerns, split it into separate commits. +- A `!` after the prefix (e.g. `feat!:`) signals a breaking change. + +### PR labels + +Labels on pull requests are applied automatically by the [labeler workflow](.github/workflows/auto-label.yml) based on which files changed. You don't need to set them manually — they exist for filtering and release-note generation. + +| Label | Files that trigger it | +|-------|-----------------------| +| `filters` | `config/filters.json` | +| `content-script` | `content_scripts/**` | +| `ui` | `ui/**` | +| `manifest` | `manifest.json` | +| `background` | `background.js`, `utils.js` | +| `docs` | `*.md`, `sources/**` | +| `config` | `config/**` | + +## Pull request checklist + +- [ ] `npm test` passes (no new test failures) +- [ ] `web-ext lint` passes (or run CI lint locally with `npx web-ext lint`) +- [ ] New features have a corresponding entry in `CHANGELOG.md` +- [ ] Version in `manifest.json` and `package.json` are bumped if this is a release PR +- [ ] No `innerHTML` / `eval` with external data introduced +- [ ] PR description is at least 2 sentences explaining what and why diff --git a/LICENSE b/LICENSE index cb9beaf..0cfb54f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 AetherAssembly (Aster, Ollie, Milo) +Copyright (c) 2026 AetherAssembly Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 3798b9a..f4cd8da 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@
MindTab Logo - **MindTab** is a free, open-source browser extension that cleans up your feed, blocks scam ads, helps you write better, and keeps you learning — all without sending your data anywhere. + **MindTab** is a free, open-source browser extension that cleans up your feed, blocks scam ads, helps you write better, and keeps you learning. All without sending your data anywhere.
diff --git a/background.js b/background.js index 09759eb..0c9fe7b 100644 --- a/background.js +++ b/background.js @@ -95,6 +95,23 @@ async function updateFilterLists() { } if (successCount > 0) { + const newTotal = [...Object.values(merged)].reduce((n, s) => n + s.size, 0); + + // Integrity check: if selector count drops >30% vs cache, keep cached data. + // Guards against truncated fetches or a compromised filter list source. + const { mindtabExternalFilters: cached } = await chrome.storage.local.get('mindtabExternalFilters'); + const cachedTotal = cached + ? Object.values(cached).reduce((n, a) => n + a.length, 0) + : 0; + + if (cachedTotal > 0 && newTotal < cachedTotal * 0.7) { + console.warn(`[MindTab] Selector count dropped unexpectedly (${cachedTotal} → ${newTotal}) — keeping cached selectors.`); + await chrome.storage.local.set({ + mindtabFiltersStatus: `Warning: selector count dropped unexpectedly (${cachedTotal} → ${newTotal}) — keeping cached selectors` + }); + return; + } + const totals = Object.fromEntries( Object.entries(merged).map(([k, v]) => [k, v.size]) ); @@ -103,7 +120,7 @@ async function updateFilterLists() { Object.entries(merged).map(([k, v]) => [k, [...v]]) ), mindtabFiltersUpdated: Date.now(), - mindtabFiltersStatus: `Updated — ${Object.values(totals).reduce((a, b) => a + b, 0)} selectors across ${Object.keys(totals).length} sites` + mindtabFiltersStatus: `Updated — ${newTotal} selectors across ${Object.keys(totals).length} sites` }); console.log('[MindTab] Filter lists updated:', totals); } else { diff --git a/content_scripts/flashcard.js b/content_scripts/flashcard.js index c761d91..4c73bbb 100644 --- a/content_scripts/flashcard.js +++ b/content_scripts/flashcard.js @@ -54,6 +54,20 @@ function initFlashcards() { letter-spacing: 1px; margin-bottom: 10px; } + #mt-card .mt-header-left { + display: flex; + align-items: center; + gap: 8px; + } + #mt-card .mt-due-badge { + font-size: 10px; + background: rgba(74,144,226,0.15); + color: #4A90E2; + border-radius: 10px; + padding: 1px 7px; + letter-spacing: 0; + text-transform: none; + } #mt-card .mt-close { background: none; border: none; @@ -102,9 +116,15 @@ function initFlashcards() { // --- Card DOM --- const card = document.createElement('div'); card.id = 'mt-card'; + card.setAttribute('role', 'dialog'); + card.setAttribute('aria-modal', 'true'); + card.setAttribute('aria-label', 'Flashcard'); card.innerHTML = `
- ⚡ MindTab +
+ ⚡ MindTab + +
@@ -121,9 +141,46 @@ function initFlashcards() { const revealBtn = card.querySelector('.mt-reveal'); const skipBtn = card.querySelector('.mt-skip'); const closeBtn = card.querySelector('.mt-close'); + const dueBadge = card.querySelector('#mt-due-badge'); let autoHideTimer; let showing = false; + let currentCard = null; + + // --- SRS helpers --- + const SRS_KEY = 'mindtabSRS'; + const DAY_MS = 24 * 60 * 60 * 1000; + + function cardKey(c) { + // Stable key from first 40 chars of the question + return c.q.slice(0, 40).replace(/[^a-zA-Z0-9]/g, '_'); + } + + async function getSRS() { + const { mindtabSRS } = await chrome.storage.sync.get(SRS_KEY); + return mindtabSRS || {}; + } + + async function saveSRS(srs) { + await chrome.storage.sync.set({ [SRS_KEY]: srs }); + } + + async function recordResult(c, gotIt) { + const srs = await getSRS(); + const key = cardKey(c); + const data = srs[key] || { ease: 2.5, interval: 1 }; + + if (gotIt) { + data.ease = Math.min(3.0, data.ease + 0.1); + data.interval = Math.max(1, Math.round(data.interval * data.ease)); + } else { + data.ease = Math.max(1.3, data.ease - 0.2); + data.interval = 1; + } + data.nextDue = Date.now() + data.interval * DAY_MS; + srs[key] = data; + await saveSRS(srs); + } async function getAllCards() { const { mindtabCards } = await chrome.storage.sync.get('mindtabCards'); @@ -132,21 +189,45 @@ function initFlashcards() { async function pickCard(cards) { if (cards.length === 1) return cards[0]; - const { mindtabCardIdx } = await chrome.storage.local.get('mindtabCardIdx'); - let next; - do { next = Math.floor(Math.random() * cards.length); } - while (next === mindtabCardIdx); - await chrome.storage.local.set({ mindtabCardIdx: next }); - return cards[next]; + const srs = await getSRS(); + const now = Date.now(); + + // Prefer cards that are due (new cards or past their interval) + const due = cards.filter(c => { + const d = srs[cardKey(c)]; + return !d || d.nextDue <= now; + }); + + const pool = due.length > 0 ? due : cards; + + // Avoid repeating the last-shown card + const { mindtabLastCard } = await chrome.storage.local.get('mindtabLastCard'); + let pick = pool[Math.floor(Math.random() * pool.length)]; + if (pool.length > 1 && cardKey(pick) === mindtabLastCard) { + const others = pool.filter(c => cardKey(c) !== mindtabLastCard); + if (others.length) pick = others[Math.floor(Math.random() * others.length)]; + } + + await chrome.storage.local.set({ mindtabLastCard: cardKey(pick) }); + + // Show due count in badge + if (due.length > 0) { + dueBadge.textContent = `${due.length} due`; + dueBadge.style.display = 'inline'; + } else { + dueBadge.style.display = 'none'; + } + + return pick; } async function showCard() { if (showing) return; const cards = await getAllCards(); - const current = await pickCard(cards); + currentCard = await pickCard(cards); - questionEl.textContent = current.q; - answerEl.textContent = current.a; + questionEl.textContent = currentCard.q; + answerEl.textContent = currentCard.a; answerEl.style.display = 'none'; revealBtn.textContent = 'Reveal'; revealBtn.className = 'mt-btn mt-reveal'; @@ -154,13 +235,21 @@ function initFlashcards() { card.style.display = 'block'; showing = true; - autoHideTimer = setTimeout(dismiss, settings.displayDurationSeconds * 1000); + // Move focus to the card for accessibility + revealBtn.focus(); + + autoHideTimer = setTimeout(() => { + // Timeout counts as a skip (card not recalled) + if (currentCard) recordResult(currentCard, false); + dismiss(); + }, settings.displayDurationSeconds * 1000); } function dismiss() { clearTimeout(autoHideTimer); card.style.display = 'none'; showing = false; + currentCard = null; schedule(); } @@ -175,13 +264,23 @@ function initFlashcards() { revealBtn.textContent = 'Got it ✓'; revealBtn.className = 'mt-btn mt-got-it'; } else { + if (currentCard) recordResult(currentCard, true); dismiss(); } }); - skipBtn.addEventListener('click', dismiss); + skipBtn.addEventListener('click', () => { + if (currentCard) recordResult(currentCard, false); + dismiss(); + }); + closeBtn.addEventListener('click', dismiss); + // Alt+Shift+F triggers a card on demand + document.addEventListener('keydown', e => { + if (e.altKey && e.shiftKey && e.key === 'F' && !showing) showCard(); + }); + schedule(); } diff --git a/content_scripts/toneTranslator.js b/content_scripts/toneTranslator.js index 7bdf1f2..85f821a 100644 --- a/content_scripts/toneTranslator.js +++ b/content_scripts/toneTranslator.js @@ -59,49 +59,60 @@ function mtDetectTone(lower, toneConfig) { return best; } -function mtAnalyzeLocally(text, toneConfig) { +function mtAnalyzeLocally(text, toneConfig, checks, longThreshold) { const lower = text.toLowerCase(); const words = text.split(/\s+/).filter(Boolean); const sentences = text.split(/[.!?]+/).filter(s => s.trim().split(/\s+/).length > 2); const suggestions = []; + const threshold = longThreshold || 30; // Passive voice - const passiveHits = [...text.matchAll(MT_PASSIVE_RE)]; - if (passiveHits.length) { - const ex = passiveHits.slice(0, 2).map(m => `"${m[0]}"`).join(', '); - suggestions.push({ level: 'warn', text: `Passive voice: ${ex}` }); + if (checks?.passive !== false) { + const passiveHits = [...text.matchAll(MT_PASSIVE_RE)]; + if (passiveHits.length) { + const ex = passiveHits.slice(0, 2).map(m => `"${m[0]}"`).join(', '); + suggestions.push({ level: 'warn', text: `Passive voice: ${ex}` }); + } } // Hedge words - const weakHits = MT_WEAK_WORDS.filter(w => new RegExp(`\\b${w}\\b`, 'i').test(text)); - if (weakHits.length) { - const total = weakHits.reduce((n, w) => - n + (lower.match(new RegExp(`\\b${w}\\b`, 'g')) || []).length, 0); - suggestions.push({ level: 'warn', text: `${total} hedge word${total > 1 ? 's' : ''}: "${weakHits.slice(0, 3).join('", "')}"` }); + if (checks?.weak !== false) { + const weakHits = MT_WEAK_WORDS.filter(w => new RegExp(`\\b${w}\\b`, 'i').test(text)); + if (weakHits.length) { + const total = weakHits.reduce((n, w) => + n + (lower.match(new RegExp(`\\b${w}\\b`, 'g')) || []).length, 0); + suggestions.push({ level: 'warn', text: `${total} hedge word${total > 1 ? 's' : ''}: "${weakHits.slice(0, 3).join('", "')}"` }); + } } - // Long sentences (>30 words) - const longCount = sentences.filter(s => s.split(/\s+/).filter(Boolean).length > 30).length; - if (longCount) { - suggestions.push({ level: 'info', text: `${longCount} long sentence${longCount > 1 ? 's' : ''} — try splitting for clarity` }); + // Long sentences + if (checks?.long !== false) { + const longCount = sentences.filter(s => s.split(/\s+/).filter(Boolean).length > threshold).length; + if (longCount) { + suggestions.push({ level: 'info', text: `${longCount} long sentence${longCount > 1 ? 's' : ''} — try splitting for clarity` }); + } } // Filler words - const fillerHits = MT_FILLER_WORDS.filter(w => lower.includes(w)); - if (fillerHits.length) { - suggestions.push({ level: 'info', text: `Filler: "${fillerHits.slice(0, 2).join('", "')}"` }); + if (checks?.filler !== false) { + const fillerHits = MT_FILLER_WORDS.filter(w => lower.includes(w)); + if (fillerHits.length) { + suggestions.push({ level: 'info', text: `Filler: "${fillerHits.slice(0, 2).join('", "')}"` }); + } } // Repeated meaningful words (3+ times) - const freq = {}; - words.forEach(w => { - const c = w.toLowerCase().replace(/[^a-z]/g, ''); - if (c.length > 4 && !MT_STOP_WORDS.has(c)) freq[c] = (freq[c] || 0) + 1; - }); - const repeated = Object.entries(freq).filter(([, n]) => n >= 3).sort((a, b) => b[1] - a[1]); - if (repeated.length) { - const ex = repeated.slice(0, 2).map(([w, n]) => `"${w}" ×${n}`).join(', '); - suggestions.push({ level: 'info', text: `Repeated: ${ex}` }); + if (checks?.repeat !== false) { + const freq = {}; + words.forEach(w => { + const c = w.toLowerCase().replace(/[^a-z]/g, ''); + if (c.length > 4 && !MT_STOP_WORDS.has(c)) freq[c] = (freq[c] || 0) + 1; + }); + const repeated = Object.entries(freq).filter(([, n]) => n >= 3).sort((a, b) => b[1] - a[1]); + if (repeated.length) { + const ex = repeated.slice(0, 2).map(([w, n]) => `"${w}" ×${n}`).join(', '); + suggestions.push({ level: 'info', text: `Repeated: ${ex}` }); + } } return { @@ -238,13 +249,15 @@ function mtBuildPanel() { const panel = document.createElement('div'); panel.id = 'mt-panel'; + panel.setAttribute('role', 'complementary'); + panel.setAttribute('aria-label', 'MindTab Writing Assistant'); panel.setAttribute('aria-live', 'polite'); panel.innerHTML = `
⚡ MindTab
- +
@@ -386,8 +399,11 @@ function initToneTranslator() { const toneConfig = window.__MindTab.toneConfig; if (!toneConfig) return; - const serverUrl = (window.__MindTab.state.toneApiUrl || '').trim(); - const minWords = toneConfig.minWords || 15; + const state = window.__MindTab.state; + const serverUrl = (state.toneApiUrl || '').trim(); + const minWords = toneConfig.minWords || 15; + const checks = state.toneChecks || {}; + const longThresh = state.longSentenceThreshold || 30; const panel = mtBuildPanel(); let hidden = false; // user closed it for this page session @@ -396,6 +412,10 @@ function initToneTranslator() { let activeEl = null; let lastText = ''; + // Cooldown: prevent hammering the grammar server faster than once per 750ms + let lastServerCall = 0; + const SERVER_COOLDOWN_MS = 750; + // Header buttons document.getElementById('mt-min-btn').addEventListener('click', () => { minimized = !minimized; @@ -412,6 +432,14 @@ function initToneTranslator() { if (lastText) analyze(lastText); }); + // Esc closes the panel + document.addEventListener('keydown', e => { + if (e.key === 'Escape' && panel.style.display !== 'none') { + panel.style.display = 'none'; + hidden = true; + } + }); + // Set initial server dot state if (!serverUrl) { mtSetDot('none'); @@ -421,10 +449,14 @@ function initToneTranslator() { lastText = text; // Local analysis (instant) - const local = mtAnalyzeLocally(text, toneConfig); + const local = mtAnalyzeLocally(text, toneConfig, checks, longThresh); mtRenderLocal(local, toneConfig); if (serverUrl) { + const now = Date.now(); + if (now - lastServerCall < SERVER_COOLDOWN_MS) return; + lastServerCall = now; + // Cancel previous in-flight request if (abortCtrl) abortCtrl.abort(); abortCtrl = new AbortController(); diff --git a/manifest.json b/manifest.json index ae5a4bd..1dbf413 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "MindTab", - "version": "1.1.2", + "version": "1.2.0", "description": "Feed sanitizer, malicious ad blocker, tone awareness, and flash learning — all in one.", "permissions": ["storage", "tabs", "alarms"], "host_permissions": [""], diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..bee35dc --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2243 @@ +{ + "name": "mindtab", + "version": "1.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mindtab", + "version": "1.2.0", + "devDependencies": { + "jsdom": "^25.0.0", + "vitest": "^2.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "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, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "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, + "license": "MIT", + "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, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "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, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "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, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.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, + "license": "MIT", + "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, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "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, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "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, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "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, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "dev": true, + "license": "MIT", + "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/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..48a6304 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "mindtab", + "version": "1.2.0", + "description": "Feed sanitizer, malicious ad blocker, tone awareness, and flash learning — all in one.", + "type": "module", + "scripts": { + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "jsdom": "^25.0.0", + "vitest": "^2.0.0" + } +} diff --git a/tests/corsOrigin.test.js b/tests/corsOrigin.test.js new file mode 100644 index 0000000..932b7b5 --- /dev/null +++ b/tests/corsOrigin.test.js @@ -0,0 +1,60 @@ +// Tests for CORS origin validation logic from server/index.js. + +import { describe, it, expect } from 'vitest'; + +// ── Inlined from server/index.js ────────────────────────────────────────────── + +function isAllowedOrigin(origin) { + return !origin || + origin.startsWith('moz-extension://') || + origin.startsWith('chrome-extension://') || + /^https?:\/\/localhost(:\d+)?$/.test(origin); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('CORS origin validation', () => { + it('allows requests with no origin (same-origin or non-browser)', () => { + expect(isAllowedOrigin(undefined)).toBe(true); + expect(isAllowedOrigin(null)).toBe(true); + expect(isAllowedOrigin('')).toBe(true); + }); + + it('allows Firefox extension origins', () => { + expect(isAllowedOrigin('moz-extension://abc123-some-uuid')).toBe(true); + expect(isAllowedOrigin('moz-extension://00000000-0000-0000-0000-000000000000')).toBe(true); + }); + + it('allows Chrome/Edge extension origins', () => { + expect(isAllowedOrigin('chrome-extension://abcdefghijklmn')).toBe(true); + expect(isAllowedOrigin('chrome-extension://nkbihfbeogaeaoehlefnkodbefgpgknn')).toBe(true); + }); + + it('allows localhost with and without port', () => { + expect(isAllowedOrigin('http://localhost')).toBe(true); + expect(isAllowedOrigin('http://localhost:3000')).toBe(true); + expect(isAllowedOrigin('https://localhost')).toBe(true); + expect(isAllowedOrigin('https://localhost:8080')).toBe(true); + }); + + it('blocks arbitrary web origins', () => { + expect(isAllowedOrigin('https://example.com')).toBe(false); + expect(isAllowedOrigin('https://evil.com')).toBe(false); + expect(isAllowedOrigin('http://mysite.org')).toBe(false); + }); + + it('blocks spoofed origins that start with allowed prefixes in the path', () => { + expect(isAllowedOrigin('https://moz-extension.evil.com')).toBe(false); + expect(isAllowedOrigin('https://chrome-extension.evil.com')).toBe(false); + }); + + it('blocks localhost-lookalike domains', () => { + expect(isAllowedOrigin('https://localhost.evil.com')).toBe(false); + expect(isAllowedOrigin('http://localhostproxy.com')).toBe(false); + }); + + it('blocks null-origin string (distinct from null/undefined)', () => { + // The string "null" can appear in sandboxed iframes — must be blocked + expect(isAllowedOrigin('null')).toBe(false); + }); +}); diff --git a/tests/filterParser.test.js b/tests/filterParser.test.js new file mode 100644 index 0000000..25e1b5a --- /dev/null +++ b/tests/filterParser.test.js @@ -0,0 +1,145 @@ +// Tests for the ABP/uBlock cosmetic filter list parser from background.js. + +import { describe, it, expect } from 'vitest'; + +// ── Inlined from background.js ──────────────────────────────────────────────── + +const TARGET_DOMAINS = ['youtube.com', 'instagram.com', 'facebook.com']; + +function parseFilterList(text) { + const result = {}; + for (const d of TARGET_DOMAINS) result[d] = new Set(); + + for (const raw of text.split('\n')) { + const line = raw.trim(); + if (!line || line.startsWith('!') || line.startsWith('[') || line.startsWith('@@')) continue; + + const sep = line.indexOf('##'); + if (sep === -1) continue; + + const domainsPart = line.substring(0, sep); + if (!domainsPart) continue; + + const selector = line.substring(sep + 2); + if (!selector) continue; + + if (selector.includes(':matches') || selector.includes(':upward(') || + selector.includes(':is(') && selector.includes(':not(') || + (selector.startsWith(':') && selector.includes('('))) continue; + + const lineDomains = domainsPart.split(',').map(d => d.trim().toLowerCase()); + + for (const target of TARGET_DOMAINS) { + if (lineDomains.some(d => { + if (d.startsWith('~')) return false; + return d === target || target.endsWith('.' + d); + })) { + result[target].add(selector); + } + } + } + + return Object.fromEntries( + Object.entries(result).map(([k, v]) => [k, [...v]]) + ); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('parseFilterList', () => { + it('parses a basic domain##selector rule', () => { + const input = 'youtube.com##ytd-rich-shelf-renderer[is-shorts]'; + const result = parseFilterList(input); + expect(result['youtube.com']).toContain('ytd-rich-shelf-renderer[is-shorts]'); + }); + + it('ignores comment lines starting with !', () => { + const input = '! This is a comment\nyoutube.com##.some-class'; + const result = parseFilterList(input); + expect(result['youtube.com']).toContain('.some-class'); + expect(result['youtube.com'].length).toBe(1); + }); + + it('ignores lines starting with @@ (exception rules)', () => { + const input = '@@youtube.com##.some-class'; + const result = parseFilterList(input); + expect(result['youtube.com'].length).toBe(0); + }); + + it('ignores global rules with no domain part', () => { + const input = '##.global-selector'; + const result = parseFilterList(input); + for (const domain of TARGET_DOMAINS) { + expect(result[domain].length).toBe(0); + } + }); + + it('ignores exclusion domain rules (~domain)', () => { + const input = '~youtube.com##.excluded-element'; + const result = parseFilterList(input); + expect(result['youtube.com'].length).toBe(0); + }); + + it('matches rules with multiple domains', () => { + const input = 'youtube.com,instagram.com##.shorts-reel-element'; + const result = parseFilterList(input); + expect(result['youtube.com']).toContain('.shorts-reel-element'); + expect(result['instagram.com']).toContain('.shorts-reel-element'); + expect(result['facebook.com'].length).toBe(0); + }); + + it('skips procedural/extended filter selectors', () => { + const proc = [ + 'youtube.com##:matches-css(display: block)', + 'youtube.com##:upward(div)', + ]; + for (const line of proc) { + const result = parseFilterList(line); + expect(result['youtube.com'].length).toBe(0); + } + }); + + it('deduplicates identical selectors', () => { + const input = 'youtube.com##.dup\nyoutube.com##.dup\nyoutube.com##.dup'; + const result = parseFilterList(input); + expect(result['youtube.com'].filter(s => s === '.dup').length).toBe(1); + }); + + it('handles empty input gracefully', () => { + const result = parseFilterList(''); + for (const domain of TARGET_DOMAINS) { + expect(Array.isArray(result[domain])).toBe(true); + expect(result[domain].length).toBe(0); + } + }); + + it('handles lines with no ## separator', () => { + const input = 'youtube.com/some-network-rule'; + const result = parseFilterList(input); + expect(result['youtube.com'].length).toBe(0); + }); + + it('does not assign rules to non-target domains', () => { + const input = 'twitter.com##.tweet-ad'; + const result = parseFilterList(input); + for (const domain of TARGET_DOMAINS) { + expect(result[domain].length).toBe(0); + } + }); + + it('does not capture subdomain rules under their parent target', () => { + // The parser checks target.endsWith('.' + d), not d.endsWith('.' + target). + // A rule for www.youtube.com is more specific than our youtube.com target bucket + // and is correctly NOT captured — callers add www.youtube.com rules separately. + const input = 'www.youtube.com##.shorts-shelf'; + const result = parseFilterList(input); + expect(result['youtube.com']).not.toContain('.shorts-shelf'); + }); + + it('ignores lines starting with [', () => { + const input = '[Adblock Plus 2.0]\nyoutube.com##.shorts'; + const result = parseFilterList(input); + expect(result['youtube.com']).toContain('.shorts'); + expect(result['youtube.com'].length).toBe(1); + }); +}); diff --git a/tests/toneAnalysis.test.js b/tests/toneAnalysis.test.js new file mode 100644 index 0000000..bdba39b --- /dev/null +++ b/tests/toneAnalysis.test.js @@ -0,0 +1,284 @@ +// Tests for pure tone analysis functions from content_scripts/toneTranslator.js. +// Functions are inlined here because the extension uses no module system. + +import { describe, it, expect } from 'vitest'; + +// ── Inlined from toneTranslator.js ──────────────────────────────────────────── + +const MT_WEAK_WORDS = [ + 'very', 'really', 'quite', 'basically', 'actually', 'literally', + 'honestly', 'just', 'simply', 'obviously', 'clearly', 'definitely', + 'probably', 'maybe', 'perhaps', 'somewhat', 'rather', 'fairly', + 'pretty', 'sort of', 'kind of', 'a bit', 'a little', 'needless to say' +]; + +const MT_FILLER_WORDS = [ + 'um,', 'uh,', 'er,', 'you know,', 'i mean,', 'like i said', 'as i said' +]; + +const MT_PASSIVE_RE = /\b(am|is|are|was|were|be|been|being)\s+(\w+(?:ed|en))\b/gi; + +const MT_STOP_WORDS = new Set([ + 'the','a','an','and','or','but','in','on','at','to','for','of','with', + 'is','are','was','were','be','been','have','has','had','do','did','will', + 'would','could','should','may','might','can','this','that','these','those', + 'i','you','he','she','it','we','they','my','your','his','her','its','our','their', + 'not','no','so','if','as','by','from','then','than','when','what','which','who', + 'how','all','some','more','most','also','just','into','up','out','about' +]); + +function mtSyllables(word) { + const w = word.toLowerCase().replace(/[^a-z]/g, ''); + if (w.length <= 3) return 1; + const stripped = w.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '').replace(/^y/, ''); + const m = stripped.match(/[aeiouy]{1,2}/g); + return m ? Math.max(1, m.length) : 1; +} + +function mtReadability(words, sentences) { + if (!sentences || !words?.length) return null; + const syllables = words.reduce((n, w) => n + mtSyllables(w), 0); + const grade = Math.round( + 0.39 * (words.length / sentences) + + 11.8 * (syllables / words.length) - 15.59 + ); + const g = Math.max(1, Math.min(16, grade)); + const labels = [,'Elementary','Elementary','Elementary','Elementary','Elementary', + '6th grade','7th grade','8th grade','9th grade','10th grade', + '11th grade','12th grade','College','College','Graduate','Graduate']; + return { grade: g, label: labels[g] }; +} + +function mtDetectTone(lower, toneConfig) { + let best = null, bestScore = 0; + for (const [key, def] of Object.entries(toneConfig.tones)) { + const score = def.keywords.filter(k => lower.includes(k)).length; + if (score > bestScore) { bestScore = score; best = key; } + } + return best; +} + +function mtAnalyzeLocally(text, toneConfig, checks = {}, longThreshold = 30) { + const lower = text.toLowerCase(); + const words = text.split(/\s+/).filter(Boolean); + const sentences = text.split(/[.!?]+/).filter(s => s.trim().split(/\s+/).length > 2); + const suggestions = []; + + if (checks.passive !== false) { + const passiveHits = [...text.matchAll(MT_PASSIVE_RE)]; + if (passiveHits.length) { + const ex = passiveHits.slice(0, 2).map(m => `"${m[0]}"`).join(', '); + suggestions.push({ level: 'warn', text: `Passive voice: ${ex}` }); + } + } + + if (checks.weak !== false) { + const weakHits = MT_WEAK_WORDS.filter(w => new RegExp(`\\b${w}\\b`, 'i').test(text)); + if (weakHits.length) { + const total = weakHits.reduce((n, w) => + n + (lower.match(new RegExp(`\\b${w}\\b`, 'g')) || []).length, 0); + suggestions.push({ level: 'warn', text: `${total} hedge word${total > 1 ? 's' : ''}: "${weakHits.slice(0, 3).join('", "')}"` }); + } + } + + if (checks.long !== false) { + const longCount = sentences.filter(s => s.split(/\s+/).filter(Boolean).length > longThreshold).length; + if (longCount) { + suggestions.push({ level: 'info', text: `${longCount} long sentence${longCount > 1 ? 's' : ''} — try splitting for clarity` }); + } + } + + if (checks.filler !== false) { + const fillerHits = MT_FILLER_WORDS.filter(w => lower.includes(w)); + if (fillerHits.length) { + suggestions.push({ level: 'info', text: `Filler: "${fillerHits.slice(0, 2).join('", "')}"` }); + } + } + + if (checks.repeat !== false) { + const freq = {}; + words.forEach(w => { + const c = w.toLowerCase().replace(/[^a-z]/g, ''); + if (c.length > 4 && !MT_STOP_WORDS.has(c)) freq[c] = (freq[c] || 0) + 1; + }); + const repeated = Object.entries(freq).filter(([, n]) => n >= 3).sort((a, b) => b[1] - a[1]); + if (repeated.length) { + const ex = repeated.slice(0, 2).map(([w, n]) => `"${w}" ×${n}`).join(', '); + suggestions.push({ level: 'info', text: `Repeated: ${ex}` }); + } + } + + return { + tone: mtDetectTone(lower, toneConfig), + suggestions, + stats: { + words: words.length, + sentences: sentences.length, + readability: mtReadability(words, sentences.length) + } + }; +} + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const TONE_CONFIG = { + tones: { + aggressive: { keywords: ['hate', 'terrible', 'stupid', 'awful', 'ridiculous'] }, + passive_aggressive: { keywords: ['fine', 'whatever', 'noted', 'as per my last', 'friendly reminder'] }, + formal: { keywords: ['sincerely', 'regards', 'hereby', 'pursuant', 'kindly'] }, + casual: { keywords: ['hey', 'yeah', 'gonna', 'lol', 'tbh', 'ngl'] }, + positive: { keywords: ['great', 'excellent', 'amazing', 'thank you', 'appreciate'] }, + urgent: { keywords: ['asap', 'urgent', 'immediately', 'critical', 'deadline'] }, + } +}; + +// ── mtSyllables ─────────────────────────────────────────────────────────────── + +describe('mtSyllables', () => { + it('returns 1 for short words', () => { + expect(mtSyllables('the')).toBe(1); + expect(mtSyllables('a')).toBe(1); + }); + + it('counts syllables in common words', () => { + expect(mtSyllables('beautiful')).toBeGreaterThanOrEqual(3); + expect(mtSyllables('education')).toBeGreaterThanOrEqual(4); + expect(mtSyllables('cat')).toBe(1); + }); + + it('never returns less than 1', () => { + expect(mtSyllables('rhythm')).toBeGreaterThanOrEqual(1); + }); +}); + +// ── mtReadability ───────────────────────────────────────────────────────────── + +describe('mtReadability', () => { + it('returns null when sentences is 0', () => { + expect(mtReadability(['hello', 'world'], 0)).toBeNull(); + }); + + it('returns null when words array is empty', () => { + expect(mtReadability([], 1)).toBeNull(); + }); + + it('returns a grade between 1 and 16', () => { + const words = 'The cat sat on the mat it was very flat'.split(' '); + const r = mtReadability(words, 2); + expect(r).not.toBeNull(); + expect(r.grade).toBeGreaterThanOrEqual(1); + expect(r.grade).toBeLessThanOrEqual(16); + expect(typeof r.label).toBe('string'); + }); + + it('scores simple text lower than complex text', () => { + const simple = 'The cat sat. The dog ran.'.split(' '); + const complex = 'The implementation of aforementioned constitutional amendments precipitates substantial jurisprudential ramifications.'.split(' '); + const rSimple = mtReadability(simple, 2); + const rComplex = mtReadability(complex, 1); + expect(rComplex.grade).toBeGreaterThan(rSimple.grade); + }); +}); + +// ── mtDetectTone ────────────────────────────────────────────────────────────── + +describe('mtDetectTone', () => { + it('detects aggressive tone', () => { + expect(mtDetectTone('this is terrible and stupid', TONE_CONFIG)).toBe('aggressive'); + }); + + it('detects casual tone', () => { + expect(mtDetectTone('hey yeah gonna do it lol ngl tbh', TONE_CONFIG)).toBe('casual'); + }); + + it('detects positive tone', () => { + expect(mtDetectTone('thank you this is great and amazing', TONE_CONFIG)).toBe('positive'); + }); + + it('detects formal tone', () => { + expect(mtDetectTone('sincerely regards hereby kindly', TONE_CONFIG)).toBe('formal'); + }); + + it('detects urgent tone', () => { + expect(mtDetectTone('asap urgent critical deadline', TONE_CONFIG)).toBe('urgent'); + }); + + it('returns null when no keywords match', () => { + expect(mtDetectTone('hello world this is a test message', TONE_CONFIG)).toBeNull(); + }); + + it('picks the tone with the most keyword matches', () => { + // 3 aggressive + 1 positive → aggressive + const lower = 'terrible stupid awful thank you'; + expect(mtDetectTone(lower, TONE_CONFIG)).toBe('aggressive'); + }); +}); + +// ── mtAnalyzeLocally ────────────────────────────────────────────────────────── + +describe('mtAnalyzeLocally', () => { + it('returns stats with word count and sentence count', () => { + const text = 'Hello world. This is a test sentence.'; + const r = mtAnalyzeLocally(text, TONE_CONFIG); + expect(r.stats.words).toBeGreaterThan(0); + expect(r.stats.sentences).toBeGreaterThan(0); + }); + + it('detects passive voice', () => { + const text = 'The letter was written by the student who was motivated by curiosity.'; + const r = mtAnalyzeLocally(text, TONE_CONFIG); + const hasPassive = r.suggestions.some(s => s.text.startsWith('Passive voice')); + expect(hasPassive).toBe(true); + }); + + it('detects hedge words', () => { + const text = 'I very basically just really think this is probably maybe a bit obvious.'; + const r = mtAnalyzeLocally(text, TONE_CONFIG); + const hasWeak = r.suggestions.some(s => s.text.includes('hedge')); + expect(hasWeak).toBe(true); + }); + + it('detects filler words', () => { + const text = 'I mean, um, you know, this is something that, like i said, matters a lot.'; + const r = mtAnalyzeLocally(text, TONE_CONFIG); + const hasFiller = r.suggestions.some(s => s.text.startsWith('Filler')); + expect(hasFiller).toBe(true); + }); + + it('detects repeated words', () => { + const text = 'The project project project needs attention because the project manager wants the project done.'; + const r = mtAnalyzeLocally(text, TONE_CONFIG); + const hasRepeat = r.suggestions.some(s => s.text.startsWith('Repeated')); + expect(hasRepeat).toBe(true); + }); + + it('skips disabled checks', () => { + const text = 'The letter was written. I very basically just really probably think this.'; + const r = mtAnalyzeLocally(text, TONE_CONFIG, { passive: false, weak: false }); + const hasPassive = r.suggestions.some(s => s.text.startsWith('Passive')); + const hasWeak = r.suggestions.some(s => s.text.includes('hedge')); + expect(hasPassive).toBe(false); + expect(hasWeak).toBe(false); + }); + + it('respects custom long sentence threshold', () => { + // 10-word sentence — above threshold of 8, below default of 30 + const text = 'This sentence has exactly ten words total in it.'; + const defaultR = mtAnalyzeLocally(text, TONE_CONFIG, {}, 30); + const lowR = mtAnalyzeLocally(text, TONE_CONFIG, {}, 8); + const defaultLong = defaultR.suggestions.some(s => s.text.includes('long sentence')); + const lowLong = lowR.suggestions.some(s => s.text.includes('long sentence')); + expect(defaultLong).toBe(false); + expect(lowLong).toBe(true); + }); + + it('has no suggestions for clean text', () => { + const text = 'The team completed the project on time and delivered excellent results.'; + const r = mtAnalyzeLocally(text, TONE_CONFIG); + // No passive, no hedge, no filler, no repeats — suggestions may be empty + const hasPassive = r.suggestions.some(s => s.text.startsWith('Passive')); + const hasFiller = r.suggestions.some(s => s.text.startsWith('Filler')); + expect(hasPassive).toBe(false); + expect(hasFiller).toBe(false); + }); +}); diff --git a/ui/cards.css b/ui/cards.css index 8487197..a1b01d2 100644 --- a/ui/cards.css +++ b/ui/cards.css @@ -82,7 +82,16 @@ body { display: flex; align-items: center; justify-content: space-between; + gap: 8px; margin-bottom: 12px; + flex-wrap: wrap; +} + +.section-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; } .section-title { @@ -164,6 +173,14 @@ body { .btn-primary { background: var(--accent); color: #fff; } +.btn-secondary { + background: var(--surface2); + color: var(--text); + border: 1px solid var(--border); + font-size: 12px; + padding: 5px 12px; +} + .btn-danger { background: transparent; color: var(--red); @@ -241,3 +258,18 @@ body { } .section-defaults { opacity: 0.8; } + +.page-footer { + text-align: center; + font-size: 11px; + color: var(--subtext); + margin-top: 32px; + padding-bottom: 16px; +} + +.page-footer a { + color: var(--accent); + text-decoration: none; +} + +.page-footer a:hover { text-decoration: underline; } diff --git a/ui/cards.html b/ui/cards.html index 079cfd6..62b7faa 100644 --- a/ui/cards.html +++ b/ui/cards.html @@ -35,7 +35,12 @@

Add a Card

Your Custom Cards 0

- +
+ + + + +
  • No custom cards yet. Add one above!
  • @@ -51,6 +56,10 @@

    Default Cards + MindTab by AetherAssembly + + diff --git a/ui/cards.js b/ui/cards.js index 2275a17..54c3e48 100644 --- a/ui/cards.js +++ b/ui/cards.js @@ -118,4 +118,38 @@ document.addEventListener('DOMContentLoaded', async () => { await saveCustomCards([]); render(); }); + + // Export custom cards as JSON + document.getElementById('btn-export').addEventListener('click', async () => { + const cards = await getCustomCards(); + if (cards.length === 0) { showMsg('No custom cards to export.', '#e0a050'); return; } + const blob = new Blob([JSON.stringify(cards, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'mindtab-cards.json'; + a.click(); + URL.revokeObjectURL(url); + showMsg(`Exported ${cards.length} card${cards.length !== 1 ? 's' : ''}!`); + }); + + // Import cards from JSON file + document.getElementById('import-file').addEventListener('change', async e => { + const file = e.target.files[0]; + if (!file) return; + try { + const text = await file.text(); + const data = JSON.parse(text); + if (!Array.isArray(data)) throw new Error('Expected a JSON array'); + const valid = data.filter(c => typeof c?.q === 'string' && typeof c?.a === 'string' && c.q.trim() && c.a.trim()); + if (valid.length === 0) throw new Error('No valid {q, a} entries found'); + const current = await getCustomCards(); + await saveCustomCards([...current, ...valid]); + showMsg(`Imported ${valid.length} card${valid.length !== 1 ? 's' : ''}!`); + render(); + } catch (err) { + showMsg(`Import failed: ${err.message}`, '#e74c3c'); + } + e.target.value = ''; + }); }); diff --git a/ui/popup.html b/ui/popup.html index f8216f2..8b45ca3 100644 --- a/ui/popup.html +++ b/ui/popup.html @@ -64,7 +64,7 @@

    MindTab

diff --git a/ui/settings.css b/ui/settings.css index 3d348f4..b61bc58 100644 --- a/ui/settings.css +++ b/ui/settings.css @@ -223,6 +223,140 @@ body { margin: 16px 0; } +/* Segmented control (theme picker) */ +.segmented { + display: flex; + gap: 0; + border: 1px solid var(--border); + border-radius: 7px; + overflow: hidden; + align-self: flex-start; +} + +.seg-opt { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.seg-opt input[type="radio"] { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.seg-opt span, +.seg-opt { + padding: 7px 18px; + font-size: 12px; + font-weight: 500; + color: var(--subtext); + background: var(--surface2); + transition: background 0.15s, color 0.15s; + user-select: none; + border-right: 1px solid var(--border); +} + +.seg-opt:last-child, +.seg-opt:last-child span { border-right: none; } + +.seg-opt input:checked + span, +.seg-opt:has(input:checked) { + background: var(--accent); + color: #fff; +} + +/* Writing checks grid */ +.checks-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-top: 4px; +} + +.check-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + cursor: pointer; +} + +.check-item input[type="checkbox"] { + accent-color: var(--accent); + width: 15px; + height: 15px; + cursor: pointer; +} + +/* Range slider */ +.range-input { + width: 100%; + accent-color: var(--accent); + cursor: pointer; +} + +/* Filter URL list */ +.url-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 4px; +} + +.url-item { + display: flex; + align-items: center; + gap: 8px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 7px; + padding: 8px 12px; + font-size: 11px; + color: var(--subtext); + word-break: break-all; +} + +.url-item span { flex: 1; } + +.url-del { + background: none; + border: none; + color: #555; + cursor: pointer; + font-size: 14px; + padding: 0 2px; + flex-shrink: 0; + transition: color 0.15s; +} + +.url-del:hover { color: var(--red); } + +/* Dark/light theme overrides — used when data-theme is set explicitly */ +[data-theme="dark"] { + --bg: #0f0f1a; + --surface: #1a1a2e; + --surface2: #22223a; + --accent-dim: rgba(74, 144, 226, 0.15); + --text: #e8e8f0; + --subtext: #888; + --border: rgba(255,255,255,0.07); +} + +[data-theme="light"] { + --bg: #f4f4fb; + --surface: #ffffff; + --surface2: #eaeaf5; + --accent-dim: rgba(74, 144, 226, 0.12); + --text: #1a1a2e; + --subtext: #666; + --border: rgba(0,0,0,0.09); +} + /* Footer */ .page-footer { text-align: center; diff --git a/ui/settings.html b/ui/settings.html index df6192e..8fb5927 100644 --- a/ui/settings.html +++ b/ui/settings.html @@ -14,6 +14,20 @@

Settings

+ +
+

Appearance

+
+ Theme +
+ + + +
+ +
+
+

Tone Translator

@@ -45,6 +59,29 @@

Tone Translator

+ +
+

Writing Checks

+
+ Choose which suggestions appear in the Tone Translator panel. +
+ + + + + +
+
+
+ + +
+
+ + +
+
+

Feed Filter Lists

@@ -64,8 +101,27 @@

Feed Filter Lists

+ +
+

Filter List Sources

+
+ + Add or remove filter list URLs (ABP/uBlock cosmetic format). Changes take effect on the next update. + +
    +
    + + +
    + +
    +
    + +
    +
    + diff --git a/ui/settings.js b/ui/settings.js index a76f597..6011e85 100644 --- a/ui/settings.js +++ b/ui/settings.js @@ -1,9 +1,19 @@ +const DEFAULT_FILTER_LISTS = [ + 'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances-social.txt', + 'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/quick-fixes.txt', + 'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/AnnoyancesFilter/sections/social-widget.txt' +]; + const DEFAULTS = { feedSanitizer: true, adBlocker: true, toneTranslator: true, flashcards: true, - toneApiUrl: '' + toneApiUrl: '', + theme: 'system', + toneChecks: { passive: true, weak: true, long: true, filler: true, repeat: true }, + longSentenceThreshold: 30, + filterListUrls: null // null means use DEFAULT_FILTER_LISTS }; async function getState() { @@ -16,6 +26,19 @@ async function setState(patch) { await chrome.storage.sync.set({ mindtab: { ...current, ...patch } }); } +// ─── Theme ──────────────────────────────────────────────────────────────────── + +function applyTheme(theme) { + const root = document.documentElement; + if (theme === 'light' || theme === 'dark') { + root.setAttribute('data-theme', theme); + } else { + root.removeAttribute('data-theme'); + } +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + function formatTimestamp(ms) { if (!ms) return 'Never'; const diff = Date.now() - ms; @@ -25,6 +48,13 @@ function formatTimestamp(ms) { return new Date(ms).toLocaleDateString(); } +function showMsg(id, text, type, duration = 3000) { + const el = document.getElementById(id); + el.textContent = text; + el.className = `status-msg ${type}`; + setTimeout(() => { el.textContent = ''; el.className = 'status-msg'; }, duration); +} + async function refreshFilterStatus() { const statusEl = document.getElementById('filter-status'); chrome.runtime.sendMessage({ type: 'GET_FILTER_STATUS' }, result => { @@ -40,13 +70,6 @@ async function refreshFilterStatus() { }); } -function showSaveMsg(text, type) { - const el = document.getElementById('save-msg'); - el.textContent = text; - el.className = `status-msg ${type}`; - setTimeout(() => { el.textContent = ''; el.className = 'status-msg'; }, 3000); -} - function showConnResult(ok, headline, detail) { const box = document.getElementById('conn-result'); box.innerHTML = ''; @@ -65,35 +88,71 @@ function showConnResult(ok, headline, detail) { } } +// ─── Filter URL list UI ─────────────────────────────────────────────────────── + +function renderUrlList(urls) { + const list = document.getElementById('filter-url-list'); + list.innerHTML = ''; + urls.forEach((url, i) => { + const li = document.createElement('li'); + li.className = 'url-item'; + const span = document.createElement('span'); + span.textContent = url; + const del = document.createElement('button'); + del.className = 'url-del'; + del.setAttribute('aria-label', 'Remove URL'); + del.textContent = '✕'; + del.addEventListener('click', async () => { + const state = await getState(); + const current = state.filterListUrls || DEFAULT_FILTER_LISTS; + const updated = current.filter((_, j) => j !== i); + await setState({ filterListUrls: updated.length ? updated : DEFAULT_FILTER_LISTS }); + renderUrlList(updated.length ? updated : DEFAULT_FILTER_LISTS); + showMsg('url-msg', 'Removed. Update filters to apply.', 'warn'); + }); + li.append(span, del); + list.appendChild(li); + }); +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + document.addEventListener('DOMContentLoaded', async () => { const state = await getState(); - // Grammar server URL + // Apply stored theme immediately + applyTheme(state.theme || 'system'); + + // ── Theme selector ────────────────────────────────────────────────────────── + const themeRadios = document.querySelectorAll('input[name="theme"]'); + themeRadios.forEach(r => { + if (r.value === (state.theme || 'system')) r.checked = true; + r.addEventListener('change', async () => { + const theme = r.value; + applyTheme(theme); + await setState({ theme }); + showMsg('theme-msg', 'Theme saved!', 'ok'); + }); + }); + + // ── Grammar server URL ────────────────────────────────────────────────────── const apiInput = document.getElementById('tone-api-url'); apiInput.value = state.toneApiUrl || ''; - // Save document.getElementById('btn-save').addEventListener('click', async () => { const val = apiInput.value.trim(); if (val && !apiInput.validity.valid) { - showSaveMsg('Invalid URL — include https://', 'error'); + showMsg('save-msg', 'Invalid URL — include https://', 'error'); return; } await setState({ toneApiUrl: val }); - showSaveMsg('Saved!', 'ok'); + showMsg('save-msg', 'Saved!', 'ok'); }); - // Test connection document.getElementById('btn-test').addEventListener('click', async () => { const val = apiInput.value.trim(); - if (!val) { - showConnResult(false, 'Enter a server URL first.'); - return; - } - if (!apiInput.validity.valid) { - showConnResult(false, 'Invalid URL — include https://'); - return; - } + if (!val) { showConnResult(false, 'Enter a server URL first.'); return; } + if (!apiInput.validity.valid) { showConnResult(false, 'Invalid URL — include https://'); return; } const testBtn = document.getElementById('btn-test'); testBtn.disabled = true; @@ -111,18 +170,13 @@ document.addEventListener('DOMContentLoaded', async () => { if (res.ok) { const data = await res.json().catch(() => ({})); - const detail = [ - `${latency} ms`, - data.upstream ? `upstream: ${data.upstream}` : '' - ].filter(Boolean).join(' · '); + const detail = [`${latency} ms`, data.upstream ? `upstream: ${data.upstream}` : ''].filter(Boolean).join(' · '); showConnResult(true, 'Connected', detail); } else { showConnResult(false, `Server responded with ${res.status}`, 'Check that the MindTab proxy server is running.'); } } catch (e) { - const msg = e.name === 'TimeoutError' - ? 'Request timed out (8 s)' - : e.message || 'Could not reach server'; + const msg = e.name === 'TimeoutError' ? 'Request timed out (8 s)' : e.message || 'Could not reach server'; showConnResult(false, 'Connection failed', msg); } finally { testBtn.disabled = false; @@ -130,39 +184,86 @@ document.addEventListener('DOMContentLoaded', async () => { } }); - // Filter list update - const updateBtn = document.getElementById('btn-update-filters'); - const filterMsg = document.getElementById('filter-msg'); - let filterTimer; + // ── Writing checks ────────────────────────────────────────────────────────── + const checks = state.toneChecks || DEFAULTS.toneChecks; + document.getElementById('chk-passive').checked = checks.passive !== false; + document.getElementById('chk-weak').checked = checks.weak !== false; + document.getElementById('chk-long').checked = checks.long !== false; + document.getElementById('chk-filler').checked = checks.filler !== false; + document.getElementById('chk-repeat').checked = checks.repeat !== false; + + const thresholdSlider = document.getElementById('long-threshold'); + const thresholdVal = document.getElementById('threshold-val'); + thresholdSlider.value = state.longSentenceThreshold || 30; + thresholdVal.textContent = thresholdSlider.value; + thresholdSlider.addEventListener('input', () => { + thresholdVal.textContent = thresholdSlider.value; + }); + document.getElementById('btn-save-checks').addEventListener('click', async () => { + await setState({ + toneChecks: { + passive: document.getElementById('chk-passive').checked, + weak: document.getElementById('chk-weak').checked, + long: document.getElementById('chk-long').checked, + filler: document.getElementById('chk-filler').checked, + repeat: document.getElementById('chk-repeat').checked, + }, + longSentenceThreshold: parseInt(thresholdSlider.value, 10), + }); + showMsg('checks-msg', 'Saved!', 'ok'); + }); + + // ── Filter list status ────────────────────────────────────────────────────── await refreshFilterStatus(); - updateBtn.addEventListener('click', () => { + document.getElementById('btn-update-filters').addEventListener('click', () => { + const updateBtn = document.getElementById('btn-update-filters'); updateBtn.disabled = true; updateBtn.textContent = 'Updating…'; - filterMsg.textContent = ''; chrome.runtime.sendMessage({ type: 'UPDATE_FILTERS' }, result => { updateBtn.disabled = false; updateBtn.textContent = 'Update Now'; if (chrome.runtime.lastError || !result?.ok) { - filterMsg.textContent = 'Update failed — check your connection.'; - filterMsg.className = 'status-msg error'; + showMsg('filter-msg', 'Update failed — check your connection.', 'error'); } else { - filterMsg.textContent = 'Filter lists updated!'; - filterMsg.className = 'status-msg ok'; + showMsg('filter-msg', 'Filter lists updated!', 'ok'); } - - clearTimeout(filterTimer); - filterTimer = setTimeout(() => { - filterMsg.textContent = ''; - filterMsg.className = 'status-msg'; - }, 3000); refreshFilterStatus(); }); }); - // Back / close button + // ── Custom filter URLs ────────────────────────────────────────────────────── + const currentUrls = state.filterListUrls || DEFAULT_FILTER_LISTS; + renderUrlList(currentUrls); + + document.getElementById('btn-add-url').addEventListener('click', async () => { + const input = document.getElementById('filter-url-input'); + const val = input.value.trim(); + if (!val) return; + try { new URL(val); } catch { showMsg('url-msg', 'Invalid URL.', 'error'); return; } + + const st = await getState(); + const urls = [...(st.filterListUrls || DEFAULT_FILTER_LISTS), val]; + await setState({ filterListUrls: urls }); + renderUrlList(urls); + input.value = ''; + showMsg('url-msg', 'Added. Update filters to apply.', 'ok'); + }); + + document.getElementById('filter-url-input').addEventListener('keydown', e => { + if (e.key === 'Enter') document.getElementById('btn-add-url').click(); + }); + + document.getElementById('btn-reset-urls').addEventListener('click', async () => { + if (!confirm('Reset filter sources to defaults?')) return; + await setState({ filterListUrls: null }); + renderUrlList(DEFAULT_FILTER_LISTS); + showMsg('url-msg', 'Reset to defaults.', 'ok'); + }); + + // ── Back button ───────────────────────────────────────────────────────────── document.getElementById('btn-back').addEventListener('click', () => window.close()); }); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..014f97e --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + }, +});