From 7fbf82e37c5865df3328c66c1b7b912ce74cae14 Mon Sep 17 00:00:00 2001
From: Ollie <231715700+olliemochi@users.noreply.github.com>
Date: Sat, 16 May 2026 21:23:09 -0400
Subject: [PATCH 1/9] docs: update LICENSE doc
---
LICENSE | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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
From 2bc7b54d3a400715d7604dfa226f041fb04c23bc Mon Sep 17 00:00:00 2001
From: Ollie <231715700+olliemochi@users.noreply.github.com>
Date: Sat, 16 May 2026 21:29:49 -0400
Subject: [PATCH 2/9] chore: add vitest config, package.json, and tests
- Add a Vitest-based test harness and initial unit tests for core extension logic.
- Introduces package.json (version 1.2.0) with test scripts and vitest devDependency, and vitest.config.js (node environment, globals).
- Adds tests for CORS origin validation, ABP/uBlock filter parser, and local tone analysis (functions inlined from source for isolated testing).
- Includes a minor README wording tweak.
---
README.md | 2 +-
package.json | 13 ++
tests/corsOrigin.test.js | 60 ++++++++
tests/filterParser.test.js | 145 +++++++++++++++++++
tests/toneAnalysis.test.js | 284 +++++++++++++++++++++++++++++++++++++
vitest.config.js | 8 ++
6 files changed, 511 insertions(+), 1 deletion(-)
create mode 100644 package.json
create mode 100644 tests/corsOrigin.test.js
create mode 100644 tests/filterParser.test.js
create mode 100644 tests/toneAnalysis.test.js
create mode 100644 vitest.config.js
diff --git a/README.md b/README.md
index 3798b9a..f4cd8da 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@

- **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/package.json b/package.json
new file mode 100644
index 0000000..43175f3
--- /dev/null
+++ b/package.json
@@ -0,0 +1,13 @@
+{
+ "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": {
+ "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/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,
+ },
+});
From 3955a129fbfa100a0be50b7e9eba1a583c04dc3f Mon Sep 17 00:00:00 2001
From: Ollie <231715700+olliemochi@users.noreply.github.com>
Date: Sat, 16 May 2026 21:59:59 -0400
Subject: [PATCH 3/9] feat: add theme, import/export, and filter UI
Introduce several UI features and styles across the settings and cards views:
- Add a segmented theme picker, writing-checks controls (checkboxes + range), and a configurable filter-URL list with add/remove/reset.
- Add import/export buttons and file input for custom cards, group section actions, and add a page footer component.
- Add new CSS utilities (segmented control, checks grid, url-list, btn-secondary, section-actions, page-footer) and minor layout tweaks (wrapping/gaps).
- Update credit links from Aster1630 to AetherAssembly in popup, settings and cards.
- Accessibility improvements: radio/group roles and aria-live/status messages, and aria-label on the import file input.
---
ui/cards.css | 32 +++++++++++
ui/cards.html | 11 +++-
ui/popup.html | 2 +-
ui/settings.css | 134 +++++++++++++++++++++++++++++++++++++++++++++++
ui/settings.html | 58 +++++++++++++++++++-
5 files changed, 234 insertions(+), 3 deletions(-)
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
- No custom cards yet. Add one above!
@@ -51,6 +56,10 @@ Default Cards