Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
463ce62
Document the page corpus and the e2e harness in the README
EnesYilmazcode Sep 24, 2026
5b23c4f
Add a chat module that builds the Gemini request from the current run
EnesYilmazcode Sep 24, 2026
62c365b
Answer chat questions in the service worker through the chat module
EnesYilmazcode Sep 24, 2026
ce417b2
Restore a Gemini key field in the popup under AI chat settings
EnesYilmazcode Sep 24, 2026
3169638
Show the chat's key state and run in the widget, and keep a short his…
EnesYilmazcode Sep 24, 2026
f54aef5
Document the chat key setting and the new request flow
EnesYilmazcode Sep 24, 2026
11040e5
Write the bidi characters in the title cleaner as escapes
EnesYilmazcode Sep 24, 2026
9a92a72
Keep Gemini thinking low so it cannot use up the output cap
EnesYilmazcode Sep 24, 2026
d44ae0b
Classify each page and follow the real Next link
EnesYilmazcode Sep 24, 2026
8f31273
Add a run record that belongs to one tab and ends with a reason
EnesYilmazcode Sep 24, 2026
d026f95
Tell content scripts their tab id and end a run when its tab closes
EnesYilmazcode Sep 24, 2026
7155cef
Bind each scrape run to the tab it started in
EnesYilmazcode Sep 24, 2026
da189d4
Mark the fixed run scenarios as passing and add three more
EnesYilmazcode Sep 24, 2026
4d4a16c
Document how a scrape run starts, pages and ends
EnesYilmazcode Sep 24, 2026
d6817c1
End a run as selectors_broken when no result card parses, even at the…
EnesYilmazcode Sep 24, 2026
c3bfae8
Keep a run stopped when Stop lands during a page save
EnesYilmazcode Sep 24, 2026
2338f7e
Add a USD-only price parser that returns null for other currencies
EnesYilmazcode Sep 24, 2026
65e58cf
Store a missing title, price, rating or review count as null, not 0 o…
EnesYilmazcode Sep 24, 2026
26089b3
Flag sponsored cards and link every product to its /dp/ page
EnesYilmazcode Sep 24, 2026
8bd950d
Take the title from the linked h2 and skip unit and list prices
EnesYilmazcode Sep 24, 2026
eba67aa
Read the result count from "of over 10,000 results" headers
EnesYilmazcode Sep 24, 2026
f9ec905
Keep each ASIN once per page with its placements and organic rank
EnesYilmazcode Sep 24, 2026
c72c544
Store each ASIN once per run and take its delta only on first sight
EnesYilmazcode Sep 24, 2026
ed7778a
Keep the last good value in lastValues when a field fails to parse
EnesYilmazcode Sep 24, 2026
c20297a
Count placements and sponsored cards in the synced run header
EnesYilmazcode Sep 24, 2026
3c79a32
Check dedupe, sponsored flags and /dp/ links in the Chromium harness
EnesYilmazcode Sep 24, 2026
e8208ae
Document the product record and the current selectors
EnesYilmazcode Sep 24, 2026
9a546fb
Say what the search parser returns in its header comment
EnesYilmazcode Sep 24, 2026
856cb54
Count organic ranks only within the current run
EnesYilmazcode Sep 24, 2026
1f47200
Reject storage writes that fail and warn when storage is nearly full
EnesYilmazcode Sep 24, 2026
63ff0c1
Stop queueing scraped products for sync while cloud sync is off
EnesYilmazcode Sep 24, 2026
e1c4b1e
Hide Export to ProScan and refuse cloud export while sync is off
EnesYilmazcode Sep 24, 2026
7cffa6a
Cap lastValues by age and count so it cannot fill storage
EnesYilmazcode Sep 24, 2026
5bc02bc
Quote every CSV field, neutralise formulas and export price as a number
EnesYilmazcode Sep 24, 2026
5a88ad9
Write Excel prices and scores as numbers and leave unknowns empty
EnesYilmazcode Sep 24, 2026
d89fbeb
Capture the storage the 2.0 store build leaves behind as a fixture
EnesYilmazcode Sep 24, 2026
2b6b5f5
Add the schemaVersion 2 to 3 migration for users coming from 2.0
EnesYilmazcode Sep 24, 2026
44eb795
Migrate storage on update and browser start, and stamp fresh installs
EnesYilmazcode Sep 24, 2026
659db7d
Expect the upgrade harness to pass now that 2.0 storage is migrated
EnesYilmazcode Sep 24, 2026
02d6d9a
Cover the sync-off build in the Chromium harness
EnesYilmazcode Sep 24, 2026
c2dcf34
Document storage, migrations and the export format
EnesYilmazcode Sep 24, 2026
97da2da
Ask Chromium for the build's install warnings and compare with 2.0
EnesYilmazcode Sep 24, 2026
0044e2c
End a run on a page it did not open, and stop calling card-less pages…
EnesYilmazcode Sep 24, 2026
4ed57b8
Follow the result count when a storefront has no Next link
EnesYilmazcode Sep 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 73 additions & 22 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,19 @@ AmazonSellerScraper/
├── popup/ # UI Layer
│ ├── popup.html # Popup interface (dashboard + settings)
│ ├── popup.css # Popup styling
│ └── popup.js # UI logic + API key management
│ ├── popup.js # UI logic
│ └── ai-key.js # Gemini key field (AI chat settings)
├── scripts/
│ ├── content/
│ │ ├── scraper.js # DOM scraping on Amazon pages
│ │ ├── chatbot.js # Floating AI chatbot widget (Shadow DOM)
│ │ └── offer-fetcher.js # Seller price fetching for spread analysis
│ ├── lib/
│ │ └── parsers.js # Pure search/offer parsing (global Parsers)
│ │ ├── parsers.js # Pure search/offer parsing (global Parsers)
│ │ ├── run.js # Scrape run record, bound to one tab (global Run)
│ │ ├── flags.js # Build flags (global Flags); CLOUD_SYNC is off in 2.1
│ │ ├── migrate.js # Storage schema migrations, run by the SW
│ │ └── chat.js # Gemini request builder, run scoping, error text
│ ├── background/
│ │ └── service-worker.js # Message routing + Gemini API calls
│ └── modules/
Expand Down Expand Up @@ -69,27 +74,29 @@ AmazonSellerScraper/
### Scraping

1. User clicks "Start Scraping" in popup
2. `popup.js` sends `START_SCRAPING` via Chrome runtime
3. `scraper.js` extracts products from Amazon page
4. Results stored in `chrome.storage.local` via `storage.js`
5. Auto-navigates to next page (2s delay) until complete
6. `analyzer.js` generates insights and opportunity scores
7. User exports via `exporter.js` (Excel/CSV/JSON)
2. `popup.js` pings the tab; with no answer it offers `chrome.tabs.reload` and starts after the reload
3. `popup.js` writes a run record (`scripts/lib/run.js`, key `run`) bound to the tab id, then sends `START_SCRAPING`
4. `scraper.js` classifies the page (results, last, empty, captcha, interstitial, signin, unknown) and extracts products
5. Results and run progress stored in `chrome.storage.local` in one write per page
6. Follows the page's Next link after 2 to 4 seconds, up to `settings.maxPages`; on each load the content script asks the SW for its tab id (`WHO_AM_I`) and acts only if its tab owns the run
7. The run ends with a reason; Stop goes to the run's tab and cancels the pending navigation
8. `analyzer.js` generates insights and opportunity scores
9. User exports via `exporter.js` (Excel/CSV/JSON)

### AI Chatbot (client-side, no server)

1. `chatbot.js` injects a floating widget (bottom-right) on Amazon pages with product listings
2. Widget uses Shadow DOM to isolate styles from Amazon's CSS
3. User types a question (e.g. "What's the best deal under $30?")
4. `chatbot.js` reads scraped products from `chrome.storage.local`
5. Sends `CHAT_MESSAGE` to `service-worker.js` with question + product data
6. Service worker calls Gemini API (`gemini-2.0-flash`, free tier) with product context
7. Response displayed in chat bubble
4. `chatbot.js` sends `CHAT_MESSAGE` to `service-worker.js` with the question and the last few turns
5. The service worker reads the user's key and the current run from `chrome.storage.local`; the content script never sees the key
6. `scripts/lib/chat.js` builds the request: model id in `GEMINI_MODEL`, key in the `x-goog-api-key` header, titles in a fenced JSON block marked untrusted
7. Response displayed in chat bubble as text, never HTML

## Setup

1. Load unpacked extension in `chrome://extensions`
2. Click the ProScan popup → open Settings → paste your Gemini API key (free at [aistudio.google.com/apikey](https://aistudio.google.com/apikey))
2. Click the ProScan popup, expand AI chat settings, paste your Gemini API key (free at [aistudio.google.com/apikey](https://aistudio.google.com/apikey))
3. Navigate to Amazon seller/search page → scrape → export
4. The AI chatbot button appears in the bottom-right corner on Amazon pages with product listings

Expand All @@ -109,7 +116,7 @@ npm run test:coverage # With coverage report
- Content scripts (`scraper.js`, `offer-fetcher.js`) have no `module.exports` — loaded via `vm.runInContext` into a JSDOM context with Chrome API mocks and an `innerText` polyfill
- HTML fixtures in `tests/fixtures/` match the exact CSS selectors the code uses
- Chrome APIs (`storage`, `runtime`, `tabs`, `downloads`) are mocked in `tests/setup/chrome-mock.js`
- XLSX is mocked with jest.fn() stubs for workbook creation
- XLSX is mocked with jest.fn() stubs in exporter.test.js; export-xlsx.test.js uses the real libs/xlsx.full.min.js

## Chrome APIs Used

Expand All @@ -118,34 +125,78 @@ npm run test:coverage # With coverage report
- `chrome.downloads` — file downloads
- `chrome.tabs` — active tab messaging

## DOM Selectors (Amazon-specific, updated Feb 2026)
## DOM Selectors (Amazon-specific, updated Sep 2026)

```javascript
// Product container
// Product container: real result cards when the page marks them,
// otherwise any result item with an ASIN
'[data-component-type="s-search-result"]'
'.s-result-item[data-asin]:not([data-asin=""])'

// Title — structural first, class fallback
// Title: the h2 inside the product link, then the last title-recipe h2,
// then the old fallbacks (a brand line can be its own h2)
'a h2'
'[data-cy="title-recipe"] h2'
'h2 span'
'.a-size-base-plus.a-color-base.a-text-normal'

// Price — data attribute for main price
// Price: the main price; a-text-price is a unit or list price
'.a-price[data-a-size="xl"] .a-offscreen'
'.a-price .a-offscreen'
'.a-price:not(.a-text-price) .a-offscreen' // outside secondary-offer-recipe

// Rating — cascading: data-cy > star-mini > star-small > plain text
// Rating: cascading data-cy > star-mini > star-small > plain text
'[data-cy="reviews-ratings-slot"] .a-icon-alt'
'.a-icon-star-mini .a-icon-alt'
'.a-icon-star-small .a-icon-alt'
'[data-cy="reviews-block"] span.a-size-base.a-color-secondary'

// Review count — aria-label has full number, display text has K/M suffix
'a[aria-label$="ratings"]'
// Review count: aria-label has the full number, display text has K/M
'a[aria-label$="ratings"], a[aria-label$="rating"]'
'.a-size-mini.puis-normal-weight-text.s-underline-text'

// Sponsored: AdHolder card, the label, or an sspa ad link
'.puis-sponsored-label-text, [data-component-type="sp-sponsored-result"], a[href*="/sspa/"]'

// Prime badge
'.a-icon-prime'
```

## Product record

One record per ASIN per run, from `Parsers.parseSearchPage` and the scraper:

- `name`, `price`, `rating`, `reviewCount`: null when the card has none, never 0 or "N/A"
- `priceCents`: integer cents from a dollar price only; `currency` is `USD`, another symbol, or null
- `url`: always `https://www.amazon.com/dp/{asin}`, never the sspa ad link
- `sponsored`: true if any placement was an ad; `organicRank`: run-wide rank of the first organic card, or null
- `placements`: every card the ASIN had, as `{page, position, sponsored, rank}`
- `delta`: taken once, on the ASIN's first sighting in the run, against `lastValues` from earlier runs.
A field that fails to parse keeps its last good value in `lastValues`, with the time in `carried`

## Storage

- `chrome.storage.local` carries `schemaVersion` (3). Storage without it came from 2.0.
`scripts/lib/migrate.js` brings it up to date on install, update and browser start;
each step is idempotent. 2 to 3 adds `priceCents`, nulls and `/dp/` URLs to 2.0 rows,
seeds `lastValues` from them (with `firstSeenAt`), ends a run the update cut off as
`updated`, and drops the untouched 2.0 default settings.
`tests/fixtures/v2.0-storage.json` is what the live 2.0 build stores, captured with
`node tests/e2e/capture-v20-storage.mjs`.
- `lastValues` is capped by `Delta.prune`: 5,000 ASINs, none older than a year.
- `Storage` rejects when `chrome.runtime.lastError` is set (code `storage_full` on a
quota error). The popup warns from 90% of the quota.
- Cloud sync is off in 2.1 (`Flags.CLOUD_SYNC`): nothing goes into `syncQueue`, the SW
refuses `PROSCAN_EXPORT`, and the popup hides sign-in and Export to ProScan.

## Export

- CSV: UTF-8 with a BOM, CRLF, every text field quoted (RFC 4180). Text starting with
`=`, `+`, `-`, `@`, tab or CR gets a leading `'`. Price is a number of dollars from
`priceCents`; unknown values are empty cells and real zeros stay 0.
- Excel: prices, ratings, review counts and scores are numbers; unknowns are empty.
`tests/unit/export-xlsx.test.js` writes the workbook with the real SheetJS and checks
every XML part parses.

## Analytics

### Opportunity Score
Expand Down
38 changes: 25 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<img src="https://img.shields.io/badge/Chrome_Web_Store-Install-4285F4?logo=googlechrome&logoColor=white" alt="Chrome Web Store">
</a>
<img src="https://img.shields.io/badge/Manifest-V3-brightgreen" alt="Manifest V3">
<img src="https://img.shields.io/badge/AI-Gemini_2.0_Flash-blue" alt="Gemini AI">
<img src="https://img.shields.io/badge/AI-Gemini_Flash-blue" alt="Gemini AI">
<img src="https://img.shields.io/badge/License-MIT-green" alt="MIT License">
</p>

Expand All @@ -28,7 +28,7 @@ ProScan is a Chrome extension that scrapes Amazon product listings across multip
- **Multi-page scraping** -- Automatically navigates and extracts product data (name, ASIN, price, rating, reviews, Prime status) across paginated Amazon results
- **Opportunity scoring** -- Proprietary formula identifies high-value arbitrage opportunities based on rating, review velocity, and price positioning
- **Price spread analysis** -- Fetches competing seller prices for each product and calculates variability (Coefficient of Variation) to identify pricing disagreement -- a strong arbitrage signal
- **AI chatbot** -- Floating widget on Amazon pages answers questions about scraped products using Gemini 2.0 Flash (e.g., "What's the best deal under $30?")
- **AI chatbot** -- Floating widget on Amazon pages answers questions about your last scan using Gemini Flash and your own free API key (e.g., "What's the best deal under $30?")
- **Analytics dashboard** -- Real-time stats, underpriced product detection, and quality distribution analysis
- **Multi-format export** -- Excel (with styled sheets and charts), CSV, and JSON with full analytics
- **Shadow DOM isolation** -- Chatbot widget styles are fully isolated from Amazon's CSS
Expand Down Expand Up @@ -58,11 +58,15 @@ ProScan is a Chrome extension that scrapes Amazon product listings across multip

```
User clicks "Start Scraping"
→ popup.js sends START_SCRAPING message
→ scraper.js extracts products from DOM using cascading selectors
→ Results stored in chrome.storage.local
→ Auto-navigates to next page (2s delay for rate limiting)
→ Repeats until no more pages
→ popup.js pings the tab (offers a reload if ProScan is not loaded there)
→ popup.js creates a run bound to that tab and sends START_SCRAPING
→ scraper.js classifies the page, then extracts products
→ Results and run progress stored in chrome.storage.local
→ Follows the page's Next link after a 2 to 4 second delay
→ Repeats until the last page or the page cap (settings.maxPages, default 20)
→ The run ends with a reason: complete, stopped, blocked (captcha,
bot check, sign-in), selectors_broken, storage_full, interrupted or
updated (the extension updated mid-run)
→ analyzer.js generates insights and opportunity scores
→ User exports via exporter.js (Excel/CSV/JSON)
```
Expand All @@ -71,10 +75,10 @@ User clicks "Start Scraping"

```
User types question in floating widget
→ chatbot.js reads products from chrome.storage.local
→ Sends CHAT_MESSAGE to service-worker.js
→ Service worker calls Gemini 2.0 Flash API with product context
→ Response displayed in chat bubble
→ chatbot.js sends CHAT_MESSAGE (question + last few turns) to service-worker.js
→ Service worker reads the user's key and the current run from storage
→ scripts/lib/chat.js builds the Gemini request (model id is GEMINI_MODEL there)
→ Response displayed in chat bubble as plain text
```

## Analytics Engine
Expand Down Expand Up @@ -136,6 +140,8 @@ See [docs/PRICE_SPREAD_ANALYSIS.md](docs/PRICE_SPREAD_ANALYSIS.md) for the full
3. Enable **Developer mode** (top-right toggle)
4. Click **Load unpacked** and select the `dist/` folder. The repo root does not load on its own, because the service worker has to be bundled.

To use the AI chat, open the ProScan popup, expand **AI chat settings** and paste a Gemini API key (free at [aistudio.google.com/apikey](https://aistudio.google.com/apikey)). The key stays in `chrome.storage.local`; only the service worker reads it and sends it to Google.

For local Firebase work, `npm run build:dev` points the build at the emulators under the `demo-proscan` project.

## Release checks
Expand All @@ -144,6 +150,8 @@ For local Firebase work, `npm run build:dev` points the build at the emulators u

The Jest suite includes a golden corpus of saved Amazon pages (`tests/pages/`, see its README). `npm run test:e2e` loads the built extension into Chromium and runs scrape scenarios against those pages, with every request answered locally. Run `npx playwright install --no-shell chromium` once first. Known bugs run as expected failures tagged with their audit finding id; `PROSCAN_SHOW_KNOWN=1 npm run test:e2e` shows what they fail on.

The Jest suite includes a golden corpus of saved Amazon pages (`tests/pages/`, see its README). `npm run test:e2e` loads the built extension into Chromium and runs scrape scenarios against those pages, with every request answered locally. Run `npx playwright install --no-shell chromium` once first. Known bugs run as expected failures tagged with their audit finding id; `PROSCAN_SHOW_KNOWN=1 npm run test:e2e` shows what they fail on.

## Usage

1. Navigate to any Amazon search results or seller page
Expand All @@ -161,7 +169,7 @@ The Jest suite includes a golden corpus of saved Amazon pages (`tests/pages/`, s
| Extension | Chrome Manifest V3 | Extension framework |
| Extension | Shadow DOM | Chatbot style isolation |
| Extension | XLSX.js | Excel generation |
| AI | Google Gemini 2.0 Flash | Chatbot |
| AI | Google Gemini Flash (bring your own key) | Chatbot |

## Chrome APIs Used

Expand All @@ -185,7 +193,11 @@ AmazonSellerScraper/
│ │ ├── chatbot.js # Floating AI chatbot (Shadow DOM)
│ │ └── offer-fetcher.js # Seller offer page fetching for spread analysis
│ ├── lib/
│ │ └── parsers.js # Pure search and offer page parsing
│ │ ├── parsers.js # Pure search and offer page parsing
│ │ ├── run.js # The scrape run record and its end reasons
│ │ ├── flags.js # Build flags (cloud sync is off in 2.1)
│ │ ├── migrate.js # Storage schema migrations (schemaVersion)
│ │ └── chat.js # Gemini request builder and run scoping
│ ├── background/
│ │ └── service-worker.js # Message routing + Gemini API
│ └── modules/
Expand Down
10 changes: 5 additions & 5 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ module.exports = {
roots: ['<rootDir>/tests'],
setupFiles: ['<rootDir>/tests/setup/chrome-mock.js'],
testMatch: ['**/*.test.js'],
// sync.js is the one source file authored as ESM (it is bundled into the
// service worker by esbuild). A tiny scoped transform rewrites its
// import/export to CommonJS so it can be unit-tested; every other file
// keeps the default babel-jest transform.
// sync.js and service-worker.js are authored as ESM (esbuild bundles
// them). A tiny scoped transform rewrites their import/export to
// CommonJS so they can be unit-tested; every other file keeps the
// default babel-jest transform.
transform: {
'[\\\\/]scripts[\\\\/]background[\\\\/]sync\\.js$': '<rootDir>/tests/setup/esm-to-cjs-transform.js',
'[\\\\/]scripts[\\\\/]background[\\\\/](sync|service-worker)\\.js$': '<rootDir>/tests/setup/esm-to-cjs-transform.js',
'\\.[jt]sx?$': 'babel-jest'
},
coverageDirectory: 'coverage',
Expand Down
2 changes: 2 additions & 0 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
"js": [
"scripts/modules/price.js",
"scripts/lib/parsers.js",
"scripts/lib/run.js",
"scripts/lib/flags.js",
"scripts/modules/delta.js",
"scripts/content/scraper.js",
"scripts/content/chatbot.js",
Expand Down
75 changes: 75 additions & 0 deletions popup/ai-key.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* @fileoverview "AI chat settings" in the popup: the user's own Gemini key.
*
* The key is kept in chrome.storage.local under geminiApiKey. Only the
* popup writes it and only the service worker reads it; the chat widget on
* Amazon asks the worker whether a key is set and never sees the value.
*
* @module AiKey
*/

const AiKey = (() => {
const STORAGE_KEY = 'geminiApiKey';

/** Trims a pasted key. Returns '' for anything that cannot be a key. */
function normalize(raw) {
const key = String(raw == null ? '' : raw).trim();
return /^[A-Za-z0-9_.\-]{20,200}$/.test(key) ? key : '';
}

async function hasKey() {
const data = await chrome.storage.local.get([STORAGE_KEY]);
return typeof data[STORAGE_KEY] === 'string' && data[STORAGE_KEY].trim() !== '';
}

async function save(raw) {
const key = normalize(raw);
if (!key) return false;
await chrome.storage.local.set({ [STORAGE_KEY]: key });
return true;
}

async function clear() {
await chrome.storage.local.remove(STORAGE_KEY);
}

function init(doc) {
const input = doc.getElementById('geminiKeyInput');
const saveBtn = doc.getElementById('geminiKeySave');
const clearBtn = doc.getElementById('geminiKeyClear');
const status = doc.getElementById('geminiKeyStatus');
if (!input || !saveBtn || !clearBtn || !status) return Promise.resolve();

const show = (set, message) => {
status.textContent = message || (set ? 'Key saved. The chat button on Amazon is ready.' : 'No key set. AI chat is off.');
input.placeholder = set ? 'Key saved (hidden). Paste a new one to replace it.' : 'Paste your Gemini API key';
clearBtn.classList.toggle('hidden', !set);
};

saveBtn.addEventListener('click', async () => {
if (await save(input.value)) {
input.value = '';
show(true);
} else {
show(await hasKey(), 'That does not look like a Gemini API key.');
}
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') saveBtn.click();
});
clearBtn.addEventListener('click', async () => {
await clear();
show(false, 'Key removed.');
});

return hasKey().then((set) => show(set));
}

return { STORAGE_KEY, normalize, hasKey, save, clear, init };
})();

if (typeof module !== 'undefined' && module.exports) {
module.exports = AiKey;
} else {
document.addEventListener('DOMContentLoaded', () => AiKey.init(document));
}
32 changes: 32 additions & 0 deletions popup/popup.css
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,38 @@ h1 {
color: var(--color-text-primary);
}

/* AI chat settings */
.ai-settings summary {
cursor: pointer;
list-style: none;
margin-bottom: 0;
}

.ai-settings[open] summary {
margin-bottom: var(--spacing-sm);
}

.ai-key-actions {
display: flex;
gap: var(--spacing-sm);
align-items: stretch;
}

.ai-key-actions .auth-signout {
margin-top: var(--spacing-xs);
}

.ai-key-status,
.ai-key-help {
margin-top: var(--spacing-sm);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}

.ai-key-help a {
color: var(--color-accent-green);
}

/* Cloud Export */
.export-cloud-button {
width: 100%;
Expand Down
Loading
Loading