diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index b904983..f86ea9d 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1,7 +1,7 @@
name: Tests
on:
push:
- branches: [main, dev]
+ branches: [main]
pull_request:
branches: [main]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 76fe7ba..0a75667 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,28 @@
# Changelog
+## Unreleased
+
+### Added
+- Playwright smoke test suite (40 tests): dashboard UI, detail panel, tabs, PSA stats, arbitrage, mobile viewport, static assets
+- Sort dropdown on listing tabs (price ascending/descending)
+- Result counts in tab labels: "Active (6)" / "Sold (3)"
+- Condition badges on raw listing cards using detectedCondition from API
+- Price outlier warnings (flagPriceOutliers applied in API pipeline)
+- GRADED badge for slab listings in detail panel
+- Inline PSA stats in Prices tab with gem progress bar
+- Price chart x-axis date labels
+- Arbitrage "Best Price" chip and savings summary
+- Fade-up entrance animations, sticky frosted header, sticky search bar
+
+### Changed
+- Dashboard UI synced with casecomp.xyz frontend: Inter Tight + JetBrains Mono fonts, pill-style tabs/hints, ghost view button
+- Moved lib/demo.js to lib/data/, lib/output.js to lib/search/
+- Umbreon demo data: added detectedCondition (NM/LP) based on AI grades
+- Detail panel: prefer detectedCondition over "Ungraded"
+- Consistent shipping display with green "Free shipping"
+- CI: unit + smoke run in parallel, test gate job, removed duplicate dev push trigger
+- Demo rate limit shown correctly as 360/min
+
## 1.0.0-beta.1 (2026-05-10)
Initial public beta.
diff --git a/README.md b/README.md
index aa5d26e..314d0ca 100644
--- a/README.md
+++ b/README.md
@@ -87,6 +87,7 @@ terraform/ GCP infra: Cloud Run ×2, Firestore, LB + CDN, Secret Manage
test/
unit-test.js 81 unit tests
api-test.js 62 API integration tests
+ smoke-test.js 40 Playwright smoke tests (dashboard UI)
```
## Web Dashboard
@@ -139,7 +140,7 @@ curl -H "Authorization: Bearer $CASECOMP_KEY" \
| Endpoint | Limit |
|----------|-------|
| Authenticated (`CC_LIVE_` key) | 60 req/min |
-| Sample data (`?demo=true`) | 20 req/min |
+| Sample data (`?demo=true`) | 360 req/min |
| Health, docs, static | No limit |
### Public endpoints (no key)
@@ -226,11 +227,11 @@ Load unpacked from `extension/` in `chrome://extensions`.
## Tests
-143 tests: 81 unit (filters, grading, query builder, card identity, condition detection, demo data) + 62 API (health, drops, webhooks, search, sold, PSA, grade, auth, admin keys, arbitrage, price-history, condition, demo validation).
+183 tests: 81 unit (filters, grading, query builder, card identity, condition detection, demo data) + 62 API (health, drops, webhooks, search, sold, PSA, grade, auth, admin keys, arbitrage, price-history, condition, demo validation) + 40 Playwright smoke (dashboard UI, detail panel, tabs, PSA stats, arbitrage, mobile viewport).
## Contributing
-Contributions welcome. Fork the repo, create a branch, and open a PR against `dev`. Run `yarn test` before submitting — all 105 tests must pass.
+Contributions welcome. Fork the repo, create a branch, and open a PR against `dev`. Run `yarn test` before submitting — all tests must pass.
For bug reports or feature requests, open an [issue](https://github.com/Pyronewbic/casecomp/issues).
diff --git a/public/app.js b/public/app.js
index 4ba4dcb..161f048 100644
--- a/public/app.js
+++ b/public/app.js
@@ -411,7 +411,8 @@ function selectItem(itemId) {
${gradeHtml}
- ${renderPsaInline(currentPsaSignal)}
+ ${slabLabel ? renderPsaInline(currentPsaSignal) : ""}
+
Price History
@@ -444,6 +445,7 @@ function selectItem(itemId) {
loadCardIdentity(currentQuery);
loadPriceChart(currentQuery);
loadArbitrage(currentQuery);
+ loadGradingRoi(item);
}
async function loadCardIdentity(query) {
@@ -515,6 +517,62 @@ async function loadArbitrage(query) {
} catch {}
}
+function loadGradingRoi(item) {
+ const container = document.getElementById("grading-roi");
+ if (!container) return;
+ if (!currentPsaSignal || item.listingGradeLabel) return;
+
+ const psa = currentPsaSignal;
+ const rawPrice = item.totalCost || item.price;
+ if (!rawPrice || !psa.estCost) return;
+
+ const gradingCost = parseFloat(psa.estCost.replace(/[^0-9.]/g, ""));
+ if (!gradingCost) return;
+
+ const gemPct = psa.gem10Pct || 0;
+ const totalCost = rawPrice + gradingCost;
+ const diffClass = psa.difficulty === "easy" ? "easy" : psa.difficulty === "hard" || psa.difficulty === "brutal" ? "hard" : "moderate";
+
+ container.classList.remove("hidden");
+ const profitable = gemPct >= 50;
+ const verdictClass = profitable ? "roi-yes" : "roi-no";
+ const verdict = profitable ? "Worth grading" : "Risky";
+
+ container.innerHTML = `
+
Grade This Card?
+
+
+
Raw Price
+
${formatPrice(rawPrice, "USD")}
+
+
+
${esc(psa.tier)} Grading
+
${esc(psa.estCost)}
+
+
+
Total Cost
+
${formatPrice(totalCost, "USD")}
+
+
+
Gem Rate
+
${gemPct}%
+
+
+
+ Pop ${psa.totalPop ? psa.totalPop.toLocaleString() : "—"}
+ Difficulty ${esc(psa.difficulty || "—")}
+ PSA 10 ${psa.pop10 ? psa.pop10.toLocaleString() : "—"}
+ PSA 9 ${psa.pop9 ? psa.pop9.toLocaleString() : "—"}
+
+
+ ${verdict}
+ ${gemPct >= 50
+ ? `${gemPct}% gem rate at ${esc(psa.estCost)} grading — favorable odds`
+ : `${gemPct}% gem rate — high risk of PSA 9 or lower`}
+
+ `;
+}
+
async function loadPriceChart(query) {
const container = document.getElementById("price-chart-container");
const canvas = document.getElementById("price-chart");
diff --git a/public/index.html b/public/index.html
index 4580a6f..410db1e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -19,6 +19,7 @@
Beta
diff --git a/public/style.css b/public/style.css
index db02c7e..430dbc0 100644
--- a/public/style.css
+++ b/public/style.css
@@ -87,6 +87,8 @@ header {
line-height: 1;
}
+nav { display: flex; gap: 20px; }
+
.nav-link {
color: var(--muted);
font-size: 14px;
@@ -698,6 +700,78 @@ main {
.card-id-sep { width: 1px; height: 14px; background: var(--border); flex-shrink: 0; }
.card-id-lang { font-size: 9px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; margin-right: 2px; }
+.grading-roi {
+ background: var(--inset);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 14px 16px;
+ margin-bottom: 14px;
+}
+.roi-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 8px;
+ margin-top: 8px;
+}
+.roi-stat { text-align: center; }
+.roi-label {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--muted);
+ margin-bottom: 4px;
+}
+.roi-value {
+ font-family: 'Space Grotesk', system-ui, sans-serif;
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--text);
+}
+.roi-total { color: var(--gold); }
+.roi-psa-row {
+ display: flex;
+ gap: 16px;
+ justify-content: center;
+ margin-top: 10px;
+ padding-top: 10px;
+ border-top: 1px solid var(--border);
+ font-size: 11px;
+ color: var(--muted);
+}
+.roi-psa-row b {
+ color: var(--text);
+ font-family: 'Space Grotesk', system-ui, sans-serif;
+ margin-left: 3px;
+}
+.roi-psa-row b.easy { color: var(--green); }
+.roi-psa-row b.hard { color: var(--red); }
+.roi-psa-row b.moderate { color: var(--gold); }
+.roi-verdict {
+ margin-top: 10px;
+ padding-top: 10px;
+ border-top: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.roi-verdict-label {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ padding: 3px 8px;
+ border-radius: 4px;
+ flex-shrink: 0;
+}
+.roi-yes .roi-verdict-label { color: var(--green); background: rgba(124, 224, 168, 0.1); border: 1px solid rgba(124, 224, 168, 0.25); }
+.roi-no .roi-verdict-label { color: var(--red); background: rgba(255, 93, 93, 0.08); border: 1px solid rgba(255, 93, 93, 0.25); }
+.roi-verdict-detail {
+ font-size: 12px;
+ color: var(--muted);
+}
+
.arbitrage-container { background: var(--inset); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; margin-bottom: 14px; }
.arbitrage-sources { display: flex; gap: 8px; margin-top: 8px; }
.arb-source { flex: 1; background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 10px; text-align: center; }